Skip to content

feat / Secure the Gateway with auto-generated shared mTLS certs (SEC-048) - #185

Merged
cardosofede merged 9 commits into
mainfrom
feat/secure_gateway_mtls
Jun 29, 2026
Merged

feat / Secure the Gateway with auto-generated shared mTLS certs (SEC-048)#185
cardosofede merged 9 commits into
mainfrom
feat/secure_gateway_mtls

Conversation

@cardosofede

Copy link
Copy Markdown
Contributor

Summary

Closes SEC-048. The Gateway was deployed unsecured (DEV=true → plain HTTP, no client-cert auth) and published on all interfaces, exposing full wallet/trade control to anyone reaching port 15888. This makes it run secured by default with TLS + mutual-cert (mTLS) auth, using one shared cert set auto-generated on first start — no manual step for the operator.

What changed

  • utils/gateway_certs.py (new): single source of truth for the shared mTLS cert set. Idempotent generation (existing CA reused), mirrors client certs to root_path()/certs for the in-process GatewayHttpClient, builds the client SSLContext. All private keys encrypted with one secret (CONFIG_PASSWORD). Lives under bots/ so it's inside the shared host↔container mount, with the write path (container-local) split from the bind-mount source (host).
  • gateway_service / models/gateway: dev_mode defaults False; certs generated + mounted read-only at /home/gateway/certs; GATEWAY_PASSPHRASE sourced from CONFIG_PASSWORD; bridge-mode host publish bound to 127.0.0.1 instead of 0.0.0.0.
  • gateway_client / accounts_service: client speaks https + presents its cert, with the SSLContext built lazily (certs don't exist until the Gateway is started — picked up with no API restart).
  • main.py: sync client certs to root_path()/certs when SSL is on.
  • docker_service: deployed instances mount the shared certs at /home/hummingbot/certs and get gateway_use_ssl: true.
  • docker-compose.yml: API reaches the Gateway in-network at https://gateway:15888 (host.docker.internal is unreachable once the publish is loopback-bound).

dev_mode=True remains an explicit, loopback-only escape hatch for local debugging.

Verified live (containerized deployment, gateway image v2.14.0)

  • Gateway boots secured ("Running in secured mode"), certs auto-generated under bots/gateway-files/certs/, host publish bound to 127.0.0.1:15888.
  • mTLS enforced (curl): no client cert → refused; client cert + passphrase → {"status":"ok"}; plain HTTP → rejected.
  • API reaches the Gateway over mTLS end-to-end: GET /gateway/connectors, /gateway/chains, and a Meteora clmm/pool-info query all returned live data.
  • Deployed instance received the shared certs (matching CA sha256) + gateway_use_ssl: true.
  • Only unexercised criterion: a live liquidity-changing DEX transaction (needs a funded wallet).

Migration note

GATEWAY_PASSPHRASE now comes from CONFIG_PASSWORD. A Gateway whose config was previously encrypted with a different user-supplied passphrase must re-init its Gateway config. Operators on https must also set GATEWAY_URL=https://....

🤖 Generated with Claude Code

cardosofede and others added 7 commits June 25, 2026 23:12
The Gateway was deployed unsecured (DEV=true -> plain HTTP, no client-cert
auth) and published on all interfaces, exposing full wallet/trade control to
anyone reaching port 15888. Run it secured by default with TLS + mTLS, using
one shared cert set auto-generated on first start so the operator does nothing.

- utils/gateway_certs.py: single source of truth for the shared mTLS cert set.
  Idempotent generation (existing CA reused), mirrors client certs to
  root_path()/certs for the in-process GatewayHttpClient, and builds the client
  SSLContext. All private keys encrypted with one secret (CONFIG_PASSWORD).
- gateway_service: default dev_mode=False; generate + mount certs read-only at
  /home/gateway/certs; GATEWAY_PASSPHRASE sourced from CONFIG_PASSWORD; bind the
  bridge-mode host publish to 127.0.0.1 instead of 0.0.0.0.
- gateway_client: accept an SSLContext and use it for https; accounts_service
  builds it for https URLs. config default URL -> https://localhost:15888.
- main.py: sync client certs to root_path()/certs when SSL is enabled.
- docker_service: mount the shared certs into each instance at
  /home/hummingbot/certs and set conf_client.yml gateway_use_ssl: true.

dev_mode=True remains an explicit, loopback-only escape hatch for local debugging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(SEC-048)

The containerized API mounts only ./bots:/hummingbot-api/bots, while gateway-files
lived at the repo root and BOTS_PATH is the host path. So a containerized API wrote
the mTLS certs to its own ephemeral filesystem and the Gateway container bind-mounted
an empty host dir -> Gateway crash-looped on missing ./certs/server_key.pem.

Move the shared dir under bots/ (inside the one host<->container mount) and split the
path used to WRITE (container-local, the mounted bots/ dir) from the bind-mount SOURCE
(host path), mirroring how DockerService handles instance volumes. Local runs are
unaffected (both bases resolve to the repo).

- utils/gateway_certs: GATEWAY_SUBPATH = bots/gateway-files; gateway_certs_dir(host=...)
  returns the local read/write path or the host mount-source path.
- gateway_service: _gateway_base(host=...); makedirs on local base, bind-mount sources on
  host base (conf/logs/certs); remove() uses the local base.
- docker_service: mount instance certs from the host path.
- .gitignore: ignore bots/gateway-files/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The custom GatewayClient built its SSL context once at API startup, but the shared
certs only exist after the Gateway is started (post-startup). The wallet/balance/swap
client therefore stayed without mTLS until an API restart. Build the context lazily on
first https request (cached), so certs generated once the Gateway starts are picked up
with no restart; a still-missing cert set returns a clean 503 instead of crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…C-048)

