3 Commits

Author SHA1 Message Date
6a1a2c4552 CI/CD 안정성 강화: 동시 배포 방지, 자기 재빌드 제거, 헬스체크 추가
- deploy.sh: flock으로 동시 배포 방지, deployer를 빌드 대상에서 제외
- deploy.sh: 배포 후 헬스체크 (4개 서비스 /health 확인)
- deploy.sh: 릴리즈 백업 최근 5개만 유지, 원자적 백업 (mv)
- deploy-nas.sh: .env 동기화 제거 (운영 시크릿 보호), __pycache__ 제외
- deployer: threading.Lock으로 동시 배포 방어, TimeoutExpired 개별 처리
- docker-compose: deployer 포트 localhost 바인딩, stock-lab 환경변수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:20:25 +09:00
ff975defbd AI Coach 백엔드 프록시 추가 및 trade 엔드포인트 인증 적용
- POST /api/stock/ai-coach: Anthropic API 프록시 (API 키 서버 보관)
- trade/balance, trade/order: ADMIN_API_KEY 헤더 인증 추가
- print() → logging 모듈 전환 (stock-lab)
- .env.example: ADMIN_API_KEY, ANTHROPIC_API_KEY, CORS_ALLOW_ORIGINS 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:12:31 +09:00
bc9ba3901e 보안 강화: CORS 제한, Path Traversal 방어, 헬스체크 추가
- travel-proxy: get_thumb NameError 수정 및 경로 조작 방어
- stock-lab, music-lab: CORS allow_origins=* → 환경변수 기반 도메인 제한
- travel-proxy, deployer: /health 엔드포인트 추가
- 전 서비스 .dockerignore 추가 (.git, __pycache__, .env 제외)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:08:39 +09:00
13 changed files with 209 additions and 82 deletions

View File

@@ -50,3 +50,12 @@ PGID=1000
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
ADMIN_API_KEY=
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
ANTHROPIC_API_KEY=
# CORS 허용 도메인 (콤마 구분)
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080

6
backend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

6
deployer/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -1,14 +1,18 @@
import os, hmac, hashlib, subprocess
import os, hmac, hashlib, subprocess, threading
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
import logging
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("deployer")
app = FastAPI()
SECRET = os.getenv("WEBHOOK_SECRET", "")
if not SECRET:
logger.warning("WEBHOOK_SECRET is not set! All webhooks will be rejected.")
_deploy_lock = threading.Lock()
def verify(sig: str, body: bytes) -> bool:
if not SECRET or not sig:
return False
@@ -18,19 +22,30 @@ def verify(sig: str, body: bytes) -> bool:
return any(hmac.compare_digest(sig, c) for c in candidates)
def run_deploy_script():
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김"""
logger.info("Starting deployment script...")
"""배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
if not _deploy_lock.acquire(blocking=False):
logger.info("Deploy already in progress, skipping")
return
try:
# 타임아웃 10분 설정
logger.info("Starting deployment script...")
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
if p.returncode == 0:
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
else:
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
except subprocess.TimeoutExpired:
logger.error("Deployment TIMEOUT (10 min exceeded)")
except Exception as e:
logger.exception(f"Exception during deployment: {e}")
finally:
_deploy_lock.release()
@app.get("/health")
def health():
return {"status": "healthy", "service": "deployer"}
@app.post("/webhook")
async def webhook(req: Request, background_tasks: BackgroundTasks):

View File

@@ -31,6 +31,9 @@ services:
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
@@ -45,6 +48,7 @@ services:
- TZ=${TZ:-Asia/Seoul}
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${MUSIC_DATA_PATH:-./data/music}:/app/data
@@ -88,7 +92,7 @@ services:
container_name: webpage-deployer
restart: unless-stopped
ports:
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천)
- "127.0.0.1:19010:9000" # localhost만 허용 (nginx /webhook 프록시 경유)
environment:
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
volumes:

6
music-lab/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -16,11 +16,13 @@ from .db import (
app = FastAPI()
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_origins=[o.strip() for o in _cors_origins],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")

View File

@@ -30,33 +30,14 @@ cd "$SRC"
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
rsync -a --delete \
--exclude ".git" \
--exclude ".releases" \
"$SRC/backend/" "$DST/backend/"
rsync -a --delete \
--exclude ".git" \
"$SRC/travel-proxy/" "$DST/travel-proxy/"
rsync -a --delete \
--exclude ".git" \
"$SRC/deployer/" "$DST/deployer/"
rsync -a --delete \
--exclude ".git" \
"$SRC/stock-lab/" "$DST/stock-lab/"
rsync -a --delete \
--exclude ".git" \
"$SRC/music-lab/" "$DST/music-lab/"
rsync -a --delete \
--exclude ".git" \
"$SRC/nginx/" "$DST/nginx/"
rsync -a --delete \
--exclude ".git" \
"$SRC/scripts/" "$DST/scripts/"
RSYNC_EXCLUDES="--exclude .git --exclude __pycache__ --exclude *.pyc --exclude data/"
# compose 파일 / env 파일
for dir in backend travel-proxy deployer stock-lab music-lab nginx scripts; do
rsync -a --delete $RSYNC_EXCLUDES \
"$SRC/$dir/" "$DST/$dir/"
done
# compose 파일만 동기화 (.env는 절대 동기화하지 않음 — 운영 시크릿 보호)
rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml"
if [ -f "$SRC/.env" ]; then
rsync -a "$SRC/.env" "$DST/.env"
fi
echo "SYNC_OK"

View File

@@ -1,6 +1,10 @@
#!/bin/bash
set -euo pipefail
# ── 동시 배포 방지 (flock) ──
exec 200>/tmp/deploy.lock
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
# 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then
echo "Detected Docker Container environment."
@@ -13,10 +17,9 @@ else
set -a; source .env; set +a
fi
# 환경변수가 없으면 현재 디렉토리를 SRC로
SRC="${REPO_PATH:-$(pwd)}"
DST="${RUNTIME_PATH:-/volume1/docker/webpage}" # 기본값 설정
DST="${RUNTIME_PATH:-/volume1/docker/webpage}"
if [ -z "$DST" ]; then
echo "Error: RUNTIME_PATH is not set."
exit 1
@@ -32,19 +35,40 @@ cd "$SRC"
git fetch --all --prune
git pull --ff-only
# 릴리즈 백업(롤백용): 아래 5번과 연결
# ── 릴리즈 백업 (롤백용) ──
TAG="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DST/.releases/$TAG"
BACKUP_DIR="$DST/.releases/$TAG"
mkdir -p "$BACKUP_DIR.tmp"
rsync -a --delete \
--exclude ".releases" \
"$DST/" "$DST/.releases/$TAG/"
"$DST/" "$BACKUP_DIR.tmp/"
mv "$BACKUP_DIR.tmp" "$BACKUP_DIR"
# 소스 → 운영 반영 (네가 이미 만든 deploy-nas.sh가 있으면 그걸 호출해도 됨)
# 예: repo/scripts/deploy-nas.sh가 운영으로 복사/동기화하는 로직이라면:
# 오래된 릴리즈 정리 (최근 5개만 유지)
ls -dt "$DST/.releases"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
# ── 소스 → 운영 반영 ──
bash "$SRC/scripts/deploy-nas.sh"
# ── 컨테이너 재빌드 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단됨) ──
cd "$DST"
docker compose up -d --build backend travel-proxy stock-lab frontend deployer
docker compose up -d --build backend travel-proxy stock-lab music-lab frontend
docker exec lotto-frontend nginx -s reload 2>/dev/null || true
echo "DEPLOY_OK $TAG"
# ── 배포 후 헬스체크 ──
echo "Waiting for services to start..."
sleep 5
HEALTH_OK=true
for endpoint in "http://backend:8000/health" "http://stock-lab:8000/health" "http://travel-proxy:8000/health" "http://music-lab:8000/health"; do
if ! curl -sf --max-time 5 "$endpoint" > /dev/null 2>&1; then
echo "HEALTH_FAIL: $endpoint"
HEALTH_OK=false
fi
done
if [ "$HEALTH_OK" = false ]; then
echo "DEPLOY_WARN: Some services failed health check. Consider rollback: $TAG"
else
echo "DEPLOY_OK $TAG"
fi

6
stock-lab/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -1,14 +1,18 @@
import os
import json
import logging
from datetime import date as date_type
from typing import Optional
from fastapi import FastAPI, Query
from fastapi import FastAPI, Query, Header, Depends, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("stock-lab")
from .db import (
init_db, save_articles, get_latest_articles,
add_portfolio_item, get_all_portfolio, get_portfolio_item,
@@ -23,12 +27,13 @@ from .price_fetcher import get_current_prices
app = FastAPI()
# CORS 설정 (프론트엔드 접근 허용)
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 운영 시에는 구체적인 도메인으로 제한하는 것이 좋음
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=[o.strip() for o in _cors_origins],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
@@ -36,6 +41,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
# Windows AI Server URL (NAS .env에서 설정)
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
# Admin API Key 인증
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
def verify_admin(x_admin_key: str = Header(None)):
"""admin/trade 엔드포인트 보호용 API 키 검증"""
if not ADMIN_API_KEY:
return # 키 미설정 시 인증 비활성화 (개발 환경)
if x_admin_key != ADMIN_API_KEY:
raise HTTPException(status_code=401, detail="Unauthorized")
# Anthropic API 프록시용 키 (서버 측 보관)
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
# 공휴일 목록 로드
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
try:
@@ -123,53 +141,87 @@ def trigger_scrap():
run_scraping_job()
return {"ok": True}
# --- Trading API (Windows Proxy) ---
# --- Trading API (Windows Proxy, 인증 필요) ---
@app.get("/api/trade/balance")
@app.get("/api/trade/balance", dependencies=[Depends(verify_admin)])
def get_balance():
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
print(f"[Proxy] Requesting Balance from {WINDOWS_AI_SERVER_URL}...")
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
try:
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
if resp.status_code != 200:
print(f"[ProxyError] Balance Error: {resp.status_code} {resp.text}")
logger.error(f"Balance Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json())
print("[Proxy] Balance Success")
return resp.json()
except requests.JSONDecodeError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
except Exception as e:
print(f"[ProxyError] Connection Failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
)
logger.error(f"Balance Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
class OrderRequest(BaseModel):
ticker: str # 종목 코드 (예: "005930")
action: str # "BUY" or "SELL"
quantity: int # 주문 수량
price: int = 0 # 0이면 시장가
reason: Optional[str] = "Manual Order" # 주문 사유 (AI 기록용)
ticker: str
action: str
quantity: int
price: int = 0
reason: Optional[str] = "Manual Order"
@app.post("/api/trade/order")
@app.post("/api/trade/order", dependencies=[Depends(verify_admin)])
def order_stock(req: OrderRequest):
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
print(f"[Proxy] Order Request: {req.dict()} to {WINDOWS_AI_SERVER_URL}...")
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
try:
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
if resp.status_code != 200:
print(f"[ProxyError] Order Error: {resp.status_code} {resp.text}")
logger.error(f"Order Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json())
return resp.json()
except requests.JSONDecodeError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
except Exception as e:
print(f"[ProxyError] Order Connection Failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
logger.error(f"Order Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
# --- AI Coach 프록시 (API 키를 서버에 보관) ---
class AiCoachRequest(BaseModel):
model: str = "claude-haiku-4-5-20251001"
prompt: str
max_tokens: int = 1024
@app.post("/api/stock/ai-coach")
def ai_coach(req: AiCoachRequest):
"""AI 포트폴리오 코치 — Anthropic API 프록시 (API 키 서버 보관)"""
if not ANTHROPIC_API_KEY:
raise HTTPException(503, "AI Coach not configured (ANTHROPIC_API_KEY missing)")
allowed_models = {"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}
model = req.model if req.model in allowed_models else "claude-haiku-4-5-20251001"
try:
resp = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"Content-Type": "application/json",
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
json={
"model": model,
"max_tokens": req.max_tokens,
"messages": [{"role": "user", "content": req.prompt}],
},
timeout=30,
)
if resp.status_code != 200:
logger.error(f"Anthropic API error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content={"error": "AI API error"})
return resp.json()
except requests.Timeout:
return JSONResponse(status_code=504, content={"error": "AI API timeout"})
except Exception as e:
logger.error(f"AI Coach error: {e}")
return JSONResponse(status_code=500, content={"error": "AI Coach failed"})

View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -168,6 +168,10 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
# -----------------------------
# Routes
# -----------------------------
@app.get("/health")
def health():
return {"status": "healthy", "service": "travel-proxy"}
@app.get("/api/travel/regions")
def regions():
_meta_changed_invalidate_cache()
@@ -239,12 +243,18 @@ def photos(
@app.get("/media/travel/.thumb/{album}/{filename}")
def get_thumb(album: str, filename: str):
if ".." in album or ".." in filename:
raise HTTPException(400, "Invalid path")
src = (ROOT / album / filename).resolve()
if not str(src).startswith(str(ROOT)):
raise HTTPException(403, "Access denied")
if not src.exists() or not src.is_file():
raise HTTPException(404, "Source not found")
p = ensure_thumb(src, album)
if not p.exists() or not p.is_file():
raise HTTPException(404, "Thumbnail not found")
# src로부터 thumb 생성/확인 (원본 확장자 유지)
p = ensure_thumb(src, album)
return FileResponse(str(p))
@app.get("/api/version")