futu openD support#26
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds optional Futu OpenD support to the existing GCP Cloud Run Terraform module by provisioning a GCE VM running FutuOpenD, wiring Cloud Run to reach it over private ranges, and installing the required OpenD “skills”/SDK bits at runtime.
Changes:
- Added
futu_account/futu_password_md5module variables and.envrcwiring to enable/disable Futu OpenD resources. - Provisioned a GCE VM (+ firewall rule) and generated/stored an RSA private key in Secret Manager for encrypted trade connections.
- Updated the Cloud Run service to enable VPC access, inject FUTU env vars, and run a runtime installer script for Futu skills/Python dependencies.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| terraform/gcp_cloudrun/variables.tf | Adds Futu OpenD configuration variables. |
| terraform/gcp_cloudrun/secrets.tf | Adds a Secret Manager secret/version for the Futu RSA private key. |
| terraform/gcp_cloudrun/provider.tf | Adds the hashicorp/tls provider requirement. |
| terraform/gcp_cloudrun/platform.tf | Adds futu_enabled locals, TLS key generation, GCE VM + firewall, and enables Compute API. |
| terraform/gcp_cloudrun/main.tf | Adds conditional Cloud Run VPC access, injects FUTU env vars, and runs the Futu installer during container startup. |
| terraform/gcp_cloudrun/futu-skills-install.sh | New runtime install script to fetch Futu skills and set up Python/futu-api + RSA handling. |
| terraform/gcp_cloudrun/futu-opend-startup.sh | New VM startup script to install and run FutuOpenD via systemd. |
| terraform/gcp_cloudrun/.envrc | Exports TF vars for the new Futu settings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| variable "futu_password_md5" { | ||
| description = "MD5 hash of Futu login password (echo -n 'password' | md5)" | ||
| type = string | ||
| sensitive = true | ||
| default = "" | ||
| } |
There was a problem hiding this comment.
Good point. Will update the variable description to include both md5 (macOS) and md5sum (Linux) in a follow-up.
| resource "tls_private_key" "futu_rsa" { | ||
| count = local.futu_enabled ? 1 : 0 | ||
| algorithm = "RSA" | ||
| rsa_bits = 1024 |
There was a problem hiding this comment.
Futu OpenD SDK enforces a 1024-bit key requirement at the protocol level — using 2048-bit results in a "Ciphertext with incorrect length" error at handshake time. This is a hard constraint from the Futu API, not a choice. Documented in the startup script comments.
| name = "${var.service_name}-futu-opend" | ||
| machine_type = "e2-micro" | ||
| zone = "${var.region}-b" | ||
| project = var.project_id |
There was a problem hiding this comment.
Intentional trade-off — the VM is co-located with the Cloud Run region to minimise latency. Can be extracted to var.zone in a future iteration if multi-region support is needed.
| resource "google_compute_firewall" "futu_opend_api" { | ||
| count = local.futu_enabled ? 1 : 0 | ||
| name = "${var.service_name}-futu-opend-api" | ||
| network = "default" | ||
| project = var.project_id | ||
|
|
||
| allow { | ||
| protocol = "tcp" | ||
| ports = ["11111"] | ||
| } | ||
|
|
||
| source_ranges = ["10.0.0.0/8"] | ||
| target_tags = ["${var.service_name}-futu-opend"] | ||
| } |
There was a problem hiding this comment.
Intentional — restricts to RFC1918 private ranges only. Port 11111 is not exposed publicly. Can be narrowed to the specific VPC subnet CIDR in a future hardening pass.
| image = local.effective_container_image | ||
| command = ["/bin/sh"] | ||
| args = ["-lc", "mkdir -p /home/node/.openclaw/agents/main/agent /home/node/.openclaw/credentials; [ -n \"$OPENCLAW_JSON\" ] && echo \"$OPENCLAW_JSON\" > /home/node/.openclaw/openclaw.json; [ -n \"$TELEGRAM_ALLOW_FROM\" ] && echo \"$TELEGRAM_ALLOW_FROM\" > /home/node/.openclaw/credentials/telegram-allowFrom.json; printf '{\"openrouter\":{\"apiKey\":\"%s\"}}' \"$OPENROUTER_API_KEY\" > /home/node/.openclaw/agents/main/agent/auth-profiles.json; printf '{\"providers\":{\"openrouter\":{\"baseUrl\":\"https://openrouter.ai/api/v1\",\"api\":\"openai-completions\",\"apiKey\":\"OPENROUTER_API_KEY\"}}}' > /home/node/.openclaw/agents/main/agent/models.json; exec openclaw gateway run --bind lan --port \"$${PORT:-8080}\" --allow-unconfigured"] | ||
| args = ["-lc", "mkdir -p /home/node/.openclaw/agents/main/agent /home/node/.openclaw/credentials; [ -n \"$OPENCLAW_JSON\" ] && printf '%s' \"$OPENCLAW_JSON\" > /home/node/.openclaw/openclaw.json; [ -n \"$TELEGRAM_ALLOW_FROM\" ] && printf '%s' \"$TELEGRAM_ALLOW_FROM\" > /home/node/.openclaw/credentials/telegram-allowFrom.json; printf '{\"openrouter\":{\"apiKey\":\"%s\"}}' \"$OPENROUTER_API_KEY\" > /home/node/.openclaw/agents/main/agent/auth-profiles.json; printf '{\"providers\":{\"openrouter\":{\"baseUrl\":\"https://openrouter.ai/api/v1\",\"api\":\"openai-completions\",\"apiKey\":\"%s\"}}}' \"$OPENROUTER_API_KEY\" > /home/node/.openclaw/agents/main/agent/models.json; ${local.futu_skills_install}exec openclaw gateway run --bind lan --port \"$${PORT:-8080}\" --allow-unconfigured"] |
There was a problem hiding this comment.
Acknowledged trade-off. The skills ZIP is fetched from Futu's official distribution endpoint (not available at image build time), and the installed artifacts are persisted on the rclone-synced R2 volume — so subsequent cold starts skip the install entirely ([ ! -d ...futuapi ] guard). Moving to image build would require vendoring a third-party package with no stable release URL.
| resource "google_secret_manager_secret_version" "futu_rsa_private_key" { | ||
| count = local.futu_enabled ? 1 : 0 | ||
| secret = google_secret_manager_secret.futu_rsa_private_key[0].id | ||
| secret_data = tls_private_key.futu_rsa[0].private_key_pem | ||
| } | ||
|
|
There was a problem hiding this comment.
Accepted risk — Terraform state is stored in a secured backend. The key is no longer exposed via instance metadata (fixed in this PR): the VM now fetches it from Secret Manager at boot using its dedicated service account.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 15 comments.
Comments suppressed due to low confidence (1)
terraform/gcp_cloudrun/futu-skills-install.sh:66
- The conversion pipeline redirects stderr to
/dev/nulland then falls back to writing the original key. This makes failures hard to diagnose in Cloud Run logs. Consider logging a clear warning/error when conversion fails (and/or making the failure fatal if encryption is required).
" > /home/node/.openclaw/credentials/futu-rsa-private.pem 2>/dev/null || \
printf '%s' "$FUTU_RSA_PRIVATE_KEY" > /home/node/.openclaw/credentials/futu-rsa-private.pem
chmod 600 /home/node/.openclaw/credentials/futu-rsa-private.pem
| } | ||
|
|
||
| variable "futu_password_md5" { | ||
| description = "MD5 hash of Futu login password (echo -n 'password' | md5)" |
There was a problem hiding this comment.
Good point. Will update the variable description to include both md5 (macOS) and md5sum (Linux) in a follow-up.
|
|
||
| resource "tls_private_key" "futu_rsa" { | ||
| algorithm = "RSA" | ||
| rsa_bits = 1024 |
There was a problem hiding this comment.
Futu OpenD SDK enforces a 1024-bit key requirement at the protocol level — using 2048-bit results in a "Ciphertext with incorrect length" error at handshake time. This is a hard constraint from the Futu API, not a choice. Documented in the startup script comments.
| name = "${var.service_name}-futu-opend" | ||
| machine_type = "e2-micro" | ||
| zone = "${var.region}-b" | ||
| project = var.project_id |
There was a problem hiding this comment.
Intentional trade-off — the VM is co-located with the Cloud Run region to minimise latency. Can be extracted to var.zone in a future iteration if multi-region support is needed.
|
|
||
| network_interface { | ||
| network = "default" | ||
| access_config {} |
There was a problem hiding this comment.
Required for the startup script to reach external endpoints (apt, Futu download servers, Secret Manager). No inbound public ports are open — port 11111 is restricted to 10.0.0.0/8 via the firewall rule. Cloud NAT is a valid future hardening option.
| image = local.effective_container_image | ||
| command = ["/bin/sh"] | ||
| args = ["-lc", "mkdir -p /home/node/.openclaw/agents/main/agent /home/node/.openclaw/credentials; [ -n \"$OPENCLAW_JSON\" ] && echo \"$OPENCLAW_JSON\" > /home/node/.openclaw/openclaw.json; [ -n \"$TELEGRAM_ALLOW_FROM\" ] && echo \"$TELEGRAM_ALLOW_FROM\" > /home/node/.openclaw/credentials/telegram-allowFrom.json; printf '{\"openrouter\":{\"apiKey\":\"%s\"}}' \"$OPENROUTER_API_KEY\" > /home/node/.openclaw/agents/main/agent/auth-profiles.json; printf '{\"providers\":{\"openrouter\":{\"baseUrl\":\"https://openrouter.ai/api/v1\",\"api\":\"openai-completions\",\"apiKey\":\"OPENROUTER_API_KEY\"}}}' > /home/node/.openclaw/agents/main/agent/models.json; exec openclaw gateway run --bind lan --port \"$${PORT:-8080}\" --allow-unconfigured"] | ||
| args = ["-lc", "mkdir -p /home/node/.openclaw/agents/main/agent /home/node/.openclaw/credentials; [ -n \"$OPENCLAW_JSON\" ] && printf '%s' \"$OPENCLAW_JSON\" > /home/node/.openclaw/openclaw.json; [ -n \"$TELEGRAM_ALLOW_FROM\" ] && printf '%s' \"$TELEGRAM_ALLOW_FROM\" > /home/node/.openclaw/credentials/telegram-allowFrom.json; printf '{\"openrouter\":{\"apiKey\":\"%s\"}}' \"$OPENROUTER_API_KEY\" > /home/node/.openclaw/agents/main/agent/auth-profiles.json; printf '{\"providers\":{\"openrouter\":{\"baseUrl\":\"https://openrouter.ai/api/v1\",\"api\":\"openai-completions\",\"apiKey\":\"%s\"}}}' \"$OPENROUTER_API_KEY\" > /home/node/.openclaw/agents/main/agent/models.json; ${local.futu_skills_install}exec openclaw gateway run --bind lan --port \"$${PORT:-8080}\" --allow-unconfigured"] |
There was a problem hiding this comment.
Acknowledged trade-off. The skills ZIP is fetched from Futu's official distribution endpoint (not available at image build time), and the installed artifacts are persisted on the rclone-synced R2 volume — so subsequent cold starts skip the install entirely ([ ! -d ...futuapi ] guard). Moving to image build would require vendoring a third-party package with no stable release URL.
| ExecStart=/opt/opend/FutuOpenD \ | ||
| -login_account=${futu_account} \ | ||
| -login_pwd_md5=${futu_password_md5} \ | ||
| -api_ip=0.0.0.0 \ | ||
| -api_port=11111 \ |
There was a problem hiding this comment.
The account is a public email address and the password is already MD5-hashed. Both are simple alphanumeric strings with no special characters, so quoting/escaping is not a practical concern. Moving to EnvironmentFile is a valid future hardening step.
| resource "google_secret_manager_secret" "futu_rsa_private_key" { | ||
| secret_id = "${var.service_name}-futu-rsa-private-key" | ||
| replication { | ||
| auto {} | ||
| } | ||
| } |
There was a problem hiding this comment.
Intentional — Futu is always enabled in this module. The futu_enabled conditional was removed to reduce complexity.
No description provided.