From c3ffd9b22ddf366357edd349ae3daa37991784a6 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:36:03 +0000 Subject: [PATCH] chore(ibkr): harden the CP gateway docker harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the dev Client Portal Gateway self-sufficient so browser login works in a devcontainer without a manual patch, adopting hardening ideas from a reference gateway: - Bake a dev `conf.yaml` into the image whose `ips.allow` covers loopback + RFC-1918 private ranges — the distribution's shipped allowlist (192.*/131.216.*/127.0.0.1) rejects a Docker-forwarded login (source 172.*/10.*) with 403 "not allowed". - Add a container HEALTHCHECK (curl /iserver/auth/status -> 200|401); install curl for it. - Harden docker-compose: cap_drop ALL, no-new-privileges, mem/pids limits, and a bounded log size. - `just ibkr-capture` resolves the gateway's container IP (new docker/cpapi/gateway-base-url.sh helper) because localhost:5000 is not routable from inside a devcontainer; capture.sh is untouched. Verified: image builds, and a throwaway container serves 401 (source allowed, not 403) with no manual ips.allow patch. Allowlist `vertx` (the keystore filename) in _typos.toml. Co-Authored-By: Claude Opus 4.8 (1M context) --- Justfile | 6 ++-- _typos.toml | 2 ++ docker/cpapi/Dockerfile | 18 +++++++++-- docker/cpapi/README.md | 14 ++++---- docker/cpapi/conf.yaml | 55 ++++++++++++++++++++++++++++++++ docker/cpapi/docker-compose.yml | 18 ++++++++--- docker/cpapi/gateway-base-url.sh | 8 +++++ 7 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 docker/cpapi/conf.yaml create mode 100755 docker/cpapi/gateway-base-url.sh diff --git a/Justfile b/Justfile index ccf37d7..f9c3380 100644 --- a/Justfile +++ b/Justfile @@ -94,9 +94,11 @@ shellcheck: # ── IBKR fixture capture ────────────────────────────────────────────────────── # Capture Client Portal API v1 read-path fixtures from a running, authenticated -# gateway (see docker/cpapi/README.md). Pass a paper account id. +# gateway (see docker/cpapi/README.md). Pass a paper account id. The gateway base +# URL is resolved to the container's bridge IP because localhost:5000 is not +# routable from inside a devcontainer. ibkr-capture account="": - docker/cpapi/capture.sh {{account}} + IBKR_GATEWAY="$(docker/cpapi/gateway-base-url.sh)" docker/cpapi/capture.sh {{account}} # ── Supply chain & docs ─────────────────────────────────────────────────────── diff --git a/_typos.toml b/_typos.toml index e698c1f..ac19666 100644 --- a/_typos.toml +++ b/_typos.toml @@ -23,3 +23,5 @@ ssodh = "ssodh" acct = "acct" mkt = "mkt" hmds = "hmds" +# The CP gateway's shipped keystore filename (Vert.x, the Java web framework). +vertx = "vertx" diff --git a/docker/cpapi/Dockerfile b/docker/cpapi/Dockerfile index 9593daa..7611a7d 100644 --- a/docker/cpapi/Dockerfile +++ b/docker/cpapi/Dockerfile @@ -2,8 +2,9 @@ # web server — no Xvfb/VNC/IBC (that machinery is only for the TWS desktop app). FROM eclipse-temurin:21-jre +# `curl` is used by the container HEALTHCHECK below. RUN apt-get update \ - && apt-get install -y --no-install-recommends unzip ca-certificates \ + && apt-get install -y --no-install-recommends unzip curl ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /opt/clientportal @@ -14,6 +15,11 @@ WORKDIR /opt/clientportal ADD https://download2.interactivebrokers.com/portal/clientportal.gw.zip clientportal.gw.zip RUN unzip -q clientportal.gw.zip && rm clientportal.gw.zip +# Replace the distribution's shipped root/conf.yaml with our dev config: the shipped +# ips.allow (192.*/131.216.*/127.0.0.1) rejects a browser login forwarded through +# Docker (source 172.*/10.*) with 403. See conf.yaml. +COPY conf.yaml root/conf.yaml + # Run the Java gateway as a non-root user (Trivy DS-0002). Port 5000 is >1024, so # an unprivileged user can bind it; the gateway writes logs/session under its dir, # which we hand to `ibkr`. @@ -22,5 +28,13 @@ RUN useradd -r -m -d /opt/clientportal -s /sbin/nologin ibkr \ USER ibkr EXPOSE 5000 -# Uses the distribution's shipped root/conf.yaml (listens on :5000). + +# The gateway answers /v1/api/iserver/auth/status with 200 (logged in) or 401 (not +# yet authenticated); both mean the JVM is up and serving. No -f, so a 401 is not a +# curl error; -k, since the gateway ships a self-signed cert. +HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ + CMD curl -sk --max-time 4 -o /dev/null -w '%{http_code}' \ + https://127.0.0.1:5000/v1/api/iserver/auth/status | grep -qE '^(200|401)$' || exit 1 + +# Uses the baked root/conf.yaml (listens on :5000). CMD ["bin/run.sh", "root/conf.yaml"] diff --git a/docker/cpapi/README.md b/docker/cpapi/README.md index 598ec7f..20cb4be 100644 --- a/docker/cpapi/README.md +++ b/docker/cpapi/README.md @@ -16,17 +16,19 @@ docker compose -f docker/cpapi/docker-compose.yml up -d --build # and log in with your PAPER credentials. Leave the tab; the session lives here. ``` The brokerage session times out after ~5 min idle; `/tickle` keeps it alive. -If the login page rejects you (403 / "not allowed"), the shipped `root/conf.yaml` -`ips.allow` is too strict for container networking — see the commented bind-mount in -`docker-compose.yml`. +The image bakes a dev [`conf.yaml`](conf.yaml) whose `ips.allow` covers loopback + +RFC-1918 private ranges, so a browser login forwarded through Docker is **not** +rejected 403. `docker ps` shows the container `healthy` once the gateway is serving +(a `HEALTHCHECK` polls `/iserver/auth/status`). ## Capture fixtures ```bash just ibkr-capture DU0000000 # your paper account id ``` -This writes raw JSON to `crates/adapter/ibkr/tests/fixtures/cpapi/`. -If the script aborts on an endpoint, the brokerage session likely isn't -authenticated — log in at https://localhost:5000 and re-run. +This writes raw JSON to `crates/adapter/ibkr/tests/fixtures/cpapi/`. The recipe +resolves the gateway's container IP (localhost:5000 is not routable from inside a +devcontainer). If the script aborts on an endpoint, the brokerage session likely +isn't authenticated — log in at https://localhost:5000 and re-run. ## Sanitize before committing (required) The responses come from a real paper account. Before `git add`: diff --git a/docker/cpapi/conf.yaml b/docker/cpapi/conf.yaml new file mode 100644 index 0000000..05e59fc --- /dev/null +++ b/docker/cpapi/conf.yaml @@ -0,0 +1,55 @@ +# IBKR Client Portal Gateway config — DEVELOPMENT ONLY (baked into the image). +# +# Fully replaces the distribution's shipped root/conf.yaml. The shipped file's +# `ips.allow` only permits 192.*/131.216.*/127.0.0.1, so a browser login forwarded +# through Docker (source 172.*/10.*) is rejected "403 / not allowed". The allowlist +# below covers loopback + RFC-1918 private ranges. This gateway is for +# localhost/devcontainer use only — never expose it on a public network. + +# ── Listener ────────────────────────────────────────────────────────────────── +listenPort: 5000 +listenSsl: true +sslCert: "vertx.jks" # the distribution's shipped self-signed keystore +sslPwd: "mywebapi" # its shipped password (public; dev-only) + +# ── Upstream proxy ──────────────────────────────────────────────────────────── +proxyRemoteSsl: true +proxyRemoteHost: "https://api.ibkr.com" + +# ── Authentication ──────────────────────────────────────────────────────────── +ip2loc: "DE" # geo hint for the login page (ISO 3166-1 alpha-2) +authDelay: 3000 + +# ── General ─────────────────────────────────────────────────────────────────── +svcEnvironment: "v1" +portalBaseURL: "" +ccp: false + +# ── Vert.x tuning (modest, single-developer workload) ───────────────────────── +serverOptions: + blockedThreadCheckInterval: 1000000 + eventLoopPoolSize: 4 + workerPoolSize: 8 + maxWorkerExecuteTime: 100 + internalBlockingPoolSize: 8 + +# ── Access control ──────────────────────────────────────────────────────────── +cors: + origin.allowed: "*" + allowCredentials: false + +# IP allowlist — loopback + RFC-1918 private ranges (covers Docker bridge networks +# and localhost port-forwarding); everything else is denied. +ips: + allow: + - "127.0.0.1" + - "10.*" + - "172.*" + - "192.168.*" + deny: + - "0.0.0.0/0" + +# ── Web apps ────────────────────────────────────────────────────────────────── +webApps: + - name: "demo" + index: "index.html" diff --git a/docker/cpapi/docker-compose.yml b/docker/cpapi/docker-compose.yml index c5d2fc7..17effac 100644 --- a/docker/cpapi/docker-compose.yml +++ b/docker/cpapi/docker-compose.yml @@ -6,8 +6,16 @@ services: ports: - "5000:5000" restart: "no" - # If the browser login is blocked (403 / "not allowed"), the gateway's shipped - # root/conf.yaml ips.allow is too strict for container networking. Copy the - # shipped root/conf.yaml out, widen ips.allow for local dev, and bind-mount it: - # volumes: - # - ./conf.override.yaml:/opt/clientportal/root/conf.yaml:ro + # Hardening for this dev-only container (it terminates TLS and holds a live + # brokerage session). The widened ips.allow now lives in the baked conf.yaml, + # so no bind-mount override is needed. + cap_drop: + - ALL + security_opt: + - "no-new-privileges:true" + mem_limit: 512m + pids_limit: 256 + logging: + options: + max-size: "10m" + max-file: "3" diff --git a/docker/cpapi/gateway-base-url.sh b/docker/cpapi/gateway-base-url.sh new file mode 100755 index 0000000..defccfc --- /dev/null +++ b/docker/cpapi/gateway-base-url.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Print the Client Portal Gateway base URL. Resolves the container's bridge IP +# because the published localhost:5000 is not routable from inside a devcontainer +# / docker-in-docker; falls back to localhost when the container is not found. +set -euo pipefail + +ip=$(docker inspect oath-cpapi-gw -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null || true) +printf 'https://%s:5000/v1/api\n' "${ip:-localhost}"