From b1ea7d3d3fb3b1d8a2741062ddef5d425bd270c8 Mon Sep 17 00:00:00 2001 From: "kihwan.song" Date: Fri, 18 Sep 2026 18:01:45 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B3=B5=ED=86=B5=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20nginx=20=EB=AF=B8=EB=B0=98=EC=98=81=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=B5=EA=B5=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증서 파일이 사라진 호스트가 있으면 nginx 설정 검사가 실패해 NPM의 모든 등록/수정이 500으로 막힌다. 이때 NPM은 DB 변경은 되돌리지 않아 레코드만 남고, 다음 실행에서 "이미 존재"로 판정돼 도메인이 죽은 채로 고착됐다. - 기존 도메인이 있어도 SSL 미적용/nginx 미반영이면 이어서 복구 - 생성·발급 실패 시 남은 레코드를 정리해 고착 방지 - 인증서 파일이 사라진 호스트를 찾아 안내 (unblock으로 비활성화) - PUT 성공과 무관하게 nginx 반영 여부를 확인 후 완료 처리 - jq 설치에 sudo 누락 수정 --- create-domain/action.yml | 434 +++++++++++++++++++++++++++++++-------- 1 file changed, 354 insertions(+), 80 deletions(-) diff --git a/create-domain/action.yml b/create-domain/action.yml index d4846f2..d279f65 100644 --- a/create-domain/action.yml +++ b/create-domain/action.yml @@ -28,6 +28,19 @@ inputs: required: false default: "true" description: 웹소켓 지원 활성화 + repair: + required: false + default: "true" + description: > + 이미 등록된 도메인이 미완성(SSL 미적용) 또는 nginx 미반영 상태면 이어서 복구합니다. + false면 기존 동작대로 손대지 않고 종료합니다. + unblock: + required: false + default: "false" + description: > + nginx 반영에 실패한 다른 호스트를 비활성화해 NPM 전체의 설정 반영 막힘을 해소합니다. + 인증서 파일이 사라진 호스트가 하나라도 있으면 모든 도메인의 등록/수정이 막히기 때문입니다. + 다른 팀의 도메인을 중단시킬 수 있으므로 기본값은 false입니다. outputs: host-id: @@ -36,6 +49,9 @@ outputs: created: description: 이번 실행에서 새로 생성했으면 true, 이미 있었으면 false value: ${{ steps.create.outputs.created }} + repaired: + description: 이번 실행에서 기존 호스트를 복구했으면 true + value: ${{ steps.create.outputs.repaired }} runs: using: composite @@ -50,13 +66,16 @@ runs: exit 0 fi + SUDO="" + [ "$(id -u)" -ne 0 ] && SUDO="sudo" + # 패키지 매니저에 따라 jq를 설치합니다. if command -v apt-get >/dev/null 2>&1; then - apt-get update && apt-get install -y jq + $SUDO apt-get update && $SUDO apt-get install -y jq elif command -v apk >/dev/null 2>&1; then - apk add --no-cache jq + $SUDO apk add --no-cache jq elif command -v yum >/dev/null 2>&1; then - yum install -y jq + $SUDO yum install -y jq else echo "jq 설치 실패: 지원하지 않는 러너 이미지" exit 1 @@ -74,6 +93,8 @@ runs: FORWARD_HOST: ${{ inputs.forward-host }} FORWARD_PORT: ${{ inputs.forward-port }} WEBSOCKETS: ${{ inputs.websockets }} + REPAIR: ${{ inputs.repair }} + UNBLOCK: ${{ inputs.unblock }} run: | set -eu @@ -81,9 +102,21 @@ runs: BASE="http://${NPM_HOST}:${NPM_PORT}/api" # NPM_SCHEME=http 고정 FORWARD_SCHEME="http" # 대상 프로토콜 http 고정 - # NPM API 호출 헬퍼: 응답 본문과 상태코드를 분리해, 2xx가 아니면 - # NPM이 보낸 에러 본문을 그대로 출력하고 실패시킵니다. - # 사용법: api [JSON_DATA] (성공 시 응답 본문을 stdout으로) + WORKDIR="$(mktemp -d)" + trap 'rm -rf "$WORKDIR"' EXIT + + REPAIRED=false + + # --------------------------------------------------------------- + # NPM API 호출 헬퍼 + # + # 주의: NPM은 설정을 DB에 먼저 쓰고 나서 nginx를 reload합니다. + # reload가 실패하면 500을 반환하지만 DB 변경은 되돌리지 않습니다. + # 즉 POST/PUT/DELETE의 500은 "아무 일도 없었다"는 뜻이 아닙니다. + # 반면 disable/enable은 실패해도 200을 주고 meta.nginx_online에 + # 결과를 기록합니다. 그래서 반영 여부 판단은 상태코드가 아니라 + # meta.nginx_online으로 합니다. + # --------------------------------------------------------------- api() { local method="$1" path="$2" data="${3:-}" resp code body if [ -n "$data" ]; then @@ -101,6 +134,176 @@ runs: printf '%s' "$body" } + # 도메인으로 프록시 호스트를 찾습니다. 없으면 빈 문자열. + find_host() { + api GET "/nginx/proxy-hosts" \ + | jq -c --arg d "$DOMAIN" 'map(select(.domain_names | index($d))) | .[0] // empty' + } + + # 프록시 호스트 페이로드를 만듭니다. $1=certificate_id $2=ssl_forced $3=http2_support + host_payload() { + jq -nc \ + --arg d "$DOMAIN" \ + --arg fh "$FORWARD_HOST" \ + --arg fp "$FORWARD_PORT" \ + --arg fs "$FORWARD_SCHEME" \ + --argjson cert "$1" \ + --argjson ssl "$2" \ + --argjson h2 "$3" \ + --argjson ws "$WS" \ + '{domain_names:[$d], + forward_host:$fh, + forward_port:($fp|tonumber), + forward_scheme:$fs, + certificate_id:$cert, + ssl_forced:$ssl, + http2_support:$h2, + hsts_enabled:false, + hsts_subdomains:false, + caching_enabled:false, + allow_websocket_upgrade:$ws, + block_exploits:true, + access_list_id:0, + advanced_config:"", + locations:[], + meta:{}}' + } + + # disable → enable 로 nginx 설정 파일을 다시 쓰게 만듭니다. + # POST/PUT이 500으로 실패해 설정 파일이 생성되지 않은 호스트를 + # 되살리는 유일한 API 경로입니다. + recycle_host() { + echo " nginx 반영 재시도 (disable → enable): host id=$1" + api POST "/nginx/proxy-hosts/$1/disable" >/dev/null 2>&1 || true + sleep 2 + api POST "/nginx/proxy-hosts/$1/enable" >/dev/null 2>&1 || true + sleep 2 + } + + # 호스트가 실제로 nginx에 반영됐는지 확인하고, 아니면 한 번 복구를 시도합니다. + ensure_online() { + local id="$1" j + j="$(api GET "/nginx/proxy-hosts/$id")" || return 1 + if printf '%s' "$j" | jq -e '.meta.nginx_online == true' >/dev/null 2>&1; then + return 0 + fi + + recycle_host "$id" + REPAIRED=true + + j="$(api GET "/nginx/proxy-hosts/$id")" || return 1 + if printf '%s' "$j" | jq -e '.meta.nginx_online == true' >/dev/null 2>&1; then + return 0 + fi + + echo "nginx 반영 실패 (host id=$id):" >&2 + printf '%s' "$j" | jq -r '.meta.nginx_err // "(NPM이 오류 내용을 남기지 않았습니다)"' >&2 + return 1 + } + + # 500으로 실패했지만 DB에는 남았을 수 있는 레코드를 정리합니다. + # 이걸 남겨두면 다음 배포에서 "이미 존재"로 오판해 도메인이 영구히 + # 죽은 상태로 고착됩니다. + cleanup_orphan() { + local j id + j="$(find_host || true)" + id="$(printf '%s' "$j" | jq -r '.id // empty' 2>/dev/null || true)" + if [ -n "$id" ]; then + echo " 실패로 남은 레코드를 정리합니다: host id=$id" + # DELETE도 막힌 상태에서는 500을 주지만 삭제 자체는 수행됩니다. + api DELETE "/nginx/proxy-hosts/$id" >/dev/null 2>&1 || true + fi + } + + # 인증서 파일이 디스크에서 사라졌는지 확인합니다. + # download 엔드포인트는 실제 파일을 읽으므로, 파일이 없으면 404를 줍니다. + # 200=파일 있음 404=파일 없음(이 인증서를 쓰는 호스트가 NPM 전체를 막습니다) + # 400=Let's Encrypt 인증서가 아님(판별 불가) + # 인증서마다 한 번만 조회하도록 결과를 캐시합니다. + cert_file_missing() { + local cid="$1" cache="$WORKDIR/cert-$1" code + if [ -f "$cache" ]; then + [ "$(cat "$cache")" = "missing" ] + return + fi + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + "${BASE}/nginx/certificates/${cid}/download" -H "$AUTH" 2>/dev/null || echo 000)" + if [ "$code" = "404" ]; then + echo missing > "$cache" + return 0 + fi + echo present > "$cache" + return 1 + } + + # NPM의 설정 반영을 막고 있는 호스트를 찾습니다. + # 결과는 전역 배열 BLOCKER_IDS(비활성화 대상)와 BLOCKER_LINES(출력용), + # 그리고 개수 BLOCKING_COUNT/FINDING_COUNT에 담깁니다. + # (구형 bash에서 set -u와 빈 배열이 충돌하므로 개수는 별도 변수로 셉니다.) + scan_blockers() { + BLOCKER_IDS=() + BLOCKER_LINES=() + BLOCKING_COUNT=0 + FINDING_COUNT=0 + + local hosts hid cid names online reason + hosts="$(api GET "/nginx/proxy-hosts")" || return 0 + + while IFS=$'\t' read -r hid cid names online; do + [ -z "${hid:-}" ] && continue + + reason="" + if [ "$cid" != "0" ] && cert_file_missing "$cid"; then + # 이 호스트가 nginx 설정 검사를 실패시켜 NPM 전체를 막습니다. + reason="인증서 파일이 사라짐 (cert id=${cid}) — NPM 전체를 막습니다" + BLOCKER_IDS+=("$hid") + BLOCKING_COUNT=$((BLOCKING_COUNT + 1)) + elif [ "$online" != "true" ]; then + # NPM이 이미 격리한 호스트입니다. 다른 도메인을 막지는 않습니다. + reason="nginx 미반영 (이 도메인만 동작하지 않음)" + fi + + if [ -n "$reason" ]; then + BLOCKER_LINES+=(" - id=${hid} ${names} — ${reason}") + FINDING_COUNT=$((FINDING_COUNT + 1)) + fi + done <<< "$(printf '%s' "$hosts" | jq -r --arg d "$DOMAIN" \ + '.[] + | select(.enabled == true and ((.domain_names | index($d)) | not)) + | [.id, .certificate_id, (.domain_names | join(",")), (.meta.nginx_online // false | tostring)] + | @tsv')" + } + + diagnose_blocked() { + scan_blockers + if [ "$FINDING_COUNT" -gt 0 ]; then + echo "" >&2 + echo "문제가 있는 프록시 호스트:" >&2 + printf '%s\n' "${BLOCKER_LINES[@]+"${BLOCKER_LINES[@]}"}" >&2 + fi + cat >&2 <<'MSG' + + ──────────────────────────────────────────────────────────── + NPM이 nginx 설정을 반영하지 못하는 상태로 보입니다. + + 인증서 파일이 사라진 프록시 호스트가 하나라도 있으면, nginx 설정 + 검사가 통째로 실패해 NPM의 모든 등록/수정이 500으로 막힙니다. + 내 도메인과 무관한 호스트가 원인일 수 있습니다. + + 해소 방법 + 1. unblock: "true" 로 이 액션을 다시 실행하거나 + 2. NPM 관리 화면에서 아래 목록의 호스트를 Disable 하거나 + 3. NPM 컨테이너에서 직접 정리 + docker exec nginx -t + docker exec rm -f /data/nginx/proxy_host/.conf + docker exec nginx -s reload + + 주의: 이 상태로 NPM을 재시작하면 nginx가 아예 기동하지 못해 + 모든 도메인과 관리 화면이 함께 중단됩니다. + ──────────────────────────────────────────────────────────── + MSG + } + # --------------------------------------------------------------- # 1. 토큰 발급 (로그인) # --------------------------------------------------------------- @@ -119,55 +322,107 @@ runs: WS=$([ "$WEBSOCKETS" = "true" ] && echo true || echo false) # --------------------------------------------------------------- - # 2. 기존 프록시 호스트 확인 → 있으면 즉시 종료 (덮어쓰지 않음) - # 이미 등록된 도메인은 기존 설정을 보존하기 위해 아무것도 건드리지 않습니다. + # 2. 사전 점검 — nginx에 반영되지 않은 호스트 확인 + # 이 중 인증서를 잃은 호스트가 NPM 전체의 반영을 막습니다. # --------------------------------------------------------------- - HOST_ID=$(api GET "/nginx/proxy-hosts" \ - | jq -r --arg d "$DOMAIN" \ - 'map(select(.domain_names | index($d))) | .[0].id // empty') + scan_blockers - if [ -n "$HOST_ID" ]; then - echo "이미 존재하는 도메인입니다. 덮어쓰지 않고 종료합니다: ${DOMAIN} (host id=${HOST_ID})" - echo "host-id=${HOST_ID}" >> "$GITHUB_OUTPUT" - echo "created=false" >> "$GITHUB_OUTPUT" - exit 0 + if [ "$FINDING_COUNT" -gt 0 ]; then + echo "경고: 문제가 있는 프록시 호스트 ${FINDING_COUNT}건을 발견했습니다." + printf '%s\n' "${BLOCKER_LINES[@]+"${BLOCKER_LINES[@]}"}" + + if [ "$BLOCKING_COUNT" -gt 0 ]; then + if [ "$UNBLOCK" = "true" ]; then + echo " unblock=true — 막고 있는 호스트를 비활성화합니다." + for bid in "${BLOCKER_IDS[@]+"${BLOCKER_IDS[@]}"}"; do + api POST "/nginx/proxy-hosts/${bid}/disable" >/dev/null 2>&1 || true + echo " host id=${bid} 비활성화" + done + sleep 2 + else + echo " 인증서를 잃은 호스트가 있어 아래 작업이 실패할 수 있습니다." + echo " 해소하려면 unblock: \"true\" 로 실행하세요." + fi + fi fi # --------------------------------------------------------------- - # 3. HTTP 전용 프록시 호스트 먼저 생성 (SSL 없이) - # 도메인이 80포트로 응답해야 Let's Encrypt HTTP-01 검증이 통과합니다. + # 3. 기존 프록시 호스트 확인 + # 존재하더라도 SSL이 붙지 않았거나 nginx에 반영되지 않았으면 + # 미완성으로 보고 이어서 복구합니다. 존재 여부만 보고 건너뛰면 + # 한 번 실패한 도메인이 영구히 죽은 채로 고착됩니다. # --------------------------------------------------------------- - echo "HTTP 프록시 호스트 생성 중: ${DOMAIN} -> ${FORWARD_SCHEME}://${FORWARD_HOST}:${FORWARD_PORT}" - HTTP_PAYLOAD=$(jq -nc \ - --arg d "$DOMAIN" \ - --arg fh "$FORWARD_HOST" \ - --arg fp "$FORWARD_PORT" \ - --arg fs "$FORWARD_SCHEME" \ - --argjson ws "$WS" \ - '{domain_names:[$d], - forward_host:$fh, - forward_port:($fp|tonumber), - forward_scheme:$fs, - certificate_id:0, - ssl_forced:false, - http2_support:false, - hsts_enabled:false, - hsts_subdomains:false, - caching_enabled:false, - allow_websocket_upgrade:$ws, - block_exploits:true, - access_list_id:0, - advanced_config:"", - locations:[], - meta:{}}') - HOST_ID=$(api POST "/nginx/proxy-hosts" "$HTTP_PAYLOAD" | jq -r '.id') - if [ -z "$HOST_ID" ] || [ "$HOST_ID" = "null" ]; then - echo "프록시 호스트 생성 실패: ${DOMAIN}" + EXISTING="$(find_host)" + HOST_ID="" + CREATED=false + + if [ -n "$EXISTING" ]; then + HOST_ID="$(printf '%s' "$EXISTING" | jq -r '.id')" + + if printf '%s' "$EXISTING" \ + | jq -e '.certificate_id != 0 and .meta.nginx_online == true' >/dev/null 2>&1; then + echo "이미 완성된 도메인입니다. 덮어쓰지 않고 종료합니다: ${DOMAIN} (host id=${HOST_ID})" + { + echo "host-id=${HOST_ID}" + echo "created=false" + echo "repaired=false" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$REPAIR" != "true" ]; then + echo "이미 존재하는 도메인입니다. 덮어쓰지 않고 종료합니다: ${DOMAIN} (host id=${HOST_ID})" + printf '%s' "$EXISTING" \ + | jq -r '" 주의: certificate_id=\(.certificate_id) nginx_online=\(.meta.nginx_online // "null") — 미완성 상태입니다."' + echo " repair: \"true\" 로 실행하면 이어서 복구합니다." + { + echo "host-id=${HOST_ID}" + echo "created=false" + echo "repaired=false" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "미완성 상태의 기존 도메인을 복구합니다: ${DOMAIN} (host id=${HOST_ID})" + printf '%s' "$EXISTING" \ + | jq -r '" 현재 상태: certificate_id=\(.certificate_id) nginx_online=\(.meta.nginx_online // "null")"' + REPAIRED=true + else + # ------------------------------------------------------------- + # 4. HTTP 전용 프록시 호스트 먼저 생성 (SSL 없이) + # 도메인이 80포트로 응답해야 Let's Encrypt HTTP-01 검증이 통과합니다. + # ------------------------------------------------------------- + echo "HTTP 프록시 호스트 생성 중: ${DOMAIN} -> ${FORWARD_SCHEME}://${FORWARD_HOST}:${FORWARD_PORT}" + if ! CREATE_RESP="$(api POST "/nginx/proxy-hosts" "$(host_payload 0 false false)")"; then + echo "프록시 호스트 생성 실패: ${DOMAIN}" >&2 + diagnose_blocked + cleanup_orphan + exit 1 + fi + + HOST_ID="$(printf '%s' "$CREATE_RESP" | jq -r '.id // empty')" + if [ -z "$HOST_ID" ]; then + echo "프록시 호스트 생성 실패: ${DOMAIN} (응답에 id가 없습니다)" >&2 + cleanup_orphan + exit 1 + fi + CREATED=true + fi + + # --------------------------------------------------------------- + # 5. 호스트가 실제로 nginx에 반영됐는지 확인 + # 여기서 80포트 응답이 확보돼야 Let's Encrypt 검증이 통과합니다. + # --------------------------------------------------------------- + if ! ensure_online "$HOST_ID"; then + diagnose_blocked + if [ "$CREATED" = "true" ]; then + cleanup_orphan + fi exit 1 fi # --------------------------------------------------------------- - # 4. Let's Encrypt 인증서 발급 (HTTP 호스트가 떠 있는 상태에서 검증) + # 6. Let's Encrypt 인증서 발급 (HTTP 호스트가 떠 있는 상태에서 검증) # 같은 도메인 인증서가 이미 있으면 재사용합니다. # --------------------------------------------------------------- CERT_ID=$(api GET "/nginx/certificates" \ @@ -178,15 +433,25 @@ runs: echo "Let's Encrypt 인증서 발급 중: ${DOMAIN}" # 이메일/약관동의는 NPM 설정(Settings > Default Let's Encrypt)에서 가져오므로 # 요청 meta에는 dns_challenge만 보냅니다. (스키마가 그 외 키를 거부함) - CERT_ID=$(api POST "/nginx/certificates" \ - "$(jq -nc --arg d "$DOMAIN" \ - '{provider:"letsencrypt", - domain_names:[$d], - meta:{dns_challenge:false}}')" \ - | jq -r '.id') + if ! CERT_RESP="$(api POST "/nginx/certificates" \ + "$(jq -nc --arg d "$DOMAIN" \ + '{provider:"letsencrypt", + domain_names:[$d], + meta:{dns_challenge:false}}')")"; then + echo "인증서 발급 실패: ${DOMAIN} (도메인 DNS가 NPM을 가리키는지 확인하세요)" >&2 + if [ "$CREATED" = "true" ]; then + echo " SSL 없는 호스트를 남기지 않기 위해 방금 만든 호스트를 정리합니다." >&2 + cleanup_orphan + fi + exit 1 + fi + CERT_ID="$(printf '%s' "$CERT_RESP" | jq -r '.id // empty')" if [ -z "$CERT_ID" ] || [ "$CERT_ID" = "null" ]; then - echo "인증서 발급 실패: ${DOMAIN} (도메인 DNS가 NPM을 가리키는지 확인하세요)" + echo "인증서 발급 실패: ${DOMAIN} (도메인 DNS가 NPM을 가리키는지 확인하세요)" >&2 + if [ "$CREATED" = "true" ]; then + cleanup_orphan + fi exit 1 fi else @@ -194,34 +459,43 @@ runs: fi # --------------------------------------------------------------- - # 5. 프록시 호스트에 SSL 적용 (PUT 업데이트: SSL 강제 + HTTP/2) + # 7. 프록시 호스트에 SSL 적용 (PUT 업데이트: SSL 강제 + HTTP/2) # --------------------------------------------------------------- echo "프록시 호스트에 SSL 적용 중: ${DOMAIN} (cert id=${CERT_ID})" - SSL_PAYLOAD=$(jq -nc \ - --arg d "$DOMAIN" \ - --arg fh "$FORWARD_HOST" \ - --arg fp "$FORWARD_PORT" \ - --arg fs "$FORWARD_SCHEME" \ - --argjson cert "$CERT_ID" \ - --argjson ws "$WS" \ - '{domain_names:[$d], - forward_host:$fh, - forward_port:($fp|tonumber), - forward_scheme:$fs, - certificate_id:$cert, - ssl_forced:true, - http2_support:true, - hsts_enabled:false, - hsts_subdomains:false, - caching_enabled:false, - allow_websocket_upgrade:$ws, - block_exploits:true, - access_list_id:0, - advanced_config:"", - locations:[], - meta:{}}') - api PUT "/nginx/proxy-hosts/${HOST_ID}" "$SSL_PAYLOAD" > /dev/null + if ! api PUT "/nginx/proxy-hosts/${HOST_ID}" "$(host_payload "$CERT_ID" true true)" >/dev/null; then + echo "SSL 적용 요청이 실패했습니다. 반영 상태를 확인합니다." >&2 + diagnose_blocked + fi - echo "도메인 생성/SSL 적용 완료: ${DOMAIN} (host id=${HOST_ID})" - echo "host-id=${HOST_ID}" >> "$GITHUB_OUTPUT" - echo "created=true" >> "$GITHUB_OUTPUT" + # --------------------------------------------------------------- + # 8. 최종 검증 — nginx에 실제로 반영됐고 인증서가 붙었는지 확인 + # PUT이 200을 줘도 nginx reload가 실패했을 수 있으므로 + # meta.nginx_online으로 확인합니다. + # --------------------------------------------------------------- + verify() { + api GET "/nginx/proxy-hosts/${HOST_ID}" \ + | jq -e --argjson c "$CERT_ID" \ + '.meta.nginx_online == true and .certificate_id == $c' >/dev/null 2>&1 + } + + if ! verify; then + echo " SSL이 nginx에 반영되지 않았습니다. 복구를 시도합니다." + recycle_host "$HOST_ID" + REPAIRED=true + fi + + if ! verify; then + echo "도메인 생성 실패: ${DOMAIN} — SSL이 nginx에 반영되지 않았습니다." >&2 + api GET "/nginx/proxy-hosts/${HOST_ID}" \ + | jq -r '" certificate_id=\(.certificate_id) nginx_online=\(.meta.nginx_online // "null")", + " nginx_err: \(.meta.nginx_err // "(없음)")"' >&2 + diagnose_blocked + exit 1 + fi + + echo "도메인 생성/SSL 적용 완료: ${DOMAIN} (host id=${HOST_ID}, cert id=${CERT_ID})" + { + echo "host-id=${HOST_ID}" + echo "created=${CREATED}" + echo "repaired=${REPAIRED}" + } >> "$GITHUB_OUTPUT"