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
114 changes: 79 additions & 35 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
name: Deploy and Keep Active

on:
push:
branches: [ main ]
paths:
- 'Dockerfile'
- 'docker-compose.yml'
- '2fa_bot.py'
- 'requirements.txt'
- 'scripts/**'
- 'container_overrides/**'
# Runs automatically every day at 0:00 UTC (8:00 AM Beijing Time)
# CI validates every push. Runtime maintenance never installs unreviewed
# source; binary/container changes require an explicit, selected rollout.
# Runs automatically every day at 0:00 UTC (8:00 AM Beijing Time).
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
Expand Down Expand Up @@ -57,6 +50,7 @@ jobs:
env:
TARGETS_JSON: ${{ vars.IB_GATEWAY_TARGETS_JSON }}
SELECTED_TARGET: ${{ github.event.inputs.target }}
SELECTED_DEPLOY_MODE: ${{ github.event.inputs.deploy_mode }}
LEGACY_GCP_PROJECT_ID: ${{ vars.IB_GATEWAY_GCP_PROJECT_ID }}
LEGACY_GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.IB_GATEWAY_GCP_WORKLOAD_IDENTITY_PROVIDER }}
LEGACY_GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.IB_GATEWAY_GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
Expand Down Expand Up @@ -170,6 +164,12 @@ jobs:
normalized.append(target)

selected_name = env("SELECTED_TARGET") or "all"
selected_deploy_mode = env("SELECTED_DEPLOY_MODE") or "keepalive"
if selected_deploy_mode == "full" and selected_name == "all":
raise SystemExit(
"A full gateway deployment requires one explicit target; "
"use keepalive for all-target maintenance."
)
if selected_name == "all":
selected = normalized
else:
Expand Down Expand Up @@ -242,10 +242,6 @@ jobs:
- name: Check whether deployment config is complete
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
TWS_USERID: ${{ secrets.TWS_USERID }}
TWS_PASSWORD: ${{ secrets.TWS_PASSWORD }}
TOTP_SECRET: ${{ secrets.TOTP_SECRET }}
VNC_SERVER_PASSWORD: ${{ secrets.VNC_SERVER_PASSWORD }}
run: |
set -euo pipefail

Expand Down Expand Up @@ -283,6 +279,30 @@ jobs:
}

require_secret_source SSH_PRIVATE_KEY_SECRET_NAME SSH_PRIVATE_KEY SSH_PRIVATE_KEY

- name: Check full-deploy credential config
if: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_mode == 'full' }}
env:
TWS_USERID: ${{ secrets.TWS_USERID }}
TWS_PASSWORD: ${{ secrets.TWS_PASSWORD }}
TOTP_SECRET: ${{ secrets.TOTP_SECRET }}
VNC_SERVER_PASSWORD: ${{ secrets.VNC_SERVER_PASSWORD }}
run: |
set -euo pipefail

require_secret_source() {
local secret_name_var="$1"
local fallback_var="$2"
local label="$3"

if [ -n "${!secret_name_var:-}" ] || [ -n "${!fallback_var:-}" ]; then
return 0
fi

echo "${label} is required. Set ${secret_name_var} as a GitHub variable pointing to Secret Manager, or set ${fallback_var} as a GitHub Actions secret." >&2
exit 1
}

require_secret_source TWS_USERID_SECRET_NAME TWS_USERID TWS_USERID
require_secret_source TWS_PASSWORD_SECRET_NAME TWS_PASSWORD TWS_PASSWORD
require_secret_source VNC_SERVER_PASSWORD_SECRET_NAME VNC_SERVER_PASSWORD VNC_SERVER_PASSWORD
Expand All @@ -305,7 +325,7 @@ jobs:
version: '>= 416.0.0'

