feat(gateway): API Key 인증 + HTTPS (Caddy) 보안 강화

- gateway.py: auth middleware — /api/* 엔드포인트에 Bearer token 필수
- Caddyfile: Let's Encrypt 자동 HTTPS 리버스 프록시
- docker-compose.yml: Caddy 추가, Gateway 포트 내부 전용
- config.py: GATEWAY_API_KEY 설정 추가
- .env: 키 생성 명령어 가이드 포함
This commit is contained in:
Variet Worker
2026-03-11 19:49:24 +09:00
parent 6dbbb57fa7
commit 95da3e9307
5 changed files with 57 additions and 7 deletions

9
Caddyfile Normal file
View File

@@ -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
}

View File

@@ -43,6 +43,7 @@ class Config:
# Bot mode: 'local' (file-based bridge) or 'remote' (HTTP polling — future) # Bot mode: 'local' (file-based bridge) or 'remote' (HTTP polling — future)
BOT_MODE: str = os.getenv("BOT_MODE", "local") BOT_MODE: str = os.getenv("BOT_MODE", "local")
REMOTE_BRIDGE_URL: str = os.getenv("REMOTE_BRIDGE_URL", "") REMOTE_BRIDGE_URL: str = os.getenv("REMOTE_BRIDGE_URL", "")
GATEWAY_API_KEY: str = os.getenv("GATEWAY_API_KEY", "")
@classmethod @classmethod
def validate(cls) -> list[str]: def validate(cls) -> list[str]:

View File

@@ -3,14 +3,15 @@ services:
build: . build: .
container_name: gravity-gateway container_name: gravity-gateway
restart: unless-stopped restart: unless-stopped
ports: # Port NOT exposed directly — Caddy handles external access
- "8585:8585" expose:
- "8585"
environment: environment:
- DISCORD_TOKEN=${DISCORD_TOKEN} - DISCORD_TOKEN=${DISCORD_TOKEN}
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID} - DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- BOT_MODE=gateway - BOT_MODE=gateway
- GATEWAY_PORT=8585 - 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 - BRAIN_PATH=/app/data/brain
volumes: volumes:
- gateway-data:/app/data - gateway-data:/app/data
@@ -20,5 +21,21 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" 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: volumes:
gateway-data: gateway-data:
caddy-data:
caddy-config:

View File

@@ -28,11 +28,12 @@ logger = logging.getLogger(__name__)
class GatewayAPI: class GatewayAPI:
"""HTTP API server for Collector ↔ Gateway communication.""" """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.bot = bot
self.host = host self.host = host
self.port = port self.port = port
self.app = web.Application() self.api_key = api_key
self.app = web.Application(middlewares=[self._auth_middleware])
self._setup_routes() self._setup_routes()
# In-memory stores (Gateway is stateless across restarts) # 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_post("/api/register", self._post_register)
self.app.router.add_get("/api/commands/{project}", self._get_commands) 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 ─── # ─── Health ───
async def _health(self, request: web.Request) -> web.Response: async def _health(self, request: web.Request) -> web.Response:
@@ -170,4 +192,5 @@ class GatewayAPI:
await runner.setup() await runner.setup()
site = web.TCPSite(runner, self.host, self.port) site = web.TCPSite(runner, self.host, self.port)
await site.start() 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}]")

View File

@@ -84,7 +84,7 @@ async def main():
if Config.BOT_MODE == 'gateway': if Config.BOT_MODE == 'gateway':
from gateway import GatewayAPI from gateway import GatewayAPI
gateway_port = int(os.environ.get('GATEWAY_PORT', '8585')) 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() await gateway.start()
logger.info(f"Gateway API running on port {gateway_port}") logger.info(f"Gateway API running on port {gateway_port}")