Skip to content
Open
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
3 changes: 3 additions & 0 deletions terraform/gcp_cloudrun/.envrc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fi

export TF_VAR_openrouter_api_key="$OPENROUTER_API_KEY"
export TF_VAR_telegram_bot_token="$TELEGRAM_BOT_TOKEN"
export TF_VAR_futu_telegram_bot_token="${FUTU_TELEGRAM_BOT_TOKEN:-}"
export TF_VAR_openclaw_gateway_token="$OPENCLAW_GATEWAY_TOKEN"
export TF_VAR_brave_api_key="${BRAVE_API_KEY:-}"
export TF_VAR_telegram_owner_id="${TELEGRAM_OWNER_ID:-}"
Expand All @@ -24,3 +25,5 @@ export TF_VAR_cloudflare_api_token="${CF_API_TOKEN:-}"
export TF_VAR_r2_access_key_id="${R2_ACCESS_KEY_ID:-}"
export TF_VAR_r2_secret_access_key="${R2_SECRET_ACCESS_KEY:-}"
export TF_VAR_r2_bucket_name="${R2_BUCKET_NAME:-}"
export TF_VAR_futu_account="${FUTU_ACCOUNT:-}"
export TF_VAR_futu_password_md5="${FUTU_PASSWORD_MD5:-}"
68 changes: 68 additions & 0 deletions terraform/gcp_cloudrun/futu-opend-startup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/bin/bash
set -e

apt-get update -y
apt-get install -y curl ca-certificates libatomic1 openssl

mkdir -p /opt/opend
cd /opt/opend
curl -fsSL "https://www.futunn.com/download/fetch-lasted-link?name=opend-ubuntu" \
-o opend.tar.gz
tar -xzf opend.tar.gz --strip-components=2 --exclude='*GUI*'
rm opend.tar.gz
chmod +x FutuOpenD

mkdir -p /root/.com.futunn.FutuOpenD/F3CNN

# Fetch RSA private key from Secret Manager (never embedded in metadata)
gcloud secrets versions access latest \
--secret="${rsa_secret_name}" \
--project="${project_id}" \
> /root/futu-rsa-raw.pem

if [ ! -s /root/futu-rsa-raw.pem ]; then
echo "ERROR: Failed to fetch RSA private key from Secret Manager" >&2
exit 1
fi

# Convert PKCS#8 to PKCS#1 (FutuOpenD -rsa_private_key requires -----BEGIN RSA PRIVATE KEY-----)
openssl rsa -in /root/futu-rsa-raw.pem -out /root/futu-rsa-private.pem 2>&1
rm -f /root/futu-rsa-raw.pem

if ! grep -q "BEGIN RSA PRIVATE KEY" /root/futu-rsa-private.pem; then
echo "ERROR: RSA key conversion to PKCS#1 failed" >&2
exit 1
fi
chmod 600 /root/futu-rsa-private.pem

cat > /etc/systemd/system/futu-opend.service << 'SVCEOF'
[Unit]
Description=Futu OpenD
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Restart=always
RestartSec=10
WorkingDirectory=/opt/opend
Environment=LD_LIBRARY_PATH=/opt/opend
ExecStart=/opt/opend/FutuOpenD \
-login_account=${futu_account} \
-login_pwd_md5=${futu_password_md5} \
-api_ip=0.0.0.0 \
-api_port=11111 \
Comment on lines +50 to +54
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

-telnet_ip=127.0.0.1 \
-telnet_port=22222 \
-rsa_private_key=/root/futu-rsa-private.pem \
-lang=en
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
SVCEOF

systemctl daemon-reload
systemctl enable futu-opend.service
systemctl start futu-opend.service
19 changes: 19 additions & 0 deletions terraform/gcp_cloudrun/futu-send-sms-code.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash
# Usage: ./futu-send-sms-code.sh <6-digit-code>

set -e

CODE="${1:-}"
if [ -z "$CODE" ]; then
echo "Usage: $0 <sms-code>" >&2
exit 1
fi

PROJECT=$(gcloud config get-value project 2>/dev/null)
ZONE=$(gcloud compute instances list \
--project="$PROJECT" --filter="name~futu-opend" --format="value(zone)" | head -1)
INSTANCE=$(gcloud compute instances list \
--project="$PROJECT" --filter="name~futu-opend" --format="value(name)" | head -1)

