Skip to content

Capture Gateway Screen #19

Capture Gateway Screen

Capture Gateway Screen #19

name: Capture Gateway Screen
on:
workflow_dispatch:
inputs:
target:
description: Gateway target (from IB_GATEWAY_TARGETS_JSON)
required: false
default: all
type: string
screen_action:
description: Optional interaction before capturing the screen
required: false
default: capture-only
type: choice
options:
- capture-only
- resend-notification
- challenge-response
- qr-code
wait_for_second_factor:
description: Wait for the Second Factor Authentication window before capturing
required: false
default: false
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.resolve.outputs.matrix }}
steps:
- name: Resolve gateway targets
id: resolve
uses: actions/github-script@v8
with:
script: |
const raw = ${{ toJSON(vars.IB_GATEWAY_TARGETS_JSON) }};
if (!raw || !raw.trim()) {
core.setFailed("IB_GATEWAY_TARGETS_JSON is required");
return;
}
const loaded = JSON.parse(raw);
const targets = Array.isArray(loaded)
? loaded
: Object.entries(loaded).map(([name, config]) => ({...config, name}));
if (!Array.isArray(targets)) {
core.setFailed("IB_GATEWAY_TARGETS_JSON must be an object or list");
return;
}
const selectedName = ${{ toJSON(github.event.inputs.target || 'all') }};
const selected = targets
.map((target, targetIndex) => ({target, targetIndex}))
.filter(({target}) => selectedName === "all" || target.name === selectedName);
if (!selected.length) {
core.setFailed("Unknown gateway target; choose one of the configured targets");
return;
}
const maskedTarget = (name) => {
const digits = String(name).replace(/\D/g, "");
return digits.length >= 4 ? `U***${digits.slice(-4)}` : "<target>";
};
core.info(`Targets: ${selected.map(({target}) => maskedTarget(target.name)).join(", ")}`);
const crypto = require("crypto");
const matrixTargets = selected.map(({target, targetIndex}) => ({
target_index: targetIndex,
target_digest: crypto.createHash("sha256").update(JSON.stringify(target)).digest("hex")
}));
core.setOutput("matrix", JSON.stringify({include: matrixTargets}));
capture:
name: Capture gateway screen
needs: resolve
if: needs.resolve.outputs.matrix != '{"include":[]}'
runs-on: ubuntu-latest
timeout-minutes: 8
strategy:
matrix: ${{ fromJson(needs.resolve.outputs.matrix) }}
fail-fast: false
permissions:
contents: read
id-token: write
env:
SCREEN_ACTION: ${{ github.event.inputs.screen_action }}
WAIT_FOR_SECOND_FACTOR: ${{ github.event.inputs.wait_for_second_factor }}
steps:
- name: Mask target metadata
id: metadata
uses: actions/github-script@v8
with:
script: |
const raw = ${{ toJSON(vars.IB_GATEWAY_TARGETS_JSON) }};
if (!raw || !raw.trim()) {
core.setFailed("IB_GATEWAY_TARGETS_JSON is required");
return;
}
const loaded = JSON.parse(raw);
const targets = Array.isArray(loaded)
? loaded
: Object.entries(loaded).map(([name, config]) => ({...config, name}));
const target = targets[Number(${{ toJSON(matrix.target_index) }})];
if (!target) {
core.setFailed("Resolved gateway target is unavailable");
return;
}
const crypto = require("crypto");
const targetDigest = crypto.createHash("sha256").update(JSON.stringify(target)).digest("hex");
if (targetDigest !== ${{ toJSON(matrix.target_digest) }}) {
core.setFailed("Resolved gateway target changed between jobs");
return;
}
const metadata = {
TARGET_NAME: target.name,
GCP_PROJECT_ID: target.gcp_project_id,
GCP_WORKLOAD_IDENTITY_PROVIDER: target.gcp_workload_identity_provider,
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: target.gcp_workload_identity_service_account,
GCE_USER: target.gce_user,
GCE_INSTANCE_NAME: target.gce_instance_name,
GCE_ZONE: target.gce_zone,
DEPLOY_PATH: target.deploy_path,
SSH_PRIVATE_KEY_SECRET_NAME: target.ssh_private_key_secret_name,
IB_GATEWAY_MODE: target.mode,
IB_GATEWAY_CONTAINER_NAME: target.container_name,
IB_GATEWAY_COMPOSE_SERVICE_NAME: target.compose_service_name,
IB_GATEWAY_UNIT_SUFFIX: target.unit_suffix || ""
};
const authenticationOutputs = new Set([
"GCP_PROJECT_ID",
"GCP_WORKLOAD_IDENTITY_PROVIDER",
"GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT"
]);
for (const [name, value] of Object.entries(metadata)) {
if (value && !authenticationOutputs.has(name)) core.setSecret(String(value));
core.exportVariable(name, value || "");
if (authenticationOutputs.has(name)) core.setOutput(name.toLowerCase(), value || "");
}
- name: Mask authentication metadata
uses: actions/github-script@v8
with:
script: |
const values = [
${{ toJSON(steps.metadata.outputs.gcp_project_id) }},
${{ toJSON(steps.metadata.outputs.gcp_workload_identity_provider) }},
${{ toJSON(steps.metadata.outputs.gcp_workload_identity_service_account) }}
];
for (const value of values) {
if (value) core.setSecret(String(value));
}
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ steps.metadata.outputs.gcp_workload_identity_provider }}
service_account: ${{ steps.metadata.outputs.gcp_workload_identity_service_account }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ steps.metadata.outputs.gcp_project_id }}
version: '>= 416.0.0'
- name: Prepare SSH key
run: |
set -euo pipefail
if [ -z "${SSH_PRIVATE_KEY_SECRET_NAME:-}" ]; then
echo "SSH_PRIVATE_KEY_SECRET_NAME is required." >&2
exit 1
fi
install -d -m 700 "$RUNNER_TEMP/ssh"
SSH_KEY_FILE="$RUNNER_TEMP/ssh/google_compute_engine"
ssh_private_key="$(gcloud secrets versions access latest \
--project "${GCP_PROJECT_ID}" \
--secret "${SSH_PRIVATE_KEY_SECRET_NAME}")"
printf '%s\n' "$ssh_private_key" | tr -d '\r' > "$SSH_KEY_FILE"
chmod 600 "$SSH_KEY_FILE"
ssh-keygen -y -f "$SSH_KEY_FILE" > "$SSH_KEY_FILE.pub"
chmod 644 "$SSH_KEY_FILE.pub"
echo "SSH_KEY_FILE=$SSH_KEY_FILE" >> "$GITHUB_ENV"
- name: Capture X11 screen
run: |
set -euo pipefail
for var_name in GCE_USER GCE_INSTANCE_NAME GCE_ZONE; do
if [ -z "${!var_name:-}" ]; then
echo "${var_name} is required." >&2
exit 1
fi
done
REMOTE_TARGET="${GCE_USER}@${GCE_INSTANCE_NAME}"
SSH_FLAGS=(
--project "${GCP_PROJECT_ID}"
--zone "${GCE_ZONE}"
--quiet
--tunnel-through-iap
--ssh-key-file "${SSH_KEY_FILE}"
--ssh-flag="-o ServerAliveInterval=30"
--ssh-flag="-o ServerAliveCountMax=4"
--ssh-flag="-o TCPKeepAlive=yes"
)
SCP_FLAGS=(
--project "${GCP_PROJECT_ID}"
--zone "${GCE_ZONE}"
--quiet
--tunnel-through-iap
--ssh-key-file "${SSH_KEY_FILE}"
--scp-flag="-o ServerAliveInterval=30"
--scp-flag="-o ServerAliveCountMax=4"
--scp-flag="-o TCPKeepAlive=yes"
)
remote_xwd="/tmp/ibkr-gateway-screen.xwd"
local_xwd="$RUNNER_TEMP/ibkr-gateway-screen.xwd"
local_png="$RUNNER_TEMP/ibkr-gateway-screen.png"
REMOTE_COMMAND=$(cat <<'EOF'
set -euo pipefail
sudo docker exec -u root ib-gateway bash -lc '
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if ! command -v xdotool >/dev/null 2>&1 || ! command -v xwd >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends xdotool x11-apps
apt-get clean
rm -rf /var/lib/apt/lists/*
fi
export DISPLAY=:1
screen_action=__SCREEN_ACTION__
wait_for_second_factor=__WAIT_FOR_SECOND_FACTOR__
window_id=""
if [ "$wait_for_second_factor" = "true" ]; then
deadline=$((SECONDS + 120))
while [ "$SECONDS" -lt "$deadline" ]; do
window_id=$(xdotool search --name "Second Factor Authentication" 2>/dev/null | tail -n 1 || true)
if [ -n "$window_id" ]; then
break
fi
sleep 2
done
if [ -z "$window_id" ]; then
echo "Second Factor Authentication window not found before timeout." >&2
exit 1
fi
fi
if [ "$screen_action" != "capture-only" ]; then
if [ -z "$window_id" ]; then
window_id=$(xdotool search --name "Second Factor Authentication" 2>/dev/null | tail -n 1 || true)
fi
if [ -z "$window_id" ]; then
echo "Second Factor Authentication window not found." >&2
exit 1
fi
geometry=$(xdotool getwindowgeometry --shell "$window_id")
width=$(printf "%s\n" "$geometry" | sed -n "s/^WIDTH=//p")
height=$(printf "%s\n" "$geometry" | sed -n "s/^HEIGHT=//p")
case "$screen_action" in
resend-notification)
click_x=$((width * 42 / 100))
click_y=$((height * 78 / 100))
;;
challenge-response)
click_x=$((width * 48 / 100))
click_y=$((height * 83 / 100))
;;
qr-code)
click_x=$((width * 42 / 100))
click_y=$((height * 87 / 100))
;;
*)
echo "Unsupported screen action: $screen_action" >&2
exit 1
;;
esac
xdotool windowactivate --sync "$window_id" || true
xdotool windowfocus "$window_id" || true
xdotool mousemove --window "$window_id" "$click_x" "$click_y"
xdotool click 1
sleep 3
fi
if [ -n "$window_id" ]; then
xwd -id "$window_id" -silent -out /tmp/ibkr-gateway-screen.xwd
else
xwd -root -silent -out /tmp/ibkr-gateway-screen.xwd
fi
chmod 0644 /tmp/ibkr-gateway-screen.xwd
'
sudo docker cp ib-gateway:/tmp/ibkr-gateway-screen.xwd /tmp/ibkr-gateway-screen.xwd
sudo chmod 0644 /tmp/ibkr-gateway-screen.xwd
ls -lh /tmp/ibkr-gateway-screen.xwd
EOF
)
screen_action_quoted="$(printf '%q' "${SCREEN_ACTION:-capture-only}")"
REMOTE_COMMAND="${REMOTE_COMMAND/__SCREEN_ACTION__/${screen_action_quoted}}"
wait_for_second_factor_quoted="$(printf '%q' "${WAIT_FOR_SECOND_FACTOR:-false}")"
REMOTE_COMMAND="${REMOTE_COMMAND/__WAIT_FOR_SECOND_FACTOR__/${wait_for_second_factor_quoted}}"
gcloud compute ssh "${REMOTE_TARGET}" "${SSH_FLAGS[@]}" --command "${REMOTE_COMMAND}"
gcloud compute scp "${REMOTE_TARGET}:${remote_xwd}" "${local_xwd}" "${SCP_FLAGS[@]}"
sudo apt-get update
sudo apt-get install -y --no-install-recommends netpbm
xwdtopnm "${local_xwd}" | pnmtopng > "${local_png}"
ls -lh "${local_png}"
echo "SCREENSHOT_PATH=${local_png}" >> "$GITHUB_ENV"
- name: Upload screenshot
uses: actions/upload-artifact@v7
with:
name: ibkr-gateway-screen
path: ${{ env.SCREENSHOT_PATH }}
if-no-files-found: error