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
135 changes: 100 additions & 35 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ on:
- cron: '0 0 * * *'
workflow_dispatch:

env:
GCP_PROJECT_ID: interactivebrokersquant
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-ibkr-gateway-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-gateway-deploy@interactivebrokersquant.iam.gserviceaccount.com

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
env:
GCE_USER: ${{ vars.IB_GATEWAY_GCE_USER }}
GCE_INSTANCE_NAME: ${{ vars.IB_GATEWAY_INSTANCE_NAME }}
Expand All @@ -21,23 +29,16 @@ jobs:
ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY: ${{ vars.IB_GATEWAY_ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY }}
TWS_ACCEPT_INCOMING: ${{ vars.IB_GATEWAY_TWS_ACCEPT_INCOMING }}
READ_ONLY_API: ${{ vars.IB_GATEWAY_READ_ONLY_API }}
SSH_PRIVATE_KEY_SECRET_NAME: ${{ vars.IB_GATEWAY_SSH_PRIVATE_KEY_SECRET_NAME }}
TWS_USERID_SECRET_NAME: ${{ vars.IB_GATEWAY_TWS_USERID_SECRET_NAME }}
TWS_PASSWORD_SECRET_NAME: ${{ vars.IB_GATEWAY_TWS_PASSWORD_SECRET_NAME }}
TOTP_SECRET_SECRET_NAME: ${{ vars.IB_GATEWAY_TOTP_SECRET_SECRET_NAME }}
VNC_SERVER_PASSWORD_SECRET_NAME: ${{ vars.IB_GATEWAY_VNC_SERVER_PASSWORD_SECRET_NAME }}
steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Authenticate to Google Cloud
id: auth
uses: google-github-actions/auth@v3
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}

- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ steps.auth.outputs.project_id }}
version: '>= 416.0.0'

- name: Prepare SSH key and deployment files
- name: Check whether deployment config is complete
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
TWS_USERID: ${{ secrets.TWS_USERID }}
Expand All @@ -52,45 +53,115 @@ jobs:
GCE_INSTANCE_NAME
GCE_ZONE
DEPLOY_PATH
SSH_PRIVATE_KEY
TWS_USERID
TWS_PASSWORD
TOTP_SECRET
VNC_SERVER_PASSWORD
IB_GATEWAY_MODE
CLOUD_RUN_EGRESS_CIDR
ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY
)

for var_name in "${required_vars[@]}"; do
if [ -z "${!var_name:-}" ]; then
echo "${var_name} is required. Set it in GitHub Actions secrets before running this workflow." >&2
echo "${var_name} is required. Set it in GitHub Actions variables before running this workflow." >&2
exit 1
fi
done

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 SSH_PRIVATE_KEY_SECRET_NAME SSH_PRIVATE_KEY SSH_PRIVATE_KEY
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 TOTP_SECRET_SECRET_NAME TOTP_SECRET TOTP_SECRET
require_secret_source VNC_SERVER_PASSWORD_SECRET_NAME VNC_SERVER_PASSWORD VNC_SERVER_PASSWORD

- name: Authenticate to Google Cloud
id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}

- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
version: '>= 416.0.0'

- name: Prepare SSH key and deployment files
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

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:-}")"
tws_userid="$(resolve_secret "${TWS_USERID_SECRET_NAME:-}" "${TWS_USERID:-}")"
tws_password="$(resolve_secret "${TWS_PASSWORD_SECRET_NAME:-}" "${TWS_PASSWORD:-}")"
totp_secret="$(resolve_secret "${TOTP_SECRET_SECRET_NAME:-}" "${TOTP_SECRET:-}")"
vnc_server_password="$(resolve_secret "${VNC_SERVER_PASSWORD_SECRET_NAME:-}" "${VNC_SERVER_PASSWORD:-}")"

for required_value in ssh_private_key tws_userid tws_password totp_secret vnc_server_password; do
if [ -z "${!required_value:-}" ]; then
echo "Resolved ${required_value} is empty." >&2
exit 1
fi
done

