Skip to content

Commit fb2ddc9

Browse files
committed
(feat) adapt gateway certs
1 parent 99cc36e commit fb2ddc9

2 files changed

Lines changed: 83 additions & 26 deletions

File tree

main.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -125,35 +125,36 @@ async def lifespan(app: FastAPI):
125125
)
126126

127127
# Initialize GatewayHttpClient singleton
128-
from utils.gateway_certs import certs_present, sync_client_certs_to_root
128+
from utils.gateway_certs import ensure_gateway_certs
129129
parsed_gateway_url = urlparse(settings.gateway.url)
130130
gateway_use_ssl = parsed_gateway_url.scheme == "https"
131131
if gateway_use_ssl:
132-
# SEC-048: the in-process GatewayHttpClient reads its client certs only from
133-
# root_path()/certs. Mirror the shared cert set there if the Gateway was already
134-
# started in a previous run (no-op when certs haven't been generated yet).
135-
sync_client_certs_to_root()
132+
# SEC-048: generate the shared mTLS cert set up front (idempotent; an existing CA is
133+
# reused untouched) and mirror the client certs into root_path()/certs, the only place
134+
# hummingbot's in-process GatewayHttpClient looks. Doing this at startup rather than
135+
# lazily on Gateway start means the client can always build its SSL context — a missing
136+
# ca_cert.pem used to raise FileNotFoundError on every single request.
137+
try:
138+
ensure_gateway_certs(settings.security.config_password)
139+
except Exception as e:
140+
# Non-fatal: the API must boot even if the cert dir isn't writable.
141+
logging.warning(f"Could not prepare Gateway mTLS certs: {e}")
136142
gateway_config = GatewayConfigMap(
137143
gateway_api_host=parsed_gateway_url.hostname or "localhost",
138144
gateway_api_port=str(parsed_gateway_url.port or 15888),
139145
gateway_use_ssl=gateway_use_ssl
140146
)
141-
gateway_client = GatewayHttpClient.get_instance(gateway_config)
147+
GatewayHttpClient.get_instance(gateway_config)
142148
# Start the Gateway status monitor so Gateway's network connectors (e.g.
143149
# 'solana-mainnet-beta', and any newly added chain like 'ethereum-unichain') are
144150
# discovered from /config/chains and registered in AllConnectorSettings. Without
145151
# it, a Gateway network only lands in AllConnectorSettings lazily, when a connector
146152
# for it is first constructed; the monitor makes new chains enumerable without
147153
# first deploying a bot on them, and picks them up when Gateway comes online later.
148-
# On a fresh install the shared mTLS certs don't exist until the Gateway is first
149-
# started, and every 2s ping would fail loudly building the SSL context — defer;
150-
# GatewayService.start() starts the monitor once the certs are generated.
151-
if not gateway_use_ssl or certs_present():
152-
gateway_client.start_monitor()
153-
else:
154-
logging.info(
155-
"Gateway mTLS certs not generated yet; status monitor deferred until the Gateway is started"
156-
)
154+
# The monitor polls every 2s and logs loudly on every failure, so it is only started
155+
# once there is a Gateway to poll — see the gateway_service block below, which has the
156+
# Docker client needed to check. GatewayService.start() starts it when the Gateway
157+
# comes up later.
157158
logging.info(f"Initialized GatewayHttpClient with URL: {settings.gateway.url}")
158159

