name: 도메인 생성 description: nginx-proxy-manager에 프록시 호스트를 등록하고 Let's Encrypt SSL을 적용 (멱등성 보장) inputs: npm-host: required: true description: nginx-proxy-manager 주소 (npm-api.conf의 NGINX_IP) npm-port: required: false default: "81" description: nginx-proxy-manager 관리 API 포트 (npm-api.conf의 NGINX_PORT) npm-user: required: true description: NPM 로그인 이메일 (npm-api.conf의 API_USER) npm-password: required: true description: NPM 로그인 비밀번호 (npm-api.conf의 API_PASS) domain: required: true description: "생성할 도메인 (예: app.example.com)" forward-host: required: true description: 프록시가 바라볼 대상 호스트/IP forward-port: required: true description: 프록시가 바라볼 대상 포트 websockets: 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: description: 생성되었거나 이미 존재하는 프록시 호스트 ID value: ${{ steps.create.outputs.host-id }} created: description: 이번 실행에서 새로 생성했으면 true, 이미 있었으면 false value: ${{ steps.create.outputs.created }} repaired: description: 이번 실행에서 기존 호스트를 복구했으면 true value: ${{ steps.create.outputs.repaired }} runs: using: composite steps: - name: jq 설치 확인 shell: bash run: | set -eu # jq가 이미 있으면 건너뜁니다. if command -v jq >/dev/null 2>&1; then exit 0 fi SUDO="" [ "$(id -u)" -ne 0 ] && SUDO="sudo" # 패키지 매니저에 따라 jq를 설치합니다. if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update && $SUDO apt-get install -y jq elif command -v apk >/dev/null 2>&1; then $SUDO apk add --no-cache jq elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y jq else echo "jq 설치 실패: 지원하지 않는 러너 이미지" exit 1 fi - name: 프록시 호스트 생성 id: create shell: bash env: NPM_HOST: ${{ inputs.npm-host }} NPM_PORT: ${{ inputs.npm-port }} NPM_USER: ${{ inputs.npm-user }} NPM_PASS: ${{ inputs.npm-password }} DOMAIN: ${{ inputs.domain }} FORWARD_HOST: ${{ inputs.forward-host }} FORWARD_PORT: ${{ inputs.forward-port }} WEBSOCKETS: ${{ inputs.websockets }} REPAIR: ${{ inputs.repair }} UNBLOCK: ${{ inputs.unblock }} run: | set -eu # 개발서버 전용 고정값 BASE="http://${NPM_HOST}:${NPM_PORT}/api" # NPM_SCHEME=http 고정 FORWARD_SCHEME="http" # 대상 프로토콜 http 고정 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 resp=$(curl -sS -w $'\n%{http_code}' -X "$method" "${BASE}${path}" \ -H "$AUTH" -H "Content-Type: application/json" -d "$data") else resp=$(curl -sS -w $'\n%{http_code}' -X "$method" "${BASE}${path}" -H "$AUTH") fi code="${resp##*$'\n'}" body="${resp%$'\n'*}" if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then echo "API 오류 [$method $path] HTTP $code: $body" >&2 return 1 fi 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. 토큰 발급 (로그인) # --------------------------------------------------------------- TOKEN_RESP=$(curl -sS -w $'\n%{http_code}' -X POST "${BASE}/tokens" \ -H "Content-Type: application/json" \ -d "$(jq -nc --arg i "$NPM_USER" --arg s "$NPM_PASS" '{identity:$i, secret:$s}')") TOKEN_CODE="${TOKEN_RESP##*$'\n'}" TOKEN_BODY="${TOKEN_RESP%$'\n'*}" if [ "$TOKEN_CODE" != "200" ]; then echo "NPM 토큰 발급 실패 (HTTP $TOKEN_CODE): $TOKEN_BODY" exit 1 fi TOKEN=$(printf '%s' "$TOKEN_BODY" | jq -r '.token') AUTH="Authorization: Bearer ${TOKEN}" WS=$([ "$WEBSOCKETS" = "true" ] && echo true || echo false) # --------------------------------------------------------------- # 2. 사전 점검 — nginx에 반영되지 않은 호스트 확인 # 이 중 인증서를 잃은 호스트가 NPM 전체의 반영을 막습니다. # --------------------------------------------------------------- scan_blockers 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. 기존 프록시 호스트 확인 # 존재하더라도 SSL이 붙지 않았거나 nginx에 반영되지 않았으면 # 미완성으로 보고 이어서 복구합니다. 존재 여부만 보고 건너뛰면 # 한 번 실패한 도메인이 영구히 죽은 채로 고착됩니다. # --------------------------------------------------------------- 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 # --------------------------------------------------------------- # 6. Let's Encrypt 인증서 발급 (HTTP 호스트가 떠 있는 상태에서 검증) # 같은 도메인 인증서가 이미 있으면 재사용합니다. # --------------------------------------------------------------- CERT_ID=$(api GET "/nginx/certificates" \ | jq -r --arg d "$DOMAIN" \ 'map(select(.provider=="letsencrypt" and (.domain_names | index($d)))) | .[0].id // empty') if [ -z "$CERT_ID" ]; then echo "Let's Encrypt 인증서 발급 중: ${DOMAIN}" # 이메일/약관동의는 NPM 설정(Settings > Default Let's Encrypt)에서 가져오므로 # 요청 meta에는 dns_challenge만 보냅니다. (스키마가 그 외 키를 거부함) 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을 가리키는지 확인하세요)" >&2 if [ "$CREATED" = "true" ]; then cleanup_orphan fi exit 1 fi else echo "기존 인증서 재사용: ${DOMAIN} (cert id=${CERT_ID})" fi # --------------------------------------------------------------- # 7. 프록시 호스트에 SSL 적용 (PUT 업데이트: SSL 강제 + HTTP/2) # --------------------------------------------------------------- echo "프록시 호스트에 SSL 적용 중: ${DOMAIN} (cert id=${CERT_ID})" if ! api PUT "/nginx/proxy-hosts/${HOST_ID}" "$(host_payload "$CERT_ID" true true)" >/dev/null; then echo "SSL 적용 요청이 실패했습니다. 반영 상태를 확인합니다." >&2 diagnose_blocked fi # --------------------------------------------------------------- # 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"