- name: Sync GitHub secrets to Secret Manager
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sync_github_secrets_to_secret_manager }}
if: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_mode == 'full' && inputs.sync_github_secrets_to_secret_manager }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
TWS_USERID: ${{ secrets.TWS_USERID }}
Expand Down Expand Up @@ -381,9 +401,43 @@ jobs:
sync_secret "${TOTP_SECRET_SECRET_NAME:-}" "${TOTP_SECRET:-}" "TOTP_SECRET"
fi

- name: Prepare SSH key and deployment files
- name: Prepare SSH key
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
set -euo pipefail

resolve_secret() {
local secret_name="$1"
local fallback_value="$2"

if [ -n "${secret_name:-}" ]; then
gcloud secrets versions access latest --project "${GCP_PROJECT_ID}" --secret "${secret_name}"
else
printf '%s' "${fallback_value}"
fi
}

ssh_private_key="$(resolve_secret "${SSH_PRIVATE_KEY_SECRET_NAME:-}" "${SSH_PRIVATE_KEY:-}")"
if [ -z "${ssh_private_key:-}" ]; then
echo "Resolved ssh_private_key is empty." >&2
exit 1
fi

install -d -m 700 "$RUNNER_TEMP/ssh"
SSH_KEY_FILE="$RUNNER_TEMP/ssh/google_compute_engine"
printf '%s\n' "$ssh_private_key" | tr -d '\r' > "$SSH_KEY_FILE"
chmod 600 "$SSH_KEY_FILE"
if ! ssh-keygen -y -f "$SSH_KEY_FILE" > "$SSH_KEY_FILE.pub"; then
echo "Resolved SSH private key is invalid or passphrase-protected; provide an unencrypted private key that matches the VM authorized_keys entry." >&2
exit 1
fi
chmod 644 "$SSH_KEY_FILE.pub"
echo "SSH_KEY_FILE=$SSH_KEY_FILE" >> "$GITHUB_ENV"

- name: Prepare full-deploy runtime environment
if: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_mode == 'full' }}
env:
TWS_USERID: ${{ secrets.TWS_USERID }}
TWS_PASSWORD: ${{ secrets.TWS_PASSWORD }}
TOTP_SECRET: ${{ secrets.TOTP_SECRET }}
Expand All @@ -402,7 +456,6 @@ jobs:
fi
}

ssh_private_key="$(resolve_secret "${SSH_PRIVATE_KEY_SECRET_NAME:-}" "${SSH_PRIVATE_KEY:-}")"
tws_userid="$(resolve_secret "${TWS_USERID_SECRET_NAME:-}" "${TWS_USERID:-}")"
tws_password="$(resolve_secret "${TWS_PASSWORD_SECRET_NAME:-}" "${TWS_PASSWORD:-}")"
totp_secret=""
Expand All @@ -411,7 +464,7 @@ jobs:
fi
vnc_server_password="$(resolve_secret "${VNC_SERVER_PASSWORD_SECRET_NAME:-}" "${VNC_SERVER_PASSWORD:-}")"

required_values=(ssh_private_key tws_userid tws_password vnc_server_password)
required_values=(tws_userid tws_password vnc_server_password)
twofa_autofill="$(printf '%s' "${IBKR_2FA_AUTOFILL:-}" | tr '[:upper:]' '[:lower:]')"
if [ "${twofa_autofill}" = "yes" ] || [ "${twofa_autofill}" = "true" ] || [ "${twofa_autofill}" = "1" ]; then
required_values+=(totp_secret)
Expand All @@ -435,17 +488,6 @@ jobs:
mask_if_present "${totp_secret:-}"
mask_if_present "${vnc_server_password:-}"

install -d -m 700 "$RUNNER_TEMP/ssh"
SSH_KEY_FILE="$RUNNER_TEMP/ssh/google_compute_engine"
printf '%s\n' "$ssh_private_key" | tr -d '\r' > "$SSH_KEY_FILE"
chmod 600 "$SSH_KEY_FILE"
if ! ssh-keygen -y -f "$SSH_KEY_FILE" > "$SSH_KEY_FILE.pub"; then
echo "Resolved SSH private key is invalid or passphrase-protected; provide an unencrypted private key that matches the VM authorized_keys entry." >&2
exit 1
fi
chmod 644 "$SSH_KEY_FILE.pub"
echo "SSH_KEY_FILE=$SSH_KEY_FILE" >> "$GITHUB_ENV"

