Skip to content

Repository files navigation

ibg-controller

CI Release image Latest release License: MIT cosign signed

Unattended IB Gateway in Docker. ibg-controller starts Gateway, logs in, completes 2FA, applies your API settings and keeps the session running with nobody at the screen. A Python controller runs the login state machine, and a small Java agent inside Gateway's JVM does the clicking and typing.

  • TOTP 2FA without a phone. Set TWOFACTOR_CODE and the weekly re-authentication completes on its own.
  • Gateway's own daily restart, adopted. When Gateway restarts itself, the controller follows the new process instead of launching a second one.
  • Built to be monitored. /health for probes and stable ALERT_* log tokens for paging.
  • Signed releases. Multi-arch images, cosign-signed, with an SBOM.

Coming from IBC? IBC was retired on 1 September 2026 and its repository is archived. Your existing env vars from gnzsnz's image, like TWS_USERID, TRADING_MODE and TWOFA_DEVICE, work unchanged. The command server uses IBC's command names, and a one-shot tool converts your config.ini: docs/FROM_IBC.md.

Release images build on gnzsnz's ib-gateway image, pinned by digest, which supplies Gateway itself, Xvfb and VNC. UPSTREAM_IMAGE is a build arg if you need a different base.

Quick start

docker pull ghcr.io/code-hustler-ft3d/ibg-controller:latest

docker run -d --name ibkr \
  --restart on-failure \
  --env-file /path/to/your/.env \
  -e USE_IBG_CONTROLLER=yes \
  -e TRADING_MODE=paper \
  -e TWS_SERVER_PAPER=cdc1.ibllc.com \
  -p 127.0.0.1:4002:4004 \
  ghcr.io/code-hustler-ft3d/ibg-controller:latest

USE_IBG_CONTROLLER=yes is required. Without it the image starts the IBC build that ships in its base image.

