From 46c1cca801a277d7442a4bc666e732ce3a8b8998 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 25 Jun 2026 17:36:12 +0200 Subject: [PATCH 1/9] (feat) secure Gateway with auto-generated shared mTLS certs (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. 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) --- config.py | 6 ++- main.py | 9 +++- models/gateway.py | 13 ++++- services/accounts_service.py | 15 +++++- services/docker_service.py | 18 +++++++ services/gateway_client.py | 17 +++++- services/gateway_service.py | 38 +++++++++++-- utils/gateway_certs.py | 102 +++++++++++++++++++++++++++++++++++ 8 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 utils/gateway_certs.py diff --git a/config.py b/config.py index a77a886d..502dc3a6 100644 --- a/config.py +++ b/config.py @@ -128,8 +128,10 @@ class GatewaySettings(BaseSettings): """Gateway service configuration.""" url: str = Field( - default="http://localhost:15888", - description="Gateway service URL (use 'http://gateway:15888' when running in Docker)" + default="https://localhost:15888", + description="Gateway service URL. Defaults to https for the secured (mTLS) Gateway " + "(SEC-048); use 'https://gateway:15888' when running in Docker. Set an " + "'http://' scheme only when the Gateway is started in dev_mode." ) model_config = SettingsConfigDict(env_prefix="GATEWAY_", extra="ignore") diff --git a/main.py b/main.py index 1dfb91d1..d5271204 100644 --- a/main.py +++ b/main.py @@ -115,10 +115,17 @@ async def lifespan(app: FastAPI): # Initialize GatewayHttpClient singleton parsed_gateway_url = urlparse(settings.gateway.url) + gateway_use_ssl = parsed_gateway_url.scheme == "https" + if gateway_use_ssl: + # SEC-048: the in-process GatewayHttpClient reads its client certs only from + # root_path()/certs. Mirror the shared cert set there if the Gateway was already + # started in a previous run (no-op when certs haven't been generated yet). + from utils.gateway_certs import sync_client_certs_to_root + sync_client_certs_to_root() gateway_config = GatewayConfigMap( gateway_api_host=parsed_gateway_url.hostname or "localhost", gateway_api_port=str(parsed_gateway_url.port or 15888), - gateway_use_ssl=parsed_gateway_url.scheme == "https" + gateway_use_ssl=gateway_use_ssl ) GatewayHttpClient.get_instance(gateway_config) logging.info(f"Initialized GatewayHttpClient with URL: {settings.gateway.url}") diff --git a/models/gateway.py b/models/gateway.py index 5528326c..84fad2a3 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -9,10 +9,19 @@ class GatewayConfig(BaseModel): """Configuration for Gateway container deployment""" - passphrase: str = Field(description="Gateway passphrase for configuration encryption") + passphrase: Optional[str] = Field( + default=None, + description="Gateway passphrase for config encryption and mTLS cert keys. " + "Defaults to the API's CONFIG_PASSWORD so a single secret secures " + "the Gateway, this API, and deployed instances (SEC-048)." + ) image: str = Field(default="hummingbot/gateway:latest", description="Docker image for Gateway") port: int = Field(default=15888, description="Port for Gateway API") - dev_mode: bool = Field(default=True, description="Enable development mode") + dev_mode: bool = Field( + default=False, + description="Opt-in escape hatch: plain HTTP, no mTLS, bound to loopback only. " + "Defaults to False so the Gateway runs secured with TLS + client-cert auth." + ) class GatewayStatus(BaseModel): diff --git a/services/accounts_service.py b/services/accounts_service.py index 592c3530..bbe13545 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -17,6 +17,7 @@ from services.perpetual_trading_service import PerpetualTradingService from services.portfolio_analytics_service import PortfolioAnalyticsService from utils.file_system import fs_util +from utils.gateway_certs import build_client_ssl_context # Create module-specific logger logger = logging.getLogger(__name__) @@ -109,9 +110,19 @@ def __init__(self, self._market_data_service = market_data_service # MarketDataService self._trading_service = trading_service # TradingService - # Initialize Gateway client + # Initialize Gateway client. For a secured (https) Gateway, present the shared + # client cert over mTLS; certs are decrypted with CONFIG_PASSWORD (SEC-048). self.gateway_base_url = gateway_url - self.gateway_client = GatewayClient(gateway_url) + gateway_ssl_context = None + if gateway_url.lower().startswith("https://"): + try: + gateway_ssl_context = build_client_ssl_context(settings.security.config_password) + except FileNotFoundError as e: + logger.warning( + f"Gateway client certs not available yet: {e} " + "Gateway calls will fail until the Gateway is started and certs are generated." + ) + self.gateway_client = GatewayClient(gateway_url, ssl_context=gateway_ssl_context) # Composed services: gateway wallet CRUD/balances, perpetual trading and pure portfolio analytics self.gateway_wallet_service = GatewayWalletService(self.gateway_client) diff --git a/services/docker_service.py b/services/docker_service.py index 6aa6da40..e9a7583b 100644 --- a/services/docker_service.py +++ b/services/docker_service.py @@ -12,6 +12,7 @@ from config import settings from models import V2ControllerDeployment from utils.file_system import fs_util +from utils.gateway_certs import ensure_gateway_certs # Create module-specific logger logger = logging.getLogger(__name__) @@ -246,6 +247,18 @@ def create_hummingbot_instance(self, config: V2ControllerDeployment): conf_file_path = f"instances/{instance_name}/conf/conf_client.yml" client_config = fs_util.read_yaml_file(conf_file_path) client_config['instance_id'] = instance_name + + # SEC-048: point the instance at the secured (mTLS) Gateway and give it the shared + # cert set. Cert keys are decrypted inside the container with CONFIG_PASSWORD, so this + # is only enabled when a config password is set. Generation is idempotent: the instance + # reuses (or seeds) the same CA the Gateway uses. + gateway_certs_dir = None + if settings.security.config_password: + gateway_certs_dir = ensure_gateway_certs(settings.security.config_password) + gateway_section = client_config.get('gateway') or {} + gateway_section['gateway_use_ssl'] = True + client_config['gateway'] = gateway_section + fs_util.dump_dict_to_yaml(conf_file_path, client_config) # Set up Docker volumes @@ -269,6 +282,11 @@ def create_hummingbot_instance(self, config: V2ControllerDeployment): shared_controllers: {'bind': '/home/hummingbot/controllers', 'mode': 'rw'}, } + # SEC-048: mount the shared mTLS certs read-only where hummingbot reads them + # (root_path()/certs == /home/hummingbot/certs inside the instance container). + if gateway_certs_dir: + volumes[gateway_certs_dir] = {'bind': '/home/hummingbot/certs', 'mode': 'ro'} + # Set up environment variables environment = {} password = settings.security.config_password diff --git a/services/gateway_client.py b/services/gateway_client.py index 8ac120a1..0dcd16c6 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -1,4 +1,5 @@ import logging +import ssl from typing import Any, Dict, List, Optional import aiohttp @@ -12,8 +13,16 @@ class GatewayClient: Provides essential functionality for wallet management and balance queries. """ - def __init__(self, base_url: str = "http://localhost:15888"): + def __init__(self, base_url: str = "http://localhost:15888", ssl_context: Optional[ssl.SSLContext] = None): + """ + Args: + base_url: Gateway base URL. Use an ``https://`` scheme together with ``ssl_context`` + to talk to a secured (mTLS) Gateway (SEC-048). + ssl_context: Client SSLContext presenting the shared client cert. Required for + ``https://`` URLs; ignored for plain ``http://``. + """ self.base_url = base_url + self._ssl_context = ssl_context self._session: Optional[aiohttp.ClientSession] = None @staticmethod @@ -46,7 +55,11 @@ async def get_wallet_address_or_default(self, chain: str, wallet_address: Option async def _get_session(self) -> aiohttp.ClientSession: """Get or create aiohttp session""" if self._session is None or self._session.closed: - self._session = aiohttp.ClientSession() + if self._ssl_context is not None: + connector = aiohttp.TCPConnector(ssl=self._ssl_context) + self._session = aiohttp.ClientSession(connector=connector) + else: + self._session = aiohttp.ClientSession() return self._session async def close(self): diff --git a/services/gateway_service.py b/services/gateway_service.py index 2448013d..eed520cd 100644 --- a/services/gateway_service.py +++ b/services/gateway_service.py @@ -8,7 +8,9 @@ from docker.errors import DockerException from docker.types import LogConfig +from config import settings from models.gateway import GatewayConfig, GatewayStatus +from utils.gateway_certs import ensure_gateway_certs # Create module-specific logger logger = logging.getLogger(__name__) @@ -22,6 +24,8 @@ class GatewayService: GATEWAY_CONTAINER_NAME = "gateway" GATEWAY_DIR = "gateway-files" + # Path inside the Gateway container where it reads the mTLS cert set. + GATEWAY_CERTS_BIND = "/home/gateway/certs" def __init__(self): self.SOURCE_PATH = os.getcwd() @@ -40,14 +44,17 @@ def _ensure_gateway_directories(self): conf_dir = os.path.join(gateway_base, "conf") logs_dir = os.path.join(gateway_base, "logs") + certs_dir = os.path.join(gateway_base, "certs") os.makedirs(conf_dir, exist_ok=True) os.makedirs(logs_dir, exist_ok=True) + os.makedirs(certs_dir, exist_ok=True) return { "base": gateway_base, "conf": conf_dir, - "logs": logs_dir + "logs": logs_dir, + "certs": certs_dir, } def _get_gateway_container(self) -> Optional[docker.models.containers.Container]: @@ -116,6 +123,12 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: # Ensure directories exist dirs = self._ensure_gateway_directories() + # SEC-048: a single secret (CONFIG_PASSWORD) secures the Gateway, this API, and + # deployed instances. The per-request passphrase is honoured if provided, but for + # mTLS to work end-to-end it must match the secret the clients/instances decrypt with. + passphrase = config.passphrase or settings.security.config_password + secured = not config.dev_mode + # Set up volumes - use BOTS_PATH which contains the HOST path volumes = { os.path.join(self.BOTS_PATH, self.GATEWAY_DIR, "conf"): {'bind': '/home/gateway/conf', 'mode': 'rw'}, @@ -124,10 +137,23 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: # Set up environment variables environment = { - "GATEWAY_PASSPHRASE": config.passphrase, + "GATEWAY_PASSPHRASE": passphrase, "DEV": str(config.dev_mode).lower(), } + if secured: + # SEC-048: run the Gateway with TLS + client-cert auth. Generate the shared mTLS + # cert set once (idempotent; existing CA reused) and mount it read-only so the + # Gateway decrypts its server key with the same passphrase. + ensure_gateway_certs(passphrase, dirs["certs"]) + volumes[dirs["certs"]] = {'bind': self.GATEWAY_CERTS_BIND, 'mode': 'ro'} + logger.info("Starting Gateway in secured mode (TLS + mTLS, DEV=false)") + else: + logger.warning( + "Starting Gateway in dev_mode: plain HTTP with NO TLS and NO client-cert auth. " + "Bound to loopback only; do not use with funded wallets exposed to a network." + ) + # Configure logging log_config = LogConfig( type="json-file", @@ -169,8 +195,12 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: # Linux: Use host networking container_config["network_mode"] = "host" else: - # macOS/Windows: Use bridge networking with port mapping - container_config["ports"] = {'15888/tcp': config.port} + # macOS/Windows: Use bridge networking with port mapping. + # SEC-048: bind the host publish to loopback so the socket is never offered on + # the public interface (container-to-container traffic still flows over the + # emqx-bridge network by container name). Host networking on Linux can't be + # loopback-scoped here; mTLS is the control there. + container_config["ports"] = {'15888/tcp': ('127.0.0.1', config.port)} container = self.client.containers.run(**container_config) diff --git a/utils/gateway_certs.py b/utils/gateway_certs.py new file mode 100644 index 00000000..c544541b --- /dev/null +++ b/utils/gateway_certs.py @@ -0,0 +1,102 @@ +"""Shared mTLS certificate management for the Hummingbot Gateway (SEC-048). + +A single self-signed CA plus server/client cert set secures the Gateway transport. +Every consumer shares one cert set, with all private keys encrypted using a single +secret (``settings.security.config_password``): + +- the Gateway server container (decrypts ``server_key.pem`` with ``GATEWAY_PASSPHRASE``), +- this API's two Gateway clients (the custom :class:`GatewayClient` and hummingbot's + in-process ``GatewayHttpClient``), and +- deployed Hummingbot instances (decrypt with ``CONFIG_PASSWORD``). + +Generation is idempotent: an existing CA is reused so already-deployed instances keep +working across ``start``/``restart``. +""" +import os +import shutil +import ssl +from typing import Optional + +from hummingbot import root_path +from hummingbot.core.utils.ssl_cert import create_self_sign_certs + +# Full set written by create_self_sign_certs that makes up the mTLS chain. +SERVER_CERT_FILES = ( + "ca_cert.pem", "ca_key.pem", + "server_cert.pem", "server_key.pem", + "client_cert.pem", "client_key.pem", +) +# Subset a *client* needs to trust the server and present its own cert. +CLIENT_CERT_FILES = ("ca_cert.pem", "client_cert.pem", "client_key.pem") + +GATEWAY_DIR = "gateway-files" +CERTS_SUBDIR = "certs" + + +def _bots_path() -> str: + # Mirrors GatewayService/DockerService: BOTS_PATH (host path) when containerized. + return os.environ.get("BOTS_PATH", os.getcwd()) + + +def gateway_certs_dir() -> str: + """Canonical host directory holding the shared mTLS cert set.""" + return os.path.join(_bots_path(), GATEWAY_DIR, CERTS_SUBDIR) + + +def certs_present(certs_dir: Optional[str] = None) -> bool: + """True only when the full server+client set exists (a partial set is treated as absent).""" + certs_dir = certs_dir or gateway_certs_dir() + return all(os.path.exists(os.path.join(certs_dir, name)) for name in SERVER_CERT_FILES) + + +def ensure_gateway_certs(passphrase: str, certs_dir: Optional[str] = None) -> str: + """Generate the shared mTLS cert set if absent, then mirror client certs to root_path(). + + Idempotent: when the set already exists the existing CA is reused untouched, so + instances deployed before/after the Gateway starts all share one CA. Returns the + canonical certs directory. + """ + certs_dir = certs_dir or gateway_certs_dir() + os.makedirs(certs_dir, exist_ok=True) + if not certs_present(certs_dir): + create_self_sign_certs(passphrase, certs_dir) + sync_client_certs_to_root(certs_dir) + return certs_dir + + +def sync_client_certs_to_root(certs_dir: Optional[str] = None) -> None: + """Mirror the client cert set into ``root_path()/certs``. + + hummingbot's in-process ``GatewayHttpClient`` reads certs only from + ``root_path()/certs``, so the same CA must be available there for its SSL calls to + succeed. No-op when the source set is absent. + """ + certs_dir = certs_dir or gateway_certs_dir() + if not certs_present(certs_dir): + return + target = os.path.join(str(root_path()), "certs") + os.makedirs(target, exist_ok=True) + for name in CLIENT_CERT_FILES: + src = os.path.join(certs_dir, name) + if os.path.exists(src): + shutil.copy2(src, os.path.join(target, name)) + + +def build_client_ssl_context(passphrase: str, certs_dir: Optional[str] = None) -> ssl.SSLContext: + """Build an SSLContext that trusts the shared CA and presents the client cert. + + Raises ``FileNotFoundError`` if the cert set has not been generated yet. + """ + certs_dir = certs_dir or gateway_certs_dir() + ca_file = os.path.join(certs_dir, "ca_cert.pem") + if not os.path.exists(ca_file): + raise FileNotFoundError( + f"Gateway client certs not found in {certs_dir}; start the Gateway to generate them." + ) + ctx = ssl.create_default_context(cafile=ca_file) + ctx.load_cert_chain( + certfile=os.path.join(certs_dir, "client_cert.pem"), + keyfile=os.path.join(certs_dir, "client_key.pem"), + password=passphrase, + ) + return ctx From 14003f5dba02e6200a1ff5ee6dfa1ade16ca165b Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 25 Jun 2026 17:54:55 +0200 Subject: [PATCH 2/9] (fix) place shared Gateway dir under bots/ so certs reach containers (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) --- .gitignore | 3 ++- services/docker_service.py | 13 ++++++++----- services/gateway_service.py | 39 +++++++++++++++++++++++++------------ utils/gateway_certs.py | 26 +++++++++++++++++++------ 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 94908b9a..64ad9ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,5 @@ bots/conf/ # IDE files .vscode/ .idea/ -improvements \ No newline at end of file +improvements +bots/gateway-files/ diff --git a/services/docker_service.py b/services/docker_service.py index e9a7583b..426f6be5 100644 --- a/services/docker_service.py +++ b/services/docker_service.py @@ -12,7 +12,7 @@ from config import settings from models import V2ControllerDeployment from utils.file_system import fs_util -from utils.gateway_certs import ensure_gateway_certs +from utils.gateway_certs import ensure_gateway_certs, gateway_certs_dir # Create module-specific logger logger = logging.getLogger(__name__) @@ -252,9 +252,12 @@ def create_hummingbot_instance(self, config: V2ControllerDeployment): # cert set. Cert keys are decrypted inside the container with CONFIG_PASSWORD, so this # is only enabled when a config password is set. Generation is idempotent: the instance # reuses (or seeds) the same CA the Gateway uses. - gateway_certs_dir = None + gateway_certs_host_dir = None if settings.security.config_password: - gateway_certs_dir = ensure_gateway_certs(settings.security.config_password) + # Generate/locate the shared cert set (written to the local-base dir) and resolve the + # matching HOST path for the bind-mount source. + ensure_gateway_certs(settings.security.config_password) + gateway_certs_host_dir = gateway_certs_dir(host=True) gateway_section = client_config.get('gateway') or {} gateway_section['gateway_use_ssl'] = True client_config['gateway'] = gateway_section @@ -284,8 +287,8 @@ def create_hummingbot_instance(self, config: V2ControllerDeployment): # SEC-048: mount the shared mTLS certs read-only where hummingbot reads them # (root_path()/certs == /home/hummingbot/certs inside the instance container). - if gateway_certs_dir: - volumes[gateway_certs_dir] = {'bind': '/home/hummingbot/certs', 'mode': 'ro'} + if gateway_certs_host_dir: + volumes[gateway_certs_host_dir] = {'bind': '/home/hummingbot/certs', 'mode': 'ro'} # Set up environment variables environment = {} diff --git a/services/gateway_service.py b/services/gateway_service.py index eed520cd..a5d10d57 100644 --- a/services/gateway_service.py +++ b/services/gateway_service.py @@ -23,7 +23,10 @@ class GatewayService: """ GATEWAY_CONTAINER_NAME = "gateway" - GATEWAY_DIR = "gateway-files" + # Shared Gateway dir lives UNDER bots/ so it sits inside the single host<->container bind + # mount (./bots:/hummingbot-api/bots). Anything written outside that mount by a containerized + # API lands on the container's ephemeral FS and never reaches the Gateway container. + GATEWAY_SUBPATH = os.path.join("bots", "gateway-files") # Path inside the Gateway container where it reads the mTLS cert set. GATEWAY_CERTS_BIND = "/home/gateway/certs" @@ -37,10 +40,19 @@ def __init__(self): logger.error(f"Failed to connect to Docker. Error: {e}") raise + def _gateway_base(self, host: bool = False) -> str: + """Gateway-files base. host=False is the path the API process reads/writes + (container-local); host=True is the host path used as a Docker bind-mount source.""" + base = self.BOTS_PATH if host else self.SOURCE_PATH + return os.path.join(base, self.GATEWAY_SUBPATH) + def _ensure_gateway_directories(self): - """Create necessary directories for Gateway if they don't exist""" - # Gateway files are at root level, same as bots directory - gateway_base = os.path.join(self.BOTS_PATH, self.GATEWAY_DIR) + """Create necessary directories for Gateway if they don't exist. + + Directories are created on the path the API process can actually write (container-local, + inside the mounted bots/ dir); the matching host paths are used for the bind mounts. + """ + gateway_base = self._gateway_base(host=False) conf_dir = os.path.join(gateway_base, "conf") logs_dir = os.path.join(gateway_base, "logs") @@ -129,10 +141,12 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: passphrase = config.passphrase or settings.security.config_password secured = not config.dev_mode - # Set up volumes - use BOTS_PATH which contains the HOST path + # Set up volumes - bind-mount SOURCES must be HOST paths (the Docker daemon runs on the + # host), so use the host-side base even though the API process wrote via the local base. + host_base = self._gateway_base(host=True) volumes = { - os.path.join(self.BOTS_PATH, self.GATEWAY_DIR, "conf"): {'bind': '/home/gateway/conf', 'mode': 'rw'}, - os.path.join(self.BOTS_PATH, self.GATEWAY_DIR, "logs"): {'bind': '/home/gateway/logs', 'mode': 'rw'}, + os.path.join(host_base, "conf"): {'bind': '/home/gateway/conf', 'mode': 'rw'}, + os.path.join(host_base, "logs"): {'bind': '/home/gateway/logs', 'mode': 'rw'}, } # Set up environment variables @@ -143,10 +157,11 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: if secured: # SEC-048: run the Gateway with TLS + client-cert auth. Generate the shared mTLS - # cert set once (idempotent; existing CA reused) and mount it read-only so the - # Gateway decrypts its server key with the same passphrase. + # cert set once (idempotent; existing CA reused) into the local-base dir, and mount + # the matching host path read-only so the Gateway decrypts its server key with the + # same passphrase. ensure_gateway_certs(passphrase, dirs["certs"]) - volumes[dirs["certs"]] = {'bind': self.GATEWAY_CERTS_BIND, 'mode': 'ro'} + volumes[os.path.join(host_base, "certs")] = {'bind': self.GATEWAY_CERTS_BIND, 'mode': 'ro'} logger.info("Starting Gateway in secured mode (TLS + mTLS, DEV=false)") else: logger.warning( @@ -313,7 +328,7 @@ def remove(self, remove_data: bool = False) -> Dict[str, Any]: if container is None: if remove_data: # No container, but try to remove data if requested - gateway_dir = os.path.join(self.SOURCE_PATH, self.GATEWAY_DIR) + gateway_dir = self._gateway_base(host=False) if os.path.exists(gateway_dir): try: shutil.rmtree(gateway_dir) @@ -340,7 +355,7 @@ def remove(self, remove_data: bool = False) -> Dict[str, Any]: # Remove data if requested if remove_data: - gateway_dir = os.path.join(self.SOURCE_PATH, self.GATEWAY_DIR) + gateway_dir = self._gateway_base(host=False) if os.path.exists(gateway_dir): shutil.rmtree(gateway_dir) logger.info(f"Removed Gateway data directory: {gateway_dir}") diff --git a/utils/gateway_certs.py b/utils/gateway_certs.py index c544541b..b20dc0df 100644 --- a/utils/gateway_certs.py +++ b/utils/gateway_certs.py @@ -29,18 +29,32 @@ # Subset a *client* needs to trust the server and present its own cert. CLIENT_CERT_FILES = ("ca_cert.pem", "client_cert.pem", "client_key.pem") -GATEWAY_DIR = "gateway-files" +# The shared Gateway dir lives UNDER bots/ so it is inside the one host<->container bind mount +# (docker-compose maps ./bots:/hummingbot-api/bots). Files placed anywhere else are written to +# the API container's ephemeral filesystem and never reach the Gateway/instance containers. +GATEWAY_SUBPATH = os.path.join("bots", "gateway-files") CERTS_SUBDIR = "certs" -def _bots_path() -> str: - # Mirrors GatewayService/DockerService: BOTS_PATH (host path) when containerized. +def _local_base() -> str: + """Base path the API *process* reads/writes (container-local; the mounted bots/ dir).""" + return os.getcwd() + + +def _host_base() -> str: + """Base path for Docker bind-mount SOURCES (the host path, BOTS_PATH when containerized).""" return os.environ.get("BOTS_PATH", os.getcwd()) -def gateway_certs_dir() -> str: - """Canonical host directory holding the shared mTLS cert set.""" - return os.path.join(_bots_path(), GATEWAY_DIR, CERTS_SUBDIR) +def gateway_certs_dir(host: bool = False) -> str: + """Directory holding the shared mTLS cert set. + + ``host=False`` (default) returns the path the API process reads/writes; ``host=True`` + returns the host path to use as a Docker bind-mount source. The two are identical when not + containerized and resolve to the same files (under the shared bots/ mount) when they are. + """ + base = _host_base() if host else _local_base() + return os.path.join(base, GATEWAY_SUBPATH, CERTS_SUBDIR) def certs_present(certs_dir: Optional[str] = None) -> bool: From 787a1476fba88c337be927041684f9e6b35a4920 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 25 Jun 2026 17:58:43 +0200 Subject: [PATCH 3/9] (fix) build Gateway client mTLS context lazily (SEC-048) 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) --- services/accounts_service.py | 18 ++++++-------- services/gateway_client.py | 48 ++++++++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/services/accounts_service.py b/services/accounts_service.py index bbe13545..73908917 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -110,19 +110,15 @@ def __init__(self, self._market_data_service = market_data_service # MarketDataService self._trading_service = trading_service # TradingService - # Initialize Gateway client. For a secured (https) Gateway, present the shared - # client cert over mTLS; certs are decrypted with CONFIG_PASSWORD (SEC-048). + # Initialize Gateway client. For a secured (https) Gateway, present the shared client + # cert over mTLS; certs are decrypted with CONFIG_PASSWORD (SEC-048). The SSL context is + # built lazily so certs generated once the Gateway is started are picked up without an + # API restart. self.gateway_base_url = gateway_url - gateway_ssl_context = None + ssl_context_factory = None if gateway_url.lower().startswith("https://"): - try: - gateway_ssl_context = build_client_ssl_context(settings.security.config_password) - except FileNotFoundError as e: - logger.warning( - f"Gateway client certs not available yet: {e} " - "Gateway calls will fail until the Gateway is started and certs are generated." - ) - self.gateway_client = GatewayClient(gateway_url, ssl_context=gateway_ssl_context) + ssl_context_factory = lambda: build_client_ssl_context(settings.security.config_password) # noqa: E731 + self.gateway_client = GatewayClient(gateway_url, ssl_context_factory=ssl_context_factory) # Composed services: gateway wallet CRUD/balances, perpetual trading and pure portfolio analytics self.gateway_wallet_service = GatewayWalletService(self.gateway_client) diff --git a/services/gateway_client.py b/services/gateway_client.py index 0dcd16c6..ef744078 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -1,6 +1,6 @@ import logging import ssl -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import aiohttp @@ -13,16 +13,24 @@ class GatewayClient: Provides essential functionality for wallet management and balance queries. """ - def __init__(self, base_url: str = "http://localhost:15888", ssl_context: Optional[ssl.SSLContext] = None): + def __init__( + self, + base_url: str = "http://localhost:15888", + ssl_context_factory: Optional[Callable[[], ssl.SSLContext]] = None, + ): """ Args: - base_url: Gateway base URL. Use an ``https://`` scheme together with ``ssl_context`` - to talk to a secured (mTLS) Gateway (SEC-048). - ssl_context: Client SSLContext presenting the shared client cert. Required for - ``https://`` URLs; ignored for plain ``http://``. + base_url: Gateway base URL. Use an ``https://`` scheme together with + ``ssl_context_factory`` to talk to a secured (mTLS) Gateway (SEC-048). + ssl_context_factory: Zero-arg callable returning a client SSLContext presenting the + shared client cert. Called lazily (and cached) on the first ``https`` request, so + certs generated *after* the API started — e.g. once the Gateway is started — are + picked up without an API restart. Ignored for plain ``http://``. """ self.base_url = base_url - self._ssl_context = ssl_context + self._ssl_context_factory = ssl_context_factory + self._ssl_context: Optional[ssl.SSLContext] = None + self._is_https = base_url.lower().startswith("https://") self._session: Optional[aiohttp.ClientSession] = None @staticmethod @@ -52,11 +60,24 @@ async def get_wallet_address_or_default(self, chain: str, wallet_address: Option raise ValueError(f"No valid wallet configured for chain '{chain}' (found placeholder: {default_wallet})") return default_wallet + def _get_ssl_context(self) -> Optional[ssl.SSLContext]: + """Lazily build and cache the client SSLContext for https Gateways. + + Deferred so certs created after startup (once the Gateway is started) are picked up. + Raises FileNotFoundError (from the factory) while the cert set is still absent. + """ + if not self._is_https or self._ssl_context_factory is None: + return None + if self._ssl_context is None: + self._ssl_context = self._ssl_context_factory() + return self._ssl_context + async def _get_session(self) -> aiohttp.ClientSession: """Get or create aiohttp session""" if self._session is None or self._session.closed: - if self._ssl_context is not None: - connector = aiohttp.TCPConnector(ssl=self._ssl_context) + ssl_context = self._get_ssl_context() + if ssl_context is not None: + connector = aiohttp.TCPConnector(ssl=ssl_context) self._session = aiohttp.ClientSession(connector=connector) else: self._session = aiohttp.ClientSession() @@ -69,9 +90,16 @@ async def close(self): async def _request(self, method: str, path: str, params: Dict = None, json: Dict = None) -> Optional[Dict]: """Make HTTP request to Gateway""" - session = await self._get_session() url = f"{self.base_url}/{path}" + try: + session = await self._get_session() + except FileNotFoundError as e: + # https Gateway selected but the shared certs aren't available yet (Gateway not + # started). Return a clean error instead of crashing the caller. + logger.warning(f"Gateway mTLS certs unavailable, cannot reach {url}: {e}") + return {"error": "Gateway client certificates not available; start the Gateway first", "status": 503} + try: if method == "GET": async with session.get(url, params=params) as response: From 4d6aded376c465dd45f54644a0f2af369b3049c2 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 25 Jun 2026 18:14:16 +0200 Subject: [PATCH 4/9] (fix) point API at secured Gateway via in-network name over https (SEC-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) --- docker-compose.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3c89d448..feefbe3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,11 @@ services: # Override specific values for Docker networking - BROKER_HOST=emqx - DATABASE_URL=postgresql+asyncpg://hbot:hummingbot-api@postgres:5432/hummingbot_api - - GATEWAY_URL=http://host.docker.internal:15888 + # SEC-048: reach the secured Gateway container-to-container over the shared emqx-bridge + # network (the Gateway container is attached to it on start). https + the 'gateway' cert SAN. + # Note: the Gateway's host publish is bound to 127.0.0.1, so host.docker.internal can no + # longer reach it — the in-network container name is the correct (and more secure) path. + - GATEWAY_URL=https://gateway:15888 # Point psutil at the mounted host paths (see volumes above) - HOST_PROC=/host/proc - HOST_DISK_PATH=/host/disk-probe From cb25dfd0c6daabcf48b6a41f1c8d9b4abb0163d9 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 25 Jun 2026 20:42:27 +0200 Subject: [PATCH 5/9] (fix) always use CONFIG_PASSWORD for Gateway passphrase; drop override (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) --- models/gateway.py | 15 ++++++++------- services/gateway_service.py | 10 ++++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/models/gateway.py b/models/gateway.py index 84fad2a3..41d84232 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -8,13 +8,14 @@ class GatewayConfig(BaseModel): - """Configuration for Gateway container deployment""" - passphrase: Optional[str] = Field( - default=None, - description="Gateway passphrase for config encryption and mTLS cert keys. " - "Defaults to the API's CONFIG_PASSWORD so a single secret secures " - "the Gateway, this API, and deployed instances (SEC-048)." - ) + """Configuration for Gateway container deployment. + + Note: there is intentionally no ``passphrase`` field. The Gateway (v2.x) uses a single + ``GATEWAY_PASSPHRASE`` for *both* TLS cert-key decryption and wallet encryption, and the + shared mTLS cert set must be decryptable by this API's clients — which decrypt the client + cert with ``CONFIG_PASSWORD``. The passphrase is therefore always ``CONFIG_PASSWORD``; a + separate value would only break the API<->Gateway mTLS chain (SEC-048). + """ image: str = Field(default="hummingbot/gateway:latest", description="Docker image for Gateway") port: int = Field(default=15888, description="Port for Gateway API") dev_mode: bool = Field( diff --git a/services/gateway_service.py b/services/gateway_service.py index a5d10d57..9c7d6a6f 100644 --- a/services/gateway_service.py +++ b/services/gateway_service.py @@ -135,10 +135,12 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: # Ensure directories exist dirs = self._ensure_gateway_directories() - # SEC-048: a single secret (CONFIG_PASSWORD) secures the Gateway, this API, and - # deployed instances. The per-request passphrase is honoured if provided, but for - # mTLS to work end-to-end it must match the secret the clients/instances decrypt with. - passphrase = config.passphrase or settings.security.config_password + # SEC-048: a single secret (CONFIG_PASSWORD) secures the Gateway, this API, and deployed + # instances. The Gateway uses GATEWAY_PASSPHRASE for both TLS and wallet encryption, and + # the shared mTLS certs must be decryptable by this API's clients (which use + # CONFIG_PASSWORD) — so the passphrase is always CONFIG_PASSWORD; a different value would + # break the mTLS chain. There is deliberately no per-request override. + passphrase = settings.security.config_password secured = not config.dev_mode # Set up volumes - bind-mount SOURCES must be HOST paths (the Docker daemon runs on the From aa67c83e7315f331cbfa59cda8e02149f0371c10 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 25 Jun 2026 20:48:14 +0200 Subject: [PATCH 6/9] (docs) Gateway starts secured by default; drop dev-mode/passphrase instructions (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) --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8dc76d6b..19ac329e 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,17 @@ Restart Claude Desktop after adding. Gateway enables decentralized exchange trading. Start it via MCP: -> "Start Gateway in development mode with passphrase 'admin'" +> "Start Gateway" -Or via API at http://localhost:8000/docs using the Gateway endpoints. +Or via API at http://localhost:8000/docs using the Gateway endpoints (`POST /gateway/start` with an empty body). -Once running, Gateway is available at http://localhost:15888 +Gateway starts **secured by default**: it runs with TLS + mutual-cert (mTLS) authentication and the +required certificates are auto-generated on first start under `bots/gateway-files/certs/` — no +passphrase prompt and no manual cert step. The single secret protecting the Gateway (TLS + wallet +encryption) is your `CONFIG_PASSWORD`. Once running, Gateway is available at https://localhost:15888. + +> Development mode (plain HTTP, no TLS, loopback only) is available as an explicit opt-in for local +> debugging by passing `{"dev_mode": true}` to `POST /gateway/start` — do not use it with funded wallets. ## Configuration From 3537119686684d3802b5374eb4ffcd031cd0f53d Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 25 Jun 2026 21:08:11 +0200 Subject: [PATCH 7/9] (feat) remove Gateway dev mode entirely; always run secured (SEC-048) 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) --- README.md | 12 ++++++------ config.py | 5 ++--- models/gateway.py | 18 ++++++++---------- services/gateway_service.py | 35 +++++++++++++---------------------- 4 files changed, 29 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 19ac329e..5b8b7187 100644 --- a/README.md +++ b/README.md @@ -122,13 +122,13 @@ Gateway enables decentralized exchange trading. Start it via MCP: Or via API at http://localhost:8000/docs using the Gateway endpoints (`POST /gateway/start` with an empty body). -Gateway starts **secured by default**: it runs with TLS + mutual-cert (mTLS) authentication and the -required certificates are auto-generated on first start under `bots/gateway-files/certs/` — no -passphrase prompt and no manual cert step. The single secret protecting the Gateway (TLS + wallet -encryption) is your `CONFIG_PASSWORD`. Once running, Gateway is available at https://localhost:15888. +Gateway always runs **secured**: it serves TLS + mutual-cert (mTLS) authentication and the required +certificates are auto-generated on first start under `bots/gateway-files/certs/` — no passphrase +prompt and no manual cert step. The single secret protecting the Gateway (TLS + wallet encryption) +is your `CONFIG_PASSWORD`. Once running, Gateway is available at https://localhost:15888. -> Development mode (plain HTTP, no TLS, loopback only) is available as an explicit opt-in for local -> debugging by passing `{"dev_mode": true}` to `POST /gateway/start` — do not use it with funded wallets. +> There is no development/insecure mode: a Gateway holding wallet keys must never be served over +> plain HTTP, so the API only ever runs it with mTLS. ## Configuration diff --git a/config.py b/config.py index 502dc3a6..c11a0307 100644 --- a/config.py +++ b/config.py @@ -129,9 +129,8 @@ class GatewaySettings(BaseSettings): url: str = Field( default="https://localhost:15888", - description="Gateway service URL. Defaults to https for the secured (mTLS) Gateway " - "(SEC-048); use 'https://gateway:15888' when running in Docker. Set an " - "'http://' scheme only when the Gateway is started in dev_mode." + description="Gateway service URL. The Gateway always runs secured (mTLS), so this must use " + "the 'https' scheme (SEC-048); use 'https://gateway:15888' when running in Docker." ) model_config = SettingsConfigDict(env_prefix="GATEWAY_", extra="ignore") diff --git a/models/gateway.py b/models/gateway.py index 41d84232..87d6d862 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -10,19 +10,17 @@ class GatewayConfig(BaseModel): """Configuration for Gateway container deployment. - Note: there is intentionally no ``passphrase`` field. The Gateway (v2.x) uses a single - ``GATEWAY_PASSPHRASE`` for *both* TLS cert-key decryption and wallet encryption, and the - shared mTLS cert set must be decryptable by this API's clients — which decrypt the client - cert with ``CONFIG_PASSWORD``. The passphrase is therefore always ``CONFIG_PASSWORD``; a - separate value would only break the API<->Gateway mTLS chain (SEC-048). + The Gateway always runs secured (TLS + mTLS); there is intentionally no ``dev_mode`` and no + ``passphrase`` field (SEC-048): + - A Gateway that holds wallet keys must never be served over plain HTTP, so the API does not + support a dev/insecure mode. + - The Gateway (v2.x) uses a single ``GATEWAY_PASSPHRASE`` for *both* TLS cert-key decryption + and wallet encryption, and the shared mTLS cert set must be decryptable by this API's + clients (which use ``CONFIG_PASSWORD``). The passphrase is therefore always + ``CONFIG_PASSWORD``; a separate value would only break the API<->Gateway mTLS chain. """ image: str = Field(default="hummingbot/gateway:latest", description="Docker image for Gateway") port: int = Field(default=15888, description="Port for Gateway API") - dev_mode: bool = Field( - default=False, - description="Opt-in escape hatch: plain HTTP, no mTLS, bound to loopback only. " - "Defaults to False so the Gateway runs secured with TLS + client-cert auth." - ) class GatewayStatus(BaseModel): diff --git a/services/gateway_service.py b/services/gateway_service.py index 9c7d6a6f..97cf488c 100644 --- a/services/gateway_service.py +++ b/services/gateway_service.py @@ -135,13 +135,13 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: # Ensure directories exist dirs = self._ensure_gateway_directories() - # SEC-048: a single secret (CONFIG_PASSWORD) secures the Gateway, this API, and deployed - # instances. The Gateway uses GATEWAY_PASSPHRASE for both TLS and wallet encryption, and - # the shared mTLS certs must be decryptable by this API's clients (which use - # CONFIG_PASSWORD) — so the passphrase is always CONFIG_PASSWORD; a different value would - # break the mTLS chain. There is deliberately no per-request override. + # SEC-048: the API only ever runs the Gateway secured (TLS + mTLS). There is no dev-mode + # escape hatch — a Gateway holding wallet keys must never be served over plain HTTP. + # A single secret (CONFIG_PASSWORD) secures the Gateway, this API, and deployed instances: + # the Gateway uses GATEWAY_PASSPHRASE for both TLS and wallet encryption, and the shared + # mTLS certs must be decryptable by this API's clients (which use CONFIG_PASSWORD), so the + # passphrase is always CONFIG_PASSWORD. passphrase = settings.security.config_password - secured = not config.dev_mode # Set up volumes - bind-mount SOURCES must be HOST paths (the Docker daemon runs on the # host), so use the host-side base even though the API process wrote via the local base. @@ -151,25 +151,16 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: os.path.join(host_base, "logs"): {'bind': '/home/gateway/logs', 'mode': 'rw'}, } - # Set up environment variables + # Run the Gateway with TLS + client-cert auth (DEV=false). Generate the shared mTLS cert + # set once (idempotent; existing CA reused) into the local-base dir, and mount the matching + # host path read-only so the Gateway decrypts its server key with the same passphrase. + ensure_gateway_certs(passphrase, dirs["certs"]) + volumes[os.path.join(host_base, "certs")] = {'bind': self.GATEWAY_CERTS_BIND, 'mode': 'ro'} environment = { "GATEWAY_PASSPHRASE": passphrase, - "DEV": str(config.dev_mode).lower(), + "DEV": "false", } - - if secured: - # SEC-048: run the Gateway with TLS + client-cert auth. Generate the shared mTLS - # cert set once (idempotent; existing CA reused) into the local-base dir, and mount - # the matching host path read-only so the Gateway decrypts its server key with the - # same passphrase. - ensure_gateway_certs(passphrase, dirs["certs"]) - volumes[os.path.join(host_base, "certs")] = {'bind': self.GATEWAY_CERTS_BIND, 'mode': 'ro'} - logger.info("Starting Gateway in secured mode (TLS + mTLS, DEV=false)") - else: - logger.warning( - "Starting Gateway in dev_mode: plain HTTP with NO TLS and NO client-cert auth. " - "Bound to loopback only; do not use with funded wallets exposed to a network." - ) + logger.info("Starting Gateway in secured mode (TLS + mTLS, DEV=false)") # Configure logging log_config = LogConfig( From 62bfec551624defbe29f495ade8463cf2f24803a Mon Sep 17 00:00:00 2001 From: cardosofede Date: Mon, 29 Jun 2026 13:14:14 +0200 Subject: [PATCH 8/9] (feat) fix gateway service --- services/gateway_service.py | 68 ++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/services/gateway_service.py b/services/gateway_service.py index 97cf488c..a0969272 100644 --- a/services/gateway_service.py +++ b/services/gateway_service.py @@ -79,6 +79,52 @@ def _get_gateway_container(self) -> Optional[docker.models.containers.Container] logger.error(f"Error getting Gateway container: {e}") return None + def _get_self_container(self) -> Optional[docker.models.containers.Container]: + """Best-effort lookup of the container this API process is running in. + + Used to attach the Gateway to the same Docker network(s) the API is on, so the + Gateway is reachable by container name (https://gateway:15888) regardless of the + Compose project prefix (e.g. a workspace-scoped `185_emqx-bridge` network). + """ + candidates = [] + # Docker sets HOSTNAME to the short container id by default. + hostname = os.environ.get("HOSTNAME") + if hostname: + candidates.append(hostname) + # Fallback: parse the container id from cgroup/mountinfo in case HOSTNAME was overridden. + for proc_file in ("/proc/self/mountinfo", "/proc/self/cgroup"): + try: + with open(proc_file) as f: + content = f.read() + except OSError: + continue + marker = "/docker/containers/" + idx = content.find(marker) + if idx != -1: + rest = content[idx + len(marker):] + cid = rest.split("/", 1)[0].strip() + if cid: + candidates.append(cid) + + for ident in candidates: + try: + return self.client.containers.get(ident) + except (docker.errors.NotFound, DockerException): + continue + return None + + def _get_api_network_names(self) -> list: + """User-defined Docker networks the API container is attached to. + + Excludes the special `host`/`none`/default `bridge` networks since those don't + provide container-name DNS resolution for the Gateway. + """ + container = self._get_self_container() + if container is None: + return [] + networks = container.attrs.get("NetworkSettings", {}).get("Networks", {}) + return [name for name in networks if name not in ("host", "none", "bridge")] + def get_status(self) -> GatewayStatus: """Get the current status of the Gateway container""" container = self._get_gateway_container() @@ -212,17 +258,31 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: container = self.client.containers.run(**container_config) - # On macOS/Windows, connect to emqx-bridge network if it exists + # On macOS/Windows, attach the Gateway to the same Docker network(s) the API + # container is on so it's reachable by container name (https://gateway:15888). + # Detecting the API's own networks handles Compose project prefixes (e.g. a + # workspace-scoped `185_emqx-bridge`) that fixed names would miss. Fall back to + # the legacy fixed names when self-detection isn't possible (e.g. API run on host). if not use_host_network: - possible_networks = ["hummingbot-api_emqx-bridge", "emqx-bridge"] - for net in possible_networks: + target_networks = self._get_api_network_names() + if not target_networks: + target_networks = ["hummingbot-api_emqx-bridge", "emqx-bridge"] + connected = False + for net in target_networks: try: network = self.client.networks.get(net) network.connect(container) logger.info(f"Connected Gateway to {net} network") - break + connected = True except docker.errors.NotFound: continue + except DockerException as e: + logger.warning(f"Failed to connect Gateway to {net} network: {e}") + if not connected: + logger.warning( + "Gateway not attached to any shared Docker network; the API may not " + "be able to reach it by container name" + ) logger.info(f"Gateway container started successfully: {container.id}") return { From bf31ba3e7ed9beaca2172b8e486e237cb78d92f2 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Mon, 29 Jun 2026 16:20:31 +0200 Subject: [PATCH 9/9] (feat) reconcile certs --- main.py | 11 ++++++++++ services/gateway_client.py | 16 ++++++++++++-- services/gateway_service.py | 44 ++++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index d5271204..c69444e9 100644 --- a/main.py +++ b/main.py @@ -252,6 +252,17 @@ async def lifespan(app: FastAPI): backtesting_service = BacktestingService() docker_service = DockerService() gateway_service = GatewayService() + # If a secured Gateway is already running but this API lost the shared mTLS certs (e.g. the + # API container was recreated without the persisted bots/ mount), regenerate the cert set and + # restart the Gateway so it loads a matching server cert. Non-fatal: the API must still boot + # even when Docker is unavailable or the Gateway is simply not running. + if gateway_use_ssl: + try: + reconcile = gateway_service.reconcile_certs() + if reconcile.get("action") != "none": + logging.info(f"Gateway cert reconciliation: {reconcile.get('message')}") + except Exception as e: + logging.warning(f"Gateway cert reconciliation skipped: {e}") bot_archiver = BotArchiver( settings.aws.api_key, settings.aws.secret_key, diff --git a/services/gateway_client.py b/services/gateway_client.py index ef744078..62cf238a 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -32,6 +32,10 @@ def __init__( self._ssl_context: Optional[ssl.SSLContext] = None self._is_https = base_url.lower().startswith("https://") self._session: Optional[aiohttp.ClientSession] = None + # Guards the "certs unavailable" warning so background pollers don't spam it every cycle + # while the Gateway is simply not started. Logged once on the transition, then suppressed + # until certs become available again. + self._certs_unavailable_warned = False @staticmethod def parse_network_id(network_id: str) -> tuple[str, str]: @@ -70,6 +74,8 @@ def _get_ssl_context(self) -> Optional[ssl.SSLContext]: return None if self._ssl_context is None: self._ssl_context = self._ssl_context_factory() + # Certs are now available; allow a fresh warning if they ever disappear again. + self._certs_unavailable_warned = False return self._ssl_context async def _get_session(self) -> aiohttp.ClientSession: @@ -96,8 +102,14 @@ async def _request(self, method: str, path: str, params: Dict = None, json: Dict session = await self._get_session() except FileNotFoundError as e: # https Gateway selected but the shared certs aren't available yet (Gateway not - # started). Return a clean error instead of crashing the caller. - logger.warning(f"Gateway mTLS certs unavailable, cannot reach {url}: {e}") + # started). Return a clean error instead of crashing the caller. Warn only once on + # the transition so background pollers don't spam the log every cycle while the + # Gateway stays unstarted (a normal, optional state). + if not self._certs_unavailable_warned: + logger.warning(f"Gateway mTLS certs unavailable, cannot reach {url}: {e}") + self._certs_unavailable_warned = True + else: + logger.debug(f"Gateway mTLS certs still unavailable, cannot reach {url}: {e}") return {"error": "Gateway client certificates not available; start the Gateway first", "status": 503} try: diff --git a/services/gateway_service.py b/services/gateway_service.py index a0969272..73798bb7 100644 --- a/services/gateway_service.py +++ b/services/gateway_service.py @@ -10,7 +10,7 @@ from config import settings from models.gateway import GatewayConfig, GatewayStatus -from utils.gateway_certs import ensure_gateway_certs +from utils.gateway_certs import certs_present, ensure_gateway_certs, gateway_certs_dir # Create module-specific logger logger = logging.getLogger(__name__) @@ -299,6 +299,48 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: "message": f"Failed to start Gateway: {str(e)}" } + def reconcile_certs(self) -> Dict[str, Any]: + """Make a running Gateway usable by this API's mTLS client. + + "Was the Gateway started with this API?" reduces to: does the API hold the shared client + cert set? The set lives on the bots/ volume both containers share, so a running Gateway + with the certs absent on the API side was not started by (or is inconsistent with) this + API instance — every secured request would fail the mTLS handshake. + + Decision matrix: + - container not running -> nothing (start the Gateway to generate certs) + - running + certs present -> nothing (already consistent) + - running + certs missing -> regenerate the cert set and restart the Gateway so it + loads the server cert that matches our client cert + + The restart is required: the Gateway reads its server cert/key only at startup, so writing + new certs to the shared volume has no effect on an already-running container. + """ + container = self._get_gateway_container() + if container is None or container.status != "running": + return { + "success": True, + "action": "none", + "message": "Gateway container not running; start it to generate certs", + } + + if certs_present(gateway_certs_dir()): + return { + "success": True, + "action": "none", + "message": "Gateway running and shared mTLS certs present", + } + + logger.warning( + "Gateway container is running but the shared mTLS certs are missing on the API side; " + "regenerating the cert set and restarting the Gateway so it loads a matching server cert" + ) + dirs = self._ensure_gateway_directories() + ensure_gateway_certs(settings.security.config_password, dirs["certs"]) + result = self.restart() + result["action"] = "regenerated_and_restarted" + return result + def stop(self) -> Dict[str, Any]: """Stop the Gateway container""" container = self._get_gateway_container()