diff --git a/infra/nginx/README.md b/infra/nginx/README.md new file mode 100644 index 00000000..d0adf467 --- /dev/null +++ b/infra/nginx/README.md @@ -0,0 +1,53 @@ +# nginx 설정 + +2026-09-27 이전까지 이 설정은 **서버에만** 있었다. 레포에 없으니 누가 언제 무엇을 +바꿨는지 추적할 수 없었고, 서버가 날아가면 복원할 근거도 없었다. 이 디렉토리가 원본이다. + +| 파일 | 설치 위치 | +|---|---| +| `stack-up.shop.conf` | `/etc/nginx/sites-available/stack-up.shop` (→ `sites-enabled` 심볼릭 링크) | +| `conf.d-stackup-ratelimit.conf` | `/etc/nginx/conf.d/stackup-ratelimit.conf` | +| `stack-up.shop.conf.orig` | 2026-09-27 하드닝 이전 원본 (참고용, 설치 안 함) | + +## 배포는 자동이 아니다 + +`deploy-app.yml` 은 레포를 `~/stackup` 으로 rsync 할 뿐 `/etc/nginx` 는 건드리지 않는다 +(sudo 가 필요하고, nginx 는 다른 사이트와 공유되기 때문). **수동으로 적용한다:** + +```bash +sudo cp ~/stackup/infra/nginx/conf.d-stackup-ratelimit.conf /etc/nginx/conf.d/stackup-ratelimit.conf +sudo cp ~/stackup/infra/nginx/stack-up.shop.conf /etc/nginx/sites-available/stack-up.shop +sudo nginx -t # 반드시 먼저. 실패하면 reload 하지 않는다 +sudo systemctl reload nginx +``` + +바꾸기 전 항상 백업하고, `nginx -t` 통과 후에만 reload 한다. + +## 이 호스트는 nginx 를 공유한다 + +`/etc/nginx/nginx.conf` 는 `beontteuk.com`, `udangtang.site`, `udangtangames.cloud`, +`warurulab.site` 와 공유된다. **전역(http 컨텍스트) 설정을 고치면 남의 사이트가 같이 바뀐다.** +그래서 하드닝을 전부 `server` 블록 안으로 한정했다 — `ssl_protocols`, `server_tokens`, +보안 헤더는 server 컨텍스트에서도 재정의된다. rate limit zone 만 http 컨텍스트가 필요해 +`conf.d` 로 뺐다(zone 선언은 메모리만 잡고 제한은 `limit_req` 를 붙인 곳에만 걸린다). + +## 2026-09-27 하드닝에서 바꾼 것 + +| 항목 | 이전 | 이후 | +|---|---|---| +| `/ai/` 프록시 | AI 서버가 인터넷에 공개(`/ai/docs`·`/ai/redoc`·`/ai/openapi.json` 포함) | **제거** — 쓰는 곳이 없었다 | +| TLS | 전역 설정이 TLSv1·1.1 허용, 서버가 실제로 수락 | 이 사이트만 **TLSv1.2/1.3** | +| 보안 헤더 | 전무 | HSTS·nosniff·X-Frame-Options·Referrer-Policy·Permissions-Policy | +| rate limit | 없음 | API 20r/s(burst 60), 인증 2r/s(burst 20) | +| 버전 노출 | `nginx/1.18.0 (Ubuntu)` | `server_tokens off` | + +바꾸지 않은 것(이미 올바름): `client_max_body_size 25m`(백엔드 25MB 상한과 일치), +`/realtime/` 의 `proxy_buffering off` + 1h 타임아웃(SSE·WS 가 이게 없으면 깨진다). + +### `/ai/` 를 지운 근거 + +- 프론트 운영 번들에 `/ai/` 참조 **0건** +- RealTime 은 도커 네트워크로 직접 연결(`REALTIME_AI_WS_URL=ws://ai:8000/internal/voice/stream`) +- `ai/CLAUDE.md` 가 이 서버를 "내부 통신만, 사용자 인증 없음"으로 설계했다고 명시 + +즉 순수한 잉여 노출이었다. 나중에 외부에서 AI 서버에 직접 붙어야 한다면 인증을 먼저 설계한다. diff --git a/infra/nginx/conf.d-stackup-ratelimit.conf b/infra/nginx/conf.d-stackup-ratelimit.conf new file mode 100644 index 00000000..74d94858 --- /dev/null +++ b/infra/nginx/conf.d-stackup-ratelimit.conf @@ -0,0 +1,14 @@ +# /etc/nginx/conf.d/stackup-ratelimit.conf +# +# limit_req_zone 은 http 컨텍스트에만 둘 수 있는데, 이 호스트의 nginx.conf 는 +# 다른 사이트 4개와 공유된다. 그래서 nginx.conf 를 고치는 대신 conf.d 로 분리했다 +# (nginx.conf 가 conf.d/*.conf 를 include 한다). +# +# zone 선언 자체는 다른 사이트에 영향이 없다 — 공유 메모리만 잡을 뿐, 실제 제한은 +# limit_req 를 붙인 stack-up.shop 의 location 에만 걸린다. + +# 일반 API. 페이지 한 번 열 때 여러 요청이 나가므로 burst 로 흡수한다(사이트 설정 참고). +limit_req_zone $binary_remote_addr zone=stackup_api:10m rate=20r/s; + +# 인증 경로. 자격증명 대입·OAuth 남용을 늦춘다. +limit_req_zone $binary_remote_addr zone=stackup_auth:10m rate=2r/s; diff --git a/infra/nginx/stack-up.shop.conf b/infra/nginx/stack-up.shop.conf new file mode 100644 index 00000000..a37d0f6a --- /dev/null +++ b/infra/nginx/stack-up.shop.conf @@ -0,0 +1,129 @@ +# StackUp 운영 nginx 사이트 설정 (stack-up.shop) +# +# 이 파일이 원본이다. 서버의 /etc/nginx/sites-available/stack-up.shop 은 사본이며 +# 설치 방법은 infra/nginx/README.md 참고. 2026-09-27 이전까지 이 설정은 서버에만 +# 존재했고 레포에 없었다 — 누가 언제 무엇을 바꿨는지 추적할 방법이 없었다. +# +# **중요**: /etc/nginx/nginx.conf 는 이 호스트의 다른 사이트 4개와 공유된다. +# 그래서 전역(http 컨텍스트) 설정은 건드리지 않고, 모든 하드닝을 이 server 블록 +# 안으로 한정했다. rate limit zone 만 http 컨텍스트가 필요해 +# /etc/nginx/conf.d/stackup-ratelimit.conf 로 분리했다(다른 사이트에 영향 없음 — +# zone 선언은 공유 메모리만 잡고, 실제 제한은 여기 limit_req 가 붙은 곳에만 걸린다). + +server { + if ($host = www.stack-up.shop) { + return 301 https://$host$request_uri; + } # managed by Certbot + + if ($host = stack-up.shop) { + return 301 https://$host$request_uri; + } # managed by Certbot + + listen 80; + server_name stack-up.shop www.stack-up.shop; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name stack-up.shop www.stack-up.shop; + + ssl_certificate /etc/letsencrypt/live/stack-up.shop/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/stack-up.shop/privkey.pem; # managed by Certbot + + # 전역 nginx.conf 는 TLSv1·TLSv1.1 까지 켜 두고 있고(다른 사이트와 공유라 못 건드림), + # 서버가 실제로 그 핸드셰이크를 수락하는 것을 확인했다. RFC 8996(2021)이 폐기를 권고했고 + # PCI DSS 도 금지한다 — 이 사이트만 1.2 이상으로 올린다. + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + # 응답에 nginx 버전을 싣지 않는다(전역 설정은 주석 처리돼 있어 켜져 있었다). + server_tokens off; + + # ── 보안 헤더 ──────────────────────────────────────────────────────────── + # 전부 미설정 상태였다. always 를 붙여야 4xx/5xx 응답에도 붙는다. + # HSTS 는 preload 없이 시작한다 — preload 는 등록 후 되돌리기가 매우 어렵다. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # 이 앱은 카메라·마이크를 직접 쓴다(면접 녹화·음성 답변) — self 로 허용하고 나머지는 차단. + add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=()" always; + + # 음성 답변 업로드 상한. 백엔드 VoiceAnswerSubmitService.MAX_BYTES(25MB)와 맞춘다 — + # 여기가 더 작으면 백엔드 검증에 닿기도 전에 413 이 난다. + client_max_body_size 25m; + + access_log /var/log/nginx/stack-up.shop.access.log; + error_log /var/log/nginx/stack-up.shop.error.log; + + root /var/www/stackup/frontend/dist; + index index.html; + + # OAuth 시작·콜백·토큰 재발급. 자격증명 대입을 늦추되 사용자가 몇 번 다시 눌러도 + # 막히지 않을 만큼은 준다. + location ~ ^/api/auth/ { + limit_req zone=stackup_auth burst=20 nodelay; + limit_req_status 429; + + proxy_pass http://127.0.0.1:38010; + 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-Forwarded-Proto https; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + location /api/ { + # 페이지 한 번 열 때 API 가 여러 개 나가므로 burst 를 넉넉히 준다. + # 목적은 정상 사용 제한이 아니라 스크래핑·LLM 할당량 소진 같은 남용 차단이다 + # (2026-09-17 에 실제로 게이트웨이 크레딧이 소진돼 운영이 402 로 죽은 적 있다). + limit_req zone=stackup_api burst=60 nodelay; + limit_req_status 429; + + proxy_pass http://127.0.0.1:38010; + 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-Forwarded-Proto https; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # SSE + WebSocket. 버퍼링을 끄지 않으면 SSE 이벤트가 묶여 늦게 도착한다. + # rate limit 을 걸지 않는다 — 재연결 폭주가 정상 동작인 경우가 있어 끊으면 더 나빠진다. + location /realtime/ { + proxy_pass http://127.0.0.1:38020; + + 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-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + + # location /ai/ 는 제거했다. + # + # AI 서버(FastAPI)를 인터넷에 그대로 노출하고 있었다 — /ai/docs, /ai/redoc, + # /ai/openapi.json 이 공개로 열렸고, 내부 전용 WS(/internal/voice/stream)도 + # 인터넷에서 닿았다. ai/CLAUDE.md 는 이 서버를 "내부 통신만, 사용자 인증 없음" + # 으로 설계했다고 적고 있다. + # + # 쓰는 데도 없었다: 프론트 번들에 /ai/ 참조 0건, RealTime 은 도커 네트워크로 + # 직접 붙는다(REALTIME_AI_WS_URL=ws://ai:8000/...). 순수한 잉여 노출이었다. + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/infra/nginx/stack-up.shop.conf.orig b/infra/nginx/stack-up.shop.conf.orig new file mode 100644 index 00000000..a8d4f4fe --- /dev/null +++ b/infra/nginx/stack-up.shop.conf.orig @@ -0,0 +1,84 @@ + server { + if ($host = www.stack-up.shop) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + if ($host = stack-up.shop) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + listen 80; + server_name stack-up.shop www.stack-up.shop; + + return 301 https://$host$request_uri; + + + + +} + + server { + listen 443 ssl http2; + server_name stack-up.shop www.stack-up.shop; + ssl_certificate /etc/letsencrypt/live/stack-up.shop/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/stack-up.shop/privkey.pem; # managed by Certbot + + client_max_body_size 25m; + + access_log /var/log/nginx/stack-up.shop.access.log; + error_log /var/log/nginx/stack-up.shop.error.log; + + root /var/www/stackup/frontend/dist; + index index.html; + + location /api/ { + proxy_pass http://127.0.0.1:38010; + + 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-Forwarded-Proto https; + + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + location /realtime/ { + proxy_pass http://127.0.0.1:38020; + + 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-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + + location /ai/ { + proxy_pass http://127.0.0.1:38030/; + + 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-Forwarded-Proto https; + + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + location / { + try_files $uri $uri/ /index.html; + } + + +}