gcloud compute ssh "$INSTANCE" --zone="$ZONE" --project="$PROJECT" \
-- "printf 'input_phone_verify_code -code=$CODE\r\n' | sudo nc -q 2 127.0.0.1 22222"
100 changes: 100 additions & 0 deletions terraform/gcp_cloudrun/futu-skills-install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/bin/sh
# Install futuapi ONLY into workspace-futu/skills — never into the global managed
# skills dir (~/.openclaw/skills/). The main agent's workspace (~/.openclaw/workspace/)
# therefore has NO skills dir and cannot discover futuapi regardless of config filtering.
mkdir -p /home/node/.openclaw/workspace-futu/skills

if [ ! -d /home/node/.openclaw/workspace-futu/skills/futuapi ]; then
curl -fsSL https://openapi.futunn.com/skills/opend-skills.zip -o /tmp/fs.zip \
&& mkdir -p /tmp/fs-extract \
&& node -e "
const fs=require('fs'),zl=require('zlib');
try {
const d=fs.readFileSync('/tmp/fs.zip');
let o=0;
while(o<d.length-4){
if(d.readUInt32LE(o)!==0x04034b50)break;
const m=d.readUInt16LE(o+8),cs=d.readUInt32LE(o+18),
fn=d.readUInt16LE(o+26),ex=d.readUInt16LE(o+28),
nm=d.slice(o+30,o+30+fn).toString(),
da=o+30+fn+ex,cd=d.slice(da,da+cs);
if(!nm.endsWith('/')){
const p='/tmp/fs-extract/'+nm;
fs.mkdirSync(p.slice(0,p.lastIndexOf('/')),{recursive:true});
fs.writeFileSync(p,m===0?cd:zl.inflateRawSync(cd));
}
o=da+cs;
}
} catch(e) { process.exit(1); }
" \
&& cp -r /tmp/fs-extract/skills/futuapi \
/tmp/fs-extract/skills/install-futu-opend \
/home/node/.openclaw/workspace-futu/skills/ 2>/dev/null
rm -rf /tmp/fs.zip /tmp/fs-extract
fi

# No workspace/skills symlink for the main agent — this is intentional.
# The main agent's workspace (~/.openclaw/workspace/) has no skills/ directory,
# so futuapi is physically unreachable from it.
mkdir -p /home/node/.local/bin
curl -LsSf https://astral.sh/uv/install.sh \
| UV_INSTALL_DIR=/home/node/.local/bin sh
UV=/home/node/.local/bin/uv
$UV python install 3.11
$UV venv /home/node/.futu-venv --python 3.11
$UV pip install futu-api cryptography --python /home/node/.futu-venv/bin/python
printf '#!/bin/sh\nexec /home/node/.futu-venv/bin/python "$@"\n' > /home/node/.local/bin/python
printf '#!/bin/sh\nexec /home/node/.futu-venv/bin/python "$@"\n' > /home/node/.local/bin/python3
chmod +x /home/node/.local/bin/python /home/node/.local/bin/python3
export PATH=/home/node/.local/bin:$PATH
export PYTHONPATH=/home/node/.futu-venv/lib/python3.11/site-packages${PYTHONPATH:+:$PYTHONPATH}

# Write RSA private key as PKCS#1 (futu SDK requires -----BEGIN RSA PRIVATE KEY-----)
# tls_private_key generates PKCS#8; convert using Python cryptography
if [ -n "$FUTU_RSA_PRIVATE_KEY" ]; then
mkdir -p /home/node/.openclaw/credentials
printf '%s' "$FUTU_RSA_PRIVATE_KEY" | \
/home/node/.futu-venv/bin/python3 -c "
import sys
from cryptography.hazmat.primitives import serialization
Comment thread
PCBZ marked this conversation as resolved.
from cryptography.hazmat.backends import default_backend
data = sys.stdin.buffer.read()
key = serialization.load_pem_private_key(data, password=None, backend=default_backend())
sys.stdout.buffer.write(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
))
" > /home/node/.openclaw/credentials/futu-rsa-private.pem
if ! grep -q "BEGIN RSA PRIVATE KEY" /home/node/.openclaw/credentials/futu-rsa-private.pem 2>/dev/null; then
echo "ERROR: RSA key conversion to PKCS#1 failed - futu SDK requires -----BEGIN RSA PRIVATE KEY-----" >&2
exit 1
fi
chmod 600 /home/node/.openclaw/credentials/futu-rsa-private.pem
fi

# Patch common.py: remove any old RSA block and append the current version
sed -i '/^# RSA encryption for cross-network trade connections/,$d' \
/home/node/.openclaw/workspace-futu/skills/futuapi/scripts/common.py 2>/dev/null || true
cat >> /home/node/.openclaw/workspace-futu/skills/futuapi/scripts/common.py << 'PYEOF'