The compose override sent the API to http://host.docker.internal:15888 (the host's
published port). With the Gateway publish now bound to 127.0.0.1, that path is no
longer reachable, and it was plain http besides. Use https://gateway:15888 so the API
reaches the Gateway container-to-container over the shared emqx-bridge network (the
Gateway is attached to it on start; the cert SAN includes 'gateway').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (SEC-048)

The Gateway (v2.x) uses a single GATEWAY_PASSPHRASE for both TLS cert-key decryption
and wallet encryption (readWalletKey() === readPassphrase()), and the shared mTLS cert
set must be decryptable by this API's clients, which decrypt the client cert with
CONFIG_PASSWORD (hummingbot's in-process GatewayHttpClient hardwires this). The cert /
gateway / wallet passphrase is therefore necessarily CONFIG_PASSWORD.

The previous per-request `passphrase` override was a footgun: a value other than
CONFIG_PASSWORD encrypted the certs with it while the API still decrypted the client
cert with CONFIG_PASSWORD, silently breaking every Gateway call. Remove the field and
derive the passphrase solely from CONFIG_PASSWORD. An extra `passphrase` in the request
body is ignored rather than rejected, so existing callers don't break.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…structions (SEC-048)

The README told users to "Start Gateway in development mode with passphrase 'admin'",
which (together with the old dev_mode=True default) is why a fresh install came up in
dev mode with no certs. Document the secured-by-default behavior, auto-generated certs,
CONFIG_PASSWORD as the single secret, and dev_mode as an explicit opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The API must never serve a wallet-holding Gateway over plain HTTP, so drop dev mode
altogether rather than leaving it as an opt-in footgun (and the source of "fresh install
starts in dev mode"). Remove the dev_mode field from GatewayConfig and always start the
Gateway with TLS + mTLS (DEV=false, certs generated and mounted). An extra dev_mode in a
request body is ignored, not rejected. README/config docs updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cardosofede
cardosofede force-pushed the feat/secure_gateway_mtls branch from ec4ea52 to 3537119 Compare June 25, 2026 21:12
@rapcmia rapcmia self-assigned this Jun 29, 2026
@rapcmia rapcmia changed the title (feat) Secure the Gateway with auto-generated shared mTLS certs (SEC-048) feat / Secure the Gateway with auto-generated shared mTLS certs (SEC-048) Jun 29, 2026
@rapcmia

rapcmia commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Commit 3537119

  • Setup this PR with docker make setup; make deploy
  • Test with gateway latest docker image v2.15.1

Started from a clean Docker deployment and call POST /gateway/start with an empty body. Confirmed bots/gateway-files/certs/ is created and Gateway starts secured ✅

  • Follow-up checks confirmed bots/gateway-files/certs/ was created with the expected cert files, and Gateway logs showed startup at https://localhost:15888 in secured mode.
  • POST /gateway/start with an empty JSON body returned success and started a new hummingbot/gateway:latest container on port 15888.
    • If we follow the payload for gateway/start using incorrect passphrase, it bypass the validation part and makes impression for users that it still works ❗
      #### start gateway with incorrect passphrase field in payload
      curl -s -u XXX:XXX -X POST http://localhost:8000/gateway/start -H "Content-Type: application/json" -d '
      {
        "image": "hummingbot/gateway:latest",
        "port": 15888,
        "passphrase": "XXX"
      }
      ' | jq
      {
        "success": true,
        "message": "Gateway started successfully",
        "container_id": "3655cc46fa8be1b1f3e2f4e54e9a9c4dcb86024abc34f26ac0184cd59e0cbf28",
        "port": 15888
      }
      logs: route accepted payload and started Gateway successfully; provided passphrase field did not cause route-level rejection or startup failure
      

Gateway blocks unsecured direct access on port 15888 ✅

  • Plain http://localhost:15888/ failed with curl: (52) Empty reply from server, so the secured Gateway did not serve a usable HTTP response on the TLS port.
  • https://localhost:15888/ without a client cert failed with tlsv13 alert certificate required, which confirms mTLS is enforced for direct HTTPS access.

For the next following tests:

API restart keeps Gateway routes working ✅

  • Restarting only hummingbot-api after Gateway certs already existed did not break the HAPI to Gateway connection.
  • After the API came back, the Gateway-backed routes still returned real connector, chain, and live pool-info data without restarting Gateway.

API starts before Gateway and recovers after Gateway starts ✅

  • With HAPI already running, Gateway-backed routes failed cleanly with {"detail":"Gateway service is not available"} while Gateway was stopped.
  • Starting Gateway afterward through HAPI restored the same routes immediately, without restarting the API.

Deploy a new bot instance and confirm conf_client.yml has gateway_use_ssl: true and the container mounts /home/hummingbot/certs read-only.

  • Successfully deployed bots contains gateway_use_ssl:true
    image

On Linux, verify unauthenticated remote access to port 15888 is still blocked by mTLS.

  • Direct no-cert requests to https://localhost:15888/ and https://localhost:15888/docs failed during TLS with certificate required, which confirms mTLS is still enforced.
  • Direct no-cert requests to the host IP on port 15888 returned connection refused, so remote host-IP access was not exposed during this test.
      curl -skv https://localhost:15888/
      * TLS handshake reached the server
      * Server requested a client certificate
      * OpenSSL SSL_read: ... tlsv13 alert certificate required
    
      curl -skv https://localhost:15888/docs
      * TLS handshake reached the server
      * Server requested a client certificate
      * OpenSSL SSL_read: ... tlsv13 alert certificate required
    
      curl -skv https://192.168.1.11:15888/
      * connect to 192.168.1.11 port 15888 failed: Connection refused
    
      curl -skv https://100.124.88.27:15888/
      * connect to 100.124.88.27 port 15888 failed: Connection refused
    
    • I used my agent to help investigate this, and it recommended using curl -skv, which confirmed Gateway rejected the request because no client certificate was provided ❗

@rapcmia rapcmia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • Tested using docker using make setup; make deploy
  • Tested POST /gateway/start with an empty JSON body and confirmed it started Gateway container successfully on port 15888.
  • Tested HAPI proxy routes GET /gateway/connectors, GET /gateway/chains, and the live Meteora GET /gateway/clmm/pool-info route, and confirmed they returned real Gateway data after Gateway was up.
  • Tested the HAPI recovery flow and confirmed Gateway-backed routes returned the expected unavailable response while Gateway was down, then recovered without restarting HAPI once Gateway finished booting.
  • Tested direct no-cert access to https://localhost:15888/ and https://localhost:15888/docs, and both requests were rejected during TLS with certificate required, so unsecured direct HTTPS access remained blocked.
  • Confirmed integration testing also passed with hummingbot/condor#135, where the secured Gateway flow and cross-service integration completed successfully.

Note:

  • Created separate issue #187

@cardosofede
cardosofede merged commit ee7ce5c into main Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants