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/README.md b/README.md index 8dc76d6b..5b8b7187 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 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. + +> 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 a77a886d..c11a0307 100644 --- a/config.py +++ b/config.py @@ -128,8 +128,9 @@ 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. 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/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 diff --git a/main.py b/main.py index 1dfb91d1..c69444e9 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}") @@ -245,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/models/gateway.py b/models/gateway.py index 5528326c..87d6d862 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -8,11 +8,19 @@ class GatewayConfig(BaseModel): - """Configuration for Gateway container deployment""" - passphrase: str = Field(description="Gateway passphrase for configuration encryption") + """Configuration for Gateway container deployment. + + 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=True, description="Enable development mode") class GatewayStatus(BaseModel): diff --git a/services/accounts_service.py b/services/accounts_service.py index 592c3530..73908917 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,15 @@ 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). 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 - self.gateway_client = GatewayClient(gateway_url) + ssl_context_factory = None + if gateway_url.lower().startswith("https://"): + 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/docker_service.py b/services/docker_service.py index 6aa6da40..426f6be5 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, gateway_certs_dir # Create module-specific logger logger = logging.getLogger(__name__) @@ -246,6 +247,21 @@ 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_host_dir = None + if 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 + fs_util.dump_dict_to_yaml(conf_file_path, client_config) # Set up Docker volumes @@ -269,6 +285,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_host_dir: + volumes[gateway_certs_host_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..62cf238a 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -1,5 +1,6 @@ import logging -from typing import Any, Dict, List, Optional +import ssl +from typing import Any, Callable, Dict, List, Optional import aiohttp @@ -12,9 +13,29 @@ 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_factory: Optional[Callable[[], ssl.SSLContext]] = None, + ): + """ + Args: + 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_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 + # 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]: @@ -43,10 +64,29 @@ 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() + # 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: """Get or create aiohttp session""" if self._session is None or self._session.closed: - self._session = aiohttp.ClientSession() + 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() return self._session async def close(self): @@ -56,9 +96,22 @@ 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. 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: if method == "GET": async with session.get(url, params=params) as response: diff --git a/services/gateway_service.py b/services/gateway_service.py index 2448013d..73798bb7 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 certs_present, ensure_gateway_certs, gateway_certs_dir # Create module-specific logger logger = logging.getLogger(__name__) @@ -21,7 +23,12 @@ 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" def __init__(self): self.SOURCE_PATH = os.getcwd() @@ -33,21 +40,33 @@ 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") + 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]: @@ -60,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() @@ -116,17 +181,32 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]: # Ensure directories exist dirs = self._ensure_gateway_directories() - # Set up volumes - use BOTS_PATH which contains the HOST path + # 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 + + # 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 + # 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": config.passphrase, - "DEV": str(config.dev_mode).lower(), + "GATEWAY_PASSPHRASE": passphrase, + "DEV": "false", } + logger.info("Starting Gateway in secured mode (TLS + mTLS, DEV=false)") # Configure logging log_config = LogConfig( @@ -169,22 +249,40 @@ 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) - # 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 { @@ -201,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() @@ -283,7 +423,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) @@ -310,7 +450,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 new file mode 100644 index 00000000..b20dc0df --- /dev/null +++ b/utils/gateway_certs.py @@ -0,0 +1,116 @@ +"""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") + +# 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 _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(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: + """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