333 lines
12 KiB
Bash
Executable File
333 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -Eeuo pipefail
|
|
|
|
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
|
readonly CANDIDATE="$ROOT/infrastructure/networking/host-nginx/learn-services-admin.conf"
|
|
readonly ACTIVE="/etc/nginx/sites-available/learn-services"
|
|
readonly CERT_NAME="storage-admin.learn.hyeonworks.com"
|
|
readonly CERT_DIR="/etc/letsencrypt/live/$CERT_NAME"
|
|
readonly CREDENTIALS="/home/donghyeon/.secrets/certbot/cloudflare.ini"
|
|
readonly STORAGE_HOST="storage-admin.learn.hyeonworks.com"
|
|
readonly DB_HOST="db-admin.learn.hyeonworks.com"
|
|
readonly LAN_IP="192.168.0.107"
|
|
readonly TAIL_IP="100.92.240.34"
|
|
execute=false
|
|
certificate_only=false
|
|
certbot_email=""
|
|
rollback_armed=false
|
|
backup=""
|
|
|
|
fail() {
|
|
printf 'ERROR: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
사용법:
|
|
bash scripts/bootstrap/apply-host-nginx-admin.sh
|
|
bash scripts/bootstrap/apply-host-nginx-admin.sh \
|
|
--execute --certificate-only --certbot-email you@example.com
|
|
bash scripts/bootstrap/apply-host-nginx-admin.sh --execute
|
|
|
|
dry-run은 공개 DNS, 후보 hash, Ingress endpoint, Cloudflare credential 상태를
|
|
검사합니다. --execute는 필요하면 공식 Cloudflare snap plugin과 SAN 인증서를
|
|
발급하고 Nginx를 timestamp backup 뒤 교체합니다.
|
|
USAGE
|
|
}
|
|
|
|
while (( $# > 0 )); do
|
|
case "$1" in
|
|
--certificate-only)
|
|
certificate_only=true
|
|
shift
|
|
;;
|
|
--execute)
|
|
execute=true
|
|
shift
|
|
;;
|
|
--certbot-email)
|
|
(( $# >= 2 )) || fail "--certbot-email 값이 필요합니다"
|
|
certbot_email="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
usage >&2
|
|
fail "지원하지 않는 인자: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
for cmd in awk curl dig install jq kubectl openssl rg sha256sum sleep stat sudo; do
|
|
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
|
|
done
|
|
[[ -f "$CANDIDATE" && ! -L "$CANDIDATE" ]] || fail "Nginx 후보가 없습니다"
|
|
[[ -f "$ACTIVE" && ! -L "$ACTIVE" ]] || fail "활성 Nginx site가 없습니다"
|
|
[[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요"
|
|
|
|
for name in "$STORAGE_HOST" "$DB_HOST"; do
|
|
[[ -z "$(dig +short @1.1.1.1 A "$name" | tr -d '[:space:]')" ]] || fail "$name 공개 A가 있습니다"
|
|
[[ -z "$(dig +short @1.1.1.1 AAAA "$name" | tr -d '[:space:]')" ]] || fail "$name 공개 AAAA가 있습니다"
|
|
done
|
|
|
|
kubectl -n object-storage get ingress minio-aistor-console >/dev/null 2>&1 || \
|
|
printf 'WARNING: AIStor Console Ingress가 아직 적용되지 않았습니다.\n' >&2
|
|
kubectl -n platform-admin get ingress pgadmin >/dev/null 2>&1 || \
|
|
printf 'WARNING: pgAdmin Ingress가 아직 적용되지 않았습니다.\n' >&2
|
|
|
|
candidate_sha="$(sha256sum "$CANDIDATE" | awk '{print $1}')"
|
|
active_sha="$(sha256sum "$ACTIVE" | awk '{print $1}')"
|
|
printf 'Active SHA-256: %s\n' "$active_sha"
|
|
printf 'Candidate SHA-256: %s\n' "$candidate_sha"
|
|
printf 'Certificate SAN: %s, %s\n' "$STORAGE_HOST" "$DB_HOST"
|
|
|
|
# Ubuntu의 sites-enabled/default는 미등록 HTTP Host에 welcome page(200)를
|
|
# 반환할 수 있다. HTTP status만으로 관리 도메인의 부분 적용을 판정하지 않고,
|
|
# 실제 활성 site에 관리 server_name이 들어갔는지를 먼저 확인한다.
|
|
if [[ "$active_sha" != "$candidate_sha" ]] && \
|
|
{ rg -q -F "$STORAGE_HOST" "$ACTIVE" || rg -q -F "$DB_HOST" "$ACTIVE"; }; then
|
|
fail "활성 Nginx site에 후보와 다른 admin 도메인 설정이 부분 적용돼 있습니다"
|
|
fi
|
|
|
|
check_cloudflare_credentials() {
|
|
sudo test -f "$CREDENTIALS" || \
|
|
fail "Cloudflare 제한 토큰 파일이 없습니다: $CREDENTIALS"
|
|
|
|
owner="$(sudo stat -c '%U:%G' "$CREDENTIALS")"
|
|
mode="$(sudo stat -c '%a' "$CREDENTIALS")"
|
|
size="$(sudo stat -c '%s' "$CREDENTIALS")"
|
|
[[ "$owner" == root:root && "$mode" == 600 ]] || \
|
|
fail "$CREDENTIALS 소유권/권한은 root:root 0600이어야 합니다"
|
|
(( size > 0 )) || fail "$CREDENTIALS 파일이 비어 있습니다"
|
|
|
|
contract="$(
|
|
sudo awk -F= '
|
|
BEGIN { count=0; nonempty=0; unexpected=0 }
|
|
/^[[:space:]]*($|#)/ { next }
|
|
/^[[:space:]]*dns_cloudflare_api_token[[:space:]]*=/ {
|
|
count++
|
|
value=$0
|
|
sub(/^[^=]*=[[:space:]]*/, "", value)
|
|
sub(/[[:space:]]+$/, "", value)
|
|
if (length(value) >= 20) nonempty++
|
|
next
|
|
}
|
|
{ unexpected++ }
|
|
END { printf "%d:%d:%d", count, nonempty, unexpected }
|
|
' "$CREDENTIALS"
|
|
)"
|
|
[[ "$contract" == "1:1:0" ]] || \
|
|
fail "$CREDENTIALS에는 비어 있지 않은 dns_cloudflare_api_token 한 개만 있어야 합니다"
|
|
|
|
printf 'Cloudflare credential: root:root 0600 및 key 형식 확인\n'
|
|
}
|
|
|
|
if [[ "$execute" == false ]]; then
|
|
if sudo -n test -f "$CREDENTIALS" 2>/dev/null; then
|
|
check_cloudflare_credentials
|
|
else
|
|
printf 'Cloudflare credential: root 전용이므로 --execute에서 sudo로 확인\n'
|
|
fi
|
|
printf 'DRY RUN PASS: --execute를 지정하지 않아 인증서와 Nginx를 변경하지 않았습니다.\n'
|
|
exit 0
|
|
fi
|
|
|
|
[[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다"
|
|
sudo -v
|
|
check_cloudflare_credentials
|
|
context="$(kubectl config current-context)"
|
|
printf 'Type APPLY to issue/renew the admin certificate and replace Host Nginx: '
|
|
read -r answer
|
|
[[ "$answer" == APPLY ]] || fail "취소했습니다"
|
|
|
|
if [[ ! -x /snap/bin/certbot ]]; then
|
|
fail "지원 대상으로 고정한 snap certbot이 없습니다"
|
|
fi
|
|
if ! /snap/bin/certbot plugins 2>/dev/null | rg -q 'dns-cloudflare'; then
|
|
sudo snap set certbot trust-plugin-with-root=ok
|
|
sudo snap install certbot-dns-cloudflare
|
|
fi
|
|
|
|
if ! sudo test -s "$CERT_DIR/fullchain.pem" || ! sudo test -s "$CERT_DIR/privkey.pem"; then
|
|
[[ "$certbot_email" == *@* ]] || fail "최초 인증서 발급에는 --certbot-email이 필요합니다"
|
|
sudo /snap/bin/certbot certonly \
|
|
--dns-cloudflare \
|
|
--dns-cloudflare-credentials "$CREDENTIALS" \
|
|
--dns-cloudflare-propagation-seconds 60 \
|
|
--cert-name "$CERT_NAME" \
|
|
--domains "$STORAGE_HOST" \
|
|
--domains "$DB_HOST" \
|
|
--non-interactive \
|
|
--agree-tos \
|
|
--email "$certbot_email"
|
|
fi
|
|
|
|
cert_text="$(sudo openssl x509 -in "$CERT_DIR/fullchain.pem" -noout -text)"
|
|
printf '%s\n' "$cert_text" | rg -q "DNS:$STORAGE_HOST" || fail "인증서에 storage-admin SAN이 없습니다"
|
|
printf '%s\n' "$cert_text" | rg -q "DNS:$DB_HOST" || fail "인증서에 db-admin SAN이 없습니다"
|
|
san_count="$(printf '%s\n' "$cert_text" | rg -o 'DNS:[^,[:space:]]+' | sort -u | wc -l | tr -d '[:space:]')"
|
|
[[ "$san_count" == 2 ]] || fail "admin 인증서 SAN은 정확히 두 개여야 합니다"
|
|
|
|
sudo install -d -o root -g root -m 0755 /etc/letsencrypt/renewal-hooks/deploy
|
|
sudo install -o root -g root -m 0755 \
|
|
"$ROOT/infrastructure/networking/host-nginx/reload-nginx.sh" \
|
|
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx
|
|
|
|
if [[ "$certificate_only" == true ]]; then
|
|
printf 'ADMIN CERTIFICATE READY\n'
|
|
printf 'Nginx cutover는 서비스 적용 뒤 --certificate-only 없이 다시 실행하세요.\n'
|
|
exit 0
|
|
fi
|
|
|
|
for host in "$STORAGE_HOST" "$DB_HOST"; do
|
|
code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
|
--noproxy '*' --resolve "$host:80:127.0.0.1" -H "Host: $host" "http://$host/")"
|
|
[[ "$code" == 200 || "$code" == 404 || "$code" == 301 ]] || \
|
|
fail "기존 HTTP 경계가 예상과 다릅니다: $host=$code"
|
|
printf '기존 HTTP fallback: %s=%s\n' "$host" "$code"
|
|
route_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
|
--noproxy '*' -H "Host: $host" "http://127.0.0.1:30080/")"
|
|
[[ "$route_code" == 200 || "$route_code" == 302 || "$route_code" == 303 ]] || \
|
|
fail "Traefik route가 준비되지 않았습니다: $host=$route_code"
|
|
done
|
|
|
|
probe_https_code() {
|
|
host="$1"
|
|
target_ip="$2"
|
|
source_ip="${3:-}"
|
|
|
|
interface_args=()
|
|
if [[ -n "$source_ip" ]]; then
|
|
interface_args=(--interface "$source_ip")
|
|
fi
|
|
|
|
curl --disable --silent --output /dev/null --write-out '%{http_code}' \
|
|
--connect-timeout 1 --max-time 3 --noproxy '*' \
|
|
"${interface_args[@]}" --resolve "$host:443:$target_ip" "https://$host/" \
|
|
2>/dev/null || true
|
|
}
|
|
|
|
wait_for_admin_proxy_state() {
|
|
consecutive_passes=0
|
|
storage_lan=000
|
|
db_lan=000
|
|
storage_denied=000
|
|
db_denied=000
|
|
|
|
for ((attempt = 1; attempt <= 15; attempt++)); do
|
|
storage_lan="$(probe_https_code "$STORAGE_HOST" "$LAN_IP" "$LAN_IP")"
|
|
db_lan="$(probe_https_code "$DB_HOST" "$LAN_IP" "$LAN_IP")"
|
|
storage_denied="$(probe_https_code "$STORAGE_HOST" 127.0.0.1)"
|
|
db_denied="$(probe_https_code "$DB_HOST" 127.0.0.1)"
|
|
|
|
if [[ "$storage_lan" == 200 || "$storage_lan" == 302 || "$storage_lan" == 303 ]] &&
|
|
[[ "$db_lan" == 200 || "$db_lan" == 302 || "$db_lan" == 303 ]] &&
|
|
[[ "$storage_denied" == 403 && "$db_denied" == 403 ]]; then
|
|
consecutive_passes=$((consecutive_passes + 1))
|
|
if (( consecutive_passes >= 2 )); then
|
|
printf 'Nginx admin proxy state stabilized after %d probes.\n' "$attempt"
|
|
return 0
|
|
fi
|
|
else
|
|
consecutive_passes=0
|
|
fi
|
|
|
|
if (( attempt == 1 || attempt % 5 == 0 )); then
|
|
printf 'Nginx admin proxy 대기: probe=%d LAN=%s/%s denied=%s/%s\n' \
|
|
"$attempt" "$storage_lan" "$db_lan" "$storage_denied" "$db_denied"
|
|
fi
|
|
(( attempt < 15 )) && sleep 1
|
|
done
|
|
|
|
printf 'ERROR: Nginx admin proxy가 안정화되지 않았습니다: LAN=%s/%s denied=%s/%s\n' \
|
|
"$storage_lan" "$db_lan" "$storage_denied" "$db_denied" >&2
|
|
return 1
|
|
}
|
|
|
|
wait_for_admin_tls_rejection() {
|
|
consecutive_passes=0
|
|
|
|
for ((attempt = 1; attempt <= 15; attempt++)); do
|
|
if ! curl --disable --insecure --silent --output /dev/null \
|
|
--connect-timeout 1 --max-time 3 --noproxy '*' \
|
|
--resolve "$STORAGE_HOST:443:127.0.0.1" "https://$STORAGE_HOST/" 2>/dev/null &&
|
|
! curl --disable --insecure --silent --output /dev/null \
|
|
--connect-timeout 1 --max-time 3 --noproxy '*' \
|
|
--resolve "$DB_HOST:443:127.0.0.1" "https://$DB_HOST/" 2>/dev/null; then
|
|
consecutive_passes=$((consecutive_passes + 1))
|
|
if (( consecutive_passes >= 2 )); then
|
|
return 0
|
|
fi
|
|
else
|
|
consecutive_passes=0
|
|
fi
|
|
(( attempt < 15 )) && sleep 1
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
backup="$ACTIVE.before-admin-$(date +%Y%m%d%H%M%S)"
|
|
printf 'Planned backup: %s\n' "$backup"
|
|
sudo install -o root -g root -m 0644 "$ACTIVE" "$backup"
|
|
|
|
rollback() {
|
|
set +e
|
|
printf '\nROLLBACK: %s 복원\n' "$backup" >&2
|
|
sudo install -o root -g root -m 0644 "$backup" "$ACTIVE"
|
|
sudo /usr/sbin/nginx -t
|
|
sudo systemctl reload nginx
|
|
if ! wait_for_admin_tls_rejection; then
|
|
printf 'WARNING: rollback 뒤 admin SNI 거부 안정화를 확인하지 못했습니다.\n' >&2
|
|
fi
|
|
rollback_armed=false
|
|
printf 'ROLLBACK complete.\n' >&2
|
|
}
|
|
|
|
finish() {
|
|
rc=$?
|
|
trap - EXIT INT TERM
|
|
if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then
|
|
rollback
|
|
fi
|
|
exit "$rc"
|
|
}
|
|
trap finish EXIT
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
|
|
rollback_armed=true
|
|
sudo install -o root -g root -m 0644 "$CANDIDATE" "$ACTIVE"
|
|
sudo systemctl daemon-reload
|
|
sudo /usr/sbin/nginx -t
|
|
sudo systemctl reload nginx
|
|
sudo systemctl is-active --quiet nginx
|
|
|
|
wait_for_admin_proxy_state || fail "LAN HTTPS와 allowlist 상태가 수렴하지 않았습니다"
|
|
|
|
for host in "$STORAGE_HOST" "$DB_HOST"; do
|
|
redirect_result="$(
|
|
curl --disable --silent --show-error --output /dev/null \
|
|
--write-out $'%{http_code}\n%{redirect_url}' --max-time 5 --noproxy '*' \
|
|
--resolve "$host:80:$LAN_IP" "http://$host/"
|
|
)"
|
|
[[ "$redirect_result" == $'301\nhttps://'"$host/" ]] || \
|
|
fail "HTTP redirect 검증 실패: $host=$redirect_result"
|
|
done
|
|
|
|
if curl --insecure --silent --output /dev/null --noproxy '*' \
|
|
--resolve unconfigured.invalid:443:127.0.0.1 https://unconfigured.invalid/ 2>/dev/null; then
|
|
fail "unknown SNI가 거부되지 않았습니다"
|
|
fi
|
|
|
|
sudo /snap/bin/certbot renew --dry-run
|
|
rollback_armed=false
|
|
printf 'ADMIN NGINX CUTOVER SUCCESS\n'
|
|
printf 'Backup: %s\n' "$backup"
|
|
printf 'Candidate SHA-256: %s\n' "$candidate_sha"
|