Set a restart policy. When the controller can't recover a login on its own it exits, and on-failure lets Docker start it again. Leave off the retry count: Docker never resets it after a healthy run, only when you start or recreate the container yourself, so on-failure:3 is a budget for the container's whole life and quietly runs out. Unlimited is safe because the stops that need you halt instead of exiting — a 2FA setup problem, a persistent CCP lockout, and rejected credentials (at once when Gateway says the password is wrong, or after two attempts in a row show IBKR's rejection pattern in its log). A halted container stays up and makes no further login attempts, so a restart policy never fires on it; fix the cause and restart it yourself. Any other failed login is relaunched three times with growing pauses before the controller exits and the container restarts.

Tags: :latest, :<major>.<minor>, :<major>.<minor>.<patch> and :v<major>.<minor>.<patch>. All cosign-signed; verification recipe and digest pinning in SECURITY.md.

If you use docker compose, set stop_grace_period: 90s. Docker's default 10s is too short for the clean-logout chain, and cutting it short strands IBKR session slots on every restart (timing math):

services:
  ib-gateway:
    image: ghcr.io/code-hustler-ft3d/ibg-controller:latest
    restart: on-failure
    stop_grace_period: 90s   # required
    environment:
      TRADING_MODE: paper
      TWS_SERVER_PAPER: cdc1.ibllc.com
      USE_IBG_CONTROLLER: "yes"
      # ... your other env vars

One 2FA method only. If your IBKR account has both IB Key and Mobile Authenticator enabled, unattended login fails. Gateway pre-selects IB Key and issues its challenge as the dialog opens, so switching to Mobile Authenticator starts a second session and IBKR kicks the first one, leaving a "Re-login is required" box on screen. Remove the method you don't automate in Client Portal → Settings → User Settings → Security → Secure Login System. The alternatives, all of which need a human at login time, are in 2FA.

Deeper guides:

Requirements

Requirement Notes
Linux amd64/arm64 Ubuntu 24.04 base tested
IB Gateway 10.x Release images pin 10.45.1j (gnzsnz :stable line)
Python 3.10+ Runtime; stdlib only, no pip installs
JDK 17+ Build time only — runtime uses the JRE bundled with Gateway
python3, matchbox-window-manager, curl The only packages added on top of the upstream image

What works

Feature Gateway TWS Notes
Paper / live / dual-mode cold start ✅ verified ⚠️ code in place dual mode = two isolated JVMs
TOTP 2FA (single method) ✅ verified ⚠️ code in place
IB Key push 2FA ✅ wait mode ✅ wait mode waits for you to approve on the phone
Multi-method 2FA ⚠️ needs one method ⚠️ needs one method both dialog shapes are detected and driven, but IBKR kicks the session on most accounts we've seen when the method is switched mid-login; run a single method, see 2FA
Passkey prompt (PASSKEY_AUTHENTICATE=yes) ⚠️ contributor-validated ⚠️ untested presses Authenticate; your authenticator completes WebAuthn; amd64 plus extra libraries; see 2FA
Existing-session dialog ✅ verified ⚠️ code in place
Post-login config (READ_ONLY_API, TWS_MASTER_CLIENT_ID, auto logoff/restart times) ✅ verified ⚠️ untested
Command server (STOP, RESTART, RECONNECTACCOUNT, ENABLEAPI) ✅ verified ⚠️ untested RECONNECTDATA is TWS-only

✅ verified = run end-to-end against a real IB account. ⚠️ code in place = written and unit-tested, not yet run against the real product.

2FA

  • Use Mobile Authenticator (TOTP) for unattended operation: set TWOFACTOR_CODE to the base32 secret from IBKR's authenticator setup. This is the only method a headless container can satisfy on its own.
  • IB Key push requires a human to tap approve on a phone. The controller will wait for that (leave TWOFACTOR_CODE unset), which is fine attended and a dead end for automation.
  • Attended fallback over VNC. If automation can't finish 2FA — wrong method on the account, a stale secret, a push you missed — connect to the container's VNC (port 5900, password from the upstream VNC_SERVER_PASSWORD env var) and complete the login by hand. The controller sees the API port open and carries on monitoring; nothing needs restarting. You have the 2FA wait (TWOFA_EXIT_INTERVAL, default 120 s) plus the API-port wait (180 s) — about five minutes, during which the controller only watches. Slower than that and it exits, the container restarts and you get a fresh window, but a login in progress is interrupted; raise TWOFA_EXIT_INTERVAL if you need longer. Leave TWOFA_TIMEOUT_ACTION at its default none: restart would relaunch Gateway out from under you. Publish 5900 on localhost only (-p 127.0.0.1:5900:5900). Expect to repeat it roughly weekly — IBKR forces a full re-authentication at its Sunday ~01:00 ET reset (per IBC's user guide). (Suggested by @ldicarlo in #7.)
  • Passkey / WebAuthn accounts: the controller presses Authenticate, you supply the authenticator. IBKR forced some regions (Hong Kong and Japan as of 2026-08) onto passkeys; Gateway's Second Factor dialog then asks you to "use your Passkey device". With PASSKEY_AUTHENTICATE=yes the controller presses Authenticate and stops there — the WebAuthn ceremony must be completed by an authenticator running alongside the container (a virtual FIDO device such as passless, a key passed through, or a person). The controller never holds a passkey and never emulates one; that is a different job than driving Gateway's dialogs. Unset, a passkey prompt fails loudly (ALERT_2FA_FAILED reason="passkey/WebAuthn 2FA flow ...") as it has since v0.8.1. Contributed and used in production by @jpike88; the maintainer has no passkey account, so this is ⚠️ rather than ✅. Needs an amd64 base, because IBKR's arm64 installer ships no browser. The prompt opens Gateway's embedded browser, whose system libraries this image doesn't include: add the packages from gnzsnz/ib-gateway-docker#440 in an image of your own. See #22 and #29.
  • Accounts with more than one method: Gateway pre-picks one, and the dialog shape varies by account — some get a code dialog defaulted to one method, others a device-selector list. The controller detects both shapes. If the pre-pick doesn't match TWOFACTOR_CODE it selects the right device where the dialog allows it, and otherwise fails loudly (ALERT_2FA_FAILED) with the fix in the log — it never types the code into the wrong method. The switch itself rarely survives: the pre-selected method's challenge is already in flight when the selector opens, so choosing another one starts a second session and IBKR kicks the first, which is the "Re-login is required" box in #37 (COMPETE: session kicked out in Gateway's launcher.log) and the server-side rejection in #20. The fix is to run one method: remove the one you don't automate in Client Portal → Settings → User Settings → Security → Secure Login System, and Gateway stops showing the selector altogether. In our experience deleting the IBKR Mobile app from the phone also deactivates IB Key, since it's device-bound — but confirm Mobile Authenticator logs you in first, or you can lock yourself out of the portal. If you can't change the account today, both remaining options need you at login: leave TWOFACTOR_CODE unset and approve the IB Key push on your phone, or finish the login over VNC. Background: #7, #20, #33, #37.

Env vars

Startup

Var Notes
USE_IBG_CONTROLLER yes runs ibg-controller. Unset, the image starts the IBC build from its base image instead.

Credentials

Var Notes
TWS_USERID / TWS_PASSWORD IB credentials
TWS_USERID_PAPER / TWS_PASSWORD_PAPER Paper credentials, used when TRADING_MODE=paper
TWOFACTOR_CODE The base32 secret from IBKR's Mobile Authenticator enrolment — not a generated six-digit code. Validated at startup; a wrong-shaped value exits with ALERT_2FA_FAILED reason="TWOFACTOR_CODE is not a base32 secret". Leave unset for IB Key push.
TWS_PASSWORD_FILE, TWOFACTOR_CODE_FILE Docker-secrets variants: read the value from a file
TRADING_MODE live, paper (default), or both. In both, a mode that fails to log in relaunches its own Gateway up to three times without touching the other. If that fails, live takes priority: a dead live controller stops the container so its restart policy brings both back, while a dead paper controller leaves live running and waits for the next restart. Run two single-mode containers if the modes must stay fully independent.
TWOFA_DEVICE Multi-method accounts only: names the method TWOFACTOR_CODE satisfies (default Mobile Authenticator app). Matched against Gateway's device list without regard to case or spacing; if nothing matches, the log lists the entries it found. Ignored on single-method accounts. Setting this rarely makes a two-method account work unattended — on most accounts we've seen, IBKR kicks the session when the method is switched mid-login. See 2FA.
PASSKEY_AUTHENTICATE yes makes the controller press Authenticate on Gateway's passkey prompt; an authenticator running alongside the container completes the WebAuthn ceremony. Unset, a passkey prompt fails loudly. Needs an amd64 base and extra browser libraries; see 2FA.

Connection

Var Notes
TWS_SERVER / TWS_SERVER_PAPER IBKR regional server hostname — see docs/BOOTSTRAP.md
GATEWAY_OR_TWS gateway (default) or tws. TWS is experimental: this image ships IB Gateway only, so TWS means your own install. The controller then checks TWS's API port: API_PORT when set (the image's scripts export it), otherwise 7496 live / 7497 paper

Dialog handling

Var Notes
EXISTING_SESSION_DETECTED_ACTION primary (default) / primaryoverride / secondary / manual
TWOFA_EXIT_INTERVAL Seconds to wait for the 2FA dialog (default 120)
TWOFA_TIMEOUT_ACTION On timeout: exit, restart, or none (default)
RELOGIN_AFTER_TWOFA_TIMEOUT yes/no: re-drive the login form once before the timeout action
BYPASS_WARNING Extra disclaimer button labels to auto-dismiss (comma/semicolon-separated). Bare OK is refused — it cancels in-progress logins.
TWS_COLD_RESTART yes skips the warm-state copy and cold-starts Gateway
TWS_ACCEPT_INCOMING What to do with the "Accept incoming connection" dialog Gateway or TWS shows when an API client connects from an address outside Trusted IPs: manual (default) leaves it to you, accept / reject answers it, as IBC's AcceptIncomingConnectionAction did. Adding your clients to Trusted IPs is safer than accept, which lets any client that can reach the port connect

Post-login API config

Var Notes
TWS_MASTER_CLIENT_ID Master API client ID
READ_ONLY_API yes/no
AUTO_LOGOFF_TIME / AUTO_RESTART_TIME HH:MM / HH:MM AM/PM. Gateway shows one field or the other depending on account state; set both vars and the controller handles whichever is displayed. AM times don't work yet: Gateway keeps AM/PM in a separate control the agent can't reach, so an AM value is stored as PM and ALERT_CONFIG_NOT_APPLIED fires every login. Use a PM time or an external scheduler. Gateway's own logoff timer is unreliable, so if the same Gateway is still running 5 minutes after AUTO_LOGOFF_TIME, the controller logs it off itself (LOGOFF_BACKSTOP). It only does this for a time written with AM or PM (05:01 PM) that Gateway showed back on that login, for a session that logged in before that time, within an hour after it, and only when AUTO_RESTART_TIME is unset and Gateway's time zone (TIME_ZONE) matches the container's (TZ) — so it never adds a logoff Gateway wasn't already scheduled to do. With no AUTO_LOGOFF_TIME, nothing changes.
AUTO_RESTART_ADOPT Default yes: when Gateway restarts itself at AUTO_RESTART_TIME, adopt the new JVM instead of launching a second one. Gateway normally carries the session across, so no login and no 2FA; when it doesn't, the controller re-drives login on the adopted JVM. no restores always-relaunch. Wait budget: AUTO_RESTART_ADOPT_TIMEOUT_SECONDS (90).

Command server

Var Notes
CONTROLLER_COMMAND_SERVER_PORT TCP port for STOP, RESTART, RECONNECTACCOUNT and ENABLEAPI, using IBC's command names. Unset = disabled. IBC's default port was 7462.
CONTROLLER_COMMAND_SERVER_HOST Bind address, default 0.0.0.0 so a published port reaches it. Set CONTROLLER_COMMAND_SERVER_AUTH_TOKEN too: Docker's -p 127.0.0.1:... keeps the host's network out, but not other containers on the same Docker network
CONTROLLER_COMMAND_SERVER_AUTH_TOKEN Optional shared secret; clients send AUTH <token> first. Strongly recommended if the port is reachable beyond localhost.

Health and recovery

Var Notes
CONTROLLER_HEALTH_SERVER_PORT HTTP /health port (default 8080 in the shipped image; empty disables)
CONTROLLER_HEALTH_SERVER_HOST Bind address, default 0.0.0.0
CCP_MAINTENANCE_RECOVERY_DELAY_SECONDS Delay before re-auth inside IBKR's nightly reset window (default 480)
IBKR_RESET_WINDOWS / IBKR_RESET_TZ IBKR's daily reset window (default 23:30-02:00 America/New_York, covering the published North America resets and, most of the year, Europe's). Accounts hosted in Asia: set your region's times
UPSTREAM_DOWN_GRACE_SECONDS How long Gateway may report no connection to IBKR before UPSTREAM_DOWN (default 600)
UPSTREAM_RELAUNCH Whether the controller relaunches a mode stuck in UPSTREAM_DOWN. Default: only when the login is unattended (TWOFACTOR_CODE set); yes / no to force
CCP_LOCKOUT_MAX_JVM_RESTARTS JVM restarts allowed on persistent CCP lockout (default 0 = halt loudly)

Paths and debugging

Var Notes
TWS_SETTINGS_PATH Gateway settings dir; set per instance by run.sh in dual mode
GATEWAY_WARM_STATE Optional dir copied into the settings dir before launch (seeds jts.ini + autorestart tokens)
GATEWAY_INPUT_AGENT_JAR / GATEWAY_INPUT_AGENT_SOCKET Agent jar / socket path overrides
CONTROLLER_READY_FILE Readiness signal file override
CONTROLLER_DEBUG 1 = debug logging
CONTROLLER_TEST_MODE 1 = exit right after clicking Log In (smoke tests)

Monitoring

GET /health returns controller state, JVM liveness, API port status, and last-auth timestamp — HTTP 200 when logged in and serving, 503 otherwise. GET /ready is a process-liveness probe. The shipped Dockerfile wires this into a Docker HEALTHCHECK.

Probe the published port, not 4001. Clients reach Gateway through a socat forwarder on 4003 (live) and 4004 (paper). Gateway's own port can be fine while every client is cut off, which is what happened when a forwarder died in the field. /health reports both: api_port_open for Gateway and socat_port_open for the forwarder. run.sh now restarts a forwarder that dies, so socat_port_open: false should only last a few seconds — longer means something is wrong. Expect it to read false briefly after every login too, since the forwarder starts just after Gateway is ready.

An open port doesn't mean Gateway is connected to IBKR. When Gateway loses its upstream link, its API port stays open and clients connect, but every request times out. The controller reads Gateway's own status label: upstream_connected in /health. After 10 minutes disconnected outside IBKR's reset window it reports UPSTREAM_DOWN (503) and logs ALERT_UPSTREAM_DOWN. If your login runs unattended (TWOFACTOR_CODE set), it also relaunches that mode, backing off to hourly until Gateway reads connected; while upstream_recovery_active is true, let it work rather than restarting the container. With IB Key, a passkey or a VNC login it only reports, since a relaunch would mean a phone prompt or a halt. UPSTREAM_RELAUNCH=yes|no overrides either way. A relaunch that meets a CCP lockout is handled like any CCP lockout (by default a halt).

If anything in your stack restarts unhealthy containers, read this. When a 2FA failure needs you, the controller halts instead of exiting, and /health answers 503 — so the shipped HEALTHCHECK marks the container unhealthy. Plain Docker ignores that, but Kubernetes liveness probes, Swarm, autoheal sidecars and "restart unhealthy" monitors will restart it and recreate the login loop the halt prevents. The same goes for UPSTREAM_DOWN: a restart mid-recovery also logs out the other mode. Point liveness at /ready, which stays 200 while the process is deliberately alive, and keep /health for readiness.

API clients must re-arm their subscriptions after a Gateway restart. A client that only reconnects its socket can report itself connected while market data stays frozen — the subscriptions died with the old JVM. Re-subscribe on reconnect, or watch your own bar timestamps rather than the connection flag.

The logs carry stable ALERT_* tokens (ALERT_2FA_FAILED, ALERT_LOGIN_FAILED, ALERT_CCP_PERSISTENT, ALERT_PASSWORD_EXPIRED, ALERT_SHUTDOWN, ...) that monitors can grep regardless of log level. Token names and keys are a stability contract. Full inventory, field semantics, and integration examples: docs/OBSERVABILITY.md.

Operator playbook for failure scenarios (CCP lockout, 2FA failure, JVM crash): docs/DISCONNECT_RECOVERY.md.

How it works

                   ┌────────────────────────────────────────┐
                   │  Docker container (headless)           │
                   │                                        │
                   │  Xvfb :1  ← matchbox WM                │
                   │     │                                  │
                   │     ↓                                  │
                   │  IB Gateway JVM                        │
                   │  └─ -javaagent:gateway-input-agent.jar │
                   │            │                           │
                   │            ↓                           │
                   │     Unix socket (/tmp/gateway-input-{mode}.sock)
                   │            ↑                           │
                   │  gateway_controller.py (Python)        │
                   │    ├─ state machine: login → 2FA →     │
                   │    │    config → ready → monitor       │
                   │    ├─ command server (TCP)             │
                   │    └─ /health endpoint                 │
                   │                                        │
                   └────────────────────────────────────────┘

Gateway's Swing fields reject every external input mechanism (synthetic X11 events, AT-SPI writes), so a small Java agent (~1,100 lines, no dependencies) is loaded into Gateway's JVM via -javaagent: and does the UI work from inside — setText, doClick, tree/list selection — over a line-based Unix-socket protocol. The Python controller runs the state machine and never touches the UI directly. Design history and the reasoning behind each piece: docs/ARCHITECTURE.md.

The controller doesn't use IBC, oathtool or xdotool. It computes TOTP codes with the Python standard library, and the agent types from inside the JVM.

Building

make            # build the agent jar, stage controller into dist/
make test       # build checks + full unit suite (stdlib unittest, no pip)
make release VERSION=x.y.z
make install DESTDIR=/home/ibgateway

Needs make and a JDK 17+. No Maven, no Gradle, no pip. Release tarballs with an install.sh are attached to each GitHub release.

Troubleshooting

CCP LOCKOUT DETECTED / login retries looping. IBKR's auth server rate-limits fresh logins after failed attempts. The controller detects it, backs off exponentially (60s → 600s), and retries in-JVM — just let it run; it typically clears in 5–60 minutes. If you're stuck after an hour: check the userid matches the trading mode, check TWS_SERVER against docs/BOOTSTRAP.md, and see the full playbook. On images before v0.5.12 this warning was usually a JVM-internal deadlock, not a real lockout — upgrade.

"Gateway PID unknown (agent never reported one)". The in-JVM agent didn't start. Check the -javaagent: flag is on the JVM command line, the socket in GATEWAY_INPUT_AGENT_SOCKET exists, and /tmp/jvm_console_${TRADING_MODE}.log for agent boot errors.

Container stays up but never becomes ready, log says HALTED. Deliberate. A 2FA failure that only you can clear — two methods on the account, a TWOFA_DEVICE that matches nothing, a passkey prompt without PASSKEY_AUTHENTICATE — stops the controller instead of exiting, because exiting lets Docker restart it and re-run the same failing login every few minutes. The ALERT_2FA_FAILED line above the halt says what to fix. VNC stays reachable, and if you finish the login by hand within 300 s of the failure the controller picks the session up and carries on.

"Existing session detected" loops forever. Something else keeps logging in as the same account (another container, TWS on your desktop, the mobile app). Shut the other session down.

"Auto Log Off Time" label not found. Gateway shows either the logoff or the restart field depending on account state — set both AUTO_LOGOFF_TIME and AUTO_RESTART_TIME and the controller handles whichever is displayed.

Security

Supply chain: no third-party dependencies (Python stdlib + one dependency-free Java file), digest-pinned base image, cosign-signed release images. Verification recipes and reporting: SECURITY.md.

Deployment hygiene:

  • Command server: set CONTROLLER_COMMAND_SERVER_AUTH_TOKEN. Without a token, anyone who can reach the port can send STOP/RESTART, and publishing it as -p 127.0.0.1:7462:7462 still leaves it open to other containers on the same Docker network. RESTART and RECONNECTACCOUNT are refused while the controller is halted or already relaunching.
  • Credentials: use --env-file (mode 600) or the _FILE secrets variants, never -e on the command line.
  • Logs: the controller redacts account numbers and (v0.6.3+) password fields at the source, but window titles can still carry your username, and Gateway's own launcher.log is not under our control. Review before posting publicly.
  • GATEWAY_WARM_STATE is trusted input — only point it at a directory you own.

License

MIT — see LICENSE and NOTICE.

Acknowledgements

  • @rlktradewright for IBC, which with its predecessor IBController automated TWS and Gateway for 23 years until its retirement in September 2026. Most of what we know about driving Gateway's dialogs comes from reading it.
  • Lcstyle/ibctl for the in-JVM agent idea and the edge-case catalog.
  • @gnzsnz for ib-gateway-docker and for steering this tool's architecture in issue #366.

Releases

Packages

Contributors

Languages