Compare commits
3 Commits
c1573a2b95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e1c097f2e | |||
| d27fd02866 | |||
| 84ed07fdec |
@@ -15,10 +15,11 @@ Coolify's non-secret app config (project/server UUIDs, name, domain) lives in `d
|
|||||||
|
|
||||||
## Connection details (for agents managing this NATS)
|
## Connection details (for agents managing this NATS)
|
||||||
|
|
||||||
There are two users, both admin (unrestricted publish/subscribe):
|
There are three users, all admin (unrestricted publish/subscribe):
|
||||||
|
|
||||||
- `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application.
|
- `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application.
|
||||||
- `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token).
|
- `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token).
|
||||||
|
- `external-apps` — env var `NATS_EXTERNAL_APPS_PASSWORD` on the Coolify application. Intended for third-party / external application connections. Password-style credential (random string, framed as a password rather than a bearer token). Unrestricted for now; scope down if we ever need to isolate external callers from internal traffic.
|
||||||
|
|
||||||
### Local credentials cache
|
### Local credentials cache
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ asyncio.run(main())
|
|||||||
- `8080` — WebSocket listener, `no_tls: true`. The only port in `expose:`. Traefik terminates TLS and forwards `ws://nats:8080` from `wss://nats.tes.gd`.
|
- `8080` — WebSocket listener, `no_tls: true`. The only port in `expose:`. Traefik terminates TLS and forwards `ws://nats:8080` from `wss://nats.tes.gd`.
|
||||||
- `8222` — HTTP monitoring (`/healthz`, `/varz`, `/jsz`), used by the compose healthcheck; not routed publicly.
|
- `8222` — HTTP monitoring (`/healthz`, `/varz`, `/jsz`), used by the compose healthcheck; not routed publicly.
|
||||||
- **JetStream**: enabled, persisted to the named volume `nats-data` mounted at `/data`. Limits: 256MB memory / 4GB file. Bump `max_file_store` in `nats-server.conf` if you need more.
|
- **JetStream**: enabled, persisted to the named volume `nats-data` mounted at `/data`. Limits: 256MB memory / 4GB file. Bump `max_file_store` in `nats-server.conf` if you need more.
|
||||||
- **Auth**: two users in `authorization.users` — `admin` (password from `NATS_PASSWORD`) and `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token). Both are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode.
|
- **Auth**: three users in `authorization.users` — `admin` (password from `NATS_PASSWORD`), `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token), and `external-apps` (password from `NATS_EXTERNAL_APPS_PASSWORD`, for third-party callers). All three are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode.
|
||||||
|
|
||||||
## Managing / redeploying
|
## Managing / redeploying
|
||||||
|
|
||||||
@@ -134,6 +135,21 @@ To add a second user (e.g. a scoped app account), edit the `authorization.users`
|
|||||||
|
|
||||||
- **Coolify's deploy status ≠ container health.** A deploy returning `status: "finished"` from `/api/v1/deployments/…` just means the build + `docker compose up -d` step ran to completion. If the container then crash-loops (e.g. bad `nats-server.conf`), the deploy is still "finished" but the app's `/api/v1/applications/{uuid}` shows `status: "restarting:unknown"` and `/api/v1/applications/{uuid}/logs` returns `Application is not running.` — no runtime logs via API. Fall back to `docker logs <container-name>` on the Coolify host to see why the process is dying. Container name pattern: `nats-<app-uuid>-<random>`.
|
- **Coolify's deploy status ≠ container health.** A deploy returning `status: "finished"` from `/api/v1/deployments/…` just means the build + `docker compose up -d` step ran to completion. If the container then crash-loops (e.g. bad `nats-server.conf`), the deploy is still "finished" but the app's `/api/v1/applications/{uuid}` shows `status: "restarting:unknown"` and `/api/v1/applications/{uuid}/logs` returns `Application is not running.` — no runtime logs via API. Fall back to `docker logs <container-name>` on the Coolify host to see why the process is dying. Container name pattern: `nats-<app-uuid>-<random>`.
|
||||||
|
|
||||||
|
- **`docker_compose_domains` regresses after config-touching deploys.** The stored value on this app is `{"nats":{"domain":"https://nats.tes.gd:8080"}}` — object-keyed, no `name` field. Coolify's Traefik-label generator wants each entry to carry an explicit `name`; without it, on some deploys the labels fall back to defaults (`Host(<app-uuid>.tes.gd)` on port 80) and `wss://nats.tes.gd` stops routing to the container even though the container itself is `running:healthy`. Symptom: `curl https://nats.tes.gd/` returns a 404 or lands on an unrelated service; NATS logs show `websocket handshake error: invalid value for header 'Upgrade'` for requests that do slip through. Fix by PATCHing with the **array form** (per `deploying-to-coolify-via-api` skill), then redeploying:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_UUID=$(jq -r '.app.uuid' deploy.json)
|
||||||
|
curl -sS -X PATCH "$COOLIFY_URL/api/v1/applications/$APP_UUID" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -c '.app.docker_compose_domains | to_entries | map({name: .key, domain: .value.domain}) | {docker_compose_domains: .}' deploy.json)"
|
||||||
|
curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP_UUID" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify by curling `https://nats.tes.gd/`: you want `HTTP 400 Bad Request` with a `sec-websocket-version: 13` response header (NATS's WS handler answering a non-Upgrade request). Anything else means Traefik still isn't pointed at the NATS container.
|
||||||
|
|
||||||
|
- **`nats` npm package doesn't speak WebSocket from Node.** From Node, `import { connect } from "nats"` with `servers: "wss://…"` fails with `CONNECTION_REFUSED` — that package is TCP-only from Node. For Node-side WebSocket clients use `nats.ws` with a `ws` polyfill (see the "With `nats.js` / `nats.ws`" snippet earlier in this file); in the browser `nats.ws` works directly.
|
||||||
|
|
||||||
## Files in this repo
|
## Files in this repo
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|
|||||||
+2
-1
@@ -40,7 +40,8 @@ configs:
|
|||||||
authorization {
|
authorization {
|
||||||
users = [
|
users = [
|
||||||
{ user: "${NATS_USER}", password: "${NATS_PASSWORD}" },
|
{ user: "${NATS_USER}", password: "${NATS_PASSWORD}" },
|
||||||
{ user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" }
|
{ user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" },
|
||||||
|
{ user: "external-apps", password: "${NATS_EXTERNAL_APPS_PASSWORD}" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
# NATS `external-apps` User Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a third NATS user, `external-apps`, so third-party applications can connect to `wss://nats.tes.gd` with their own unrestricted credential.
|
||||||
|
|
||||||
|
**Architecture:** One-line addition to `authorization.users` in the inline `nats-conf` config inside `docker-compose.yml`. Credential value is a random password held in the Coolify env var `NATS_EXTERNAL_APPS_PASSWORD` and substituted at compose parse time. Deployment is triggered fire-and-forget via the Coolify v4 REST API. Local `credentials.local.json` and `CLAUDE.md` are updated to reflect the new user.
|
||||||
|
|
||||||
|
**Tech Stack:** Docker Compose 2.x, NATS 2.10 (WebSocket listener), Coolify v4 REST API, `curl` + `jq` for API calls, Node + `nats.ws` for auth verification (installed on demand).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| Path | Change | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `docker-compose.yml` | Modify (append 1 line inside `authorization.users`) | Register the new user in the NATS server config |
|
||||||
|
| `CLAUDE.md` | Modify (2 sections: "Connection details" intro, "Auth" bullet) | Keep the project doc in sync with the live config |
|
||||||
|
| `credentials.local.json` | Modify (add a third `users` entry) | Local cache of the new credential (gitignored) |
|
||||||
|
| `deploy.json` | Read-only | Source of `app.uuid` for the deploy API call |
|
||||||
|
| Coolify env vars | External change via API | Where the actual secret lives; source of truth |
|
||||||
|
|
||||||
|
Both the compose file and `CLAUDE.md` are committed. `credentials.local.json` is gitignored and stays local.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- `COOLIFY_URL` and `COOLIFY_KEY` env vars are set in the shell.
|
||||||
|
- Working directory is the repo root (`/app/data/home/nats_expert`).
|
||||||
|
- Local `deploy.json` is present (already confirmed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Generate password and set the Coolify env var
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `deploy.json` (for `app.uuid`)
|
||||||
|
- External: Coolify env vars on the `nats` application
|
||||||
|
|
||||||
|
This must happen **before** the compose change is pushed. If compose substitutes an empty string for `NATS_EXTERNAL_APPS_PASSWORD` at parse time, the user `external-apps` ends up with `password: ""` and any caller sending an empty password would be authenticated.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate a 32-byte hex password and hold it in a shell variable**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEW_PW=$(openssl rand -hex 32)
|
||||||
|
echo "generated: length=${#NEW_PW}" # sanity check only — do NOT echo $NEW_PW
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `generated: length=64`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm the app UUID from deploy.json**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_UUID=$(jq -r '.app.uuid' deploy.json)
|
||||||
|
echo "app: $APP_UUID"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `app: pic1i4nuchjk810eblk2mv6a` (or whatever is in `deploy.json`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check the env var is not already set (avoid clobbering)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
|
||||||
|
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
|
||||||
|
| jq -r '.[] | .key' | grep -c '^NATS_EXTERNAL_APPS_PASSWORD$'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `0`. If it prints `1`, stop and check with the user — someone already set it and the value will be overwritten below.
|
||||||
|
|
||||||
|
- [ ] **Step 4: POST the new env var to Coolify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg v "$NEW_PW" '{key:"NATS_EXTERNAL_APPS_PASSWORD", value:$v, is_preview:false, is_build_time:false, is_literal:true}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON response with a `uuid` field for the new env var. No error.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Read the env back to confirm it landed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
|
||||||
|
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
|
||||||
|
| jq -r '.[] | select(.key=="NATS_EXTERNAL_APPS_PASSWORD") | "\(.key)=<length=\(.value|length)>"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `NATS_EXTERNAL_APPS_PASSWORD=<length=64>`. Do **not** print the value.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Save the password to a shell file for later steps in this session**
|
||||||
|
|
||||||
|
Do this so the value survives if the shell forgets `NEW_PW` between steps.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
umask 077
|
||||||
|
printf '%s\n' "$NEW_PW" > /tmp/nats-external-apps.pw
|
||||||
|
```
|
||||||
|
|
||||||
|
No commit — this file is temporary and lives outside the repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add the user to `docker-compose.yml`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker-compose.yml` (inside `configs.nats-conf.content`, the `authorization.users` array)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current file to confirm the anchor line**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n 'volcanic-agents' docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one hit like `43: { user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" }`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the edit**
|
||||||
|
|
||||||
|
Use the Edit tool. Replace:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ user: "${NATS_USER}", password: "${NATS_PASSWORD}" },
|
||||||
|
{ user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" }
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ user: "${NATS_USER}", password: "${NATS_PASSWORD}" },
|
||||||
|
{ user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" },
|
||||||
|
{ user: "external-apps", password: "${NATS_EXTERNAL_APPS_PASSWORD}" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the last entry lacks a trailing comma; make sure `volcanic-agents` now has one and `external-apps` doesn't.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the edit locally**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n 'external-apps\|volcanic-agents\|NATS_USER' docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: three lines showing all three users in order.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate the compose file parses**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml config >/dev/null && echo OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK` (may print warnings about the empty env var — that's fine, we're not deploying locally). If `docker` is not on this machine, skip this step; the Coolify deploy will fail loudly if the file is malformed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Update `CLAUDE.md`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (two sections)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the "Connection details" intro list**
|
||||||
|
|
||||||
|
Find the block that starts with `There are two users, both admin`. Replace it:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```
|
||||||
|
There are two users, both admin (unrestricted publish/subscribe):
|
||||||
|
|
||||||
|
- `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application.
|
||||||
|
- `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token).
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```
|
||||||
|
There are three users, all admin (unrestricted publish/subscribe):
|
||||||
|
|
||||||
|
- `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application.
|
||||||
|
- `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token).
|
||||||
|
- `external-apps` — env var `NATS_EXTERNAL_APPS_PASSWORD` on the Coolify application. Intended for third-party / external application connections. Password-style credential (random string, framed as a password rather than a bearer token). Unrestricted for now; scope down if we ever need to isolate external callers from internal traffic.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the "Auth" bullet under "What's inside the server"**
|
||||||
|
|
||||||
|
Find the bullet starting with `**Auth**: two users in \`authorization.users\``. Replace:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```
|
||||||
|
- **Auth**: two users in `authorization.users` — `admin` (password from `NATS_PASSWORD`) and `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token). Both are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode.
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```
|
||||||
|
- **Auth**: three users in `authorization.users` — `admin` (password from `NATS_PASSWORD`), `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token), and `external-apps` (password from `NATS_EXTERNAL_APPS_PASSWORD`, for third-party callers). All three are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify both edits landed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n 'external-apps' CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: two hits, one in each of the sections above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Commit compose + doc changes
|
||||||
|
|
||||||
|
- [ ] **Step 1: Stage and review**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-compose.yml CLAUDE.md
|
||||||
|
git status
|
||||||
|
git diff --cached
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only those two files are staged; the diff shows the new user in compose and the two doc updates.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
Add third NATS user 'external-apps' for external application connections
|
||||||
|
|
||||||
|
Unrestricted admin (same shape as admin/volcanic-agents), password
|
||||||
|
delivered via Coolify env var NATS_EXTERNAL_APPS_PASSWORD.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one new commit on `main`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push 2>&1 | sed -E "s|${GIT_KEY:-NEVER_MATCH}|***|g; s|${GIT_USER:-NEVER_MATCH}|***|g"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `main -> main`, no errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Trigger the Coolify deploy (fire-and-forget)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `deploy.json` (for `app.uuid`)
|
||||||
|
|
||||||
|
Per global instructions: trigger and stop. Do not poll build progress. The API call returning a deployment uuid is the success signal.
|
||||||
|
|
||||||
|
- [ ] **Step 1: POST the deploy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_UUID=$(jq -r '.app.uuid' deploy.json)
|
||||||
|
curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP_UUID" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_KEY" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON with a `deployments` array containing an object with `deployment_uuid`. Record the uuid in the session but do **not** poll it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Update `credentials.local.json`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `credentials.local.json` (add third `users` entry)
|
||||||
|
|
||||||
|
Kept separate from the commit so no secret is ever staged, even accidentally.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the new entry**
|
||||||
|
|
||||||
|
Use the Edit tool. Replace:
|
||||||
|
|
||||||
|
```
|
||||||
|
"volcanic-agents": {
|
||||||
|
"user": "volcanic-agents",
|
||||||
|
"password": "8f53ceb8b7bb13f3828d0b9c7cda77c1ba21d2a2031203712eecb3372cad2ae4",
|
||||||
|
"note": "wire-level user/password (NATS has no per-user token field); the password is an opaque random token stored in Coolify env NATS_VOLCANIC_AGENTS_TOKEN. Admin (unrestricted)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with (substituting the real password read from `/tmp/nats-external-apps.pw`):
|
||||||
|
|
||||||
|
```
|
||||||
|
"volcanic-agents": {
|
||||||
|
"user": "volcanic-agents",
|
||||||
|
"password": "8f53ceb8b7bb13f3828d0b9c7cda77c1ba21d2a2031203712eecb3372cad2ae4",
|
||||||
|
"note": "wire-level user/password (NATS has no per-user token field); the password is an opaque random token stored in Coolify env NATS_VOLCANIC_AGENTS_TOKEN. Admin (unrestricted)."
|
||||||
|
},
|
||||||
|
"external-apps": {
|
||||||
|
"user": "external-apps",
|
||||||
|
"password": "<paste the value from /tmp/nats-external-apps.pw>",
|
||||||
|
"note": "user/password; unrestricted permissions. Intended for third-party / external application connections. Stored in Coolify env NATS_EXTERNAL_APPS_PASSWORD."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the real password with `cat /tmp/nats-external-apps.pw` and paste it in place of the placeholder before saving.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm the file is valid JSON**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq '.users | keys' credentials.local.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `["admin","external-apps","volcanic-agents"]`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm the file is still gitignored**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git check-ignore -v credentials.local.json && echo IGNORED
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: prints the ignore rule and then `IGNORED`. If it does **not** print `IGNORED`, stop and fix `.gitignore` before doing anything else.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clean up the temp password file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shred -u /tmp/nats-external-apps.pw 2>/dev/null || rm -f /tmp/nats-external-apps.pw
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Verify auth works over WebSocket
|
||||||
|
|
||||||
|
The `nats` CLI is not installed on this workstation and `python3 -c "import nats"` fails. Use a one-shot Node script with `nats.ws` in a scratch directory.
|
||||||
|
|
||||||
|
Give the previous deploy time to roll — the compose parse step re-executes with the new env var, and until then the container is running the old config without `external-apps`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Wait for the new container to be healthy**
|
||||||
|
|
||||||
|
Poll the container status until it's `running:healthy` (not `restarting:unknown` or the old `running:healthy` from the previous config). This is not the same as polling the build; the app-status endpoint is cheap.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_UUID=$(jq -r '.app.uuid' deploy.json)
|
||||||
|
until curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
|
||||||
|
"$COOLIFY_URL/api/v1/applications/$APP_UUID" \
|
||||||
|
| jq -er '.status | test("running:healthy")' >/dev/null; do
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "healthy"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: eventually prints `healthy`. If it never does within a few minutes, fall back to the "Gotchas" workflow in `CLAUDE.md` (check `docker logs` on the Coolify host — the config parser will have logged the reason if it failed).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Set up a scratch Node project and install `nats.ws`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/nats-verify && cd /tmp/nats-verify
|
||||||
|
npm init -y >/dev/null
|
||||||
|
npm install nats --no-save --silent
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `nats` installed under `/tmp/nats-verify/node_modules`. The Node `nats` package speaks WebSocket transports natively when passed a `wss://` URL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the verification script**
|
||||||
|
|
||||||
|
Create `/tmp/nats-verify/verify.mjs`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { connect } from "nats";
|
||||||
|
|
||||||
|
const pw = process.env.NATS_EXTERNAL_APPS_PASSWORD;
|
||||||
|
if (!pw) { console.error("missing NATS_EXTERNAL_APPS_PASSWORD"); process.exit(2); }
|
||||||
|
|
||||||
|
const nc = await connect({
|
||||||
|
servers: "wss://nats.tes.gd",
|
||||||
|
user: "external-apps",
|
||||||
|
pass: pw,
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = nc.info;
|
||||||
|
console.log("connected: server_id=" + info.server_id + " version=" + info.version);
|
||||||
|
|
||||||
|
const sub = nc.subscribe("verify.>");
|
||||||
|
nc.publish("verify.ping", new TextEncoder().encode("hello"));
|
||||||
|
|
||||||
|
for await (const m of sub) {
|
||||||
|
console.log("got: subject=" + m.subject + " data=" + new TextDecoder().decode(m.data));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nc.drain();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the verification with the credential from Coolify**
|
||||||
|
|
||||||
|
Fetch the value straight from Coolify (source of truth) — do not rely on any local file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_UUID=$(jq -r '.app.uuid' /app/data/home/nats_expert/deploy.json)
|
||||||
|
export NATS_EXTERNAL_APPS_PASSWORD=$(
|
||||||
|
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
|
||||||
|
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
|
||||||
|
| jq -r '.[] | select(.key=="NATS_EXTERNAL_APPS_PASSWORD") | .value'
|
||||||
|
)
|
||||||
|
[ -n "$NATS_EXTERNAL_APPS_PASSWORD" ] && echo "have credential" || { echo "no credential"; exit 1; }
|
||||||
|
node /tmp/nats-verify/verify.mjs
|
||||||
|
unset NATS_EXTERNAL_APPS_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
have credential
|
||||||
|
connected: server_id=… version=2.10.…
|
||||||
|
got: subject=verify.ping data=hello
|
||||||
|
```
|
||||||
|
|
||||||
|
If the connection fails with an authorization error, the compose parse substituted an empty string or the wrong value — re-run Task 1 step 5 and confirm the env var is present with a 64-char value, then re-trigger the deploy (Task 5).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Cross-check that the OLD users still work**
|
||||||
|
|
||||||
|
We must not have accidentally rewritten the `admin` or `volcanic-agents` entries. Run the same script twice more, once per user, reading their values the same way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for var in NATS_PASSWORD NATS_VOLCANIC_AGENTS_TOKEN; do
|
||||||
|
case $var in
|
||||||
|
NATS_PASSWORD) user=$(jq -r '.env.NATS_USER' /app/data/home/nats_expert/deploy.json) ;;
|
||||||
|
NATS_VOLCANIC_AGENTS_TOKEN) user=volcanic-agents ;;
|
||||||
|
esac
|
||||||
|
pw=$(curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
|
||||||
|
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
|
||||||
|
| jq -r --arg k "$var" '.[] | select(.key==$k) | .value')
|
||||||
|
NATS_USER="$user" NATS_EXTERNAL_APPS_PASSWORD="$pw" node -e '
|
||||||
|
import("nats").then(async ({connect}) => {
|
||||||
|
const nc = await connect({servers:"wss://nats.tes.gd", user:process.env.NATS_USER, pass:process.env.NATS_EXTERNAL_APPS_PASSWORD});
|
||||||
|
console.log("ok: " + process.env.NATS_USER);
|
||||||
|
await nc.drain();
|
||||||
|
}).catch(e => { console.error("FAIL " + process.env.NATS_USER + ": " + e.message); process.exit(1); });
|
||||||
|
'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
ok: admin
|
||||||
|
ok: volcanic-agents
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Clean up the scratch dir**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/nats-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
|
||||||
|
- The `nats` app on Coolify has env var `NATS_EXTERNAL_APPS_PASSWORD` set to a 64-hex-char value.
|
||||||
|
- The `main` branch has one new commit that adds the `external-apps` line to `docker-compose.yml` and updates `CLAUDE.md`.
|
||||||
|
- The new container is `running:healthy` on Coolify.
|
||||||
|
- Verification script connects as `external-apps` and round-trips a message.
|
||||||
|
- The pre-existing `admin` and `volcanic-agents` users still connect successfully.
|
||||||
|
- Local `credentials.local.json` contains the new entry and is still gitignored.
|
||||||
|
- No temp password file remains on disk.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Design: NATS `external-apps` user
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a third NATS user, `external-apps`, so third-party / external applications can connect to `wss://nats.tes.gd` with their own credential instead of borrowing the `admin` or `volcanic-agents` account.
|
||||||
|
|
||||||
|
## Scope decisions
|
||||||
|
|
||||||
|
- **Permissions:** unrestricted. Same shape as the existing `admin` and `volcanic-agents` users — no `permissions` block, so full publish/subscribe on any subject, JetStream management included. Scoped permissions are explicitly out of scope for this change; if we later need to lock down external callers, that's a follow-up.
|
||||||
|
- **Credential style:** password (not opaque bearer token). Env var is `NATS_EXTERNAL_APPS_PASSWORD`, following the `NATS_PASSWORD` convention used by `admin` rather than the `..._TOKEN` convention used by `volcanic-agents`.
|
||||||
|
- **Wire protocol / transport:** unchanged. The user connects the same way as the others (WebSockets at `wss://nats.tes.gd`, standard user+password auth on the wire).
|
||||||
|
|
||||||
|
## Change surface
|
||||||
|
|
||||||
|
1. **`docker-compose.yml`** — inside `configs.nats-conf.content`, append one entry to `authorization.users`:
|
||||||
|
```
|
||||||
|
{ user: "external-apps", password: "${NATS_EXTERNAL_APPS_PASSWORD}" }
|
||||||
|
```
|
||||||
|
Substitution happens at Docker Compose parse time, so NATS sees a static config on disk.
|
||||||
|
|
||||||
|
2. **Coolify env var** — add `NATS_EXTERNAL_APPS_PASSWORD` on the `nats` application (project `Volcanic MFEs`, app UUID from local `deploy.json`). Value: a fresh random secret (e.g. `openssl rand -hex 32`).
|
||||||
|
|
||||||
|
3. **`credentials.local.json`** — add a third entry under `users`:
|
||||||
|
```json
|
||||||
|
"external-apps": { "user": "external-apps", "password": "…", "note": "…" }
|
||||||
|
```
|
||||||
|
Mirrors the Coolify env value. File is gitignored.
|
||||||
|
|
||||||
|
4. **`CLAUDE.md`** — update the two spots that enumerate users so the doc stays in sync:
|
||||||
|
- The "Connection details (for agents managing this NATS)" intro list.
|
||||||
|
- The "Auth" bullet under "What's inside the server".
|
||||||
|
Mention that `external-apps` is the intended account for third-party callers, unrestricted for now.
|
||||||
|
|
||||||
|
5. **Deploy** — commit the compose + doc changes on `main`, then fire-and-forget `POST /api/v1/deploy?uuid=<app-uuid>` on Coolify. Do not poll.
|
||||||
|
|
||||||
|
## Ordering constraint
|
||||||
|
|
||||||
|
Set the Coolify env var **before** pushing the compose change. Otherwise the compose parser substitutes an empty string, the config lands as `password: ""`, and until the next deploy anyone who connects with empty credentials for user `external-apps` is authenticated. Order:
|
||||||
|
|
||||||
|
1. PATCH the env var into Coolify.
|
||||||
|
2. Commit + push the compose + doc changes.
|
||||||
|
3. Trigger the deploy.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After the deploy triggers, connect from a workstation with the `nats` CLI as the new user and confirm auth works:
|
||||||
|
|
||||||
|
```
|
||||||
|
nats --server=wss://nats.tes.gd \
|
||||||
|
--user=external-apps --password="$NATS_EXTERNAL_APPS_PASSWORD" \
|
||||||
|
server info
|
||||||
|
```
|
||||||
|
|
||||||
|
A `Server ID` response = success. An auth error = the env var didn't land or the substitution failed; check `docker logs` on the Coolify host per the existing "Gotchas" section of `CLAUDE.md`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Scoped/restricted permissions for external callers (deliberate — noted above).
|
||||||
|
- Rotating the existing `admin` or `volcanic-agents` credentials.
|
||||||
|
- Adding a UI or self-service flow for provisioning further users.
|
||||||
Reference in New Issue
Block a user