Skip to content

Commit e783ca5

Browse files
Hexeongclaude
andauthored
fix: 내부 전용 API 의 외부 노출 차단을 nginx 설정 코드에 반영 (#82)
* fix: 내부 전용 API 의 외부 노출을 nginx 에서 차단한다 백업 실패 알림 API 가 v2.6.1 로 배포되면서 /internal 경로가 외부 인터넷에 노출되었다. 서버의 SecurityConfiguration 은 /internal/** 을 permitAll 로 두고 공유 토큰으로만 인증하므로, nginx 차단이 없으면 토큰 하나가 유일한 방어선이 된다. 운영 인스턴스에는 이미 수동으로 적용했으나 코드에 반영되지 않아 설정 스크립트가 다시 실행되면 유실되는 상태였다. - 기존 차단과 동일하게 444 를 반환해 경로 존재 자체를 숨긴다. - /internal/ 이 아닌 /internal 로 두어 trailing slash 없는 경로도 막는다. - ^~ 는 prefix priority match 라 정규식 location 보다 먼저 평가되므로 location / 앞에 둔다. DB EC2 의 알림 요청은 app 포트(8080/9080)로 직접 가므로 영향받지 않는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: 인스턴스 교체 시 nginx 설정 스크립트를 다시 실행한다 update_nginx 는 스크립트 해시만 트리거로 두고 있어, 인스턴스가 교체되어도 해시가 같으면 SSM 명령이 실행되지 않았다. user_data 에는 docker 설치만 들어 있어 새 인스턴스가 nginx 없이 뜨는 문제가 있었다. triggers 대신 lifecycle.replace_triggered_by 를 사용한다. triggers 는 state 에 저장되어 키를 추가하는 것만으로 재실행이 발생하지만, lifecycle 메타 인자는 state 에 남지 않아 참조 대상이 실제로 교체될 때만 발동한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: 인스턴스 교체 시에만 nginx 재실행하고 SSM 등록을 기다린다 리뷰 반영. replace_triggered_by 가 aws_instance.api_server 전체를 참조하고 있어 인스턴스의 in-place update 에도 발동했다. Terraform 문서상 리소스 참조는 "update 또는 replace 계획"에 반응하고 속성 참조는 "값 변경"에만 반응하므로, 교체 전용이라는 의도에 맞게 id 를 참조한다. 태그나 IAM 프로파일 변경 같은 무관한 apply 에서 apt/pip 설치와 nginx reload 가 불필요하게 도는 것을 막는다. 또한 인스턴스 교체 직후에는 SSM 에이전트가 아직 등록되지 않아 send-command 가 InvalidInstanceId 로 즉시 실패한다. 기존에는 인스턴스 교체 시 이 경로가 아예 실행되지 않아 드러나지 않던 문제로, 위 replace_triggered_by 추가로 실제 발생 가능해졌다. PingStatus 가 Online 이 될 때까지 최대 600초 대기한 뒤 명령을 보낸다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: nginx 셋업을 설치와 설정으로 분리한다 conf 한 줄을 고쳐도 apt/pip/certbot 이 전부 다시 도는 구조였다. 설정 변경이 외부 네트워크 작업에 묶여 있어, 네트워크가 흔들리면 설정과 무관한 이유로 apply 가 실패했다. nginx_install.sh.tftpl 은 패키지 설치부터 인증서 확보까지를 맡고, 설치가 이미 끝나 있으면 즉시 종료한다. ACME 챌린지용 임시 conf 도 이 스크립트 안에서만 쓰이고 끝난다. nginx_conf.sh.tftpl 은 서비스 설정 작성과 reload 만 수행하며 외부 네트워크를 타지 않는다. 인증서 확보는 install 의 책임이므로 인증서가 없으면 설정을 쓰기 전에 중단한다. null_resource 도 install_nginx 와 update_nginx_conf 로 나눈다. 설정 템플릿이 바뀌면 conf 리소스만 재생성되어 설치 작업 없이 재작성과 reload 만 수행한다. 설정 적용 실패에 대비해 기존 파일을 백업하고, nginx -t 가 실패하면 복원한 뒤 종료한다. 검증 전에 파일을 이미 덮어쓰기 때문에 되돌리지 않으면 다음 reload 때 깨진 설정이 반영된다. 아울러 lifecycle.replace_triggered_by 를 제거한다. 인스턴스 교체 경로는 EIP 와 DNS 자동화가 없어 완결되지 않은 상태라, 교체 시 certbot HTTP-01 발급이 실패한다(#84). conf 자동 반영은 triggers 의 스크립트 해시가 담당하므로 이 제거와 무관하게 유지된다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: 첫 적용이 실패하면 만들다 만 설정을 정리한다 리뷰 반영. 설정 파일이 없는 상태에서 시작하면 백업 경로가 비어 있어, nginx -t 가 실패해도 롤백 블록이 통째로 건너뛰어졌다. 방금 쓴 깨진 설정과 심볼릭 링크가 남고 default 링크는 이미 지워진 뒤라, 다음 restart 나 재부팅에서 nginx 가 기동하지 못할 수 있었다. 되돌릴 백업이 없으면 방금 만든 설정과 링크를 제거한다. default 링크는 복원하지 않는다. 이 구성은 default 를 항상 제거하며, sites-enabled 가 비어 있어도 nginx 는 정상 기동한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 9f09b57 commit e783ca5

4 files changed

Lines changed: 373 additions & 210 deletions

File tree

‎modules/app_stack/ec2.tf‎

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,17 @@ resource "aws_instance" "api_server" {
5656
}
5757

5858
locals {
59-
nginx_script_b64 = base64encode(templatefile("${path.module}/scripts/nginx_setup.sh.tftpl", {
59+
nginx_install_script_b64 = base64encode(templatefile("${path.module}/scripts/nginx_install.sh.tftpl", {
6060
domain_name = var.domain_name
6161
email = var.cert_email
6262
conf_file_name = var.nginx_conf_name
6363
}))
6464

65+
nginx_conf_script_b64 = base64encode(templatefile("${path.module}/scripts/nginx_conf.sh.tftpl", {
66+
domain_name = var.domain_name
67+
conf_file_name = var.nginx_conf_name
68+
}))
69+
6570
alloy_config = templatefile("${path.module}/../../config/side-infra/config.alloy.tftpl", {
6671
loki_ip = data.aws_instance.monitoring_server.private_ip
6772
})
@@ -75,8 +80,13 @@ locals {
7580
alloy_version = var.alloy_version
7681
}))
7782

78-
nginx_ssm_params = jsonencode({
79-
commands = ["cloud-init status --wait > /dev/null", "echo ${local.nginx_script_b64} | base64 -d | sudo bash"]
83+
nginx_install_ssm_params = jsonencode({
84+
commands = ["cloud-init status --wait > /dev/null", "echo ${local.nginx_install_script_b64} | base64 -d | sudo bash"]
85+
executionTimeout = ["3600"]
86+
})
87+
88+
nginx_conf_ssm_params = jsonencode({
89+
commands = ["cloud-init status --wait > /dev/null", "echo ${local.nginx_conf_script_b64} | base64 -d | sudo bash"]
8090
executionTimeout = ["3600"]
8191
})
8292

@@ -86,12 +96,13 @@ locals {
8696
})
8797
}
8898

89-
# [리소스 1] Nginx 설정 변경 감지 및 실행
90-
resource "null_resource" "update_nginx" {
99+
# [리소스 1] Nginx 설치 및 인증서 확보
100+
# 설치가 이미 끝나 있으면 스크립트가 즉시 종료하므로 평상시에는 사실상 실행되지 않습니다.
101+
resource "null_resource" "install_nginx" {
91102
depends_on = [aws_instance.api_server]
92103

93104
triggers = {
94-
script_hash = sha256(templatefile("${path.module}/scripts/nginx_setup.sh.tftpl", {
105+
script_hash = sha256(templatefile("${path.module}/scripts/nginx_install.sh.tftpl", {
95106
domain_name = var.domain_name
96107
email = var.cert_email
97108
conf_file_name = var.nginx_conf_name
@@ -103,10 +114,100 @@ resource "null_resource" "update_nginx" {
103114
command = <<-EOT
104115
set -euo pipefail
105116
INSTANCE_ID='${aws_instance.api_server.id}'
117+
118+
# 인스턴스가 교체된 직후에는 SSM 에이전트가 아직 등록되지 않아
119+
# send-command 가 InvalidInstanceId 로 즉시 실패합니다. 등록될 때까지 기다립니다.
120+
PING_STATUS=""
121+
SSM_ATTEMPTS=0
122+
while [ "$SSM_ATTEMPTS" -lt 60 ]; do
123+
PING_STATUS=$(aws ssm describe-instance-information \
124+
--filters "Key=InstanceIds,Values=$INSTANCE_ID" \
125+
--query "InstanceInformationList[0].PingStatus" \
126+
--output text 2>/dev/null || echo "None")
127+
if [ "$PING_STATUS" = "Online" ]; then
128+
break
129+
fi
130+
SSM_ATTEMPTS=$((SSM_ATTEMPTS + 1))
131+
sleep 10
132+
done
133+
if [ "$PING_STATUS" != "Online" ]; then
134+
echo "SSM agent not registered within 600s (last status: $PING_STATUS)" >&2
135+
exit 1
136+
fi
137+
138+
COMMAND_ID=$(aws ssm send-command \
139+
--instance-ids "$INSTANCE_ID" \
140+
--document-name "AWS-RunShellScript" \
141+
--parameters '${local.nginx_install_ssm_params}' \
142+
--output text \
143+
--query "Command.CommandId")
144+
ATTEMPTS=0
145+
while [ "$ATTEMPTS" -lt 360 ]; do
146+
STATUS=$(aws ssm get-command-invocation \
147+
--command-id "$COMMAND_ID" \
148+
--instance-id "$INSTANCE_ID" \
149+
--query "Status" --output text 2>/dev/null || echo "Pending")
150+
case "$STATUS" in
151+
Success) exit 0 ;;
152+
Failed|Cancelled|TimedOut|Undeliverable)
153+
echo "SSM command $STATUS" >&2
154+
aws ssm get-command-invocation \
155+
--command-id "$COMMAND_ID" \
156+
--instance-id "$INSTANCE_ID" \
157+
--query "StandardErrorContent" --output text >&2
158+
exit 1 ;;
159+
esac
160+
ATTEMPTS=$((ATTEMPTS + 1))
161+
sleep 10
162+
done
163+
echo "SSM command timed out after 3600s" >&2
164+
exit 1
165+
EOT
166+
}
167+
}
168+
169+
# [리소스 2] Nginx 설정 변경 감지 및 실행
170+
# 설정 템플릿이 바뀌면 이 리소스만 재생성되어, 설치 작업 없이 conf 재작성과 reload 만 수행합니다.
171+
resource "null_resource" "update_nginx_conf" {
172+
depends_on = [null_resource.install_nginx]
173+
174+
triggers = {
175+
script_hash = sha256(templatefile("${path.module}/scripts/nginx_conf.sh.tftpl", {
176+
domain_name = var.domain_name
177+
conf_file_name = var.nginx_conf_name
178+
}))
179+
}
180+
181+
provisioner "local-exec" {
182+
interpreter = ["bash", "-c"]
183+
command = <<-EOT
184+
set -euo pipefail
185+
INSTANCE_ID='${aws_instance.api_server.id}'
186+
187+
# 인스턴스가 교체된 직후에는 SSM 에이전트가 아직 등록되지 않아
188+
# send-command 가 InvalidInstanceId 로 즉시 실패합니다. 등록될 때까지 기다립니다.
189+
PING_STATUS=""
190+
SSM_ATTEMPTS=0
191+
while [ "$SSM_ATTEMPTS" -lt 60 ]; do
192+
PING_STATUS=$(aws ssm describe-instance-information \
193+
--filters "Key=InstanceIds,Values=$INSTANCE_ID" \
194+
--query "InstanceInformationList[0].PingStatus" \
195+
--output text 2>/dev/null || echo "None")
196+
if [ "$PING_STATUS" = "Online" ]; then
197+
break
198+
fi
199+
SSM_ATTEMPTS=$((SSM_ATTEMPTS + 1))
200+
sleep 10
201+
done
202+
if [ "$PING_STATUS" != "Online" ]; then
203+
echo "SSM agent not registered within 600s (last status: $PING_STATUS)" >&2
204+
exit 1
205+
fi
206+
106207
COMMAND_ID=$(aws ssm send-command \
107208
--instance-ids "$INSTANCE_ID" \
108209
--document-name "AWS-RunShellScript" \
109-
--parameters '${local.nginx_ssm_params}' \
210+
--parameters '${local.nginx_conf_ssm_params}' \
110211
--output text \
111212
--query "Command.CommandId")
112213
ATTEMPTS=0
@@ -134,7 +235,7 @@ resource "null_resource" "update_nginx" {
134235
}
135236
}
136237

137-
# [리소스 2] Side Infra 설정 변경 감지 및 실행
238+
# [리소스 3] Side Infra 설정 변경 감지 및 실행
138239
resource "null_resource" "update_side_infra" {
139240
depends_on = [aws_instance.api_server]
140241

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/bin/bash
2+
3+
set -e
4+
5+
# --- variables setting ---
6+
DOMAIN="${domain_name}"
7+
CONF_NAME="${conf_file_name}"
8+
CONF_PATH="/etc/nginx/sites-available/$CONF_NAME"
9+
UPSTREAM_CONF="/etc/nginx/conf.d/upstream.conf"
10+
CERTBOT_WEBROOT="/var/www/certbot"
11+
CERT_FULLCHAIN="/etc/letsencrypt/live/$DOMAIN/fullchain.pem"
12+
CERT_PRIVKEY="/etc/letsencrypt/live/$DOMAIN/privkey.pem"
13+
14+
echo "Start Nginx Config Update for $DOMAIN with config file: $CONF_NAME"
15+
16+
# 인증서 확보는 nginx_install.sh 의 책임이다.
17+
# 인증서가 없으면 443 블록 때문에 nginx -t 가 실패하므로 설정을 쓰기 전에 중단한다.
18+
if [ ! -f "$CERT_FULLCHAIN" ] || [ ! -f "$CERT_PRIVKEY" ]; then
19+
echo "Certificate not found for $DOMAIN. Run the nginx install script first." >&2
20+
exit 1
21+
fi
22+
23+
write_service_conf() {
24+
cat <<EOF > $CONF_PATH
25+
map \$http_upgrade \$connection_upgrade {
26+
default upgrade;
27+
'' '';
28+
}
29+
30+
# 1차 차단: 도메인과 일치하지 않는 요청 차단 (IP 직접 접근, 알 수 없는 Host 헤더)
31+
# 응답 없이 연결을 즉시 종료하여 봇이 서버 존재를 인식하지 못하게 함
32+
server {
33+
listen 80 default_server;
34+
server_name _;
35+
return 444;
36+
}
37+
38+
server {
39+
listen 443 ssl default_server;
40+
server_name _;
41+
42+
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
43+
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
44+
45+
return 444;
46+
}
47+
48+
server {
49+
listen 80;
50+
server_name $DOMAIN;
51+
52+
location ^~ /.well-known/acme-challenge/ {
53+
root $CERTBOT_WEBROOT;
54+
default_type "text/plain";
55+
try_files \$uri =404;
56+
}
57+
58+
location / {
59+
return 301 https://\$host\$request_uri;
60+
}
61+
}
62+
63+
server {
64+
listen 443 ssl;
65+
server_name $DOMAIN;
66+
67+
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
68+
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
69+
70+
client_max_body_size 10M;
71+
72+
ssl_protocols TLSv1.2 TLSv1.3;
73+
ssl_prefer_server_ciphers on;
74+
ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256";
75+
ssl_session_cache shared:SSL:10m;
76+
ssl_session_timeout 10m;
77+
ssl_stapling on;
78+
ssl_stapling_verify on;
79+
80+
# 2차 차단: 취약점 탐색용 정적 파일 확장자 요청 차단
81+
# (\$|[/?]) 로 확장자 뒤에 /path 또는 ?query 가 붙는 우회 패턴도 차단
82+
location ~* \.(php|asp|aspx|jsp|cgi|sql|bak|backup|config|ini|log|sh|xml|txt|html|htm)(\$|[/?]) {
83+
return 444;
84+
}
85+
86+
# .env, .env.production, .git 등 dotfile 변형 차단
87+
location ~* (^|/)\.(env|git) {
88+
return 444;
89+
}
90+
91+
# 3차 차단: 내부 전용 API 는 VPC 내부에서 app 포트로 직접 호출하므로 외부 노출을 막는다
92+
# DB EC2 의 백업 실패 알림은 8080/9080 직접 경로를 쓰므로 이 차단에 영향받지 않는다
93+
location ^~ /internal {
94+
return 444;
95+
}
96+
97+
location / {
98+
proxy_pass http://app_backend;
99+
proxy_http_version 1.1;
100+
proxy_set_header Host \$host;
101+
proxy_set_header X-Real-IP \$remote_addr;
102+
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
103+
proxy_set_header X-Forwarded-Proto \$scheme;
104+
proxy_set_header Upgrade \$http_upgrade;
105+
proxy_set_header Connection \$connection_upgrade;
106+
}
107+
}
108+
EOF
109+
}
110+
111+
# Create upstream config file only on first provisioning (initial active slot: blue on port 8080)
112+
# Blue-Green 배포 시 이 파일만 교체하고 nginx -s reload 로 트래픽 전환
113+
# 이미 존재하면 덮어쓰지 않음 — 재적용 시 현재 active 슬롯 유지
114+
if [ ! -f "$UPSTREAM_CONF" ]; then
115+
cat <<UPSTREAM_EOF > $UPSTREAM_CONF
116+
upstream app_backend {
117+
server 127.0.0.1:8080;
118+
}
119+
UPSTREAM_EOF
120+
fi
121+
122+
# 적용에 실패하면 되돌릴 수 있도록 기존 설정을 백업해 둔다
123+
BACKUP_PATH=""
124+
if [ -f "$CONF_PATH" ]; then
125+
BACKUP_PATH="$CONF_PATH.bak"
126+
cp "$CONF_PATH" "$BACKUP_PATH"
127+
fi
128+
129+
write_service_conf
130+
131+
ln -sf $CONF_PATH /etc/nginx/sites-enabled/$CONF_NAME
132+
rm -f /etc/nginx/sites-enabled/default
133+
134+
# 문법 검증에 실패하면 백업으로 되돌린다.
135+
# 검증 전에 파일을 이미 덮어썼기 때문에, 되돌리지 않으면 다음 reload 때 깨진 설정이 반영된다.
136+
if ! nginx -t; then
137+
echo "Nginx config test failed. Rolling back." >&2
138+
if [ -n "$BACKUP_PATH" ]; then
139+
mv "$BACKUP_PATH" "$CONF_PATH"
140+
else
141+
# 되돌릴 기존 설정이 없는 첫 적용이라면, 방금 만든 설정과 링크를 제거한다.
142+
# 그대로 두면 default 링크까지 지워진 상태라 다음 restart 때 nginx 가 기동하지 못한다.
143+
rm -f /etc/nginx/sites-enabled/$CONF_NAME "$CONF_PATH"
144+
fi
145+
nginx -t || echo "Rollback verification also failed." >&2
146+
exit 1
147+
fi
148+
149+
systemctl reload nginx || systemctl restart nginx
150+
151+
if [ -n "$BACKUP_PATH" ]; then
152+
rm -f "$BACKUP_PATH"
153+
fi
154+
155+
echo "Nginx config update complete!"

0 commit comments

Comments
 (0)