generated from ttipo/project-base
feat(docker): 리버스 프록시가 다른 서버에 있을 때 — frontend 를 사설 IP 에만 게시하는 선택 파일
운영 메일 서버를 NCP 에 따로 두고, 포털의 TLS 는 기존 서버의 Nginx Proxy Manager(다른 사이트들과 80 · 443 을 함께 쓴다)가
같은 VPC 사설망으로 넘기기로 했다. 메일 엔진이 그 서버의 443 을 직접 써야 해서(ACME · MTA-STS) 프록시를 같은 호스트에 둘 수 없다.
- docker-compose.remote-proxy.yml: frontend 를 proxy 네트워크에서 빼고 8080 을 FRONTEND_PRIVATE_BIND(사설 IPv4:포트)에만 게시한다.
compose 의 ${…:?} 는 빈 값만 막아 포트뿐인 값이면 모든 주소에 열리므로, 한 번짜리 frontend-bind-check 가 값을 먼저 보고
아니면 frontend 를 띄우지 않는다(네트워크 없음 · 비-root · DB 이미지의 셸).
- .env.production 의 COMPOSE_FILE 로 켠다 — 운영 명령(--env-file .env.production)을 그대로 쓰고 -f 를 빠뜨려 기본 구성으로 도는 일이 없게.
- TRUST_PROXY 는 <EDGE_NETWORK_SUBNET>,<프록시 서버 사설 IP> 주소 목록, NPM 은 v2.16.0 소스로 기본값을 확인해 고급 칸 세 줄
(읽기 150s · 접근 로그 끔 · 사설 대역의 X-Real-IP 믿지 않기)과 Block Common Exploits 끄기(메일 검색이 403)를 적었다.
- NCP 는 공인 IP 를 사설 IP 로 NAT 하므로 게시 주소로는 바깥을 막지 못한다 — 8080 은 ACG 가 프록시 서버 사설 IP 에서만 받는다.
- CI infra 잡이 이 조합의 config 와 frontend-bind-check 의 받을 값 · 거절할 값을 본다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+184
-3
@@ -216,14 +216,16 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
사전 조건:
|
||||
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조)
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조 ·
|
||||
프록시가 다른 서버에 있고 사설망으로 붙으면 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망))
|
||||
- `MAIL_HOSTNAME` 의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR → `MAIL_HOSTNAME` ([PTR](#ptr))
|
||||
- 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 [외부 릴레이](#외부-릴레이-ses-등))
|
||||
- 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
|
||||
- **메일 서버 전용 재귀 DNS** — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl))
|
||||
- **스팸 차단 목록 이용 조건** — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
|
||||
- **엔진 한도** — 받는 쪽 한도 값, 직원마다 내보내는 양의 한도, 직원마다 1시간 업로드 한도(크기 · 개수)를 정한다 ([엔진 한도](#엔진-한도))
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한)
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한).
|
||||
다른 서버의 Nginx Proxy Manager 라면 설정 · `TRUST_PROXY` 는 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)
|
||||
- **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다)
|
||||
- **rootful 도커 + iptables** (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 —
|
||||
rootless 도커(포트 전달이 주소를 바꾼다)나 `"iptables": false`(모든 접속이 docker-proxy 를 거친다)면 외부 접속이 도커 내부 주소로 보여
|
||||
@@ -894,7 +896,8 @@ sudo docker compose --env-file .env.production start mail
|
||||
|
||||
## 리버스 프록시와 같은 호스트에 둘 때 (대안)
|
||||
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (문서로만 정리 — 검증하지 않았다)
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (1~3 은 문서로만 정리 — 검증하지 않았다)
|
||||
메일 서버를 따로 둘 수 있으면 4 를 권한다 — 프록시와 메일 엔진이 443 을 나눠 쓰지 않는다.
|
||||
|
||||
1. **IP 를 하나 더 붙인다 (가장 단순)** — 메일 포트를 그 IP 에만 바인딩: `"<메일 전용 IP>:443:443"` 등. 나머지는 그대로.
|
||||
2. **SNI 패스스루** — L4 프록시(nginx `stream` + `ssl_preread`, HAProxy, Traefik TCP)가 SNI 가 `mx1.arasystem.it.kr` 인 TLS 를
|
||||
@@ -912,6 +915,9 @@ sudo docker compose --env-file .env.production start mail
|
||||
}
|
||||
```
|
||||
3. **프록시가 TLS 종단 → `mail:8080`** — 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다.
|
||||
4. **메일 서버를 따로 둔다** — 프록시는 지금 서버에 그대로 두고, 새 서버에 웹메일 스택 전체를 올려 메일 엔진이 그 서버의 443 을 직접 쓴다.
|
||||
프록시는 포털만 같은 VPC 사설망으로 새 서버의 frontend(8080)에 넘긴다 — 공인 IP 를 서버 수만큼만 주는 곳(NCP 등)에서 1 대신.
|
||||
→ [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)
|
||||
|
||||
⚠️ 2·3 은 Stalwart 에 **프록시 주소가 접속 주소로 보인다.** 프록시를 app-network(신뢰 대역 `APP_NETWORK_SUBNET`)에 두면
|
||||
인터넷 접속이 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다(setup 의 구성원 확인에서도 걸린다). 프록시는 반드시 app-network 밖(위의 전용 네트워크)에 두고,
|
||||
@@ -920,6 +926,181 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요)
|
||||
|
||||
---
|
||||
|
||||
## 리버스 프록시가 다른 서버에 있을 때 (NCP 등 — 사설망)
|
||||
|
||||
포털의 TLS 를 **다른 서버**의 리버스 프록시(예: 기존 서버의 Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께 쓴다)가 끝내고,
|
||||
**같은 VPC 사설망**으로 메일 서버의 frontend(8080)에 넘기는 구성이다 — [위 대안](#리버스-프록시와-같은-호스트에-둘-때-대안)의 4.
|
||||
메일 서버에는 웹메일 스택 전체(mail · backend · db · frontend)를 그대로 올리고 메일 엔진이 그 서버의 443 을 직접 쓴다(ACME TLS-ALPN · MTA-STS).
|
||||
예시 값: 서버 A(기존 — NPM) 사설 IP `10.0.1.11`, 서버 B(메일 — `mx1.arasystem.it.kr`) 사설 IP `10.0.1.12`.
|
||||
|
||||
```
|
||||
portal.arasystem.it.kr (11단계부터 files. 도) → 서버 A 공인 IP mx1.arasystem.it.kr → 서버 B 공인 IP (PTR 도)
|
||||
브라우저 ─TLS─▶ 서버 A (기존) Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께
|
||||
│ http · 같은 VPC 사설망 → 10.0.1.12:8080 (서버 B 의 ACG: 8080 은 10.0.1.11 에서만)
|
||||
▼
|
||||
서버 B (메일 — 새로) docker-compose.yml + docker-compose.remote-proxy.yml
|
||||
<PROJECT_NAME>-frontend :8080 (사설 IP 에만 게시) ─(edge)─▶ backend ─(app-network)─▶ db · mail
|
||||
mail 25 · 465 · 993 · 443 (호스트에 직접, 0.0.0.0) ◀── 바깥 메일 서버 · 메일 프로그램 · ACME
|
||||
```
|
||||
|
||||
- 선택 파일 [`docker-compose.remote-proxy.yml`](../docker-compose.remote-proxy.yml) 이 바꾸는 것은 frontend 하나다 — `proxy` 네트워크에서 빼고(서버 B 에는 없다),
|
||||
8080 을 `FRONTEND_PRIVATE_BIND`(사설 IPv4:포트)에만 게시하고, 그 값을 먼저 보는 `frontend-bind-check` 를 붙인다.
|
||||
frontend 의 환경 · nginx 설정(`X-Forwarded-For` 이어 붙이기 · `X-Request-Id` · 실시간 알림)과 다른 서비스 · 네트워크(edge · app-network)는 기본 구성과 같다.
|
||||
- 서버 A → B 구간은 평문 HTTP 다 — 같은 VPC 사설망 안이라 받아들이고, 그래서 8080 은 서버 A 에서만 받는다(아래 ACG).
|
||||
|
||||
### NCP 쪽 할 일
|
||||
|
||||
| 할 일 | 값 |
|
||||
| --- | --- |
|
||||
| 서버 B 만들기 | 서버 A 와 **같은 VPC** 의 Public 서브넷, 공인 IP 하나, 메모리 8GB (11단계의 ClamAV 까지 같은 서버에 올린다). 다른 VPC 면 Peering 이 있어야 한다 — 이 절은 같은 VPC 만 다룬다 |
|
||||
| VPC 대역 | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) · `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`)과 겹치지 않게 — 겹치면 서버 B 가 그 대역의 VPC 주소(서버 A 등)로 갈 길을 도커 브리지로 보낸다. 겹치면 두 값을 다른 사설 /24 로 바꾸고 mail setup 을 다시 돌린다 |
|
||||
| 서버 B ACG — 인바운드 | TCP 25 · 465 · 993 · 443 ← `0.0.0.0/0` (메일 수신 · 메일 프로그램 · ACME TLS-ALPN · MTA-STS) · TCP 8080 ← **서버 A 사설 IP `/32` 만** · TCP 22 ← 관리 IP 만. 그 밖(80 · 3000 등)은 열지 않는다 |
|
||||
| 서버 B ACG — 아웃바운드 | 좁힌다면 25(발송 — 열려 있음을 확인했다) · 443 · 80(ACME · 웹 포털 내려받기 · 외부 이미지 중계) · 53(DNS)은 남긴다 |
|
||||
| 서버 A ACG — 아웃바운드 | 좁혀 두었다면 서버 B 사설 IP 의 TCP 8080 을 더한다 |
|
||||
| Network ACL | 서브넷의 NACL 을 바꿔 두었다면 위 포트를 같게 연다 |
|
||||
| PTR | 서버 B 공인 IP → `mx1.arasystem.it.kr` — 가비아가 아니라 **NCP 고객지원**에 요청한다. 가비아의 `mx1` A 레코드를 먼저 넣는다(정방향 · 역방향이 맞아야 한다 — [PTR](#ptr)) |
|
||||
| DNS (가비아) | `mx1` A → 서버 B 공인 IP · `portal` A → 서버 A 공인 IP(지금 그대로) · `files` A → 서버 A 공인 IP(11단계) |
|
||||
|
||||
- **게시 주소로는 바깥을 막지 못한다** — NCP 의 공인 IP 는 서버 안에 없고 사설 IP 로 옮겨(NAT) 들어와, 사설 IP 에 게시한 8080 에도 공인 IP 로 닿는다.
|
||||
8080 을 막는 것은 ACG 다. 서버 B 의 `ip -4 addr` 에 공인 IP 가 없으면 이 경우다 — 바깥 PC 에서 `curl -m 5 http://<서버 B 공인 IP>:8080/` 이 시간 초과여야 한다.
|
||||
(NCP 의 NAT · ACG 는 NCP 문서 기준이고 이 저장소에서 시험하지 않았다 — 배포 뒤 아래 "확인"으로 본다)
|
||||
- 메일 포트(25 · 465 · 993 · 443)는 기본 구성 그대로 `0.0.0.0` 에 게시한다. 공인 IP 로 들어온 접속이 원래 주소 그대로 보이는지는 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 본다.
|
||||
- 두 서버의 사설 IP 를 바꾸면(서버를 다시 만들면) `FRONTEND_PRIVATE_BIND` · `TRUST_PROXY` · NPM 의 Forward IP · ACG 를 함께 고친다.
|
||||
|
||||
### 서버 B — env 와 실행
|
||||
|
||||
`.env.production`([운영 배포](#운영-배포))에 셋을 넣는다 — 앞의 둘은 예시 env 의 "리버스 프록시가 다른 서버에 있을 때" 칸, `TRUST_PROXY` 는 바꾼다:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml # 리눅스는 : 로 잇는다
|
||||
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 # 서버 B 의 사설 IPv4:포트 (ip -4 addr)
|
||||
TRUST_PROXY=172.16.72.0/24,10.0.1.11 # <EDGE_NETWORK_SUBNET>,<서버 A 사설 IP> — 아래 "TRUST_PROXY"
|
||||
```
|
||||
|
||||
- **`COMPOSE_FILE` 은 env 파일에 둔다** — compose 가 `--env-file` 의 이 값을 읽으므로 이 문서의 운영 명령(`sudo docker compose --env-file .env.production …`)이 `-f` 없이 그대로 두 파일을 읽는다.
|
||||
`-f` 두 개로 쓰면 명령마다 붙여야 하고, 하나라도 빠뜨린 명령은 기본 구성(서버 B 에 없는 `proxy` 네트워크)으로 돈다. `-f` 를 주면 `COMPOSE_FILE` 보다 그쪽이 이긴다.
|
||||
- 그다음은 [운영 배포](#운영-배포) 절차 그대로다. 띄우기 전 · 뒤에 본다:
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production config --networks # edge · app-network 만 — proxy 가 보이면 COMPOSE_FILE 이 먹지 않았다
|
||||
sudo docker port "${PROJECT_NAME}-frontend" # 8080/tcp -> 10.0.1.12:8080 한 줄
|
||||
sudo docker compose --env-file .env.production logs frontend-bind-check
|
||||
```
|
||||
- **게시 주소 확인** — `FRONTEND_PRIVATE_BIND` 가 비어 있으면 compose 가 바로 멈춘다(`required variable FRONTEND_PRIVATE_BIND is missing a value`).
|
||||
값이 있으면 frontend 보다 먼저 `frontend-bind-check`(셸만 쓰는 한 번짜리 컨테이너 — DB 이미지를 빌린다 · 네트워크 없음 · 비-root)가 RFC 1918 사설 IPv4(10/8 · 172.16/12 · 192.168/16):포트(1~65535, 앞자리 0 없이)인지 본다.
|
||||
아니면 `up` 이 `service "frontend-bind-check" didn't complete successfully: exit 1` 로 멈추고 frontend 는 뜨지 않는다 — 포트뿐인 값(`8080` · `:8080` — compose 는 모든 주소에 연다) · `0.0.0.0` · `127.0.0.1` · 공인 IP · IPv6.
|
||||
떠 있던 frontend 의 값을 잘못 바꾸면 compose 가 frontend 를 다시 만든 뒤 띄우지 않으므로 포털이 멈춘다(닫힌 쪽) — 값을 고치고 `up -d`.
|
||||
끝나면 멈춰 있어 `ps` 에는 없고 `ps -a` 에 `Exited (0)` 이다. 앱 네트워크에 붙지 않으므로 mail setup 의 구성원 확인과도 상관없다.
|
||||
- 확인 (2026-10-30 — compose v2.40.2(윈도) · 5.4.0(리눅스, CI 잡 이미지), 예시 env + 위 값):
|
||||
- `config` 의 frontend 는 networks `edge` 하나, ports `host_ip: 10.0.1.12` · `published: "8080"` · `target: 8080`, depends_on 에 `frontend-bind-check`(`service_completed_successfully`) —
|
||||
기본 구성과의 차이는 이 셋뿐이고 환경 · healthcheck 는 같다. 맨 위 networks 에 `proxy` 가 없다.
|
||||
- `COMPOSE_FILE` 을 `--env-file` 에만 두고 `-f` 없이 돌린 `config` 가 위와 같았다(윈도는 `;` 로 잇는다). `COMPOSE_PROFILES=mail` 도 그대로 먹었고, `-f docker-compose.yml` 을 주면 기본 구성이 나왔다.
|
||||
- `frontend-bind-check` — `10.0.1.12:8080` · `172.31.255.254:65535` · `192.168.0.10:18080` 은 0, `8080` · `:8080` · `0.0.0.0:8080` · `127.0.0.1:8080` · `203.0.113.5:8080` · `172.32.0.1:8080` · `[::]:8080` · `10.0.1.12:0` · `10.0.1.12:08080` 은 1
|
||||
(`10.0.1.12` · `010.0.1.12:8080` · `10.0.1.12:65536` 은 compose 가 먼저 거절). 잘못된 값의 `up -d` 는 종료 코드 1 · frontend `Created`(시작하지 않음)이고,
|
||||
떠 있던 frontend 에서 값을 잘못 바꾸면 다시 만들어진 frontend 가 `Created` 로 남았다 (시험용 프로젝트 — frontend 대신 alpine).
|
||||
|
||||
### TRUST_PROXY — 서버 A 의 주소 목록
|
||||
|
||||
- 값은 `<EDGE_NETWORK_SUBNET>,<서버 A 사설 IP>` (예 `172.16.72.0/24,10.0.1.11`) — 백엔드의 운영 env 검증이 받는다(2026-10-30 확인).
|
||||
서버 A 사설 IP 는 frontend 가 보는 접속 주소다 — 서버 A 의 NPM 은 도커지만 서버 밖으로는 서버 A 의 사설 IP 로 나간다. 배포 뒤 `sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"` 의 첫 칸으로 확인하고,
|
||||
같은 줄의 X-Forwarded-For(넷째 `"…"` 칸) 끝이 브라우저의 공인 주소인지 본다.
|
||||
- 홉 수 `2` 도 정상 흐름에서는 같은 IP 를 읽지만 "오른쪽에서 두 번째 주소"를 **누가 붙었든** 믿는다. 같은 VPC 의 다른 서버가 8080 에 바로 붙으면(ACG 를 잘못 열었을 때)
|
||||
`X-Forwarded-For` 를 꾸며 백엔드가 그 값을 클라이언트 IP 로 쓴다 — 로그인 실패 한도 · 요청 수 한도가 IP 기준이다. 주소 목록은 서버 A 에서 온 것만 믿어, 다른 곳에서 바로 붙으면 그 서버 자신의 주소로 센다.
|
||||
|
||||
| frontend 가 받은 `X-Forwarded-For` (백엔드가 보는 접속은 edge 의 frontend) | 주소 목록 | 홉 수 `2` |
|
||||
| --- | --- | --- |
|
||||
| 정상 — `203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` |
|
||||
| 브라우저가 꾸밈 — `1.2.3.4, 203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` |
|
||||
| 같은 VPC 의 다른 서버가 8080 에 바로 — `1.2.3.4, 10.0.2.20` | `10.0.2.20` | `1.2.3.4` (꾸민 값) |
|
||||
| 서버 A 의 다른 컨테이너가 8080 에 바로 — `1.2.3.4, 10.0.1.11` | `1.2.3.4` (꾸민 값) | `1.2.3.4` (꾸민 값) |
|
||||
|
||||
(2026-10-30, 개발 백엔드 컨테이너의 Express 5.2.1 · proxy-addr 2.0.8 과 백엔드의 `resolveTrustProxy` 로 확인)
|
||||
- **남는 것** — 서버 A 의 모든 것(다른 사이트의 컨테이너 포함)은 같은 사설 IP 로 나가 NPM 과 가를 수 없다(표의 마지막 줄). 서버 A 는 믿는 쪽에 든다 — 그 서버에 믿지 못할 것을 두지 않는다.
|
||||
NPM 이 사설 대역에서 온 접속의 `X-Real-IP` 를 클라이언트 주소로 믿는 것(같은 VPC 의 다른 서버가 NPM 을 거쳐 꾸밀 수 있다)은 아래 고급 칸으로 끈다.
|
||||
|
||||
### Nginx Proxy Manager 설정
|
||||
|
||||
서버 A 의 NPM 에 Proxy Host 를 하나 만든다. 기본값은 NPM **v2.16.0**(2026-09-24, 지금 최신) 소스로 확인했다 —
|
||||
[`nginx.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/nginx.conf) ·
|
||||
[`proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/proxy.conf) ·
|
||||
[`block-exploits.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/block-exploits.conf) ·
|
||||
[`assets.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/assets.conf) ·
|
||||
[`log-proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/log-proxy.conf) ·
|
||||
템플릿 [`proxy_host.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/proxy_host.conf) ·
|
||||
[`_listen.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/_listen.conf). 서버 A 의 NPM 판이 다르면 같은 파일을 대조한다.
|
||||
|
||||
| 탭 · 칸 | 값 | 까닭 |
|
||||
| --- | --- | --- |
|
||||
| Details › Domain Names | `portal.arasystem.it.kr` | |
|
||||
| Details › Scheme · Forward Hostname / IP · Forward Port | `http` · 서버 B 사설 IP(`10.0.1.12`) · `8080` | `FRONTEND_PRIVATE_BIND` 와 같은 값 |
|
||||
| Details › Cache Assets | **끔** | 켜면 `.js` · `.css` · `.png` 등으로 끝나는 주소를 NPM 이 30분 캐시한다 — 응답의 `Cache-Control` · `Set-Cookie` 를 무시하고 키가 호스트 + 주소뿐이라(로그인한 사람을 가르지 않는다), 그런 주소의 API 가 생기면 남의 응답이 나간다(지금 서명 그림 · 첨부는 id 로 끝난다). 우리 캐시 머리글(`index.html` no-cache · `/assets/` 30일 immutable)도 지운다 |
|
||||
| Details › Block Common Exploits | **끔** | PHP 사이트용 패턴이 메일 검색어에 걸린다 — 아래 표 |
|
||||
| Details › Websockets Support | 끔 | 웹메일은 웹소켓을 쓰지 않는다 (실시간 알림은 SSE — 보통 HTTP 응답) |
|
||||
| Details › Access List | Publicly Accessible | 로그인은 웹메일이 한다 |
|
||||
| SSL › SSL Certificate | Let's Encrypt 새 인증서 (`portal.`) | 서버 A 의 80 으로 HTTP-01 |
|
||||
| SSL › Force SSL | 켬 | |
|
||||
| SSL › HTTP/2 Support | **켬** | 실시간 알림 연결이 HTTP/1.1 의 주소당 연결 6개 가운데 하나를 1시간 잡지 않게. NPM → frontend 는 HTTP/1.1 이다(`proxy_http_version 1.1`) |
|
||||
| SSL › HSTS Enabled | 선택 | 켜면 브라우저가 이 주소를 https 로만 연다 |
|
||||
| Advanced › Custom Nginx Configuration | 아래 세 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
|
||||
|
||||
```nginx
|
||||
# 첨부 올리기 — 프론트 nginx 가 백엔드를 150초까지 기다린다(/api/mail-attachments). NPM 기본은 http 단계 90s
|
||||
proxy_read_timeout 150s;
|
||||
# 접근 로그에 검색어 · 토큰(쿼리)이 남지 않게 — NPM 의 proxy 형식은 "$request_uri"(쿼리째)와 Referer 를 남긴다.
|
||||
# 접속 기록은 프론트 nginx 의 접근 로그(쿼리 없음 · 브라우저 주소는 X-Forwarded-For 칸)로 본다
|
||||
access_log off;
|
||||
# 사설 대역에서 온 접속의 X-Real-IP 를 클라이언트 주소로 받지 않는다 — NPM 기본은 받는다(nginx.conf 의 set_real_ip_from · real_ip_header).
|
||||
# 이 호스트는 PROXY protocol 로 받지 않아 바꿔 넣을 주소가 없으므로 접속 주소가 그대로 남는다
|
||||
real_ip_header proxy_protocol;
|
||||
```
|
||||
|
||||
- **`files.arasystem.it.kr`** — 11단계(대용량 첨부의 공개 내려받기, `SHARE_PUBLIC_URL`)부터 쓴다. 포털 호스트와 같은 사설 IP:8080 으로 Proxy Host 를 하나 더 만든다 —
|
||||
위와 같은 칸 · 같은 고급 설정, 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다. 그 호스트에서 무엇을 열지는 11단계에서 정한다.
|
||||
- 고급 칸을 저장한 뒤 Proxy Host 가 Online 인지 본다 (NPM 이 저장할 때 `nginx -t` 를 돌리고, 실패하면 그 설정을 빼고 Offline 으로 오류를 보여 준다 — `backend/internal/nginx.js`).
|
||||
|
||||
v2.16.0 기본값과 이 포털에 필요한 것:
|
||||
|
||||
| 항목 | v2.16.0 기본 | 필요 | 할 일 |
|
||||
| --- | --- | --- | --- |
|
||||
| 본문 한도 | `client_max_body_size 2000m` (http) | `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m) 이상 | 그대로 — 줄이면 그 이상으로 |
|
||||
| 읽기 시간 | `proxy_read_timeout 90s` (http) | 150s 이상(첨부) · 실시간 알림은 25초 ping 보다 길면 된다 | 고급 칸 150s |
|
||||
| 응답 전체 시간 상한 | 없음 | 실시간 알림 1시간 | 그대로 |
|
||||
| 응답 버퍼링 | nginx 기본(켬) — `X-Accel-Buffering` 을 무시하게 한 곳이 없다 | `/api/mail-events` 만 바로 흘려보낸다 | 그대로 — 프론트 nginx 가 그 응답에 `X-Accel-Buffering: no` 를 넘긴다 |
|
||||
| 압축 | `gzip on` · `gzip_types` 없음 → `text/html` 만 | `text/event-stream` 은 압축하지 않는다 | 그대로 — custom `http.conf` 등으로 `gzip_types` 를 넓히지 않는다 |
|
||||
| `Accept-Encoding` | http 단계에 `proxy_set_header Accept-Encoding ""` 가 있지만, Proxy Host 의 `location /` 가 `proxy.conf` 로 머리글을 따로 정해 물려받지 않는다 | | 그대로 — 브라우저의 값이 frontend 까지 가서 JS · CSS · JSON 은 프론트 nginx 가 압축한다(SSE 는 하지 않는다) |
|
||||
| 클라이언트가 끊으면 | `proxy_ignore_client_abort off` | 바로 끊는다 | 그대로 |
|
||||
| `X-Forwarded-For` | `proxy.conf` 의 `$proxy_add_x_forwarded_for` (이어 붙인다) | 이어 붙인다 | 그대로 |
|
||||
| `X-Real-IP` 믿기 | `set_real_ip_from` 10/8 · 172.16/12 · 192.168/16 + `real_ip_header X-Real-IP` | 사설 대역에서 와도 접속 주소 그대로 | 고급 칸 `real_ip_header proxy_protocol;` |
|
||||
| 접근 로그 | Proxy Host 마다 `proxy` 형식 — `"$request_uri"`(쿼리째) · Referer | 쿼리를 남기지 않는다 | 고급 칸 `access_log off;` |
|
||||
|
||||
**Block Common Exploits**(`block-exploits.conf`)는 `$query_string` 을 대소문자 가려 보고, 걸리면 NPM 의 403(HTML — 표준 실패 JSON 이 아니다)을 낸다. 웹메일에서 걸리는 것:
|
||||
|
||||
| 요청 | 걸리는 패턴 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 메일 검색 API `?q=viagra` (소문자 — `valium` · `xanax` · `cialis` · `cocaine` · `tramadol` · `prozac` · `libido` 등) | 스팸 낱말 | 403 |
|
||||
| `?q=concat(` · `?q=<b>javascript</b>`(`%3C…script…%3E`) | SQL · 스크립트 | 403 |
|
||||
| 검색 화면 새로 고침 — `/mail/search?q=http://example.com` · `?q=../etc` (화면 주소는 `/` · `:` 를 인코딩하지 않는다) | 파일 주입 | 403 |
|
||||
| 같은 검색어의 API(`q=http:%2F%2F…` · `q=..%2F…`) · 한글 검색어 | — | 통과 |
|
||||
|
||||
(2026-10-30, `block-exploits.conf` 를 그대로 넣고 realip · 로그 형식 · 템플릿 차례를 흉내 낸 nginx 1.30.6 으로 확인. 같은 시험에서 사설 주소에서 온 `X-Real-IP: 1.2.3.4` 가 `$remote_addr` 를 바꾸고
|
||||
고급 칸의 `real_ip_header proxy_protocol;` 이 막는 것, 템플릿의 `access_log` 뒤의 `access_log off;` 로 그 Proxy Host 의 로그가 비는 것, `location /` 가 머리글을 따로 정하면 `Accept-Encoding` 이
|
||||
그대로 넘어가는 것도 봤다. 실제 NPM 에는 넣어 보지 않았다)
|
||||
|
||||
### 확인 (배포 뒤)
|
||||
|
||||
```bash
|
||||
# 서버 A 에서 — 사설망으로 닿는다 (200)
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://10.0.1.12:8080/
|
||||
# 바깥 PC 에서 — 공인 IP 의 8080 은 닫혀 있다 (시간 초과)
|
||||
curl -m 5 http://<서버 B 공인 IP>:8080/
|
||||
# 서버 B 에서 — 첫 칸이 서버 A 사설 IP, X-Forwarded-For 칸 끝이 브라우저의 공인 주소
|
||||
sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
|
||||
```
|
||||
|
||||
- 포털에 로그인해 20MB 첨부를 올려 본다(본문 한도 · 150s). 브라우저 개발자 도구 › 네트워크의 `mail-events` 가 200 으로 열린 채 25초마다 `ping` 을 받는다(버퍼링 · 압축 없음).
|
||||
- `https://portal.arasystem.it.kr/api/health` 는 바깥에서 404(`RES_001` JSON)다 — 기본 구성과 같다.
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
| 증상 | 조치 |
|
||||
|
||||
Reference in New Issue
Block a user