Files
vpd-permission-poc/scripts/configure-backoffice-https-vm.sh

283 lines
10 KiB
Bash
Executable File

#!/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/vpd-backoffice/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/vpd-backoffice/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