feat(agent): support corporate CA bundles for wss:// TLS verification

Add WS_TLS_CA_FILE and WS_TLS_REJECT_UNAUTHORIZED so the remote agent can
trust internal PKI chains instead of failing with "unable to verify the
first certificate". Apply the same TLS options to proxied HTTPS calls and
document CA bundle mounting in compose and deploy READMEs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joseph McQueen 2026-07-14 14:00:46 -04:00
parent 0506f39bf3
commit 7081221352
6 changed files with 123 additions and 5 deletions

View file

@ -15,3 +15,18 @@ WS_URL=wss://storehealthanalyzer.example.com/ws
# on the server side. Generate a strong random value once and rotate it if
# you suspect it's been exposed.
WS_TOKEN=change_me_to_a_long_random_value
# --- TLS (only needed for wss:// with a corporate / private CA) -----------
#
# Preferred: provide your root + intermediate CA(s) as a single PEM bundle.
# Concatenate them if you have separate files:
# cat root-ca.pem intermediate-ca.pem > ca-bundle.pem
# Then mount the file into the container (see docker-compose.yml) and set:
# WS_TLS_CA_FILE=/certs/ca-bundle.pem
#
# Alternative: Node's built-in NODE_EXTRA_CA_CERTS also works and applies to
# both the websocket and proxied HTTPS calls:
# NODE_EXTRA_CA_CERTS=/certs/ca-bundle.pem
#
# Last resort only (trusted networks): disable verification entirely.
# WS_TLS_REJECT_UNAUTHORIZED=false

View file

@ -70,6 +70,21 @@ Required values:
Both `.env` and `.env.*` are excluded by the top-level `.dockerignore`, so
the file is never baked into the image.
### TLS / corporate CA (wss://)
If `WS_URL` uses `wss://` and the server presents a cert signed by an
internal CA, Node will fail with `unable to verify the first certificate`.
**Provide the CA chain** — don't permanently disable verification.
1. Get the **root** and **intermediate** CA certs (PEM) from your PKI team.
2. Bundle them: `cat root-ca.pem intermediate-ca.pem > certs/ca-bundle.pem`
3. Mount into the container and set `WS_TLS_CA_FILE=/certs/ca-bundle.pem`
(see the commented `volumes` block in `docker-compose.yml`).
`NODE_EXTRA_CA_CERTS` pointing at the same PEM file also works. As a
last resort on a trusted network only, set
`WS_TLS_REJECT_UNAUTHORIZED=false` in `.env`.
## Run
### Docker CLI

View file

@ -123,3 +123,25 @@ it — handy if you need to roll back quickly.
- **Agent never connects** — check `WS_URL` (correct hostname, correct
scheme `ws://` vs `wss://`) and that there's no firewall between this
host and the server.
- **`unable to verify the first certificate`** — the server's TLS cert is
signed by a corporate/private CA that Node doesn't trust by default. **Do
not** leave this broken; provide the CA chain instead of ignoring TLS:
1. Ask your PKI team (or export from the browser) for the **root** and
**intermediate** CA certificates in PEM format.
2. Concatenate into one bundle:
```bash
cat root-ca.pem intermediate-ca.pem > certs/ca-bundle.pem
```
3. On this host, next to `docker-compose.yml`:
```bash
mkdir -p certs
# copy ca-bundle.pem into certs/
```
4. Uncomment the `volumes` + `environment` block in `docker-compose.yml`
(or add to `.env`: `WS_TLS_CA_FILE=/certs/ca-bundle.pem` and mount the
file in compose).
5. `docker compose up -d` (or `./install.sh` on first deploy).
Temporary workaround only on a fully trusted network:
`WS_TLS_REJECT_UNAUTHORIZED=false` in `.env`. This disables verification
for both the websocket and any HTTPS APIs the agent proxies.

View file

@ -16,6 +16,14 @@ services:
restart: unless-stopped
env_file:
- .env
# If WS_URL uses wss:// with a corporate/private CA, place your root +
# intermediate PEM bundle next to this compose file and uncomment:
#
# volumes:
# - ./certs/ca-bundle.pem:/certs/ca-bundle.pem:ro
# environment:
# WS_TLS_CA_FILE: /certs/ca-bundle.pem
#
# The agent is a websocket CLIENT — no ports to publish.
stop_signal: SIGTERM
stop_grace_period: 10s

View file

@ -19,6 +19,14 @@ services:
restart: unless-stopped
env_file:
- .env
# If WS_URL uses wss:// with a corporate/private CA, place your root +
# intermediate PEM bundle in docker/remote-agent/certs/ and uncomment:
#
# volumes:
# - ./certs/ca-bundle.pem:/certs/ca-bundle.pem:ro
# environment:
# WS_TLS_CA_FILE: /certs/ca-bundle.pem
#
# The agent is a websocket client — it doesn't listen on any port, so
# there's nothing to publish. It just needs outbound network access to:
# - the main StoreHealthAnalyzer server (WS_URL)

View file

@ -1,3 +1,5 @@
const fs = require('fs');
const https = require('https');
const WebSocket = require('ws');
const axios = require('axios');
require('dotenv').config();
@ -19,13 +21,60 @@ let reconnectAttempts = 0;
let shuttingDown = false;
/**
* If WS_TOKEN is provided, send it as an Authorization: Bearer header so the
* secret stays out of access logs. (The server still accepts the legacy
* ?token=... query parameter for backward compatibility.)
* TLS options for wss:// and for HTTPS calls the agent proxies (SIW, MDM, …).
*
* Preferred: mount your corporate root + intermediate CA(s) as a PEM bundle
* and point WS_TLS_CA_FILE at it. That keeps verification on.
*
* Escape hatch (trusted networks only): WS_TLS_REJECT_UNAUTHORIZED=false
*/
function readTlsOptions() {
const tls = {};
const caFile = process.env.WS_TLS_CA_FILE;
if (caFile) {
try {
tls.ca = fs.readFileSync(caFile, 'utf8');
console.log(`🔒 Using custom CA bundle: ${caFile}`);
} catch (err) {
console.error(`❌ Failed to read WS_TLS_CA_FILE (${caFile}): ${err.message}`);
process.exit(1);
}
}
if (process.env.WS_TLS_REJECT_UNAUTHORIZED !== undefined) {
const reject =
process.env.WS_TLS_REJECT_UNAUTHORIZED !== 'false' &&
process.env.WS_TLS_REJECT_UNAUTHORIZED !== '0';
tls.rejectUnauthorized = reject;
if (!reject) {
console.warn(
'⚠️ WS_TLS_REJECT_UNAUTHORIZED=false — TLS certificate verification is DISABLED.'
);
}
}
return Object.keys(tls).length ? tls : null;
}
const TLS_OPTIONS = readTlsOptions();
const HTTPS_AGENT = TLS_OPTIONS ? new https.Agent(TLS_OPTIONS) : undefined;
/**
* WebSocket client options: optional Bearer auth + optional TLS trust config.
*/
function buildClientOptions() {
if (!WS_TOKEN) return undefined;
return { headers: { Authorization: `Bearer ${WS_TOKEN}` } };
const options = {};
if (WS_TOKEN) {
options.headers = { Authorization: `Bearer ${WS_TOKEN}` };
}
if (TLS_OPTIONS) {
Object.assign(options, TLS_OPTIONS);
}
return Object.keys(options).length ? options : undefined;
}
function connect() {
@ -53,6 +102,7 @@ function connect() {
auth: request.auth || undefined,
data: request.body || undefined,
timeout: PROXY_TIMEOUT_MS,
httpsAgent: HTTPS_AGENT,
});
ws.send(