From 4619f0929e936eba7d69bb70b296824e766fa4fa Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 30 Aug 2026 15:24:58 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EB=82=B4=EB=B6=80=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20API=20=EC=9D=98=20=EC=99=B8=EB=B6=80=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20nginx=20=EC=97=90=EC=84=9C=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백업 실패 알림 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 --- modules/app_stack/scripts/nginx_setup.sh.tftpl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/app_stack/scripts/nginx_setup.sh.tftpl b/modules/app_stack/scripts/nginx_setup.sh.tftpl index 6bd8808..c6bfd40 100644 --- a/modules/app_stack/scripts/nginx_setup.sh.tftpl +++ b/modules/app_stack/scripts/nginx_setup.sh.tftpl @@ -118,6 +118,12 @@ server { return 444; } + # 3차 차단: 내부 전용 API 는 VPC 내부에서 app 포트로 직접 호출하므로 외부 노출을 막는다 + # DB EC2 의 백업 실패 알림은 8080/9080 직접 경로를 쓰므로 이 차단에 영향받지 않는다 + location ^~ /internal { + return 444; + } + location / { proxy_pass http://app_backend; proxy_http_version 1.1; From 9c580d15c118a81aac253454f77e27a29bd4866d Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 30 Aug 2026 15:24:59 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4?= =?UTF-8?q?=20=EA=B5=90=EC=B2=B4=20=EC=8B=9C=20nginx=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A5=BC=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=8B=A4=ED=96=89=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_nginx 는 스크립트 해시만 트리거로 두고 있어, 인스턴스가 교체되어도 해시가 같으면 SSM 명령이 실행되지 않았다. user_data 에는 docker 설치만 들어 있어 새 인스턴스가 nginx 없이 뜨는 문제가 있었다. triggers 대신 lifecycle.replace_triggered_by 를 사용한다. triggers 는 state 에 저장되어 키를 추가하는 것만으로 재실행이 발생하지만, lifecycle 메타 인자는 state 에 남지 않아 참조 대상이 실제로 교체될 때만 발동한다. Co-Authored-By: Claude Opus 5 --- modules/app_stack/ec2.tf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/app_stack/ec2.tf b/modules/app_stack/ec2.tf index c8514ba..f65e4b9 100644 --- a/modules/app_stack/ec2.tf +++ b/modules/app_stack/ec2.tf @@ -98,6 +98,12 @@ resource "null_resource" "update_nginx" { })) } + # 인스턴스가 교체되면 새 인스턴스에는 nginx 가 없으므로 설정 스크립트를 다시 실행합니다. + # triggers 대신 lifecycle 을 쓰는 이유: triggers 는 state 에 저장되어 키를 추가하는 것만으로 재실행이 발생합니다. + lifecycle { + replace_triggered_by = [aws_instance.api_server] + } + provisioner "local-exec" { interpreter = ["bash", "-c"] command = <<-EOT From 7884e60f707774c1f837eb69bea49bb48fab9e57 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 30 Aug 2026 16:37:54 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4?= =?UTF-8?q?=20=EA=B5=90=EC=B2=B4=20=EC=8B=9C=EC=97=90=EB=A7=8C=20nginx=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=A4=ED=96=89=ED=95=98=EA=B3=A0=20SSM=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EA=B8=B0=EB=8B=A4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 반영. 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 --- modules/app_stack/ec2.tf | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/modules/app_stack/ec2.tf b/modules/app_stack/ec2.tf index f65e4b9..a9ae719 100644 --- a/modules/app_stack/ec2.tf +++ b/modules/app_stack/ec2.tf @@ -100,8 +100,10 @@ resource "null_resource" "update_nginx" { # 인스턴스가 교체되면 새 인스턴스에는 nginx 가 없으므로 설정 스크립트를 다시 실행합니다. # triggers 대신 lifecycle 을 쓰는 이유: triggers 는 state 에 저장되어 키를 추가하는 것만으로 재실행이 발생합니다. + # 리소스 전체가 아니라 id 를 참조하는 이유: 리소스 참조는 in-place update 에도 반응하지만, + # 속성 참조는 값이 바뀔 때만 반응하므로 인스턴스 교체에만 발동합니다. lifecycle { - replace_triggered_by = [aws_instance.api_server] + replace_triggered_by = [aws_instance.api_server.id] } provisioner "local-exec" { @@ -109,6 +111,27 @@ resource "null_resource" "update_nginx" { command = <<-EOT set -euo pipefail INSTANCE_ID='${aws_instance.api_server.id}' + + # 인스턴스가 교체된 직후에는 SSM 에이전트가 아직 등록되지 않아 + # send-command 가 InvalidInstanceId 로 즉시 실패합니다. 등록될 때까지 기다립니다. + PING_STATUS="" + SSM_ATTEMPTS=0 + while [ "$SSM_ATTEMPTS" -lt 60 ]; do + PING_STATUS=$(aws ssm describe-instance-information \ + --filters "Key=InstanceIds,Values=$INSTANCE_ID" \ + --query "InstanceInformationList[0].PingStatus" \ + --output text 2>/dev/null || echo "None") + if [ "$PING_STATUS" = "Online" ]; then + break + fi + SSM_ATTEMPTS=$((SSM_ATTEMPTS + 1)) + sleep 10 + done + if [ "$PING_STATUS" != "Online" ]; then + echo "SSM agent not registered within 600s (last status: $PING_STATUS)" >&2 + exit 1 + fi + COMMAND_ID=$(aws ssm send-command \ --instance-ids "$INSTANCE_ID" \ --document-name "AWS-RunShellScript" \ From 6613175217eca084896af20cc4bb73222d947aad Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Tue, 1 Sep 2026 23:39:48 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20nginx=20=EC=85=8B=EC=97=85?= =?UTF-8?q?=EC=9D=84=20=EC=84=A4=EC=B9=98=EC=99=80=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/app_stack/ec2.tf | 100 ++++++++++++-- ...inx_setup.sh.tftpl => nginx_conf.sh.tftpl} | 127 +++++------------- .../app_stack/scripts/nginx_install.sh.tftpl | 109 +++++++++++++++ 3 files changed, 230 insertions(+), 106 deletions(-) rename modules/app_stack/scripts/{nginx_setup.sh.tftpl => nginx_conf.sh.tftpl} (52%) create mode 100644 modules/app_stack/scripts/nginx_install.sh.tftpl diff --git a/modules/app_stack/ec2.tf b/modules/app_stack/ec2.tf index a9ae719..d2f126b 100644 --- a/modules/app_stack/ec2.tf +++ b/modules/app_stack/ec2.tf @@ -56,12 +56,17 @@ resource "aws_instance" "api_server" { } locals { - nginx_script_b64 = base64encode(templatefile("${path.module}/scripts/nginx_setup.sh.tftpl", { + nginx_install_script_b64 = base64encode(templatefile("${path.module}/scripts/nginx_install.sh.tftpl", { domain_name = var.domain_name email = var.cert_email conf_file_name = var.nginx_conf_name })) + nginx_conf_script_b64 = base64encode(templatefile("${path.module}/scripts/nginx_conf.sh.tftpl", { + domain_name = var.domain_name + conf_file_name = var.nginx_conf_name + })) + alloy_config = templatefile("${path.module}/../../config/side-infra/config.alloy.tftpl", { loki_ip = data.aws_instance.monitoring_server.private_ip }) @@ -75,8 +80,13 @@ locals { alloy_version = var.alloy_version })) - nginx_ssm_params = jsonencode({ - commands = ["cloud-init status --wait > /dev/null", "echo ${local.nginx_script_b64} | base64 -d | sudo bash"] + nginx_install_ssm_params = jsonencode({ + commands = ["cloud-init status --wait > /dev/null", "echo ${local.nginx_install_script_b64} | base64 -d | sudo bash"] + executionTimeout = ["3600"] + }) + + nginx_conf_ssm_params = jsonencode({ + commands = ["cloud-init status --wait > /dev/null", "echo ${local.nginx_conf_script_b64} | base64 -d | sudo bash"] executionTimeout = ["3600"] }) @@ -86,24 +96,86 @@ locals { }) } -# [리소스 1] Nginx 설정 변경 감지 및 실행 -resource "null_resource" "update_nginx" { +# [리소스 1] Nginx 설치 및 인증서 확보 +# 설치가 이미 끝나 있으면 스크립트가 즉시 종료하므로 평상시에는 사실상 실행되지 않습니다. +resource "null_resource" "install_nginx" { depends_on = [aws_instance.api_server] triggers = { - script_hash = sha256(templatefile("${path.module}/scripts/nginx_setup.sh.tftpl", { + script_hash = sha256(templatefile("${path.module}/scripts/nginx_install.sh.tftpl", { domain_name = var.domain_name email = var.cert_email conf_file_name = var.nginx_conf_name })) } - # 인스턴스가 교체되면 새 인스턴스에는 nginx 가 없으므로 설정 스크립트를 다시 실행합니다. - # triggers 대신 lifecycle 을 쓰는 이유: triggers 는 state 에 저장되어 키를 추가하는 것만으로 재실행이 발생합니다. - # 리소스 전체가 아니라 id 를 참조하는 이유: 리소스 참조는 in-place update 에도 반응하지만, - # 속성 참조는 값이 바뀔 때만 반응하므로 인스턴스 교체에만 발동합니다. - lifecycle { - replace_triggered_by = [aws_instance.api_server.id] + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = <<-EOT + set -euo pipefail + INSTANCE_ID='${aws_instance.api_server.id}' + + # 인스턴스가 교체된 직후에는 SSM 에이전트가 아직 등록되지 않아 + # send-command 가 InvalidInstanceId 로 즉시 실패합니다. 등록될 때까지 기다립니다. + PING_STATUS="" + SSM_ATTEMPTS=0 + while [ "$SSM_ATTEMPTS" -lt 60 ]; do + PING_STATUS=$(aws ssm describe-instance-information \ + --filters "Key=InstanceIds,Values=$INSTANCE_ID" \ + --query "InstanceInformationList[0].PingStatus" \ + --output text 2>/dev/null || echo "None") + if [ "$PING_STATUS" = "Online" ]; then + break + fi + SSM_ATTEMPTS=$((SSM_ATTEMPTS + 1)) + sleep 10 + done + if [ "$PING_STATUS" != "Online" ]; then + echo "SSM agent not registered within 600s (last status: $PING_STATUS)" >&2 + exit 1 + fi + + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --parameters '${local.nginx_install_ssm_params}' \ + --output text \ + --query "Command.CommandId") + ATTEMPTS=0 + while [ "$ATTEMPTS" -lt 360 ]; do + STATUS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$INSTANCE_ID" \ + --query "Status" --output text 2>/dev/null || echo "Pending") + case "$STATUS" in + Success) exit 0 ;; + Failed|Cancelled|TimedOut|Undeliverable) + echo "SSM command $STATUS" >&2 + aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$INSTANCE_ID" \ + --query "StandardErrorContent" --output text >&2 + exit 1 ;; + esac + ATTEMPTS=$((ATTEMPTS + 1)) + sleep 10 + done + echo "SSM command timed out after 3600s" >&2 + exit 1 + EOT + } +} + +# [리소스 2] Nginx 설정 변경 감지 및 실행 +# 설정 템플릿이 바뀌면 이 리소스만 재생성되어, 설치 작업 없이 conf 재작성과 reload 만 수행합니다. +resource "null_resource" "update_nginx_conf" { + depends_on = [null_resource.install_nginx] + + triggers = { + script_hash = sha256(templatefile("${path.module}/scripts/nginx_conf.sh.tftpl", { + domain_name = var.domain_name + conf_file_name = var.nginx_conf_name + })) } provisioner "local-exec" { @@ -135,7 +207,7 @@ resource "null_resource" "update_nginx" { COMMAND_ID=$(aws ssm send-command \ --instance-ids "$INSTANCE_ID" \ --document-name "AWS-RunShellScript" \ - --parameters '${local.nginx_ssm_params}' \ + --parameters '${local.nginx_conf_ssm_params}' \ --output text \ --query "Command.CommandId") ATTEMPTS=0 @@ -163,7 +235,7 @@ resource "null_resource" "update_nginx" { } } -# [리소스 2] Side Infra 설정 변경 감지 및 실행 +# [리소스 3] Side Infra 설정 변경 감지 및 실행 resource "null_resource" "update_side_infra" { depends_on = [aws_instance.api_server] diff --git a/modules/app_stack/scripts/nginx_setup.sh.tftpl b/modules/app_stack/scripts/nginx_conf.sh.tftpl similarity index 52% rename from modules/app_stack/scripts/nginx_setup.sh.tftpl rename to modules/app_stack/scripts/nginx_conf.sh.tftpl index c6bfd40..70bd22d 100644 --- a/modules/app_stack/scripts/nginx_setup.sh.tftpl +++ b/modules/app_stack/scripts/nginx_conf.sh.tftpl @@ -4,54 +4,24 @@ set -e # --- variables setting --- DOMAIN="${domain_name}" -EMAIL="${email}" CONF_NAME="${conf_file_name}" -CRON_NAME=$(printf '%s' "$CONF_NAME" | tr -c 'A-Za-z0-9_-' '-') +CONF_PATH="/etc/nginx/sites-available/$CONF_NAME" UPSTREAM_CONF="/etc/nginx/conf.d/upstream.conf" CERTBOT_WEBROOT="/var/www/certbot" CERT_FULLCHAIN="/etc/letsencrypt/live/$DOMAIN/fullchain.pem" CERT_PRIVKEY="/etc/letsencrypt/live/$DOMAIN/privkey.pem" -echo "Start Nginx Setup for $DOMAIN with config file: $CONF_NAME" +echo "Start Nginx Config Update for $DOMAIN with config file: $CONF_NAME" -if [ -z "$CRON_NAME" ]; then - CRON_NAME="nginx" +# 인증서 확보는 nginx_install.sh 의 책임이다. +# 인증서가 없으면 443 블록 때문에 nginx -t 가 실패하므로 설정을 쓰기 전에 중단한다. +if [ ! -f "$CERT_FULLCHAIN" ] || [ ! -f "$CERT_PRIVKEY" ]; then + echo "Certificate not found for $DOMAIN. Run the nginx install script first." >&2 + exit 1 fi -reload_nginx() { - nginx -t - systemctl reload nginx || systemctl restart nginx -} - -write_http_challenge_conf() { - cat < /etc/nginx/sites-available/$CONF_NAME -# 1차 차단: 도메인과 일치하지 않는 요청 차단 (IP 직접 접근, 알 수 없는 Host 헤더) -# 응답 없이 연결을 즉시 종료하여 봇이 서버 존재를 인식하지 못하게 함 -server { - listen 80 default_server; - server_name _; - return 444; -} - -server { - listen 80; - server_name $DOMAIN; - - location ^~ /.well-known/acme-challenge/ { - root $CERTBOT_WEBROOT; - default_type "text/plain"; - try_files \$uri =404; - } - - location / { - return 301 https://\$host\$request_uri; - } -} -EOF -} - -write_full_nginx_conf() { - cat < /etc/nginx/sites-available/$CONF_NAME +write_service_conf() { + cat < $CONF_PATH map \$http_upgrade \$connection_upgrade { default upgrade; '' ''; @@ -108,8 +78,8 @@ server { ssl_stapling_verify on; # 2차 차단: 취약점 탐색용 정적 파일 확장자 요청 차단 - # ($|[/?]) 로 확장자 뒤에 /path 또는 ?query 가 붙는 우회 패턴도 차단 - location ~* \.(php|asp|aspx|jsp|cgi|sql|bak|backup|config|ini|log|sh|xml|txt|html|htm)($|[/?]) { + # (\$|[/?]) 로 확장자 뒤에 /path 또는 ?query 가 붙는 우회 패턴도 차단 + location ~* \.(php|asp|aspx|jsp|cgi|sql|bak|backup|config|ini|log|sh|xml|txt|html|htm)(\$|[/?]) { return 444; } @@ -138,22 +108,9 @@ server { EOF } -# 1. Install necessary packages for Nginx and Certbot -apt-get update -apt-get install -y nginx python3 python3-venv libaugeas0 - -# 2. Install Certbot (using pip) -python3 -m venv /opt/certbot/ -/opt/certbot/bin/pip install --upgrade pip -/opt/certbot/bin/pip install certbot certbot-nginx -ln -sf /opt/certbot/bin/certbot /usr/bin/certbot - -# 3. Prepare Nginx webroot for HTTP-01 challenge -mkdir -p "$CERTBOT_WEBROOT" - # Create upstream config file only on first provisioning (initial active slot: blue on port 8080) # Blue-Green 배포 시 이 파일만 교체하고 nginx -s reload 로 트래픽 전환 -# 이미 존재하면 덮어쓰지 않음 — 재프로비저닝 시 현재 active 슬롯 유지 +# 이미 존재하면 덮어쓰지 않음 — 재적용 시 현재 active 슬롯 유지 if [ ! -f "$UPSTREAM_CONF" ]; then cat < $UPSTREAM_CONF upstream app_backend { @@ -162,47 +119,33 @@ upstream app_backend { UPSTREAM_EOF fi -CERT_EXISTS=false -if [ -f "$CERT_FULLCHAIN" ] && [ -f "$CERT_PRIVKEY" ]; then - CERT_EXISTS=true - write_full_nginx_conf -else - write_http_challenge_conf +# 적용에 실패하면 되돌릴 수 있도록 기존 설정을 백업해 둔다 +BACKUP_PATH="" +if [ -f "$CONF_PATH" ]; then + BACKUP_PATH="$CONF_PATH.bak" + cp "$CONF_PATH" "$BACKUP_PATH" fi -ln -sf /etc/nginx/sites-available/$CONF_NAME /etc/nginx/sites-enabled/$CONF_NAME +write_service_conf + +ln -sf $CONF_PATH /etc/nginx/sites-enabled/$CONF_NAME rm -f /etc/nginx/sites-enabled/default -reload_nginx - -# 4. Issue or renew SSL certificate (Non-interactive mode) -certbot certonly --webroot \ - --webroot-path "$CERTBOT_WEBROOT" \ - --non-interactive \ - --agree-tos \ - --keep-until-expiring \ - --email "$EMAIL" \ - -d "$DOMAIN" - -echo "Certificate obtained successfully." - -# 5. Create Nginx configuration file -if [ "$CERT_EXISTS" != "true" ]; then - write_full_nginx_conf - - # 6. Create symbolic link and remove default configuration - ln -sf /etc/nginx/sites-available/$CONF_NAME /etc/nginx/sites-enabled/$CONF_NAME - rm -f /etc/nginx/sites-enabled/default -fi -# 7. Register auto-renewal cron job -cat < /etc/cron.d/certbot-$CRON_NAME -SHELL=/bin/bash -PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# 문법 검증에 실패하면 백업으로 되돌린다. +# 검증 전에 파일을 이미 덮어썼기 때문에, 되돌리지 않으면 다음 reload 때 깨진 설정이 반영된다. +if ! nginx -t; then + echo "Nginx config test failed. Rolling back." >&2 + if [ -n "$BACKUP_PATH" ]; then + mv "$BACKUP_PATH" "$CONF_PATH" + nginx -t || echo "Rollback verification also failed." >&2 + fi + exit 1 +fi -0 0,12 * * * root /opt/certbot/bin/python -c 'import random,time; time.sleep(random.random() * 3600)' && /usr/bin/certbot renew --webroot --webroot-path $CERTBOT_WEBROOT --quiet --deploy-hook "/usr/bin/systemctl reload nginx" -EOF +systemctl reload nginx || systemctl restart nginx -# 8. Nginx restart -reload_nginx +if [ -n "$BACKUP_PATH" ]; then + rm -f "$BACKUP_PATH" +fi -echo "Nginx setup complete!" +echo "Nginx config update complete!" diff --git a/modules/app_stack/scripts/nginx_install.sh.tftpl b/modules/app_stack/scripts/nginx_install.sh.tftpl new file mode 100644 index 0000000..6e3d51e --- /dev/null +++ b/modules/app_stack/scripts/nginx_install.sh.tftpl @@ -0,0 +1,109 @@ +#!/bin/bash + +set -e + +# --- variables setting --- +DOMAIN="${domain_name}" +EMAIL="${email}" +CONF_NAME="${conf_file_name}" +CRON_NAME=$(printf '%s' "$CONF_NAME" | tr -c 'A-Za-z0-9_-' '-') +CERTBOT_WEBROOT="/var/www/certbot" +CERT_FULLCHAIN="/etc/letsencrypt/live/$DOMAIN/fullchain.pem" +CERT_PRIVKEY="/etc/letsencrypt/live/$DOMAIN/privkey.pem" + +if [ -z "$CRON_NAME" ]; then + CRON_NAME="nginx" +fi + +CRON_PATH="/etc/cron.d/certbot-$CRON_NAME" + +echo "Start Nginx Install for $DOMAIN" + +# 설치 확인: 아래가 모두 갖춰져 있으면 새로 설치할 것이 없으므로 즉시 종료한다. +# 설정 파일 갱신은 nginx_conf.sh 가 담당하므로, 이 스크립트는 평상시 실행되지 않는다. +# 덕분에 conf 한 줄을 고칠 때 apt/pip/certbot 같은 외부 네트워크 작업이 돌지 않는다. +if command -v nginx >/dev/null 2>&1 \ + && [ -x /usr/bin/certbot ] \ + && [ -f "$CERT_FULLCHAIN" ] \ + && [ -f "$CERT_PRIVKEY" ] \ + && [ -f "$CRON_PATH" ]; then + echo "Nginx installation already complete. Skipping." + exit 0 +fi + +reload_nginx() { + nginx -t + systemctl reload nginx || systemctl restart nginx +} + +# ACME HTTP-01 챌린지 전용 임시 설정. +# 인증서가 없는 동안에는 443 블록을 쓸 수 없으므로(ssl_certificate 경로가 없어 nginx -t 실패) +# 발급 전까지 80 포트만 열어둔다. 발급이 끝나면 nginx_conf.sh 가 서비스 설정으로 덮어쓴다. +write_http_challenge_conf() { + cat < /etc/nginx/sites-available/$CONF_NAME +# 1차 차단: 도메인과 일치하지 않는 요청 차단 (IP 직접 접근, 알 수 없는 Host 헤더) +# 응답 없이 연결을 즉시 종료하여 봇이 서버 존재를 인식하지 못하게 함 +server { + listen 80 default_server; + server_name _; + return 444; +} + +server { + listen 80; + server_name $DOMAIN; + + location ^~ /.well-known/acme-challenge/ { + root $CERTBOT_WEBROOT; + default_type "text/plain"; + try_files \$uri =404; + } + + location / { + return 301 https://\$host\$request_uri; + } +} +EOF +} + +# 1. Install necessary packages for Nginx and Certbot +apt-get update +apt-get install -y nginx python3 python3-venv libaugeas0 + +# 2. Install Certbot (using pip) +python3 -m venv /opt/certbot/ +/opt/certbot/bin/pip install --upgrade pip +/opt/certbot/bin/pip install certbot certbot-nginx +ln -sf /opt/certbot/bin/certbot /usr/bin/certbot + +# 3. Prepare Nginx webroot for HTTP-01 challenge +mkdir -p "$CERTBOT_WEBROOT" + +# 4. 인증서가 아직 없으면 챌린지 설정으로 80 포트를 열어 발급을 준비한다 +if [ ! -f "$CERT_FULLCHAIN" ] || [ ! -f "$CERT_PRIVKEY" ]; then + write_http_challenge_conf + ln -sf /etc/nginx/sites-available/$CONF_NAME /etc/nginx/sites-enabled/$CONF_NAME + rm -f /etc/nginx/sites-enabled/default + reload_nginx +fi + +# 5. Issue or renew SSL certificate (Non-interactive mode) +certbot certonly --webroot \ + --webroot-path "$CERTBOT_WEBROOT" \ + --non-interactive \ + --agree-tos \ + --keep-until-expiring \ + --email "$EMAIL" \ + -d "$DOMAIN" + +echo "Certificate is ready." + +# 6. Register auto-renewal cron job +cat < $CRON_PATH +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +0 0,12 * * * root /opt/certbot/bin/python -c 'import random,time; time.sleep(random.random() * 3600)' && /usr/bin/certbot renew --webroot --webroot-path $CERTBOT_WEBROOT --quiet --deploy-hook "/usr/bin/systemctl reload nginx" +EOF + +echo "Nginx install complete!" From a6c0310bcbcf8da420e2194d0e8bfe87c471e031 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Tue, 1 Sep 2026 23:55:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=B2=AB=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EC=9D=B4=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EB=8B=A4=20=EB=A7=8C=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 반영. 설정 파일이 없는 상태에서 시작하면 백업 경로가 비어 있어, nginx -t 가 실패해도 롤백 블록이 통째로 건너뛰어졌다. 방금 쓴 깨진 설정과 심볼릭 링크가 남고 default 링크는 이미 지워진 뒤라, 다음 restart 나 재부팅에서 nginx 가 기동하지 못할 수 있었다. 되돌릴 백업이 없으면 방금 만든 설정과 링크를 제거한다. default 링크는 복원하지 않는다. 이 구성은 default 를 항상 제거하며, sites-enabled 가 비어 있어도 nginx 는 정상 기동한다. Co-Authored-By: Claude Opus 5 --- modules/app_stack/scripts/nginx_conf.sh.tftpl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/app_stack/scripts/nginx_conf.sh.tftpl b/modules/app_stack/scripts/nginx_conf.sh.tftpl index 70bd22d..f624103 100644 --- a/modules/app_stack/scripts/nginx_conf.sh.tftpl +++ b/modules/app_stack/scripts/nginx_conf.sh.tftpl @@ -137,8 +137,12 @@ if ! nginx -t; then echo "Nginx config test failed. Rolling back." >&2 if [ -n "$BACKUP_PATH" ]; then mv "$BACKUP_PATH" "$CONF_PATH" - nginx -t || echo "Rollback verification also failed." >&2 + else + # 되돌릴 기존 설정이 없는 첫 적용이라면, 방금 만든 설정과 링크를 제거한다. + # 그대로 두면 default 링크까지 지워진 상태라 다음 restart 때 nginx 가 기동하지 못한다. + rm -f /etc/nginx/sites-enabled/$CONF_NAME "$CONF_PATH" fi + nginx -t || echo "Rollback verification also failed." >&2 exit 1 fi