echo "::add-mask::${tws_userid}"
echo "::add-mask::${tws_password}"
echo "::add-mask::${totp_secret}"
echo "::add-mask::${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"
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 "SSH_PRIVATE_KEY is invalid or passphrase-protected; provide an unencrypted private key that matches the VM authorized_keys entry." >&2
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"
export RESOLVED_TWS_PASSWORD="$tws_password"
export RESOLVED_TOTP_SECRET="$totp_secret"
export RESOLVED_VNC_SERVER_PASSWORD="$vnc_server_password"
python3 - <<'PY'
import os

env_file = os.environ["ENV_FILE"]
values = {
"TWS_USERID": os.environ["TWS_USERID"],
"TWS_PASSWORD": os.environ["TWS_PASSWORD"],
"TOTP_SECRET": os.environ["TOTP_SECRET"],
"VNC_SERVER_PASSWORD": os.environ["VNC_SERVER_PASSWORD"],
"TWS_USERID": os.environ["RESOLVED_TWS_USERID"],
"TWS_PASSWORD": os.environ["RESOLVED_TWS_PASSWORD"],
"TOTP_SECRET": os.environ["RESOLVED_TOTP_SECRET"],
"VNC_SERVER_PASSWORD": os.environ["RESOLVED_VNC_SERVER_PASSWORD"],
"TRADING_MODE": os.environ["IB_GATEWAY_MODE"],
"ACCEPT_API_FROM_IP": os.environ["CLOUD_RUN_EGRESS_CIDR"],
"ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY": os.environ["ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY"],
Expand All @@ -113,7 +184,7 @@ jobs:

REMOTE_TARGET="${GCE_USER}@${GCE_INSTANCE_NAME}"
SSH_COMMON_FLAGS=(
--project "${{ steps.auth.outputs.project_id }}"
--project "${GCP_PROJECT_ID}"
--zone "${GCE_ZONE}"
--quiet
--tunnel-through-iap
Expand All @@ -137,12 +208,8 @@ jobs:
EOF
)

gcloud compute ssh "${REMOTE_TARGET}" \
"${SSH_COMMON_FLAGS[@]}" \
--command "${REMOTE_SYNC_COMMAND}"

gcloud compute scp "${ENV_FILE}" "${REMOTE_TARGET}:${DEPLOY_PATH}/.env" \
"${SSH_COMMON_FLAGS[@]}"
gcloud compute ssh "${REMOTE_TARGET}" "${SSH_COMMON_FLAGS[@]}" --command "${REMOTE_SYNC_COMMAND}"
gcloud compute scp "${ENV_FILE}" "${REMOTE_TARGET}:${DEPLOY_PATH}/.env" "${SSH_COMMON_FLAGS[@]}"

REMOTE_DEPLOY_COMMAND=$(cat <<EOF
set -euo pipefail
Expand All @@ -163,6 +230,4 @@ jobs:
EOF
)

gcloud compute ssh "${REMOTE_TARGET}" \
"${SSH_COMMON_FLAGS[@]}" \
--command "${REMOTE_DEPLOY_COMMAND}"
gcloud compute ssh "${REMOTE_TARGET}" "${SSH_COMMON_FLAGS[@]}" --command "${REMOTE_DEPLOY_COMMAND}"
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ The workflow maps these shared values to the gateway container's `.env`:

`ACCEPT_API_FROM_IP` is intentionally treated as required now. For manual `docker compose` usage, if you forget to set it, Compose will fail fast instead of starting a gateway that Cloud Run can never reach.

This shared GitHub config is scoped to the **IBKR deployment pair only** (`InteractiveBrokersPlatform` + `IBKRGatewayManager`). It should not be treated as a platform-wide secret set for unrelated quant projects. Secrets such as `GCP_SA_KEY`, `SSH_PRIVATE_KEY`, `TWS_USERID`, and `TWS_PASSWORD` remain repository-specific deployment credentials for this gateway module.
This shared GitHub config is scoped to the **IBKR deployment pair only** (`InteractiveBrokersPlatform` + `IBKRGatewayManager`). It should not be treated as a platform-wide secret set for unrelated quant projects. The gateway workflow now authenticates to GCP with **GitHub OIDC + Workload Identity Federation** instead of a long-lived `GCP_SA_KEY`.

### 3. Start IBKR Gateway

Expand Down Expand Up @@ -138,17 +138,32 @@ When recreating your VM, use this order:

### GitHub Config for Auto Deploy

**GitHub Authentication**

This workflow now uses **GitHub OIDC + Workload Identity Federation** for Google Cloud auth. You do **not** need `GCP_SA_KEY` anymore.

**GitHub Secrets**

| Secret | Purpose |
| :--- | :--- |
| `GCP_SA_KEY` | GCP service account JSON key |
| `SSH_PRIVATE_KEY` | SSH private key for VM login |
| `TWS_USERID` | IBKR username |
| `TWS_PASSWORD` | IBKR password |
| `TOTP_SECRET` | IBKR TOTP secret |
| `VNC_SERVER_PASSWORD` | VNC password |

**Optional GitHub Variables for Secret Manager**

If you want to stop storing the gateway credentials in GitHub Secrets, set these variables to Secret Manager secret names in project `interactivebrokersquant`. When a `*_SECRET_NAME` variable is present, the workflow reads the latest secret version from Secret Manager; otherwise it falls back to the matching GitHub secret.

| Variable | Reads secret value for |
| :--- | :--- |
| `IB_GATEWAY_SSH_PRIVATE_KEY_SECRET_NAME` | `SSH_PRIVATE_KEY` |
| `IB_GATEWAY_TWS_USERID_SECRET_NAME` | `TWS_USERID` |
| `IB_GATEWAY_TWS_PASSWORD_SECRET_NAME` | `TWS_PASSWORD` |
| `IB_GATEWAY_TOTP_SECRET_SECRET_NAME` | `TOTP_SECRET` |
| `IB_GATEWAY_VNC_SERVER_PASSWORD_SECRET_NAME` | `VNC_SERVER_PASSWORD` |

**GitHub Variables (recommended shared config)**

| Variable | Purpose |
Expand Down
34 changes: 25 additions & 9 deletions tests/test_workflow_shared_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,29 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
workflow_file="$repo_dir/.github/workflows/main.yml"

grep -Fq "vars.IB_GATEWAY_INSTANCE_NAME" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_ZONE" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_MODE" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_GCE_USER" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_DEPLOY_PATH" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_CLOUD_RUN_EGRESS_CIDR" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_TWS_ACCEPT_INCOMING" "$workflow_file"
grep -Fq "vars.IB_GATEWAY_READ_ONLY_API" "$workflow_file"
grep -Fq 'GCP_PROJECT_ID: interactivebrokersquant' "$workflow_file"
grep -Fq 'providers/github-ibkr-gateway-main' "$workflow_file"
grep -Fq 'ibkr-gateway-deploy@interactivebrokersquant.iam.gserviceaccount.com' "$workflow_file"
grep -Fq 'id-token: write' "$workflow_file"
grep -Fq 'workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}' "$workflow_file"
grep -Fq 'service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_INSTANCE_NAME' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_ZONE' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_MODE' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_GCE_USER' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_DEPLOY_PATH' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_CLOUD_RUN_EGRESS_CIDR' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_TWS_ACCEPT_INCOMING' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_READ_ONLY_API' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_SSH_PRIVATE_KEY_SECRET_NAME' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_TWS_USERID_SECRET_NAME' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_TWS_PASSWORD_SECRET_NAME' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_TOTP_SECRET_SECRET_NAME' "$workflow_file"
grep -Fq 'vars.IB_GATEWAY_VNC_SERVER_PASSWORD_SECRET_NAME' "$workflow_file"
grep -Fq 'gcloud secrets versions access latest' "$workflow_file"
grep -Fq 'resolve_secret()' "$workflow_file"
grep -Fq 'require_secret_source()' "$workflow_file"
grep -Fq '"TRADING_MODE": os.environ["IB_GATEWAY_MODE"]' "$workflow_file"
grep -Fq '"ACCEPT_API_FROM_IP": os.environ["CLOUD_RUN_EGRESS_CIDR"]' "$workflow_file"
grep -Fq 'REMOTE_DEPLOY_COMMAND=$(cat <<EOF' "$workflow_file"
Expand All @@ -22,6 +36,8 @@ if grep -Fq 'DEPLOY_SCRIPT=' "$workflow_file"; then
fi

for legacy_pattern in \
'credentials_json: ${{ secrets.GCP_SA_KEY }}' \
'secrets.GCP_SA_KEY' \
'vars.GCE_USER' \
'secrets.GCE_USER' \
'secrets.GCE_INSTANCE_NAME' \
Expand Down