ENV_FILE="$RUNNER_TEMP/ibkr-gateway.env"
export ENV_FILE
export RESOLVED_TWS_USERID="$tws_userid"
Expand Down Expand Up @@ -603,15 +645,17 @@ jobs:
copy_remote_file "${ENV_FILE}" "${DEPLOY_PATH}/.env"
}

prepare_remote_workspace

DEPLOY_MODE="full"
if [ "${DEPLOY_EVENT_NAME}" = "schedule" ]; then
DEPLOY_MODE="keepalive"
elif [ "${DEPLOY_EVENT_NAME}" = "workflow_dispatch" ]; then
DEPLOY_MODE="keepalive"
if [ "${DEPLOY_EVENT_NAME}" = "workflow_dispatch" ]; then
DEPLOY_MODE="${WORKFLOW_DISPATCH_MODE:-keepalive}"
fi

if [ "${DEPLOY_MODE}" = "full" ]; then
prepare_remote_workspace
else
log_step "Runtime keepalive uses the already released source and environment"
fi

if [ "${DEPLOY_MODE}" = "keepalive" ]; then
REMOTE_DEPLOY_COMMAND=$(cat <<EOF
set -euo pipefail
Expand Down
81 changes: 51 additions & 30 deletions 2fa_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,6 @@
"no",
"off",
}
DISMISS_SMALL_GATEWAY_DIALOGS = os.environ.get(
"IBKR_DISMISS_SMALL_GATEWAY_DIALOGS",
"yes",
).strip().lower() not in {
"0",
"false",
"no",
"off",
}

# Window titles to search for 2FA prompts. Live IBKR accounts can show mobile
# push / IB Key wording instead of the shorter TOTP-oriented prompts.
SEARCH_PATTERNS = [
Expand Down Expand Up @@ -80,14 +70,18 @@
)
DISMISSIBLE_DIALOG_SEARCH_PATTERNS = [
"Login Messages",
"IBKR Gateway",
"Gateway",
]
DISMISSIBLE_DIALOG_TITLE_KEYWORDS = (
"login messages",
)
SMALL_GATEWAY_DIALOG_TITLE = "ibkr gateway"
SMALL_CONNECTION_DIALOG_TITLE = "gateway"
GATEWAY_BLOCKER_SEARCH_PATTERNS = [
"IBKR Gateway",
"Gateway",
]
GATEWAY_BLOCKER_TITLES = (
"ibkr gateway",
"gateway",
)
SMALL_GATEWAY_DIALOG_MAX_WIDTH = 650
SMALL_GATEWAY_DIALOG_MAX_HEIGHT = 220
# Current IBKR Gateway TOTP prompts place the code field in the upper half of
Expand Down Expand Up @@ -133,6 +127,7 @@ def parse_non_negative_int_env(name, raw_value):
window_limit_warned = set()
last_submission_reset_at = time.monotonic()
dismissed_dialog_windows = set()
reported_gateway_blocker_windows = set()


@dataclass(frozen=True)
Expand Down Expand Up @@ -220,20 +215,15 @@ def is_auth_candidate(title):
return any(keyword in normalized_title for keyword in AUTH_TITLE_KEYWORDS)


