-
Notifications
You must be signed in to change notification settings - Fork 0
futu openD support #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9c7cd5d
1a862d9
303600b
ee1c957
dc5a9ba
2d3fd24
c76a564
33f7d9f
e9dd06d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 \ | ||
| -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 | ||
| 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" |
| 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 | ||
|
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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Direct VPC egress (as opposed to Serverless VPC Connector) does not require |
||
| egress = "PRIVATE_RANGES_ONLY" | ||
| } | ||
|
|
||
| containers { | ||
| name = "rclone-sync" | ||
| image = "rclone/rclone:latest" | ||
|
|
@@ -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 | ||
|
|
@@ -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" | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -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
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentional — |
||
| } | ||
|
|
||
| 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
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above — Futu is always enabled in this module. |
||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| volumes { | ||
|
|
@@ -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, | ||
| ] | ||
| } | ||
There was a problem hiding this comment.
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.