From 95da3e93071123e274dd2a83bca0085e9b9c9510 Mon Sep 17 00:00:00 2001 From: Variet Worker Date: Wed, 11 Mar 2026 19:49:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20API=20Key=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20+=20HTTPS=20(Caddy)=20=EB=B3=B4=EC=95=88=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gateway.py: auth middleware — /api/* 엔드포인트에 Bearer token 필수 - Caddyfile: Let's Encrypt 자동 HTTPS 리버스 프록시 - docker-compose.yml: Caddy 추가, Gateway 포트 내부 전용 - config.py: GATEWAY_API_KEY 설정 추가 - .env: 키 생성 명령어 가이드 포함 --- Caddyfile | 9 +++++++++ config.py | 1 + docker-compose.yml | 23 ++++++++++++++++++++--- gateway.py | 29 ++++++++++++++++++++++++++--- main.py | 2 +- 5 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 Caddyfile diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..9448949 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,9 @@ +# Gravity Gateway — Caddy Reverse Proxy +# Automatic HTTPS via Let's Encrypt +# +# 도메인을 실제 도메인으로 변경하세요 (예: gateway.variet.net) +# Caddy가 자동으로 Let's Encrypt 인증서를 발급합니다. + +gateway.variet.net { + reverse_proxy gateway:8585 +} diff --git a/config.py b/config.py index 00d4af4..cf90d8d 100644 --- a/config.py +++ b/config.py @@ -43,6 +43,7 @@ class Config: # Bot mode: 'local' (file-based bridge) or 'remote' (HTTP polling — future) BOT_MODE: str = os.getenv("BOT_MODE", "local") REMOTE_BRIDGE_URL: str = os.getenv("REMOTE_BRIDGE_URL", "") + GATEWAY_API_KEY: str = os.getenv("GATEWAY_API_KEY", "") @classmethod def validate(cls) -> list[str]: diff --git a/docker-compose.yml b/docker-compose.yml index f60a501..b6a2f2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,15 @@ services: build: . container_name: gravity-gateway restart: unless-stopped - ports: - - "8585:8585" + # Port NOT exposed directly — Caddy handles external access + expose: + - "8585" environment: - DISCORD_TOKEN=${DISCORD_TOKEN} - DISCORD_GUILD_ID=${DISCORD_GUILD_ID} - BOT_MODE=gateway - GATEWAY_PORT=8585 - # Brain path inside container (not used in gateway mode, but needed for config validation) + - GATEWAY_API_KEY=${GATEWAY_API_KEY} - BRAIN_PATH=/app/data/brain volumes: - gateway-data:/app/data @@ -20,5 +21,21 @@ services: max-size: "10m" max-file: "3" + caddy: + image: caddy:2-alpine + container_name: gravity-caddy + restart: unless-stopped + ports: + - "443:443" + - "80:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - gateway + volumes: gateway-data: + caddy-data: + caddy-config: diff --git a/gateway.py b/gateway.py index d6bd216..b2b5a6d 100644 --- a/gateway.py +++ b/gateway.py @@ -28,11 +28,12 @@ logger = logging.getLogger(__name__) class GatewayAPI: """HTTP API server for Collector ↔ Gateway communication.""" - def __init__(self, bot, host: str = "0.0.0.0", port: int = 8585): + def __init__(self, bot, host: str = "0.0.0.0", port: int = 8585, api_key: str = ""): self.bot = bot self.host = host self.port = port - self.app = web.Application() + self.api_key = api_key + self.app = web.Application(middlewares=[self._auth_middleware]) self._setup_routes() # In-memory stores (Gateway is stateless across restarts) @@ -47,6 +48,27 @@ class GatewayAPI: self.app.router.add_post("/api/register", self._post_register) self.app.router.add_get("/api/commands/{project}", self._get_commands) + # ─── Auth Middleware ─── + + @web.middleware + async def _auth_middleware(self, request: web.Request, handler): + """Reject requests without valid API key on /api/* routes.""" + # Health endpoint is public + if request.path == "/health": + return await handler(request) + + # All /api/* routes require auth + if request.path.startswith("/api/") and self.api_key: + auth = request.headers.get("Authorization", "") + if auth != f"Bearer {self.api_key}": + logger.warning(f"[GATEWAY] 401 Unauthorized: {request.method} {request.path} from {request.remote}") + return web.json_response( + {"error": "Unauthorized", "detail": "Invalid or missing API key"}, + status=401, + ) + + return await handler(request) + # ─── Health ─── async def _health(self, request: web.Request) -> web.Response: @@ -170,4 +192,5 @@ class GatewayAPI: await runner.setup() site = web.TCPSite(runner, self.host, self.port) await site.start() - logger.info(f"[GATEWAY] HTTP API started on {self.host}:{self.port}") + auth_status = "API Key enabled" if self.api_key else "⚠️ NO AUTH (set GATEWAY_API_KEY!)" + logger.info(f"[GATEWAY] HTTP API started on {self.host}:{self.port} [{auth_status}]") diff --git a/main.py b/main.py index 4ccc17c..ddde8eb 100644 --- a/main.py +++ b/main.py @@ -84,7 +84,7 @@ async def main(): if Config.BOT_MODE == 'gateway': from gateway import GatewayAPI gateway_port = int(os.environ.get('GATEWAY_PORT', '8585')) - gateway = GatewayAPI(bot, port=gateway_port) + gateway = GatewayAPI(bot, port=gateway_port, api_key=Config.GATEWAY_API_KEY) await gateway.start() logger.info(f"Gateway API running on port {gateway_port}")