Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions infra/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ infra/
│ ├── Dockerfile
│ ├── rabbitmq.conf # management.load_definitions
│ └── definitions.json # exchanges / queues / bindings (자동 import)
└── minio/
├── Dockerfile
└── init.sh # 부트스트랩: 버킷 생성
├── minio/
│ ├── Dockerfile
│ └── init.sh # 부트스트랩: 버킷 생성
├── backup/ # 운영 백업 (PG 덤프 + MinIO 아카이브, cron)
│ ├── backup.sh
│ └── README.md
└── nginx/ # 운영 nginx 설정 원본 (적용은 수동 — sudo 필요)
├── stack-up.shop.conf
├── conf.d-stackup-ratelimit.conf
└── README.md

# (루트)
docker-compose.yml # 모든 인프라 서비스 + 헬스체크
Expand Down Expand Up @@ -90,7 +97,9 @@ docker exec stackup-postgres pg_dump -U stackup stackup > backup.sql
docker exec -i stackup-postgres psql -U stackup stackup < backup.sql
```

운영 단계에서는 자동 스냅샷 (RDS) 또는 cron 기반 dump.
**운영 백업은 구현돼 있다** — `infra/backup/` (매일 04:00 cron, PG 덤프 + MinIO 아카이브,
보존 14일, 실패 시 Discord 알림, 신선도는 `/api/system/health` 의 `backup` 컴포넌트).
2026-09-26 이전까지는 백업이 **하나도 없었다**.

---

Expand Down Expand Up @@ -268,4 +277,5 @@ docker system df
- ❌ definitions.json 수동 변경 후 커밋 누락
- ❌ 운영 비밀(.env 본체) 커밋
- ❌ `docker compose down -v` 를 운영 데이터 위에서 실수로 실행
(2026-09-26 부터 `infra/backup/` 이 일 1회 백업을 남긴다 — 그래도 최대 24시간을 잃는다)
- ❌ 포트 하드코딩 (변수화 안 함)
32 changes: 31 additions & 1 deletion infra/nginx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,44 @@ sudo systemctl reload nginx
| 항목 | 이전 | 이후 |
|---|---|---|
| `/ai/` 프록시 | AI 서버가 인터넷에 공개(`/ai/docs`·`/ai/redoc`·`/ai/openapi.json` 포함) | **제거** — 쓰는 곳이 없었다 |
| TLS | 전역 설정이 TLSv1·1.1 허용, 서버가 실제로 수락 | 이 사이트만 **TLSv1.2/1.3** |
| TLS | TLSv1·1.1 수락 | **TLSv1.2/1.3** (전역 `nginx.conf` 도 변경) |
| 보안 헤더 | 전무 | 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 가 이게 없으면 깨진다).

### TLS — server 블록만으로는 안 됐다

처음엔 이 사이트의 `server` 블록에만 `ssl_protocols TLSv1.2 TLSv1.3;` 을 넣었다. **효과가 없었다.**

`ssl_protocols` 는 **SNI 이전**에 결정되므로, 같은 `443` 소켓을 여러 `server` 가 공유하면
**default server 의 값이 전체에 적용**된다. 이 호스트는 443 에 4개 사이트가 물려 있어
전역 `/etc/nginx/nginx.conf` 를 바꿔야 했다:

```diff
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
+ ssl_protocols TLSv1.2 TLSv1.3;
```

백업: `/etc/nginx/nginx.conf.bak-20260927-132844`. **이 변경은 5개 사이트 전부에 적용된다.**
사이트 블록의 선언도 그대로 남겨 뒀다 — 전역이 되돌아가도 의도가 설정에 남는다.

### TLS 버전 지원 여부를 확인하는 법 (틀리기 쉽다)

`openssl s_client` 출력의 `Protocol : TLSv1` 줄은 **클라이언트가 시도한** 버전이라
**실패해도 찍힌다.** 이걸 "수락"으로 읽어서 두 번 오판했다. 올바른 신호는 협상된 암호다:

```bash
out=$(echo | openssl s_client -connect stack-up.shop:443 -servername stack-up.shop -tls1 -cipher 'DEFAULT@SECLEVEL=0' 2>&1)
echo "$out" | grep -q "Cipher is (NONE)" && echo "거절" || echo "수락"
```

`Cipher is (NONE)` + `alert number 70`(protocol_version) = 서버가 거절한 것이다.
또한 최신 OpenSSL(3.x)은 기본적으로 TLS 1.0/1.1 을 **제안조차 하지 않으므로**
`-cipher 'DEFAULT@SECLEVEL=0'` 없이 테스트하면 서버 설정과 무관하게 항상 실패한다.

### `/ai/` 를 지운 근거

- 프론트 운영 번들에 `/ai/` 참조 **0건**
Expand Down
22 changes: 14 additions & 8 deletions infra/nginx/stack-up.shop.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
# 존재했고 레포에 없었다 — 누가 언제 무엇을 바꿨는지 추적할 방법이 없었다.
#
# **중요**: /etc/nginx/nginx.conf 는 이 호스트의 다른 사이트 4개와 공유된다.
# 그래서 전역(http 컨텍스트) 설정은 건드리지 않고, 모든 하드닝을 이 server 블록
# 안으로 한정했다. rate limit zone 만 http 컨텍스트가 필요해
# /etc/nginx/conf.d/stackup-ratelimit.conf 로 분리했다(다른 사이트에 영향 없음 —
# zone 선언은 공유 메모리만 잡고, 실제 제한은 여기 limit_req 가 붙은 곳에만 걸린다).
# 하드닝은 되도록 이 server 블록 안으로 한정했다. 예외 둘:
# · rate limit zone — http 컨텍스트 전용이라 conf.d 로 분리(다른 사이트 영향 없음.
# zone 선언은 공유 메모리만 잡고, 제한은 limit_req 가 붙은 곳에만 걸린다).
# · ssl_protocols — server 블록만으로는 효과가 없어 전역도 바꿨다(아래 주석 참고).
# 이건 5개 사이트 전부에 적용된다.

server {
if ($host = www.stack-up.shop) {
Expand All @@ -32,9 +33,12 @@ server {
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 이상으로 올린다.
# 2026-09-27: 전역 /etc/nginx/nginx.conf 도 TLSv1.2/1.3 으로 올렸다(README 참고).
# 이 줄은 남겨 둔다 — 전역이 되돌아가도 이 사이트는 지켜지고, 의도가 설정에 드러난다.
#
# 주의: ssl_protocols 는 SNI 이전에 결정되므로, 같은 443 소켓을 여러 server 가 공유하면
# **default server 의 값이 전체에 적용**된다. 이 호스트는 443 에 4개 사이트가 물려 있어
# 여기만 고쳐서는 효과가 없었고 전역을 바꿔야 했다.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;

Expand Down Expand Up @@ -113,7 +117,9 @@ server {
proxy_send_timeout 1h;
}

# location /ai/ 는 제거했다.
# AI 서버(포트 38030) 프록시 블록은 제거했다.
# 제거 여부는 아래처럼 실제 지시자만 세어 확인한다(주석까지 세면 오해한다):
# grep -cE '^\s*location' stack-up.shop → 4 (api/auth, api, realtime, /)
#
# AI 서버(FastAPI)를 인터넷에 그대로 노출하고 있었다 — /ai/docs, /ai/redoc,
# /ai/openapi.json 이 공개로 열렸고, 내부 전용 WS(/internal/voice/stream)도
Expand Down
Loading