# RSA encryption for cross-network trade connections
_futu_rsa_key_file = os.path.expanduser('~/.openclaw/credentials/futu-rsa-private.pem')
if os.path.exists(_futu_rsa_key_file):
try:
from futu import SysConfig
SysConfig.set_init_rsa_file(_futu_rsa_key_file)
except Exception:
pass
_orig_create_trade_context = create_trade_context
def create_trade_context(market=None, security_firm=None):
host, port = get_opend_config()
_check_opend_alive(host, port)
trd_market = parse_market(market) if market else get_default_market()
kwargs = dict(host=host, port=port, filter_trdmarket=trd_market, is_encrypt=True)
if _sdk_supports_ai_type:
kwargs["ai_type"] = 1
sf = security_firm if security_firm is not None else (get_default_security_firm() or SecurityFirm.NONE)
kwargs["security_firm"] = sf
return OpenSecTradeContext(**kwargs)
PYEOF
14 changes: 14 additions & 0 deletions terraform/gcp_cloudrun/identity_iam.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,17 @@ resource "google_project_iam_member" "secret_accessor" {
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloudrun.email}"
}

resource "google_service_account" "futu_opend" {
account_id = "${var.service_name}-futu-opend-sa"
display_name = "Futu OpenD VM runtime"

depends_on = [google_project_service.required]
}

resource "google_secret_manager_secret_iam_member" "futu_opend_rsa_key" {
project = var.project_id
secret_id = google_secret_manager_secret.futu_rsa_private_key.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.futu_opend.email}"
}
62 changes: 40 additions & 22 deletions terraform/gcp_cloudrun/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ resource "google_cloud_run_v2_service" "openclaw" {
max_instance_count = var.max_instances
}

vpc_access {
network_interfaces {
network = "default"
subnetwork = "default"
}
Comment on lines +16 to +20
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct VPC egress (as opposed to Serverless VPC Connector) does not require roles/compute.networkUser — Cloud Run uses the project's default network with implicit access. Confirmed working in deployment.

egress = "PRIVATE_RANGES_ONLY"
}

containers {
name = "rclone-sync"
image = "rclone/rclone:latest"
Expand Down Expand Up @@ -79,7 +87,7 @@ resource "google_cloud_run_v2_service" "openclaw" {
depends_on = ["rclone-sync"]
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; 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"]

ports {
container_port = 8080
Expand Down Expand Up @@ -154,37 +162,31 @@ resource "google_cloud_run_v2_service" "openclaw" {
}

env {
name = "OPENCLAW_GATEWAY_TOKEN"
name = "FUTU_TELEGRAM_BOT_TOKEN"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.gateway_token.secret_id
secret = google_secret_manager_secret.futu_telegram_bot_token.secret_id
version = "latest"
}
}
}

dynamic "env" {
for_each = var.brave_api_key != "" ? [1] : []
content {
name = "BRAVE_API_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.brave_api_key[0].secret_id
version = "latest"
}
env {
name = "OPENCLAW_GATEWAY_TOKEN"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.gateway_token.secret_id
version = "latest"
}
}
}

dynamic "env" {
for_each = var.telegram_owner_id != "" ? [1] : []
content {
name = "TELEGRAM_ALLOW_FROM"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.telegram_allow_from[0].secret_id
version = "latest"
}
env {
name = "BRAVE_API_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.brave_api_key.secret_id
version = "latest"
}
}
}
Expand All @@ -208,6 +210,21 @@ resource "google_cloud_run_v2_service" "openclaw" {
}
}
}

env {
name = "FUTU_OPEND_HOST"
value = google_compute_instance.futu_opend.network_interface[0].network_ip
Comment on lines +214 to +216
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — futu_enabled was removed in this PR; Futu support is always on in this module.

}

env {
name = "FUTU_RSA_PRIVATE_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.futu_rsa_private_key.secret_id
version = "latest"
Comment on lines +214 to +224
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — Futu is always enabled in this module.

}
}
}
}

volumes {
Expand All @@ -220,9 +237,10 @@ resource "google_cloud_run_v2_service" "openclaw" {
depends_on = [
google_artifact_registry_repository_iam_member.ghcr_remote_reader,
google_secret_manager_secret_version.openclaw_json,
google_secret_manager_secret_version.telegram_allow_from,
google_project_iam_member.secret_accessor,
google_secret_manager_secret_version.r2_access_key_id,
google_secret_manager_secret_version.r2_secret_access_key,
google_secret_manager_secret_version.futu_rsa_private_key,
google_secret_manager_secret_version.futu_telegram_bot_token,
]
}
Loading
Loading