feat(realestate): 내부 인증 + naver 워커 targets 엔드포인트 + nginx internal 블록
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
@@ -119,6 +119,23 @@ server {
|
|||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# naver-fetch 워커 → NAS realestate-lab internal webhook (targets/listings-ingest)
|
||||||
|
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
|
||||||
|
# Layer 3: X-Internal-Key (FastAPI dependency)
|
||||||
|
location /api/internal/realestate/ {
|
||||||
|
allow 192.168.45.0/24;
|
||||||
|
allow 100.64.0.0/10;
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Internal-Key $http_x_internal_key;
|
||||||
|
proxy_pass http://realestate-lab:8000/api/internal/realestate/;
|
||||||
|
}
|
||||||
|
|
||||||
# realestate API
|
# realestate API
|
||||||
location /api/realestate/ {
|
location /api/realestate/ {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
11
realestate-lab/app/auth.py
Normal file
11
realestate-lab/app/auth.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Windows naver-fetch 워커 → NAS realestate-lab 내부 엔드포인트 인증."""
|
||||||
|
import os
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||||
|
expected = os.getenv("INTERNAL_API_KEY")
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||||
|
if x_internal_key != expected:
|
||||||
|
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||||
27
realestate-lab/app/internal_router.py
Normal file
27
realestate-lab/app/internal_router.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""naver-fetch 워커 내부 계약: targets 조회 + listings ingest."""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from .auth import verify_internal_key
|
||||||
|
from .db import get_listing_criteria
|
||||||
|
from .lawd_codes import naver_cortar
|
||||||
|
|
||||||
|
logger = logging.getLogger("realestate-lab")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
NAVER_PAGE_LIMIT = int(os.getenv("NAVER_PAGE_LIMIT", "2"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/internal/realestate/targets", dependencies=[Depends(verify_internal_key)])
|
||||||
|
def listing_targets():
|
||||||
|
crit = get_listing_criteria()
|
||||||
|
dongs = []
|
||||||
|
for d in crit.get("dongs", []):
|
||||||
|
code = naver_cortar(d)
|
||||||
|
if not code:
|
||||||
|
logger.warning("naver cortarNo 매핑 없음 — 대상 제외: %s", d)
|
||||||
|
continue
|
||||||
|
dongs.append({"dong": d, "cortar_no": code})
|
||||||
|
return {"dongs": dongs, "deal_types": crit.get("deal_types", []),
|
||||||
|
"page_limit": NAVER_PAGE_LIMIT}
|
||||||
@@ -29,6 +29,7 @@ from .models import (
|
|||||||
AnnouncementCreate, AnnouncementUpdate, ProfileUpdate,
|
AnnouncementCreate, AnnouncementUpdate, ProfileUpdate,
|
||||||
ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest,
|
ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest,
|
||||||
)
|
)
|
||||||
|
from .internal_router import router as internal_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||||
logger = logging.getLogger("realestate-lab")
|
logger = logging.getLogger("realestate-lab")
|
||||||
@@ -96,6 +97,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
install_access_log(app)
|
install_access_log(app)
|
||||||
|
app.include_router(internal_router)
|
||||||
|
|
||||||
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
28
realestate-lab/tests/test_internal_targets.py
Normal file
28
realestate-lab/tests/test_internal_targets.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
|
||||||
|
import importlib
|
||||||
|
from app import auth as _a
|
||||||
|
importlib.reload(_a) # env 반영
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_targets_requires_key(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
# 헤더 없음 — FastAPI Header(...)는 dependency 실행 전 자체 검증하므로 422.
|
||||||
|
# image-lab/tests/test_internal_router.py의 동일 패턴과 일치(sibling 서비스 선례).
|
||||||
|
assert c.get("/api/internal/realestate/targets").status_code in (401, 422)
|
||||||
|
assert c.get("/api/internal/realestate/targets",
|
||||||
|
headers={"X-Internal-Key": "WRONG"}).status_code == 401
|
||||||
|
|
||||||
|
def test_targets_returns_dongs_with_cortar(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.get("/api/internal/realestate/targets", headers={"X-Internal-Key": "SECRET"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
dongs = {d["dong"]: d["cortar_no"] for d in body["dongs"]}
|
||||||
|
assert dongs.get("신대방동") == "1159010100" # criteria seed 6동 + cortar join
|
||||||
|
assert "전세" in body["deal_types"]
|
||||||
|
assert isinstance(body["page_limit"], int)
|
||||||
Reference in New Issue
Block a user