From 4f9ebdb5fbdd344c11d0fdca6fe7d60854a25533 Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Fri, 3 Jul 2026 10:05:05 -0400 Subject: [PATCH] Rework DECT relay bundle to ship a pre-built Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous packager (scripts/packageDectRelayAgent.js) shipped a source-only bundle and expected the DC host to build the image with `docker compose up --build`. That fails hard in corporate DCs with TLS-intercepted egress: Alpine's apk fetch of dl-cdn.alpinelinux.org can't verify the intercepted certificate ("apk: TLS: server certificate not trusted"), and npm install would fail the same way if apk had succeeded. New approach: build the image ONCE on the dev machine (where TLS works), save it as a gzipped tarball, and ship a ZIP whose install step is `docker load` + `docker compose up -d`. Zero network calls inside the DC container, ever. Bundling (dev-machine): - dect-relay-agent/bundle.sh: build → docker save → gzip → zip. Auto-derives version from package.json, records git sha + dirty flag + build date into image labels. Cross-arch friendly (--platform=linux/amd64 by default; --platform linux/arm64 for ARM DCs). Output: dect-relay-agent-bundle-.zip at repo root (typically 40-60MB). - dect-relay-agent/Dockerfile: multi-stage node:20-alpine build. No apk add. No runtime npm install. Non-root `node` user (uid 1000). Node handles SIGTERM natively via index.js handlers, so no tini/dumb-init needed. Designed to build from the REPO ROOT (not the agent folder) because the agent imports shared modules from ../integrations/cisco-dect and ../utils. - dect-relay-agent/Dockerfile.dockerignore: per-Dockerfile ignore (BuildKit ≥ 23.0) with a whitelist that keeps the build context to ~50KB. Older Docker daemons fall through to the repo-root .dockerignore, which already excludes secrets — nothing sensitive can leak either way. - package.json: `npm run package:relay` now invokes bundle.sh. Runtime (DC-host): - dect-relay-agent/docker-compose.yml: pins IMAGE_TAG from .env (install.sh writes it there — never falls back to :latest), reads the rest of the config via env_file, restart: unless-stopped, host networking (needed to reach 10.x/8 without userland proxy translation, and the agent doesn't listen on anything). Hardened: read_only: true rootfs with a 16MB /tmp tmpfs, cap_drop: ALL, no-new-privileges, log rotation at 10MB × 5 files. - dect-relay-agent/install.sh: preflight (docker + compose present, daemon reachable, bundle files intact), docker load, pin loaded tag into .env, validate .env has the three required values not still set to placeholder strings, docker compose up -d, tail last 40 log lines. Idempotent — safe to re-run on upgrades. Cleanup: - scripts/packageDectRelayAgent.js: deleted (superseded). - .gitignore: drops the scripts/* + !packageDectRelayAgent.js dance since we no longer need to whitelist that one file; add pattern for the datestamped bundle zips + staging dirs at repo root. - dect-relay-agent/README.md: replaces the deploy section with the new dev-machine-build → DC-host-load workflow, plus a troubleshooting section keyed on the exact error messages seen during the failed in-DC build (TLS cert not trusted, docker perm denied, DIGEST_401). Verified: all 113 existing tests still pass. Docker build itself requires a Docker daemon (dev machine) so can't be exercised in this sandbox — the bash scripts pass `bash -n` syntax checks. --- .gitignore | 22 +- dect-relay-agent/.dockerignore | 20 -- dect-relay-agent/Dockerfile | 135 +++++---- dect-relay-agent/Dockerfile.dockerignore | 30 ++ dect-relay-agent/README.md | 73 ++++- dect-relay-agent/bundle.sh | 204 +++++++++++++ dect-relay-agent/docker-compose.yml | 107 ++++--- dect-relay-agent/install.sh | 130 +++++++++ package.json | 2 +- scripts/packageDectRelayAgent.js | 346 ----------------------- 10 files changed, 573 insertions(+), 496 deletions(-) delete mode 100644 dect-relay-agent/.dockerignore create mode 100644 dect-relay-agent/Dockerfile.dockerignore create mode 100755 dect-relay-agent/bundle.sh create mode 100755 dect-relay-agent/install.sh delete mode 100755 scripts/packageDectRelayAgent.js diff --git a/.gitignore b/.gitignore index 5accf54..b42463a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,16 +26,7 @@ storage/ # Dev / test artifacts (local only) characterization-runs/ -# NOTE on ordering: `scripts/*` (glob) rather than `scripts/` (dir -# exclusion) because git gitignore semantics forbid re-including a -# file inside an excluded directory. Using `scripts/*` still excludes -# every file inside scripts/ by default, but leaves the door open for -# `!` overrides below. -scripts/* -# Un-ignored: the DECT relay packager is part of the deploy workflow -# and needs to be tracked so anyone with a fresh clone can build the -# data-center bundle via `npm run package:relay`. -!scripts/packageDectRelayAgent.js +scripts/ characterize-*.js # Backup & temp files @@ -57,11 +48,12 @@ dist/ build/ coverage/ .nyc_output/ -# Temporary staging dir created by scripts/packageDectRelayAgent.js. -# The script cleans this up in a finally block, but a SIGKILL can -# leave it behind. Ignoring means an interrupted run doesn't leak -# staged files into future git commits. -.package-relay-tmp/ + +# DECT relay agent deploy bundles produced by dect-relay-agent/bundle.sh. +# The datestamped zip lands at repo root and shouldn't be committed — +# it's ~40MB (Docker image tarball) and rebuildable on demand. +dect-relay-agent-bundle-*.zip +dect-relay-agent-bundle-*/ # Docker / Misc docker-compose.override.yml diff --git a/dect-relay-agent/.dockerignore b/dect-relay-agent/.dockerignore deleted file mode 100644 index ed65481..0000000 --- a/dect-relay-agent/.dockerignore +++ /dev/null @@ -1,20 +0,0 @@ -# Everything the bundle-side Dockerfile does NOT need. -# The bundle produced by scripts/packageDectRelayAgent.js only ever -# contains: workspace/, Dockerfile, docker-compose.yml, .env.example, -# .dockerignore, and README.deploy.md — so this file is mostly -# defensive (belt-and-suspenders against a stray copy or an operator -# running `docker build` in a manually-assembled bundle). - -# Never ship secrets or local overrides. -.env -.env.* -!.env.example - -# Never ship a local node_modules — the Dockerfile installs fresh. -**/node_modules - -# Never ship logs, dev artifacts, or IDE cruft. -**/*.log -**/.DS_Store -**/.git -**/.gitignore diff --git a/dect-relay-agent/Dockerfile b/dect-relay-agent/Dockerfile index 44a7553..e2d8e02 100644 --- a/dect-relay-agent/Dockerfile +++ b/dect-relay-agent/Dockerfile @@ -1,78 +1,95 @@ # syntax=docker/dockerfile:1.6 # -# DECT Relay Agent — production container image +# DECT relay agent — production image. # -# Build context: the bundle produced by scripts/packageDectRelayAgent.js. -# The bundle contains a `workspace/` directory that mirrors just enough of -# the parent repo to satisfy the agent's `../integrations/...` and -# `../utils/...` imports without any source rewriting: +# BUILD CONTEXT: the REPO ROOT (not this folder). The agent imports +# `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js`, so +# we mirror the repo's layout under /workspace/ inside the image and +# the relative paths just work. # -# workspace/ -# dect-relay-agent/ ← WORKDIR at runtime -# package.json -# index.js -# integrations/cisco-dect/{client,probes,statusXml}.js -# utils/httpDigestAuth.js +# BUILD FROM REPO ROOT: +# docker build \ +# --platform=linux/amd64 \ +# -f dect-relay-agent/Dockerfile \ +# -t collabsupport/dect-relay-agent:0.1.0 \ +# . # -# Building this Dockerfile in the raw repo (`docker build dect-relay-agent/`) -# WILL NOT WORK — the shared modules live one directory up and would be -# outside the build context. Always build from a bundle produced by the -# packager. +# Or use bundle.sh which wraps this + `docker save` + zip. +# +# WHY NO `apk add`: corporate DCs commonly TLS-intercept HTTPS. Alpine's +# apk fetch of dl-cdn.alpinelinux.org fails inside the container when +# the CA chain includes a proxy cert the container doesn't trust. We +# avoid the problem entirely by not fetching anything from Alpine at +# build time. Signal handling (SIGTERM / SIGINT / SIGUSR2) is done in +# index.js so we don't need tini/dumb-init. +# +# WHY NO RUNTIME `npm install`: the bundle.sh workflow builds this +# image ONCE outside the DC (where npm registry access works), saves +# it as a tarball, and ships the tarball. The DC only runs +# `docker load` + `docker compose up -d` — zero network calls beyond +# the initial docker load. -# ── Stage 1: install prod deps ────────────────────────────────────── -# node:20-alpine keeps the final image ~55MB. Alpine's musl libc has -# been fine for this agent's plain JS + axios + ws footprint (no -# native modules) but if you ever add one that needs glibc, switch to -# node:20-slim. -FROM node:20-alpine AS deps -WORKDIR /build +# ─── Stage 1: builder ──────────────────────────────────────────────── +# Installs prod deps in a full node image (has python/build-essentials +# just in case a native module needs building — currently `ws` ships +# pre-built optional deps for common arches but we keep the option +# open for future deps). -# Only copy the agent's manifest first so this layer caches across +FROM node:20-alpine AS builder + +WORKDIR /workspace/dect-relay-agent + +# Copy just the package manifest first so this layer caches across # code-only changes. -COPY workspace/dect-relay-agent/package.json ./package.json +COPY dect-relay-agent/package.json ./package.json -# `npm install --omit=dev` because there's no committed lockfile -# (the agent has three dependencies; every deploy resolving the same -# `^` ranges is acceptable for this operational tool). Add -# --ignore-scripts to refuse arbitrary lifecycle-script execution from -# the registry — none of our current deps use lifecycle scripts. +# Install only production deps. --ignore-scripts because we don't run +# arbitrary postinstall from transitive deps in the container build; +# any needed build steps are pinned in this Dockerfile. RUN npm install --omit=dev --ignore-scripts \ - && npm cache clean --force + && npm cache clean --force + +# ─── Stage 2: runtime ──────────────────────────────────────────────── +# Same base as builder, but only the artifacts we actually need at +# run time (node_modules + agent source + shared integrations + utils). -# ── Stage 2: runtime ──────────────────────────────────────────────── FROM node:20-alpine AS runtime -# tini gives us proper PID-1 signal handling (SIGTERM propagates -# cleanly to node so our graceful shutdown path in index.js actually -# runs on `docker stop`). -RUN apk add --no-cache tini +# node:20-alpine ships a `node` user (uid 1000) that we can just use — +# no need to install anything extra. Running as a non-root user is a +# baseline hardening we get essentially for free. +USER node -# Non-root user. UID/GID pinned so bind-mounted volumes (if any) are -# predictable across hosts. -RUN addgroup -S -g 1500 dect \ - && adduser -S -u 1500 -G dect -H -s /sbin/nologin dect +# Match the repo layout so relative imports (`../integrations/...`) +# resolve exactly as they do in development. +WORKDIR /workspace/dect-relay-agent -WORKDIR /app +# Ship the node_modules we built in stage 1. Ownership goes to `node` +# so the process can read them without needing root. +COPY --from=builder --chown=node:node /workspace/dect-relay-agent/node_modules ./node_modules -# Copy the shared workspace tree — the agent's imports of -# `../integrations/...` and `../utils/...` resolve exactly as they do -# in the source repo. See the bundle layout comment at the top of -# this file. -COPY --chown=dect:dect workspace/ ./ +# Agent source + manifest. +COPY --chown=node:node dect-relay-agent/package.json ./package.json +COPY --chown=node:node dect-relay-agent/index.js ./index.js -# Bring in the deps that stage 1 resolved. -COPY --from=deps --chown=dect:dect /build/node_modules ./dect-relay-agent/node_modules +# Shared modules the agent imports from the parent workspace. +COPY --chown=node:node integrations/cisco-dect /workspace/integrations/cisco-dect +COPY --chown=node:node utils/httpDigestAuth.js /workspace/utils/httpDigestAuth.js -USER dect -WORKDIR /app/dect-relay-agent +# Optional metadata that shows up in `docker inspect` output — useful +# in the DC for "which build am I running?" without needing to poke +# inside the container. +ARG AGENT_VERSION=dev +ARG BUILD_DATE +ARG GIT_COMMIT +LABEL org.opencontainers.image.title="dect-relay-agent" \ + org.opencontainers.image.description="Data-center-resident WSS bridge from CollabSupport bot (cloud) to Cisco DBS-210 DECT base stations on 10.x/8" \ + org.opencontainers.image.version="${AGENT_VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${GIT_COMMIT}" \ + org.opencontainers.image.source="https://git.joesjavajoint.com/jmcqueen/collabSupport" -# Runtime config comes from `docker compose` (--env-file .env) or -# `docker run --env-file ...`. Never bake secrets into the image. -# The agent process itself validates required vars and exits 1 if any -# are missing (see assertConfig() in index.js). - -# tini reaps zombies + forwards SIGTERM. `-g` puts tini in the same -# process group as node so `docker stop` also delivers SIGTERM to -# child processes if any are spawned in the future. -ENTRYPOINT ["/sbin/tini", "-g", "--"] -CMD ["node", "index.js"] +# Node handles SIGTERM natively when the process installs handlers +# (which we do in index.js). --enable-source-maps improves stack +# traces if something crashes at runtime — cheap and always-on. +CMD ["node", "--enable-source-maps", "index.js"] diff --git a/dect-relay-agent/Dockerfile.dockerignore b/dect-relay-agent/Dockerfile.dockerignore new file mode 100644 index 0000000..137d873 --- /dev/null +++ b/dect-relay-agent/Dockerfile.dockerignore @@ -0,0 +1,30 @@ +# Per-Dockerfile ignore, picked up by BuildKit ≥ 23.0 when this +# Dockerfile is used (see https://docs.docker.com/build/concepts/context/#filename-and-location). +# For older Docker daemons, the repo-root /.dockerignore is used +# instead (it already excludes node_modules, .env*, logs/, etc., so +# nothing sensitive would leak — this file is a size/speed win, not +# a security requirement). +# +# The build context is the REPO ROOT. We whitelist only the paths +# the Dockerfile actually COPYs. That keeps the transferred context +# tiny (a few dozen KB instead of the whole repo) and makes builds +# noticeably faster on slow disks / VPN uplinks. + +* + +# ─── Whitelist (paths the Dockerfile needs) ───────────────────────── +!dect-relay-agent/package.json +!dect-relay-agent/index.js +!integrations/cisco-dect/** +!utils/httpDigestAuth.js + +# ─── Never-ship, even inside whitelisted trees ────────────────────── +**/node_modules +**/.env +**/.env.* +!**/.env.example +**/*.log +**/logs +**/.git +**/.DS_Store +**/coverage diff --git a/dect-relay-agent/README.md b/dect-relay-agent/README.md index 6464ea9..fd30e7f 100644 --- a/dect-relay-agent/README.md +++ b/dect-relay-agent/README.md @@ -21,17 +21,48 @@ There are two ways to run this. Pick one based on where you're deploying. ### 1. Docker container in the data center (recommended for production) -Run the packager on your dev machine to produce a self-contained zip that includes the shared modules the agent imports: +The DC host makes **zero network calls** during install — the image is built on your dev machine, saved as a tarball, and shipped inside a self-contained ZIP. This sidesteps the TLS-interception problem that breaks `apk add` and `npm install` inside containers on corporate networks. + +**On your dev machine (with Docker Desktop / internet access):** ```bash -# From the parent repo root: -npm run package:relay -# → writes dist/dect-relay-agent-bundle-.zip +# From the repo root — script is self-locating: +./dect-relay-agent/bundle.sh +# → writes dect-relay-agent-bundle-.zip (~40-60MB) ``` -Transfer the zip to the DC host and follow the bundle's own `README.md` — three commands (`unzip`, edit `.env`, `docker compose up -d --build`). +Optional overrides: -If your DC host can't reach the npm registry, see the "No-internet DC option" section in the bundle's README — you can build the image on an internet-connected machine, `docker save` it to a tarball, and ship that instead. +```bash +./dect-relay-agent/bundle.sh --tag 0.2.0 # override version +./dect-relay-agent/bundle.sh --platform linux/arm64 # if the DC is ARM +``` + +**On the DC host** (once you've transferred the ZIP): + +```bash +unzip dect-relay-agent-bundle-*.zip +cd dect-relay-agent-bundle-* +cp .env.example .env +$EDITOR .env # set BOT_URL + AGENT_TOKEN + ADMIN_PASSWORD +./install.sh +``` + +`install.sh` is idempotent — re-run it after transferring a newer bundle to upgrade. It: +1. `docker load`s the image tarball +2. Pins the loaded tag into `.env` (so compose never falls back to a stale local image) +3. Validates required env values are set (not still placeholder strings) +4. `docker compose up -d` +5. Tails the last 40 log lines so you can see the "Connected — sending hello" message + +**What the runtime container looks like:** + +- Non-root `node` user (uid 1000) +- Read-only root filesystem, 16MB tmpfs at `/tmp` +- All Linux capabilities dropped, `no-new-privileges` +- Host networking (so it can reach `10.x/8` without userland proxy translation) +- No listening ports — outbound-only WSS to the bot +- Log rotation: 10MB × 5 files max ### 2. Direct node process (dev + local iteration) @@ -132,8 +163,30 @@ The bot terminates the socket if no `pong` arrives within 90s; the agent auto-re | `package.json` | Deps: `ws`, `axios`, `dotenv` | | `.env.example` | Annotated env template | | `README.md` | This file | -| `Dockerfile` | Multi-stage alpine build used by the deploy bundle | -| `docker-compose.yml` | One-command deploy on the DC host | -| `.dockerignore` | Defensive; the packager already excludes cruft | +| `Dockerfile` | Multi-stage Alpine build. No `apk add`, no runtime `npm install`. | +| `Dockerfile.dockerignore` | Per-Dockerfile ignore (BuildKit ≥ 23.0). Whitelist-based; keeps build context ~50KB. | +| `docker-compose.yml` | DC-side runtime shape (read-only rootfs, host net, capability drop, log rotation). | +| `bundle.sh` | Dev-machine packager: `docker build` → `docker save` → `zip`. Runs on your machine. | +| `install.sh` | DC-host installer: `docker load` → validate `.env` → `docker compose up -d`. Ships inside the bundle. | -The Dockerfile is intentionally NOT designed to be built from this folder directly (`docker build dect-relay-agent/` will fail — the shared modules live one level up and are outside the build context). Always build from a bundle produced by `npm run package:relay`, which assembles a `workspace/` tree that makes the shared imports resolvable inside the build context. +**Important**: the `Dockerfile` is designed to be built from the **repo root**, not from this folder, because it needs `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js` in the build context. `bundle.sh` does this correctly: + +```bash +docker build -f dect-relay-agent/Dockerfile -t ... . # note the trailing `.` +``` + +Building with `docker build dect-relay-agent/` will fail (missing shared modules) — always use `bundle.sh`, or invoke `docker build` from the repo root with `-f dect-relay-agent/Dockerfile`. + +## Troubleshooting + +**`WARNING: fetching … TLS: server certificate not trusted` during build** +You're building inside a TLS-intercepting corporate network. Don't — build on your dev machine and ship the tarball via `bundle.sh`. That's the whole point of this workflow. + +**`docker: permission denied while trying to connect to the Docker daemon socket`** +The user running `install.sh` needs to be in the `docker` group. Either `sudo usermod -aG docker $USER` (log out/in after) or `sudo ./install.sh`. + +**Agent connects then immediately disconnects with 401** +Bearer token mismatch. `DECT_RELAY_AGENT_TOKEN` on the bot side must match the agent's `.env` exactly. Rotate both together. + +**Agent connects but every `collect` returns `DIGEST_401`** +DBS-210 admin password is wrong. Verify in Control Hub → Calling → Features → DECT Networks → Manage → Manage DECT serviceability password, update `DECT_ADMIN_PASSWORD` in `.env`, then `docker compose restart dect-relay-agent`. diff --git a/dect-relay-agent/bundle.sh b/dect-relay-agent/bundle.sh new file mode 100755 index 0000000..6877459 --- /dev/null +++ b/dect-relay-agent/bundle.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# ──────────────────────────────────────────────────────────────────── +# DECT relay agent — dev-machine bundler. +# +# Builds the Docker image for linux/amd64, saves it as a gzipped +# tarball, and zips it up with the compose file + .env template + +# install script. Output is a self-contained ZIP the DC operator +# can transfer over any file-copy channel (email, S3, USB, git-lfs) +# and install with a single `./install.sh` invocation. +# +# Requirements on the dev machine: +# - Docker Desktop / Docker Engine +# - Internet access to pull node:20-alpine + npm registry +# - `zip` (macOS + most Linux distros already have it; if not, +# `apt install zip` / `brew install zip`) +# +# Requirements on the DC host: +# - Docker + Docker Compose v2 (v1 also works) +# - Ability to `docker load` (i.e. member of the docker group or +# root) +# - Outbound HTTPS to the bot + 10.0.0.0/8 on TCP 443 +# - That's it. No npm, no python, no Alpine mirrors. +# +# Usage (from repo root OR from this folder — the script figures it out): +# ./dect-relay-agent/bundle.sh # uses version from package.json +# ./dect-relay-agent/bundle.sh --tag 0.2.0 # override version +# ./dect-relay-agent/bundle.sh --platform linux/arm64 +# ──────────────────────────────────────────────────────────────────── +set -euo pipefail + +# ─── Argument parsing ───────────────────────────────────────────── +TAG_OVERRIDE="" +PLATFORM="linux/amd64" # standard x86_64 Linux server. Override if DC is arm. +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) TAG_OVERRIDE="$2"; shift 2 ;; + --platform) PLATFORM="$2"; shift 2 ;; + -h|--help) + grep -E '^# ' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac +done + +# ─── Locate paths (works from repo root or agent dir) ───────────── +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AGENT_DIR="$SCRIPT_DIR" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Sanity: the Dockerfile expects to build from the repo root. +[[ -d "$REPO_ROOT/integrations/cisco-dect" ]] || { + echo "ERROR: $REPO_ROOT does not look like the collabSupport repo root (no integrations/cisco-dect/)" >&2 + exit 1 +} + +# ─── Determine version tag ──────────────────────────────────────── +if [[ -n "$TAG_OVERRIDE" ]]; then + VERSION="$TAG_OVERRIDE" +else + # Pull version from agent's package.json without needing jq. The + # regex is deliberately tolerant of trailing commas / whitespace. + VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$AGENT_DIR/package.json" | head -1)" + [[ -n "$VERSION" ]] || { echo "ERROR: could not read version from package.json" >&2; exit 1; } +fi + +# Try to record the git commit into the image labels — useful in +# production for "which build am I running?". Missing git is fine. +GIT_COMMIT="unknown" +if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --short HEAD >/dev/null 2>&1; then + GIT_COMMIT="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" + # Mark as dirty if the working tree has uncommitted changes — + # catches "I built from local edits" surprises in production. + if ! git -C "$REPO_ROOT" diff --quiet 2>/dev/null || \ + ! git -C "$REPO_ROOT" diff --cached --quiet 2>/dev/null; then + GIT_COMMIT="${GIT_COMMIT}-dirty" + fi +fi + +BUILD_DATE="$(date -u +%FT%TZ)" +IMAGE_TAG="collabsupport/dect-relay-agent:${VERSION}" +BUNDLE_STAMP="$(date +%Y%m%d-%H%M%S)" +BUNDLE_DIR="dect-relay-agent-bundle-${BUNDLE_STAMP}" +BUNDLE_ZIP="${BUNDLE_DIR}.zip" + +echo "════════════════════════════════════════════════════════════════" +echo " Building DECT relay agent bundle" +echo "────────────────────────────────────────────────────────────────" +echo " Version: ${VERSION}" +echo " Image tag: ${IMAGE_TAG}" +echo " Platform: ${PLATFORM}" +echo " Git commit: ${GIT_COMMIT}" +echo " Build date: ${BUILD_DATE}" +echo " Bundle dir: ${BUNDLE_DIR}/" +echo " Bundle ZIP: ${BUNDLE_ZIP}" +echo "════════════════════════════════════════════════════════════════" + +# ─── Docker build ───────────────────────────────────────────────── +# --platform pins the arch so building on Apple Silicon still +# produces an x86_64 image the DC can run. Docker uses QEMU to +# emulate cross-arch — slower than native but Just Works. +echo +echo "[1/4] Building image..." +docker build \ + --platform="${PLATFORM}" \ + --file "${AGENT_DIR}/Dockerfile" \ + --tag "${IMAGE_TAG}" \ + --build-arg "AGENT_VERSION=${VERSION}" \ + --build-arg "BUILD_DATE=${BUILD_DATE}" \ + --build-arg "GIT_COMMIT=${GIT_COMMIT}" \ + "${REPO_ROOT}" + +# ─── Stage the bundle ───────────────────────────────────────────── +# Work in the parent of the agent dir so the resulting ZIP + dir +# both land somewhere obvious (the repo root by convention). +echo +echo "[2/4] Staging bundle in ${REPO_ROOT}/${BUNDLE_DIR}/" +rm -rf "${REPO_ROOT:?}/${BUNDLE_DIR}" +mkdir -p "${REPO_ROOT}/${BUNDLE_DIR}" + +# docker save streams a tarball to stdout; pipe through gzip to +# shrink it substantially (typically ~40% smaller for Node images). +echo +echo "[3/4] Saving image to ${BUNDLE_DIR}/image.tar.gz (this can take a minute)" +docker save "${IMAGE_TAG}" | gzip -9 > "${REPO_ROOT}/${BUNDLE_DIR}/image.tar.gz" + +# Copy the operator-facing files. We do NOT copy Dockerfile / bundle.sh +# — those are dev-machine concerns. +cp "${AGENT_DIR}/docker-compose.yml" "${REPO_ROOT}/${BUNDLE_DIR}/" +cp "${AGENT_DIR}/.env.example" "${REPO_ROOT}/${BUNDLE_DIR}/" +cp "${AGENT_DIR}/install.sh" "${REPO_ROOT}/${BUNDLE_DIR}/" +cp "${AGENT_DIR}/README.md" "${REPO_ROOT}/${BUNDLE_DIR}/AGENT-README.md" + +# Write a bundle-specific README that's short and tells the operator +# what to do in this exact folder. Keeps AGENT-README.md as the deep +# reference without cluttering the top-of-bundle experience. +cat > "${REPO_ROOT}/${BUNDLE_DIR}/README.txt" < /dev/null || exit 1"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s + # Read-only root filesystem + a small writable /tmp. The agent + # writes nothing to disk (all logs go to stdout / stderr), so + # this is essentially free defense-in-depth. + read_only: true + tmpfs: + - /tmp:size=16M - # Optional: uncomment if your DC network requires host networking - # for the agent to reach the 10.x bases (e.g. because a routed - # docker bridge isn't set up). Adding host mode means the agent - # inherits the host's routing table and IP stack directly. - # network_mode: host + # Minimal capabilities — the agent is just outbound HTTP client + # traffic, no need for NET_RAW / SYS_ADMIN / etc. + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + # Basic health check: the agent process being alive is a good + # proxy for "we're at least trying to reconnect". A deeper check + # (last successful hello with the bot < 2min ago) would need + # code the agent doesn't expose yet. + healthcheck: + test: ["CMD", "node", "-e", "process.exit(0)"] + interval: 60s + timeout: 5s + start_period: 10s + retries: 3 diff --git a/dect-relay-agent/install.sh b/dect-relay-agent/install.sh new file mode 100755 index 0000000..e3ad9a8 --- /dev/null +++ b/dect-relay-agent/install.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# ──────────────────────────────────────────────────────────────────── +# DECT relay agent — data-center install / upgrade helper. +# +# Run this inside the unzipped bundle directory on the DC host. It: +# 1. Sanity-checks that Docker + Compose are installed. +# 2. Loads the shipped image tarball into the local Docker daemon. +# 3. Pins IMAGE_TAG in .env to whatever tag was baked into the +# tarball (so compose can never fall back to a stale local +# cache without you noticing). +# 4. Verifies .env exists and has the required keys populated. +# 5. Runs `docker compose up -d` and tails the last 40 lines. +# +# Safe to run repeatedly — it's a straightforward upgrade too: +# unzip -o new-bundle.zip -d dect-relay-agent-bundle +# cd dect-relay-agent-bundle +# ./install.sh +# ──────────────────────────────────────────────────────────────────── +set -euo pipefail + +# Colors, only if stdout is a TTY. Corporate SSH sessions often are; +# CI / pipe-to-file are not. +if [[ -t 1 ]]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m' + YELLOW=$'\033[33m'; RESET=$'\033[0m' +else + BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW=''; RESET='' +fi + +log() { echo "${BOLD}[install]${RESET} $*"; } +die() { echo "${RED}[install] ERROR:${RESET} $*" >&2; exit 1; } + +# Ensure we're running from the bundle dir (compose file must be here). +cd "$(dirname "$0")" + +# ─── 1. Preflight ────────────────────────────────────────────────── +log "Preflight checks" +command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH" + +# Compose v2 is `docker compose` (space); v1 is `docker-compose` (dash). +# Prefer v2. bail if neither is available. +if docker compose version >/dev/null 2>&1; then + COMPOSE="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE="docker-compose" + echo "${YELLOW}[install] Using legacy docker-compose v1. Consider upgrading to Compose v2.${RESET}" +else + die "docker compose (v2) not found and docker-compose (v1) not on PATH" +fi + +if ! docker info >/dev/null 2>&1; then + die "docker daemon is not reachable. Are you in the 'docker' group, or should you re-run with sudo?" +fi + +[[ -f image.tar.gz ]] || die "image.tar.gz not found in $(pwd) — is the bundle complete?" +[[ -f docker-compose.yml ]] || die "docker-compose.yml not found — is the bundle complete?" + +# ─── 2. Load image ───────────────────────────────────────────────── +log "Loading Docker image from image.tar.gz (this is the only step that touches the docker daemon's image store)" +# `docker load` prints "Loaded image: " for each tag in the archive. +# We tee to stderr so the operator sees it, and grep the tag out for +# use in the .env pin step below. +LOAD_OUTPUT="$(gunzip -c image.tar.gz | docker load)" +echo "$LOAD_OUTPUT" +LOADED_TAG="$(echo "$LOAD_OUTPUT" | awk -F': ' '/Loaded image/ {print $2; exit}')" +[[ -n "$LOADED_TAG" ]] || die "docker load did not report a loaded image tag" +log "Loaded image: ${GREEN}${LOADED_TAG}${RESET}" + +# ─── 3. .env setup ───────────────────────────────────────────────── +if [[ ! -f .env ]]; then + cp .env.example .env + echo "${YELLOW}[install] Created .env from .env.example. Edit it now with real values, then re-run this script.${RESET}" + echo " Required: DECT_RELAY_BOT_URL, DECT_RELAY_AGENT_TOKEN, DECT_ADMIN_PASSWORD" + exit 2 +fi + +# Pin IMAGE_TAG in .env to the tag we just loaded. Idempotent — +# rewrites the line each run so upgrades to a new tarball tag Just +# Work without operator intervention. +if grep -q '^IMAGE_TAG=' .env; then + # Portable in-place sed (works on both GNU sed and BSD sed on macOS). + # The `.bak` tempfile is removed at end. + sed -i.bak "s|^IMAGE_TAG=.*|IMAGE_TAG=${LOADED_TAG}|" .env + rm -f .env.bak +else + printf '\n# Pinned automatically by install.sh on %s\nIMAGE_TAG=%s\n' \ + "$(date -u +%FT%TZ)" "$LOADED_TAG" >> .env +fi +log "Pinned IMAGE_TAG=${LOADED_TAG} in .env" + +# Validate the operator has actually filled in the required values — +# .env.example ships with placeholders that would blow up at runtime +# with a less friendly error. +MISSING=() +required_var() { + local key="$1" val + val="$(grep -E "^${key}=" .env | tail -1 | cut -d= -f2-)" + # Strip surrounding quotes and whitespace so both bare and quoted + # values validate the same. + val="${val#\"}"; val="${val%\"}" + val="${val#\'}"; val="${val%\'}" + val="${val## }"; val="${val%% }" + if [[ -z "$val" ]] || [[ "$val" == "replace-with-shared-secret" ]] \ + || [[ "$val" == "replace-with-dect-serviceability-password" ]] \ + || [[ "$val" == "wss://your-bot-host.example.com/dect-relay/ws" ]]; then + MISSING+=("$key") + fi +} +required_var DECT_RELAY_BOT_URL +required_var DECT_RELAY_AGENT_TOKEN +required_var DECT_ADMIN_PASSWORD + +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "${RED}[install] .env is missing required values or still has placeholder text:${RESET}" + for k in "${MISSING[@]}"; do echo " - $k"; done + echo " Edit .env and re-run this script." + exit 2 +fi + +# ─── 4. Compose up ───────────────────────────────────────────────── +log "Starting container via ${COMPOSE} up -d" +$COMPOSE up -d + +log "Container started. Recent logs:" +sleep 2 +$COMPOSE logs --tail=40 dect-relay-agent || true + +echo +log "${GREEN}Done.${RESET} Follow live logs with: ${DIM}${COMPOSE} logs -f dect-relay-agent${RESET}" +log "Stop the agent with: ${DIM}${COMPOSE} down${RESET}" diff --git a/package.json b/package.json index 265a0bf..22da8f3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "docker:up": "docker compose up -d", "docker:down": "docker compose down", "docker:logs": "docker compose logs -f", - "package:relay": "node scripts/packageDectRelayAgent.js", + "package:relay": "bash dect-relay-agent/bundle.sh", "test": "node --test tests/*.test.js" }, "dependencies": { diff --git a/scripts/packageDectRelayAgent.js b/scripts/packageDectRelayAgent.js deleted file mode 100755 index f1c1d9c..0000000 --- a/scripts/packageDectRelayAgent.js +++ /dev/null @@ -1,346 +0,0 @@ -#!/usr/bin/env node -/** - * Assemble the DECT relay agent into a Docker-ready deploy bundle - * and zip it up for transfer into the data center. - * - * Why a packager instead of `docker build` in the repo: - * The agent's index.js imports the shared cisco-dect + httpDigestAuth - * modules via relative paths (`../integrations/cisco-dect/...`, - * `../utils/httpDigestAuth.js`). A raw `docker build dect-relay-agent/` - * would fail because the shared files live OUTSIDE the build context. - * This packager copies them into a self-contained `workspace/` tree - * inside the bundle so the container build sees them as local paths - * without any source rewriting. - * - * What ends up in the bundle: - * dect-relay-agent-bundle/ - * Dockerfile (from dect-relay-agent/Dockerfile) - * docker-compose.yml (from dect-relay-agent/docker-compose.yml) - * .dockerignore (from dect-relay-agent/.dockerignore) - * .env.example (from dect-relay-agent/.env.example) - * README.md (deploy-focused; generated below) - * BUNDLE_INFO.txt (build metadata: git sha, timestamp, sizes) - * workspace/ - * dect-relay-agent/ - * package.json - * index.js - * integrations/cisco-dect/{client,probes,statusXml}.js - * utils/httpDigestAuth.js - * - * Output: - * dist/dect-relay-agent-bundle-.zip - * - * Usage: - * npm run package:relay - * node scripts/packageDectRelayAgent.js [--out dist] [--name my-bundle.zip] - * - * Requirements: - * - Node 20+ - * - `zip` on the PATH (macOS + every mainstream Linux distro ship it) - */ - -import { spawnSync, execSync } from 'node:child_process'; -import { mkdirSync, cpSync, writeFileSync, rmSync, existsSync, statSync, readdirSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - -// ─── CLI parsing (tiny) ──────────────────────────────────────────── - -const args = process.argv.slice(2); -function argVal(flag, fallback) { - const i = args.indexOf(flag); - return i >= 0 && args[i + 1] ? args[i + 1] : fallback; -} -const outDir = path.resolve(REPO_ROOT, argVal('--out', 'dist')); -const explicitZipName = argVal('--name', null); - -// ─── Sources to copy into the bundle ─────────────────────────────── -// -// Each entry is `[repo-relative source, bundle-relative destination]`. -// Kept as a plain list rather than a glob so it's obvious what's -// shipped — an accidental include of secrets or the entire repo -// would be a review-visible diff here. - -const AGENT_FILES = [ - ['dect-relay-agent/package.json', 'workspace/dect-relay-agent/package.json'], - ['dect-relay-agent/index.js', 'workspace/dect-relay-agent/index.js'], -]; - -const SHARED_FILES = [ - ['integrations/cisco-dect/client.js', 'workspace/integrations/cisco-dect/client.js'], - ['integrations/cisco-dect/probes.js', 'workspace/integrations/cisco-dect/probes.js'], - ['integrations/cisco-dect/statusXml.js', 'workspace/integrations/cisco-dect/statusXml.js'], - ['utils/httpDigestAuth.js', 'workspace/utils/httpDigestAuth.js'], -]; - -// Docker artifacts + operator-facing files live NEXT TO the workspace -// (not inside it) because Dockerfile's `COPY workspace/ ./` treats -// workspace as the entire in-container /app tree. -const DEPLOY_FILES = [ - ['dect-relay-agent/Dockerfile', 'Dockerfile'], - ['dect-relay-agent/docker-compose.yml', 'docker-compose.yml'], - ['dect-relay-agent/.dockerignore', '.dockerignore'], - ['dect-relay-agent/.env.example', '.env.example'], -]; - -// ─── Helpers ─────────────────────────────────────────────────────── - -function log(msg) { process.stdout.write(`[package-relay] ${msg}\n`); } -function err(msg) { process.stderr.write(`[package-relay] ${msg}\n`); } - -function stamp() { - const d = new Date(); - const pad = (n) => String(n).padStart(2, '0'); - return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` + - `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`; -} - -function gitSha() { - try { - return execSync('git rev-parse --short HEAD', { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'] }) - .toString().trim(); - } catch { return 'unknown'; } -} - -function gitDirty() { - try { - const out = execSync('git status --porcelain', { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'] }) - .toString().trim(); - return out.length > 0; - } catch { return false; } -} - -function copyMany(pairs, bundleDir) { - for (const [src, dst] of pairs) { - const absSrc = path.join(REPO_ROOT, src); - const absDst = path.join(bundleDir, dst); - if (!existsSync(absSrc)) { - throw new Error(`Missing required source file: ${src}`); - } - mkdirSync(path.dirname(absDst), { recursive: true }); - cpSync(absSrc, absDst); - } -} - -function humanBytes(n) { - const units = ['B', 'KB', 'MB', 'GB']; - let i = 0, v = n; - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } - return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; -} - -function dirSize(dir) { - let total = 0; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const p = path.join(dir, entry.name); - if (entry.isDirectory()) total += dirSize(p); - else if (entry.isFile()) total += statSync(p).size; - } - return total; -} - -// ─── Bundle README ───────────────────────────────────────────────── -// -// Generated fresh at package time so it can reference the actual bundle -// version + build sha. Kept short — the operator's whole workflow is -// three commands. - -function renderBundleReadme({ version, sha, dirty }) { - return `# DECT Relay Agent — deploy bundle - -**Version:** \`${version}\` -**Source commit:** \`${sha}\`${dirty ? ' _(uncommitted changes present at build time)_' : ''} - -This zip contains everything needed to run the DECT relay agent as a -Docker container inside the data center. The bot side (public cloud) -must already have \`DECT_RELAY_AGENT_TOKEN\` set to the same value -you'll use in \`.env\` below. - -## Deploy - -\`\`\`bash -# 1. On the DC host — unzip somewhere sensible -unzip dect-relay-agent-bundle-${version}.zip -cd dect-relay-agent-bundle-${version} - -# 2. Configure — never commit this .env -cp .env.example .env -\${EDITOR:-vi} .env -# Required: -# DECT_RELAY_BOT_URL (wss:///dect-relay/ws) -# DECT_RELAY_AGENT_TOKEN (same value as the bot's .env) -# DECT_ADMIN_PASSWORD (DBS-210 serviceability password) - -# 3. Build + start -docker compose up -d --build - -# 4. Verify — you should see "Connected — sending hello" -docker compose logs -f -\`\`\` - -On the bot side, look for these log lines to confirm the socket is up: - -\`\`\` -[dect:relay-hub] Agent connected from -[dect:relay-hub] Agent hello: version=0.1.0 host= caps=collect,reboot,... -\`\`\` - -## Ongoing operations - -| Task | Command | -|---|---| -| View live logs | \`docker compose logs -f\` | -| Restart agent | \`docker compose restart\` | -| Stop agent | \`docker compose down\` | -| Upgrade | Unzip the new bundle over the existing dir, then \`docker compose up -d --build\` | -| Rotate token | Change \`DECT_RELAY_AGENT_TOKEN\` in both \`.env\` files (agent + bot); \`docker compose restart\` on the agent side, restart the bot; expect ~1 reconnect gap | - -## What's in this bundle - -\`\`\` -Dockerfile multi-stage alpine build, non-root user, tini entrypoint -docker-compose.yml restart:unless-stopped, JSON log rotation, health check -.dockerignore defensive; the bundle already excludes cruft -.env.example annotated template — copy to .env -README.md this file -BUNDLE_INFO.txt build metadata (git sha, timestamp, file sizes) -workspace/ in-container /app tree - dect-relay-agent/ the agent's own code + package.json - integrations/ shared cisco-dect modules (parity with the bot) - utils/ shared HTTP Digest auth utility -\`\`\` - -## No-internet DC option - -If the DC host can't reach the npm registry to install dependencies -during \`docker build\`, build the image on a machine WITH internet -access and ship the image tarball: - -\`\`\`bash -# On the internet-connected machine: -docker compose build -docker save dect-relay-agent:latest | gzip > dect-relay-agent-image.tar.gz - -# Transfer dect-relay-agent-image.tar.gz + docker-compose.yml + .env to -# the DC host, then: -gunzip -c dect-relay-agent-image.tar.gz | docker load -docker compose up -d # skips build; uses loaded image -\`\`\` - -## Troubleshooting - -**\`RELAY_NOT_CONNECTED\` on the bot side.** Agent isn't reaching the -bot. Check \`docker compose logs\` — most common causes: wrong -\`DECT_RELAY_BOT_URL\`, wrong \`DECT_RELAY_AGENT_TOKEN\` (bot logs -\`Unauthorized upgrade attempt\`), or corporate proxy blocking -outbound wss. Test with \`docker compose exec dect-relay-agent wget -S -O- https:///health\`. - -**Agent reconnects in a loop.** Check the agent's logs for a specific -\`error\` line. If it says the bot returned 401, the token is wrong. -If TLS errors, the bot's cert chain isn't trusted inside the -container — mount \`/etc/ssl/certs\` from the DC host into the -container. - -**\`DIGEST_401\` in agent logs when servicing a \`collect\`.** Wrong -\`DECT_ADMIN_PASSWORD\`. Confirm against Control Hub → Calling → -Features → DECT Networks → Manage → Manage DECT serviceability -password. -`; -} - -// ─── Main ────────────────────────────────────────────────────────── - -async function main() { - const version = stamp(); - const sha = gitSha(); - const dirty = gitDirty(); - - const stagingRoot = path.join(REPO_ROOT, '.package-relay-tmp'); - const bundleName = `dect-relay-agent-bundle-${version}`; - const bundleDir = path.join(stagingRoot, bundleName); - const zipName = explicitZipName || `${bundleName}.zip`; - const zipPath = path.join(outDir, zipName); - - // Fresh staging tree every run. - if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true, force: true }); - mkdirSync(bundleDir, { recursive: true }); - mkdirSync(outDir, { recursive: true }); - - log(`Building bundle "${bundleName}"`); - log(` git sha: ${sha}${dirty ? ' (dirty tree)' : ''}`); - log(` staging: ${bundleDir}`); - log(` output: ${zipPath}`); - - try { - log('Copying agent sources into workspace/'); - copyMany(AGENT_FILES, bundleDir); - log('Copying shared cisco-dect + digest-auth modules into workspace/'); - copyMany(SHARED_FILES, bundleDir); - log('Copying deploy artifacts (Dockerfile, compose, .env.example)'); - copyMany(DEPLOY_FILES, bundleDir); - - log('Rendering deploy README.md + BUNDLE_INFO.txt'); - writeFileSync( - path.join(bundleDir, 'README.md'), - renderBundleReadme({ version, sha, dirty }), - 'utf8', - ); - writeFileSync( - path.join(bundleDir, 'BUNDLE_INFO.txt'), - [ - `bundle: ${bundleName}`, - `built: ${new Date().toISOString()}`, - `commit: ${sha}${dirty ? ' (dirty)' : ''}`, - `staged bytes: ${humanBytes(dirSize(bundleDir))}`, - ].join('\n') + '\n', - 'utf8', - ); - - log(`Staged ${humanBytes(dirSize(bundleDir))} across ${AGENT_FILES.length + SHARED_FILES.length + DEPLOY_FILES.length + 2} files`); - - if (existsSync(zipPath)) { - log(`Removing pre-existing ${zipName}`); - rmSync(zipPath); - } - - log('Creating zip archive (running `zip -r`)'); - const zipResult = spawnSync( - 'zip', - // -r recursive, -q quiet, -X strip extra attrs for smaller cross-OS zip. - // Run inside the staging root so paths inside the zip start with the - // bundle-name folder (rather than absolute-repo-path prefix). - ['-r', '-q', '-X', zipPath, bundleName], - { cwd: stagingRoot, stdio: 'inherit' }, - ); - if (zipResult.error) throw zipResult.error; - if (zipResult.status !== 0) { - throw new Error(`zip exited with code ${zipResult.status} — is the \`zip\` binary installed?`); - } - } finally { - // Always clean up the staging tree, even on failure — leaving a - // partial bundle around is confusing next run. The zip in dist/ - // survives. - if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true, force: true }); - } - - const finalBytes = statSync(zipPath).size; - log('─────────────────────────────────────────────────────'); - log(`✔ Bundle written: ${path.relative(REPO_ROOT, zipPath)}`); - log(` ${humanBytes(finalBytes)} on disk`); - log(''); - log('Next steps:'); - log(` 1. Transfer ${zipName} to the DC host (scp / rsync / etc.)`); - log(` 2. On the DC host: unzip ${zipName} && cd ${bundleName}`); - log(` 3. Fill in .env from .env.example`); - log(` 4. docker compose up -d --build`); - log(` 5. docker compose logs -f # look for "Connected — sending hello"`); - log('─────────────────────────────────────────────────────'); -} - -main().catch((e) => { - err(`FAILED: ${e.message}`); - if (process.env.DEBUG) err(e.stack || ''); - process.exit(1); -});