#!/usr/bin/env bash # Configure and verify the HTTPS reverse proxy for VPD Backoffice. set -Eeuo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck disable=SC1091 . "$ROOT/scripts/lib/common.sh" HOST="hermes" PUBLIC_HOST="" TLS_EMAIL="" EXPECTED_ADDRESS="" REMOTE_DIR='$HOME/apps/vpd-backoffice' APP_PORT="8082" IDENTITY_FILE="" KNOWN_HOSTS_FILE="" ACCEPT_HOST_KEY="N" DRY_RUN="N" VERIFY_ONLY="N" INSPECT_ONLY="N" usage() { cat <<'USAGE' Usage: scripts/configure-backoffice-https-vm.sh \ --public-host FQDN_OR_IPV4 --tls-email EMAIL --expected-address IPV4 [options] Options: --host SSH_ALIAS SSH target (default: hermes) --public-host FQDN_OR_IPV4 Owned public DNS name or controlled public IPv4 --tls-email EMAIL ACME expiry/security notification address --expected-address IPV4 Address that the public DNS name must resolve to --remote-dir PATH Application directory (default: $HOME/apps/vpd-backoffice) --app-port PORT Loopback Spring Boot port (default: 8082) --identity-file KEY SSH private key --known-hosts-file FILE Dedicated SSH known_hosts file --accept-host-key Accept a new host key (not recommended for routine use) --dry-run Validate and render locally without network access --inspect Read-only VM listener/Caddy/firewalld preflight --verify-only Skip configuration and run external checks only This script does not change DNS, OCI NSG, or firewalld rules. USAGE } validate_public_host() { local value="${1:-}" if [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then validate_ipv4 "$value" return fi [[ "$value" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] \ || die "공개 host는 소유한 FQDN 또는 public IPv4여야 합니다: $value" [[ "$value" != *".."* && "$value" != *"*"* ]] \ || die "wildcard/빈 label host는 허용되지 않습니다: $value" } validate_tls_email() { local value="${1:-}" [[ "$value" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] \ || die "유효한 TLS 운영 이메일이 필요합니다: $value" } validate_ssh_host() { local value="${1:-}" [[ "$value" =~ ^[A-Za-z0-9._-]+$ && "$value" != -* ]] \ || die "SSH host alias에 허용되지 않은 문자가 있습니다: $value" } validate_ipv4() { local value="${1:-}" part octet local -a parts [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] \ || die "expected address는 IPv4 형식이어야 합니다: $value" IFS=. read -r -a parts <<< "$value" for part in "${parts[@]}"; do octet=$((10#$part)) (( octet >= 0 && octet <= 255 )) || die "IPv4 octet 범위가 잘못됐습니다: $value" done } validate_port() { local value="${1:-}" port [[ "$value" =~ ^[0-9]+$ ]] || die "port는 1..65535 숫자여야 합니다: $value" port=$((10#$value)) (( port >= 1 && port <= 65535 )) \ || die "port는 1..65535 숫자여야 합니다: $value" } validate_remote_dir() { local value="${1:-}" [[ "$value" =~ ^(\$HOME|~|/)[A-Za-z0-9._/-]+$ ]] \ || die "remote-dir에 허용되지 않은 문자가 있습니다: $value" [[ "$value" != *".."* && "$value" != *"//"* ]] \ || die "remote-dir의 상위/빈 경로 segment는 허용되지 않습니다: $value" } render_caddyfile() { local output="${1:?output path is required}" tls_options="" if [[ "$PUBLIC_HOST" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then tls_options=$'tls {\n\t\tissuer acme {\n\t\t\tprofile shortlived\n\t\t}\n\t}' fi awk \ -v public_host="$PUBLIC_HOST" \ -v tls_email="$TLS_EMAIL" \ -v app_port="$APP_PORT" \ -v tls_options="$tls_options" ' { gsub(/{{PUBLIC_HOST}}/, public_host) gsub(/{{TLS_EMAIL}}/, tls_email) gsub(/{{APP_PORT}}/, app_port) gsub(/{{TLS_OPTIONS}}/, tls_options) print } ' "$ROOT/deploy/caddy/Caddyfile.template" > "$output" } build_ssh_args() { SSH_ARGS=( -o BatchMode=yes -o ConnectTimeout=8 -o ConnectionAttempts=1 -o ServerAliveInterval=5 -o ServerAliveCountMax=2 ) if [[ -n "$IDENTITY_FILE" ]]; then [[ -f "$IDENTITY_FILE" ]] || die "identity file을 찾을 수 없습니다: $IDENTITY_FILE" SSH_ARGS+=(-i "$IDENTITY_FILE") fi if [[ -n "$KNOWN_HOSTS_FILE" ]]; then SSH_ARGS+=(-o "UserKnownHostsFile=$KNOWN_HOSTS_FILE") fi if [[ "$ACCEPT_HOST_KEY" == "Y" ]]; then SSH_ARGS+=(-o StrictHostKeyChecking=accept-new) else SSH_ARGS+=(-o StrictHostKeyChecking=yes) fi } verify_dns() { local addresses if [[ "$PUBLIC_HOST" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then [[ "$PUBLIC_HOST" == "$EXPECTED_ADDRESS" ]] \ || die "public IPv4와 expected address가 다릅니다: $PUBLIC_HOST != $EXPECTED_ADDRESS" return fi addresses="$(getent ahostsv4 "$PUBLIC_HOST" | awk '{print $1}' | sort -u)" [[ -n "$addresses" ]] || die "DNS A record를 조회할 수 없습니다: $PUBLIC_HOST" grep -Fxq "$EXPECTED_ADDRESS" <<< "$addresses" \ || die "$PUBLIC_HOST DNS가 expected address $EXPECTED_ADDRESS 를 가리키지 않습니다 (actual: ${addresses//$'\n'/,})" } verify_external() { local tmp_dir http_headers https_headers cookie_line location tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-https-verify.XXXXXX")" http_headers="$tmp_dir/http.headers" https_headers="$tmp_dir/https.headers" curl -sS --connect-timeout 8 --max-time 20 -D "$http_headers" -o /dev/null \ "http://$PUBLIC_HOST/login" location="$(awk 'BEGIN{IGNORECASE=1} /^Location:/{sub(/\r$/, "", $2); print $2; exit}' "$http_headers")" [[ "$location" == "https://$PUBLIC_HOST/login" ]] \ || die "HTTP→HTTPS redirect가 올바르지 않습니다: ${location:-missing}" curl -sS --connect-timeout 8 --max-time 20 -D "$https_headers" -o /dev/null \ "https://$PUBLIC_HOST/login" grep -Eiq '^Strict-Transport-Security:.*max-age=31536000' "$https_headers" \ || die "HTTPS 응답에 1년 HSTS가 없습니다." cookie_line="$(grep -Ei '^Set-Cookie:[[:space:]]*JSESSIONID=' "$https_headers" | head -n 1 || true)" [[ "$cookie_line" == *"Secure"* && "$cookie_line" == *"HttpOnly"* ]] \ || die "JSESSIONID Secure/HttpOnly 속성을 확인할 수 없습니다." grep -Eiq '^Set-Cookie:.*JSESSIONID=.*SameSite=Lax' <<< "$cookie_line" \ || die "JSESSIONID SameSite=Lax 속성을 확인할 수 없습니다." if curl -sS --connect-timeout 3 --max-time 5 -o /dev/null \ "http://$PUBLIC_HOST:$APP_PORT/login" 2>/dev/null; then die "외부에서 애플리케이션 포트 $APP_PORT 에 직접 접근할 수 있습니다." fi rm -rf "$tmp_dir" ok "HTTPS 검증 통과: redirect, TLS, HSTS, session cookie, direct-port deny" } main() { while [[ $# -gt 0 ]]; do case "$1" in --host) HOST="${2:?--host requires a value}"; shift 2 ;; --public-host) PUBLIC_HOST="${2:?--public-host requires a value}"; shift 2 ;; --tls-email) TLS_EMAIL="${2:?--tls-email requires a value}"; shift 2 ;; --expected-address) EXPECTED_ADDRESS="${2:?--expected-address requires a value}"; shift 2 ;; --remote-dir) REMOTE_DIR="${2:?--remote-dir requires a value}"; shift 2 ;; --app-port) APP_PORT="${2:?--app-port requires a value}"; shift 2 ;; --identity-file) IDENTITY_FILE="${2:?--identity-file requires a value}"; shift 2 ;; --known-hosts-file) KNOWN_HOSTS_FILE="${2:?--known-hosts-file requires a value}"; shift 2 ;; --accept-host-key) ACCEPT_HOST_KEY="Y"; shift ;; --dry-run) DRY_RUN="Y"; shift ;; --inspect) INSPECT_ONLY="Y"; shift ;; --verify-only) VERIFY_ONLY="Y"; shift ;; -h|--help) usage; exit 0 ;; *) die "알 수 없는 옵션: $1" ;; esac done if [[ "$INSPECT_ONLY" == "Y" ]]; then validate_ssh_host "$HOST" need_cmd ssh "OpenSSH client" need_cmd timeout "bounded SSH preflight" build_ssh_args timeout 30s ssh "${SSH_ARGS[@]}" "$HOST" 'bash -s' <<'INSPECT' set -Eeuo pipefail printf 'host=%s user=%s\n' "$(hostname)" "$(whoami)" printf '%s\n' 'listeners:' ss -ltn | awk 'NR == 1 || $4 ~ /:(80|443|8082)$/' if command -v caddy >/dev/null 2>&1; then printf 'caddy=%s\n' "$(timeout 5s caddy version || true)" printf 'caddy_active=%s\n' "$(timeout 5s systemctl is-active caddy 2>/dev/null || true)" printf 'caddy_enabled=%s\n' "$(timeout 5s systemctl is-enabled caddy 2>/dev/null || true)" else printf '%s\n' 'caddy=not-installed' fi if sudo -n true >/dev/null 2>&1 && command -v firewall-cmd >/dev/null 2>&1; then printf 'firewalld_ports=%s\n' "$(timeout 5s sudo firewall-cmd --list-ports 2>/dev/null || true)" fi printf 'app_loopback_http_status=' curl -sS --connect-timeout 3 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8082/login || true INSPECT exit 0 fi validate_public_host "$PUBLIC_HOST" validate_tls_email "$TLS_EMAIL" validate_ssh_host "$HOST" validate_ipv4 "$EXPECTED_ADDRESS" validate_port "$APP_PORT" validate_remote_dir "$REMOTE_DIR" local tmp_dir candidate remote_stage tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-caddy.XXXXXX")" candidate="$tmp_dir/Caddyfile" render_caddyfile "$candidate" if [[ "$DRY_RUN" == "Y" ]]; then log "dry-run: host=$HOST" log "dry-run: public_host=$PUBLIC_HOST" log "dry-run: expected_address=$EXPECTED_ADDRESS" log "dry-run: app_upstream=127.0.0.1:$APP_PORT" log "dry-run: remote_dir=$REMOTE_DIR" cat "$candidate" rm -rf "$tmp_dir" exit 0 fi need_cmd ssh "OpenSSH client" need_cmd curl "HTTPS 검증" need_cmd getent "DNS 검증" verify_dns build_ssh_args if [[ "$VERIFY_ONLY" != "Y" ]]; then remote_stage="${REMOTE_DIR%/}/.https-stage" log "Caddy 및 sudo 사전 점검: $HOST" ssh "${SSH_ARGS[@]}" "$HOST" \ 'command -v caddy >/dev/null && command -v systemctl >/dev/null && sudo -n true' log "Caddy candidate 업로드" ssh "${SSH_ARGS[@]}" "$HOST" "mkdir -p \"$remote_stage\"" cp "$ROOT/deploy/caddy/install-caddy-config.sh" "$tmp_dir/install-caddy-config.sh" tar -C "$tmp_dir" -czf - Caddyfile install-caddy-config.sh \ | ssh "${SSH_ARGS[@]}" "$HOST" "tar -C \"$remote_stage\" -xzf - && chmod 700 \"$remote_stage/install-caddy-config.sh\"" log "Caddy 설정 검증·백업·적용" ssh "${SSH_ARGS[@]}" "$HOST" \ "\"$remote_stage/install-caddy-config.sh\" \"$remote_stage/Caddyfile\" \"$REMOTE_DIR\"" fi rm -rf "$tmp_dir" verify_external } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi