diff --git a/2fa_bot.py b/2fa_bot.py index c7d25c8..ed93ea0 100644 --- a/2fa_bot.py +++ b/2fa_bot.py @@ -25,6 +25,7 @@ PRE_ENTER_DELAY = 1 XDOTOOL_TIMEOUT = 10 MIN_TOTP_SECONDS_REMAINING = 8 +MAX_AUTOFILL_SUBMISSIONS_RAW = os.environ.get("IBKR_2FA_MAX_SUBMISSIONS", "1") # Window titles to search for 2FA prompts. Live IBKR accounts can show mobile # push / IB Key wording instead of the shorter TOTP-oriented prompts. @@ -66,6 +67,13 @@ datefmt="%Y-%m-%d %H:%M:%S", ) log = logging.getLogger("2fa_bot") +try: + MAX_AUTOFILL_SUBMISSIONS = int(MAX_AUTOFILL_SUBMISSIONS_RAW) +except ValueError: + log.error("IBKR_2FA_MAX_SUBMISSIONS must be an integer") + sys.exit(1) +autofill_submission_count = 0 +autofill_limit_warned = False @dataclass(frozen=True) @@ -81,6 +89,9 @@ def validate_config(): if not AUTOFILL_ENABLED: log.info("TOTP auto-fill is disabled; bot will only log auth popup detection") return + if MAX_AUTOFILL_SUBMISSIONS < 1: + log.error("IBKR_2FA_MAX_SUBMISSIONS must be at least 1") + sys.exit(1) if not SECRET_KEY: log.error("TOTP_SECRET not found in environment variables") sys.exit(1) @@ -197,6 +208,9 @@ def focus_input_area(candidate): def submit_totp(candidate): """Submit a TOTP code to the selected authentication popup.""" + global autofill_limit_warned + global autofill_submission_count + if not AUTOFILL_ENABLED: log.info( "Authentication window found (id=%s, title=%r, size=%sx%s); auto-fill disabled", @@ -207,6 +221,16 @@ def submit_totp(candidate): ) return + if autofill_submission_count >= MAX_AUTOFILL_SUBMISSIONS: + if not autofill_limit_warned: + log.warning( + "Authentication window found but auto-fill submission limit reached " + "(limit=%s); leaving window for manual handling", + MAX_AUTOFILL_SUBMISSIONS, + ) + autofill_limit_warned = True + return + seconds_remaining = wait_for_fresh_totp_window() log.info( "Authentication window found (id=%s, title=%r, size=%sx%s); submitting code with %ss remaining", @@ -228,6 +252,7 @@ def submit_totp(candidate): time.sleep(PRE_ENTER_DELAY) run_xdotool(["key", "--window", candidate.window_id, "Return"]) + autofill_submission_count += 1 log.info("Auto-fill submitted, waiting for gateway response...") diff --git a/README.md b/README.md index 95f7899..0d8d449 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,12 @@ VNC_SERVER_PASSWORD=your_vnc_password TRADING_MODE=live TWS_ACCEPT_INCOMING=accept READ_ONLY_API=no -TWOFA_DEVICE=IB Key +TWOFA_DEVICE=Mobile Authenticator app TWOFA_TIMEOUT_ACTION=restart RELOGIN_AFTER_TWOFA_TIMEOUT=yes EXISTING_SESSION_DETECTED_ACTION=primary IBKR_2FA_AUTOFILL=no +IBKR_2FA_MAX_SUBMISSIONS=1 JAVA_HEAP_SIZE=512 # Recommended: use the exact CIDR used by your Cloud Run egress path @@ -96,7 +97,7 @@ IB_GATEWAY_DEPLOY_PATH=/home/zwlddx0815/ib-docker IB_GATEWAY_ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY=no IB_GATEWAY_TWS_ACCEPT_INCOMING=accept IB_GATEWAY_READ_ONLY_API=no -IB_GATEWAY_TWOFA_DEVICE=IB Key +IB_GATEWAY_TWOFA_DEVICE=Mobile Authenticator app IB_GATEWAY_2FA_AUTOFILL=no ``` @@ -211,8 +212,9 @@ If you temporarily keep the values in GitHub Secrets during migration, you can r | `IB_GATEWAY_ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY` | Set to `no` for Cloud Run private IP access | | `IB_GATEWAY_TWS_ACCEPT_INCOMING` | Optional. Recommended `accept`. | | `IB_GATEWAY_READ_ONLY_API` | Optional. Recommended `no` if this service places trades. | -| `IB_GATEWAY_TWOFA_DEVICE` | Optional. Exact IBC 2FA device name, for example `IB Key` for IBKR Mobile push. | -| `IB_GATEWAY_2FA_AUTOFILL` | Optional. Set to `no` when using IBKR Mobile push instead of local TOTP auto-fill. | +| `IB_GATEWAY_TWOFA_DEVICE` | Optional. Exact IBC 2FA device name, for example `Mobile Authenticator app` for the authenticator-code prompt or `IB Key` for IBKR Mobile push. | +| `IB_GATEWAY_2FA_AUTOFILL` | Optional. Set to `no` while validating the configured TOTP secret or when using IBKR Mobile push instead of local TOTP auto-fill. | +| `IBKR_2FA_MAX_SUBMISSIONS` | Optional container env. Defaults to `1` so an invalid TOTP secret does not keep submitting codes. | The current VM is an `e2-micro`, so the deployment intentionally sets `JAVA_HEAP_SIZE=512` by default and enables a 2 GiB host swap file during keepalive/deploy. Without this, @@ -366,11 +368,12 @@ VNC_SERVER_PASSWORD=your_vnc_password TRADING_MODE=live TWS_ACCEPT_INCOMING=accept READ_ONLY_API=no -TWOFA_DEVICE=IB Key +TWOFA_DEVICE=Mobile Authenticator app TWOFA_TIMEOUT_ACTION=restart RELOGIN_AFTER_TWOFA_TIMEOUT=yes EXISTING_SESSION_DETECTED_ACTION=primary IBKR_2FA_AUTOFILL=no +IBKR_2FA_MAX_SUBMISSIONS=1 JAVA_HEAP_SIZE=512 ACCEPT_API_FROM_IP=10.8.0.0/26 diff --git a/docker-compose.yml b/docker-compose.yml index e01cfa7..4938361 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: # Set to no when IBC should use IBKR Mobile push approval instead of # the local TOTP auto-fill helper. - IBKR_2FA_AUTOFILL=${IBKR_2FA_AUTOFILL:-yes} + - IBKR_2FA_MAX_SUBMISSIONS=${IBKR_2FA_MAX_SUBMISSIONS:-1} # e2-micro has less than 1 GiB RAM. The upstream image defaults to # -Xmx768m, which can starve sshd/Docker/guest-agent. Keep this # configurable, but use a safer default for the current VM. diff --git a/tests/test_docker_compose_ports.sh b/tests/test_docker_compose_ports.sh index 9a22589..5b35cca 100644 --- a/tests/test_docker_compose_ports.sh +++ b/tests/test_docker_compose_ports.sh @@ -36,6 +36,7 @@ grep -Fq ' - TWOFA_TIMEOUT_ACTION=${TWOFA_TIMEOUT_ACTION:-restart}' "$compo grep -Fq ' - RELOGIN_AFTER_TWOFA_TIMEOUT=${RELOGIN_AFTER_TWOFA_TIMEOUT:-yes}' "$compose_file" grep -Fq ' - EXISTING_SESSION_DETECTED_ACTION=${EXISTING_SESSION_DETECTED_ACTION:-primary}' "$compose_file" grep -Fq ' - IBKR_2FA_AUTOFILL=${IBKR_2FA_AUTOFILL:-yes}' "$compose_file" +grep -Fq ' - IBKR_2FA_MAX_SUBMISSIONS=${IBKR_2FA_MAX_SUBMISSIONS:-1}' "$compose_file" grep -Fq ' - JAVA_HEAP_SIZE=${JAVA_HEAP_SIZE:-512}' "$compose_file" grep -Fq ' - IB_GATEWAY_PARALLEL_GC_THREADS=${IB_GATEWAY_PARALLEL_GC_THREADS:-2}' "$compose_file" grep -Fq ' - IB_GATEWAY_CONC_GC_THREADS=${IB_GATEWAY_CONC_GC_THREADS:-1}' "$compose_file"