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>
263 lines
9.3 KiB
Markdown
263 lines
9.3 KiB
Markdown
# StoreHealthAnalyzer Remote Agent — Docker
|
||
|
||
Standalone container for the remote agent that proxies SIW / MDM requests
|
||
from an internal network back to the main StoreHealthAnalyzer server over an
|
||
authenticated WebSocket.
|
||
|
||
## What ships in the image
|
||
|
||
- `node:22-alpine` runtime with [`tini`](https://github.com/krallin/tini) as
|
||
PID 1 so `docker stop` reaches Node's SIGTERM handler and the websocket
|
||
closes cleanly.
|
||
- Just the agent script (`remoteAgent.js`) and its three runtime deps
|
||
(`ws`, `axios`, `dotenv`). No bot framework, no Express, no test tooling.
|
||
- Runs as the unprivileged `node` user.
|
||
|
||
Final image size is small (roughly 60–80 MB depending on architecture),
|
||
compared to ~180 MB if the root `package.json` were installed.
|
||
|
||
## Files in this folder
|
||
|
||
| File | Purpose |
|
||
| --- | --- |
|
||
| `Dockerfile` | Two-stage build (`deps` → `runtime`). Uses the repo root as the build context so it can pull in `remoteAgent.js`. |
|
||
| `package.json` | Minimal manifest: `ws`, `axios`, `dotenv`. |
|
||
| `docker-compose.yml` | Convenience wrapper for **local** builds; run from the repo root. |
|
||
| `.env.example` | Copy to `.env`, fill in `WS_URL` + `WS_TOKEN`. |
|
||
| `package.sh` | Builds the image and produces a self-contained deploy ZIP under `dist/`. |
|
||
| `deploy/` | Files that get bundled into the deploy ZIP (runtime compose, `install.sh`, remote README). |
|
||
| `dist/` | Generated ZIPs (gitignored). |
|
||
|
||
## Prerequisites
|
||
|
||
- Docker 24+ (BuildKit is default and required for the `syntax=` line).
|
||
- The main StoreHealthAnalyzer server reachable from the host that will run
|
||
this container (outbound only — the agent doesn't listen on any port).
|
||
- A shared `WS_TOKEN` value matching the one configured on the server.
|
||
|
||
## Build
|
||
|
||
Always build from the **repository root** — the Dockerfile expects that
|
||
context so it can copy `remoteAgent.js`:
|
||
|
||
```bash
|
||
# From the repo root
|
||
docker build \
|
||
-f docker/remote-agent/Dockerfile \
|
||
-t sha-remote-agent:latest \
|
||
.
|
||
```
|
||
|
||
Tag with a version too if you plan to ship it to a registry:
|
||
|
||
```bash
|
||
docker tag sha-remote-agent:latest ghcr.io/<owner>/sha-remote-agent:1.0.0
|
||
docker push ghcr.io/<owner>/sha-remote-agent:1.0.0
|
||
```
|
||
|
||
## Configure
|
||
|
||
```bash
|
||
cp docker/remote-agent/.env.example docker/remote-agent/.env
|
||
$EDITOR docker/remote-agent/.env
|
||
```
|
||
|
||
Required values:
|
||
|
||
- `WS_URL` — websocket URL of the main server (e.g. `wss://sha.example.com/ws`).
|
||
- `WS_TOKEN` — shared secret matching the server's `WS_TOKEN`.
|
||
|
||
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
|
||
|
||
```bash
|
||
docker run --rm -it \
|
||
--name sha-remote-agent \
|
||
--env-file docker/remote-agent/.env \
|
||
sha-remote-agent:latest
|
||
```
|
||
|
||
Add `-d` for detached mode and `--restart unless-stopped` if you want it to
|
||
auto-recover on host reboots.
|
||
|
||
### Docker Compose (recommended)
|
||
|
||
```bash
|
||
# From the repo root
|
||
docker compose -f docker/remote-agent/docker-compose.yml up -d --build
|
||
|
||
# Tail logs
|
||
docker compose -f docker/remote-agent/docker-compose.yml logs -f
|
||
|
||
# Stop
|
||
docker compose -f docker/remote-agent/docker-compose.yml down
|
||
```
|
||
|
||
Compose sets `restart: unless-stopped` and 10 MB / 3-file JSON log rotation
|
||
so the container survives host restarts and doesn't fill the disk with
|
||
reconnect chatter.
|
||
|
||
## Deploy elsewhere (ZIP bundle — recommended)
|
||
|
||
For hosts you can't reach with a registry, use the packaging script — it
|
||
produces a single ZIP with the image, a runtime compose file, an installer,
|
||
and a checksum:
|
||
|
||
```bash
|
||
# From the repo root — defaults to building for linux/amd64
|
||
npm run agent:package
|
||
# or, equivalently:
|
||
./docker/remote-agent/package.sh
|
||
```
|
||
|
||
Output lands in `docker/remote-agent/dist/sha-remote-agent-<version>.zip`
|
||
(the folder is gitignored). Transfer that one file to the remote host and:
|
||
|
||
```bash
|
||
unzip sha-remote-agent-<version>.zip
|
||
cd sha-remote-agent-<version>
|
||
./install.sh # loads the image, seeds .env, starts the container
|
||
```
|
||
|
||
Full remote-host instructions ship inside the ZIP as `README.md` and are
|
||
also visible here for reference: [`deploy/README.md`](deploy/README.md).
|
||
|
||
The script tags the image both `sha-remote-agent:<version>` and
|
||
`sha-remote-agent:latest`, so local `docker compose` still works after
|
||
packaging.
|
||
|
||
### Target-platform selection (very important on Apple Silicon)
|
||
|
||
Docker images are architecture-specific. If you build on an Apple Silicon
|
||
Mac with `docker build`, you get an `arm64` image — which will **fail to
|
||
start** on a typical x86_64 Linux server (RHEL, Rocky, CentOS, Ubuntu)
|
||
with `exec /sbin/tini: exec format error`. The packaging script uses
|
||
`docker buildx build --platform ...` to avoid that.
|
||
|
||
The default target is `linux/amd64`. Override with `--platform` when your
|
||
remote host is different:
|
||
|
||
```bash
|
||
# x86_64 Linux (the default — Linux RH / Rocky / CentOS / Ubuntu on Intel/AMD)
|
||
./docker/remote-agent/package.sh --platform linux/amd64
|
||
|
||
# ARM Linux (Raspberry Pi 4/5, Ampere servers, etc.)
|
||
./docker/remote-agent/package.sh --platform linux/arm64
|
||
|
||
# For local testing on Apple Silicon
|
||
./docker/remote-agent/package.sh --platform linux/arm64
|
||
```
|
||
|
||
`install.sh` on the remote host also detects `image_arch != host_arch` and
|
||
refuses to start with a clear message pointing at the right rebuild command,
|
||
so a wrong-arch ZIP fails fast instead of after `docker run`.
|
||
|
||
Cross-building requires `docker buildx` — Docker Desktop ships it by
|
||
default; on Linux install the `docker-buildx-plugin` package if it isn't
|
||
already there.
|
||
|
||
#### How the script avoids the "silent arm64 image" trap
|
||
|
||
The default buildx builder on Docker Desktop uses the `docker` driver, which
|
||
is bound to the daemon's native platform. Passing `--platform linux/amd64`
|
||
to it from an Apple Silicon host can silently produce an `arm64` image (or,
|
||
depending on the Desktop version, ignore the flag with only a warning). To
|
||
sidestep that, `package.sh`:
|
||
|
||
1. Creates a dedicated `sha-remote-agent-builder` with the `docker-container`
|
||
driver on first run (isolated BuildKit instance, cross-arch capable).
|
||
2. Best-effort installs `tonistiigi/binfmt` QEMU handlers when the target
|
||
platform doesn't match the host.
|
||
3. Writes the image directly to a tarball via
|
||
`--output type=docker,dest=...` instead of `--load` + `docker save`,
|
||
so the local daemon's cross-arch storage limits are irrelevant.
|
||
4. **Verifies** the produced image's `Architecture` against `--platform`
|
||
after the build and aborts the packaging run if they disagree — so a
|
||
broken ZIP can never leave the build host.
|
||
|
||
If the verification ever fires, install binfmt explicitly and rebuild:
|
||
|
||
```bash
|
||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||
./docker/remote-agent/package.sh --platform linux/amd64
|
||
```
|
||
|
||
### Manual export (without the packaging script)
|
||
|
||
If you'd rather do it by hand:
|
||
|
||
```bash
|
||
# Export from the build host
|
||
docker save sha-remote-agent:latest | gzip > sha-remote-agent.tar.gz
|
||
|
||
# Import on the target host
|
||
gunzip -c sha-remote-agent.tar.gz | docker load
|
||
|
||
# On the target: only .env is needed; no source tree required
|
||
docker run --rm -d \
|
||
--name sha-remote-agent \
|
||
--restart unless-stopped \
|
||
--env-file /path/to/remote-agent.env \
|
||
sha-remote-agent:latest
|
||
```
|
||
|
||
## Networking
|
||
|
||
The agent is a **websocket client** — nothing listens inside the container,
|
||
so there's no port to publish. You just need outbound network access from
|
||
the container to:
|
||
|
||
- The main StoreHealthAnalyzer server (`WS_URL`).
|
||
- Whatever internal APIs the agent proxies for (SIW, MDM, ...).
|
||
|
||
If the internal APIs live only on the container host's network (e.g. a
|
||
private VLAN accessible only from the host), uncomment `network_mode: host`
|
||
in `docker-compose.yml` (Linux only). On Docker Desktop for macOS/Windows,
|
||
prefer running the container on a user-defined bridge network that has route
|
||
access to the required endpoints.
|
||
|
||
## Verifying it works
|
||
|
||
Startup logs from a healthy agent look like:
|
||
|
||
```
|
||
🔄 Connecting to wss://sha.example.com/ws...
|
||
✅ Remote Agent connected to StoreHealthAnalyzer
|
||
```
|
||
|
||
On the main server side you should see a matching `Remote agent connected`
|
||
log line. From then on, `st [number]` commands that need SIW data will
|
||
succeed instead of degrading to the "Remote agent is not connected" banner.
|
||
|
||
## Signals and shutdown
|
||
|
||
The agent handles `SIGTERM` and `SIGINT` explicitly (see
|
||
`remoteAgent.js`), closing the websocket before exiting. Because we run
|
||
`tini` as PID 1, `docker stop` (which sends `SIGTERM` then kills after the
|
||
grace period) reaches Node correctly and the exit is clean.
|
||
|
||
## Rebuilding after code changes
|
||
|
||
Because `remoteAgent.js` is copied in during the runtime stage, changing
|
||
the script requires a rebuild (`--build` with compose, or a fresh
|
||
`docker build`). The `deps` stage is cached whenever `package.json` is
|
||
unchanged, so incremental rebuilds are fast.
|