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(