Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────

Expand Down
2 changes: 2 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ hmds = "hmds"
# IBKR misspells "collision" as "collission" on the tickle response wire; we bind
# the wire key verbatim (see crates/adapter/ibkr/src/cpapi/auth.rs), so allow it.
collission = "collission"
# The CP gateway's shipped keystore filename (Vert.x, the Java web framework).
vertx = "vertx"
18 changes: 16 additions & 2 deletions docker/cpapi/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand All @@ -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"]
14 changes: 8 additions & 6 deletions docker/cpapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
55 changes: 55 additions & 0 deletions docker/cpapi/conf.yaml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 13 additions & 5 deletions docker/cpapi/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 8 additions & 0 deletions docker/cpapi/gateway-base-url.sh
Original file line number Diff line number Diff line change
@@ -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}"