159160
# Initialize database
@@ -268,6 +269,21 @@ async def lifespan(app: FastAPI):
268269
logging.info(f"Gateway cert reconciliation: {reconcile.get('message')}")
269270
except Exception as e:
270271
logging.warning(f"Gateway cert reconciliation skipped: {e}")
272+
273+
# Start the Gateway status monitor only when there is a Gateway to poll. Gating on the
274+
# container itself (rather than on cert presence, which is now always true) keeps the 2s
275+
# poll loop from spamming errors for the many deployments that never run a Gateway.
276+
# Plain-HTTP setups may point at a Gateway this API doesn't manage, so keep polling there.
277+
try:
278+
gateway_is_running = gateway_service.is_running()
279+
except Exception as e:
280+
logging.warning(f"Could not determine Gateway container state: {e}")
281+
gateway_is_running = False
282+
if not gateway_use_ssl or gateway_is_running:
283+
GatewayHttpClient.get_instance().start_monitor()
284+
else:
285+
logging.info("Gateway container not running; status monitor deferred until it is started")
286+
271287
bot_archiver = BotArchiver(
272288
settings.aws.api_key,
273289
settings.aws.secret_key,

services/gateway_service.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,46 @@ def _get_gateway_container(self) -> Optional[docker.models.containers.Container]
8080
logger.error(f"Error getting Gateway container: {e}")
8181
return None
8282

83+
def is_running(self) -> bool:
84+
"""True when the Gateway container exists and is running.
85+
86+
This is the direct signal for "is there a Gateway to talk to". Callers must not
87+
infer it from cert presence: certs are generated up front so the mTLS client is
88+
always usable, so their existence says nothing about whether the Gateway is up.
89+
"""
90+
container = self._get_gateway_container()
91+
return container is not None and container.status == "running"
92+
93+
@staticmethod
94+
def _normalize_mount_source(source: str) -> str:
95+
"""Normalize a Docker-reported bind-mount source to a comparable host path.
96+
97+
Docker Desktop reports macOS/Windows host paths through a ``/host_mnt`` prefix
98+
(host ``/Users/x`` surfaces as ``/host_mnt/Users/x``), so a raw string compare
99+
against a configured host path would never match there.
100+
"""
101+
normalized = os.path.normpath(source or "")
102+
for prefix in ("/host_mnt", "/run/desktop/mnt/host"):
103+
if normalized.startswith(prefix + os.sep):
104+
normalized = normalized[len(prefix):]
105+
return normalized
106+
107+
def _mounts_shared_certs(self, container) -> bool:
108+
"""True when the container mounts *this* API's shared cert set.
109+
110+
The Gateway reads its server cert from the directory we bind-mount at
111+
``GATEWAY_CERTS_BIND``. If that mount's source is our cert dir, the Gateway is
112+
serving a cert signed by the CA our clients trust - consistent by construction.
113+
A Gateway started by a different API instance (or a different checkout) mounts a
114+
different source path, which is exactly the mismatch we need to detect.
115+
"""
116+
expected = self._normalize_mount_source(gateway_certs_dir(host=True))
117+
for mount in container.attrs.get("Mounts", []):
118+
if mount.get("Destination") != self.GATEWAY_CERTS_BIND:
119+
continue
120+
return self._normalize_mount_source(mount.get("Source", "")) == expected
121+
return False
122+
83123
def _get_self_container(self) -> Optional[docker.models.containers.Container]:
84124
"""Best-effort lookup of the container this API process is running in.
85125
@@ -309,16 +349,17 @@ def start(self, config: GatewayConfig) -> Dict[str, Any]:
309349
def reconcile_certs(self) -> Dict[str, Any]:
310350
"""Make a running Gateway usable by this API's mTLS client.
311351
312-
"Was the Gateway started with this API?" reduces to: does the API hold the shared client
313-
cert set? The set lives on the bots/ volume both containers share, so a running Gateway
314-
with the certs absent on the API side was not started by (or is inconsistent with) this
315-
API instance — every secured request would fail the mTLS handshake.
352+
"Was the Gateway started with this API?" reduces to: does the running container mount
353+
*our* cert dir as its server cert source? Cert presence cannot answer this — the set is
354+
generated up front at API startup, so it is present even for a Gateway this API never
355+
started. A Gateway serving a cert from some other source would fail our mTLS handshake
356+
on every request.
316357
317358
Decision matrix:
318-
- container not running -> nothing (start the Gateway to generate certs)
319-
- running + certs present -> nothing (already consistent)
320-
- running + certs missing -> regenerate the cert set and restart the Gateway so it
321-
loads the server cert that matches our client cert
359+
- container not running -> nothing (start the Gateway)
360+
- running + mounts our certs -> nothing (consistent by construction)
361+
- running + mounts other/no certs-> regenerate the cert set and restart the Gateway so it
362+
loads the server cert that matches our client cert
322363
323364
The restart is required: the Gateway reads its server cert/key only at startup, so writing
324365
new certs to the shared volume has no effect on an already-running container.
@@ -331,15 +372,15 @@ def reconcile_certs(self) -> Dict[str, Any]:
331372
"message": "Gateway container not running; start it to generate certs",
332373
}
333374

334-
if certs_present(gateway_certs_dir()):
375+
if self._mounts_shared_certs(container) and certs_present(gateway_certs_dir()):
335376
return {
336377
"success": True,
337378
"action": "none",
338-
"message": "Gateway running and shared mTLS certs present",
379+
"message": "Gateway running against this API's shared mTLS cert set",
339380
}
340381

341382
logger.warning(
342-
"Gateway container is running but the shared mTLS certs are missing on the API side; "
383+
"Gateway container is running but is not serving this API's shared mTLS cert set; "
343384
"regenerating the cert set and restarting the Gateway so it loads a matching server cert"
344385
)
345386
dirs = self._ensure_gateway_directories()

0 commit comments

Comments
 (0)