diff --git a/.gitignore b/.gitignore index fe8a0ef2..a06da7ee 100644 --- a/.gitignore +++ b/.gitignore @@ -168,6 +168,7 @@ gateway-files/ # Hummingbot credentials and local data bots/credentials/ bots/instances/ +bots/conf/ # Local MCP configuration (project-specific overrides) .mcp.json diff --git a/main.py b/main.py index 80c18105..6c2ddc1b 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,10 @@ +import logging import secrets from contextlib import asynccontextmanager from typing import Annotated from urllib.parse import urlparse import logfire -import logging from dotenv import load_dotenv # Load environment variables early @@ -22,29 +22,22 @@ def patched_save_to_yml(yml_path, cm): # Apply the patch before importing hummingbot components from hummingbot.client.config import config_helpers -config_helpers.save_to_yml = patched_save_to_yml -from hummingbot.core.rate_oracle.rate_oracle import RateOracle, RATE_ORACLE_SOURCES -from hummingbot.core.gateway.gateway_http_client import GatewayHttpClient -from hummingbot.client.config.client_config_map import GatewayConfigMap +config_helpers.save_to_yml = patched_save_to_yml from fastapi import Depends, FastAPI, HTTPException, Request, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from fastapi.middleware.cors import CORSMiddleware from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from hummingbot.data_feed.market_data_provider import MarketDataProvider +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from hummingbot.client.config.client_config_map import GatewayConfigMap from hummingbot.client.config.config_crypt import ETHKeyFileSecretManger +from hummingbot.core.gateway.gateway_http_client import GatewayHttpClient +from hummingbot.core.rate_oracle.rate_oracle import RATE_ORACLE_SOURCES, RateOracle +from hummingbot.data_feed.market_data_provider import MarketDataProvider -from utils.security import BackendAPISecurity -from services.bots_orchestrator import BotsOrchestrator -from services.accounts_service import AccountsService -from services.docker_service import DockerService -from services.gateway_service import GatewayService -from services.market_data_feed_manager import MarketDataFeedManager -# from services.executor_service import ExecutorService -from utils.bot_archiver import BotArchiver -from routers import ( +from config import settings +from routers import ( # executors, accounts, archived_bots, backtesting, @@ -52,19 +45,25 @@ def patched_save_to_yml(yml_path, cm): connectors, controllers, docker, - # executors, gateway, - gateway_swap, gateway_clmm, + gateway_proxy, + gateway_swap, market_data, portfolio, rate_oracle, scripts, - trading + trading, ) +from services.accounts_service import AccountsService +from services.bots_orchestrator import BotsOrchestrator +from services.docker_service import DockerService +from services.gateway_service import GatewayService +from services.market_data_feed_manager import MarketDataFeedManager -from config import settings - +# from services.executor_service import ExecutorService +from utils.bot_archiver import BotArchiver +from utils.security import BackendAPISecurity # Set up logging configuration logging.basicConfig( @@ -311,6 +310,7 @@ def auth_user( app.include_router(backtesting.router, dependencies=[Depends(auth_user)]) app.include_router(archived_bots.router, dependencies=[Depends(auth_user)]) # app.include_router(executors.router, dependencies=[Depends(auth_user)]) +app.include_router(gateway_proxy.router, dependencies=[Depends(auth_user)]) @app.get("/") async def root(): diff --git a/routers/gateway_proxy.py b/routers/gateway_proxy.py new file mode 100644 index 00000000..d735c137 --- /dev/null +++ b/routers/gateway_proxy.py @@ -0,0 +1,131 @@ +""" +Gateway Proxy Router + +Catch-all router that forwards requests to Gateway server unchanged. +Dashboard calls /api/gateway-proxy/* and this router forwards to Gateway at localhost:15888/*. + +This allows the dashboard to access all Gateway endpoints through the API without +needing each endpoint to be explicitly defined. + +Examples: + GET /api/gateway-proxy/wallet -> GET localhost:15888/wallet + POST /api/gateway-proxy/wallet/add -> POST localhost:15888/wallet/add + GET /api/gateway-proxy/config -> GET localhost:15888/config + GET /api/gateway-proxy/trading/clmm/positions-owned -> GET localhost:15888/trading/clmm/positions-owned +""" + +import json +import logging + +import aiohttp +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import JSONResponse + +from deps import get_accounts_service +from services.accounts_service import AccountsService + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Gateway Proxy"], prefix="/gateway-proxy") + + +@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) +async def forward_to_gateway( + path: str, + request: Request, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Forward request to Gateway server unchanged. + + This catch-all route forwards any request to /api/gateway-proxy/* to the Gateway server. + The request body, headers, and query parameters are passed through unchanged. + The response from Gateway is returned unchanged. + + Examples: + GET /api/gateway-proxy/wallet -> GET localhost:15888/wallet + POST /api/gateway-proxy/wallet/add -> POST localhost:15888/wallet/add + GET /api/gateway-proxy/config -> GET localhost:15888/config + """ + gateway_client = accounts_service.gateway_client + gateway_url = gateway_client.base_url + + # Build target URL + target_url = f"{gateway_url}/{path}" + + # Get query parameters + query_params = dict(request.query_params) + + # Get request body if present + body = None + if request.method in ["POST", "PUT", "PATCH", "DELETE"]: + try: + body = await request.json() + except Exception: + # No JSON body or invalid JSON - that's OK for some requests + body = None + + try: + # Get or create aiohttp session + session = await gateway_client._get_session() + + # Forward the request + async with session.request( + method=request.method, + url=target_url, + params=query_params if query_params else None, + json=body if body else None, + ) as response: + # Read response body + response_body = await response.read() + + # Try to parse as JSON, otherwise return as-is + content_type = response.headers.get("Content-Type", "") + + if "application/json" in content_type: + try: + json_body = json.loads(response_body) + return JSONResponse( + content=json_body, + status_code=response.status, + ) + except Exception: + pass + + # Return raw response + return Response( + content=response_body, + status_code=response.status, + media_type=content_type or "application/octet-stream", + ) + + except aiohttp.ClientError as e: + logger.error(f"Gateway proxy error: {e}") + raise HTTPException( + status_code=503, + detail=f"Gateway service unavailable: {str(e)}" + ) + except Exception as e: + logger.error(f"Gateway proxy error: {e}") + raise HTTPException( + status_code=500, + detail=f"Gateway proxy error: {str(e)}" + ) + + +# Also expose the root endpoint for health checks +@router.get("") +async def gateway_root( + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Gateway health check. + Forwards to Gateway root endpoint to check if it's online. + """ + gateway_client = accounts_service.gateway_client + result = await gateway_client._request("GET", "") + if result is None: + raise HTTPException(status_code=503, detail="Gateway service unavailable") + if "error" in result: + raise HTTPException(status_code=result.get("status", 500), detail=result["error"]) + return result