Skip to content
Merged
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,5 @@ bots/conf/
# IDE files
.vscode/
.idea/
improvements
improvements
bots/gateway-files/
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions models/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 9 additions & 2 deletions services/accounts_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions services/docker_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
61 changes: 57 additions & 4 deletions services/gateway_client.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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]:
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
Loading
Loading