diff --git a/infra/CLAUDE.md b/infra/CLAUDE.md index c7c3dc76..955bcf9c 100644 --- a/infra/CLAUDE.md +++ b/infra/CLAUDE.md @@ -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 # 모든 인프라 서비스 + 헬스체크 @@ -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 이전까지는 백업이 **하나도 없었다**. --- @@ -268,4 +277,5 @@ docker system df - ❌ definitions.json 수동 변경 후 커밋 누락 - ❌ 운영 비밀(.env 본체) 커밋 - ❌ `docker compose down -v` 를 운영 데이터 위에서 실수로 실행 + (2026-09-26 부터 `infra/backup/` 이 일 1회 백업을 남긴다 — 그래도 최대 24시간을 잃는다) - ❌ 포트 하드코딩 (변수화 안 함) diff --git a/infra/nginx/README.md b/infra/nginx/README.md index d0adf467..0ecf8c0e 100644 --- a/infra/nginx/README.md +++ b/infra/nginx/README.md @@ -36,7 +36,7 @@ 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` | @@ -44,6 +44,36 @@ sudo systemctl reload nginx 바꾸지 않은 것(이미 올바름): `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건** diff --git a/infra/nginx/stack-up.shop.conf b/infra/nginx/stack-up.shop.conf index a37d0f6a..1dd4ab1a 100644 --- a/infra/nginx/stack-up.shop.conf +++ b/infra/nginx/stack-up.shop.conf @@ -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) { @@ -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; @@ -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)도