def is_small_gateway_dialog(title, width, height):
if not DISMISS_SMALL_GATEWAY_DIALOGS:
return False
if title.lower() != SMALL_GATEWAY_DIALOG_TITLE:
return False
if not width or not height:
return False
return width <= SMALL_GATEWAY_DIALOG_MAX_WIDTH and height <= SMALL_GATEWAY_DIALOG_MAX_HEIGHT
def is_gateway_blocker_dialog(title, width, height):
"""Recognize compact Gateway dialogs without acknowledging them.


def is_small_connection_dialog(title, width, height):
if not DISMISS_SMALL_GATEWAY_DIALOGS:
return False
if title.lower() != SMALL_CONNECTION_DIALOG_TITLE:
Gateway uses generic titles for both harmless notices and actionable login
failures. Sending Return to an unknown dialog can erase the only useful
diagnostic and trigger a restart loop, so these windows must remain visible
for explicit review.
"""
if title.lower() not in GATEWAY_BLOCKER_TITLES:
return False
if not width or not height:
return False
Expand All @@ -242,9 +232,7 @@ def is_small_connection_dialog(title, width, height):

def is_dismissible_dialog_candidate(title, width=None, height=None):
normalized_title = title.lower()
if any(keyword in normalized_title for keyword in DISMISSIBLE_DIALOG_TITLE_KEYWORDS):
return True
return is_small_gateway_dialog(title, width, height) or is_small_connection_dialog(title, width, height)
return any(keyword in normalized_title for keyword in DISMISSIBLE_DIALOG_TITLE_KEYWORDS)


def find_windows_by_patterns(patterns):
Expand Down Expand Up @@ -287,6 +275,36 @@ def find_dismissible_dialogs():
return candidates


def find_gateway_blocker_dialogs():
candidates = []
for window_id in reversed(find_windows_by_patterns(GATEWAY_BLOCKER_SEARCH_PATTERNS)):
title = get_window_title(window_id)
width, height = get_window_geometry(window_id)
if not is_gateway_blocker_dialog(title, width, height):
continue
candidates.append(WindowCandidate(window_id, title, width, height))
return candidates


def report_gateway_blocker_dialogs():
"""Log a stable, non-sensitive signal and leave unknown Gateway UI intact."""
found = False
for candidate in find_gateway_blocker_dialogs():
found = True
if candidate.window_id in reported_gateway_blocker_windows:
continue
log.error(
"GATEWAY_UI_BLOCKER: manual review required; automatic dismissal is disabled "
"(id=%s, title=%r, size=%sx%s)",
candidate.window_id,
candidate.title,
candidate.width or "?",
candidate.height or "?",
)
reported_gateway_blocker_windows.add(candidate.window_id)
return found


