From 7081221352746731808a445a5785ae69a00e25fd Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Tue, 14 Jul 2026 14:00:46 -0400 Subject: [PATCH] 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 --- docker/remote-agent/.env.example | 15 +++++ docker/remote-agent/README.md | 15 +++++ docker/remote-agent/deploy/README.md | 22 +++++++ docker/remote-agent/deploy/docker-compose.yml | 8 +++ docker/remote-agent/docker-compose.yml | 8 +++ remoteAgent.js | 60 +++++++++++++++++-- 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/docker/remote-agent/.env.example b/docker/remote-agent/.env.example index a8e3dbb..ca1a70f 100644 --- a/docker/remote-agent/.env.example +++ b/docker/remote-agent/.env.example @@ -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 diff --git a/docker/remote-agent/README.md b/docker/remote-agent/README.md index cb323ba..4be707a 100644 --- a/docker/remote-agent/README.md +++ b/docker/remote-agent/README.md @@ -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 diff --git a/docker/remote-agent/deploy/README.md b/docker/remote-agent/deploy/README.md index a13651d..3dfea54 100644 --- a/docker/remote-agent/deploy/README.md +++ b/docker/remote-agent/deploy/README.md @@ -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. diff --git a/docker/remote-agent/deploy/docker-compose.yml b/docker/remote-agent/deploy/docker-compose.yml index 402dd55..8fe5d6d 100644 --- a/docker/remote-agent/deploy/docker-compose.yml +++ b/docker/remote-agent/deploy/docker-compose.yml @@ -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 diff --git a/docker/remote-agent/docker-compose.yml b/docker/remote-agent/docker-compose.yml index f61bc16..af1dcab 100644 --- a/docker/remote-agent/docker-compose.yml +++ b/docker/remote-agent/docker-compose.yml @@ -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) diff --git a/remoteAgent.js b/remoteAgent.js index 9af46d4..e668808 100644 --- a/remoteAgent.js +++ b/remoteAgent.js @@ -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(