def dismiss_dialog(candidate):
if candidate.window_id not in dismissed_dialog_windows:
log.info(
Expand Down Expand Up @@ -447,6 +465,9 @@ def main():

while True:
try:
if report_gateway_blocker_dialogs():
time.sleep(CHECK_INTERVAL)
continue
if dismiss_post_login_dialogs():
time.sleep(1)
continue
Expand Down
16 changes: 8 additions & 8 deletions tests/test_docker_compose_ports.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,8 @@ grep -Fq 'INPUT_CLICK_POSITION = (0.50, 0.40)' "$repo_dir/2fa_bot.py"
grep -Fq 'MIN_TOTP_SECONDS_REMAINING = 15' "$repo_dir/2fa_bot.py"
grep -Fq 'SMALL_GATEWAY_DIALOG_MAX_WIDTH = 650' "$repo_dir/2fa_bot.py"
grep -Fq 'SMALL_GATEWAY_DIALOG_MAX_HEIGHT = 220' "$repo_dir/2fa_bot.py"
grep -Fq 'def is_small_gateway_dialog(title, width, height):' "$repo_dir/2fa_bot.py"
grep -Fq 'SMALL_CONNECTION_DIALOG_TITLE = "gateway"' "$repo_dir/2fa_bot.py"
grep -Fq 'def is_small_connection_dialog(title, width, height):' "$repo_dir/2fa_bot.py"
grep -Fq 'GATEWAY_BLOCKER_TITLES = (' "$repo_dir/2fa_bot.py"
grep -Fq 'def is_gateway_blocker_dialog(title, width, height):' "$repo_dir/2fa_bot.py"
grep -Fq 'is_dismissible_dialog_candidate(title, width, height)' "$repo_dir/2fa_bot.py"
grep -Fq 'def type_totp_into_active_window(code):' "$repo_dir/2fa_bot.py"

Expand All @@ -81,9 +80,10 @@ module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

assert module.is_dismissible_dialog_candidate("Login Messages")
assert module.is_dismissible_dialog_candidate("IBKR Gateway", 509, 131)
assert module.is_dismissible_dialog_candidate("Gateway", 510, 131)
assert not module.is_dismissible_dialog_candidate("IBKR Gateway", 700, 550)
assert not module.is_dismissible_dialog_candidate("IBKR Gateway", 790, 610)
assert not module.is_dismissible_dialog_candidate("Gateway", 790, 610)
assert not module.is_dismissible_dialog_candidate("IBKR Gateway", 509, 131)
assert not module.is_dismissible_dialog_candidate("Gateway", 510, 131)
assert module.is_gateway_blocker_dialog("IBKR Gateway", 509, 131)
assert module.is_gateway_blocker_dialog("Gateway", 510, 131)
assert not module.is_gateway_blocker_dialog("IBKR Gateway", 700, 550)
assert not module.is_gateway_blocker_dialog("Gateway", 790, 610)
PY
12 changes: 5 additions & 7 deletions tests/test_workflow_shared_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,9 @@ grep -Fq 'resolve_secret()' "$workflow_file"
grep -Fq 'require_secret_source()' "$workflow_file"
grep -Fq 'Sync GitHub secrets to Secret Manager' "$workflow_file"
grep -Fq 'gcloud secrets versions add "${secret_name}"' "$workflow_file"
grep -Fq "paths:" "$workflow_file"
grep -Fq "'scripts/**'" "$workflow_file"
grep -Fq "'container_overrides/**'" "$workflow_file"
! grep -Fq "'.github/workflows/main.yml'" "$workflow_file"
grep -Fq 'DEPLOY_MODE="full"' "$workflow_file"
grep -Fq 'if [ "${DEPLOY_EVENT_NAME}" = "schedule" ]; then' "$workflow_file"
grep -Fq 'elif [ "${DEPLOY_EVENT_NAME}" = "workflow_dispatch" ]; then' "$workflow_file"
! grep -Fq ' push:' "$workflow_file"
grep -Fq 'DEPLOY_MODE="keepalive"' "$workflow_file"
grep -Fq 'if [ "${DEPLOY_EVENT_NAME}" = "workflow_dispatch" ]; then' "$workflow_file"
grep -Fq 'DEPLOY_MODE="${WORKFLOW_DISPATCH_MODE:-keepalive}"' "$workflow_file"
grep -Fq 'Scheduled keepalive mode: skip docker build' "$workflow_file"
grep -Fq 'reset_instance_and_wait_for_ssh()' "$workflow_file"
Expand All @@ -76,6 +72,8 @@ grep -Fq 'tar -xzf' "$workflow_file"
grep -Fq 'gcloud compute instances reset "${GCE_INSTANCE_NAME}"' "$workflow_file"
grep -Fq 'run_remote_ssh "Repository sync" "${REMOTE_SYNC_COMMAND}"' "$workflow_file"
grep -Fq 'copy_remote_file "${ENV_FILE}" "${DEPLOY_PATH}/.env"' "$workflow_file"
grep -Fq 'A full gateway deployment requires one explicit target' "$workflow_file"
grep -Fq 'Runtime keepalive uses the already released source and environment' "$workflow_file"
grep -Fq 'sudo bash ./scripts/ensure_host_swap.sh' "$workflow_file"
grep -Fq 'host_2fa_bot_sha256="\$(sha256sum ./2fa_bot.py' "$workflow_file"
grep -Fq 'container_2fa_bot_sha256="\$(sudo timeout 15 docker exec' "$workflow_file"
Expand Down