#!/usr/bin/env bash set -Eeuo pipefail readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)" readonly HOST_SOURCE="$ROOT/infrastructure/networking/private-dns/host" readonly K8S_SOURCE="$ROOT/infrastructure/networking/private-dns/kubernetes" readonly LAN_IP="192.168.0.107" readonly TAIL_IP="100.92.240.34" readonly TARGET_NODE="donghyeon-system-product-name" readonly BUSYBOX_IMAGE="docker.io/library/busybox:1.37.0@sha256:7a3ebe5bfd1a4a19797d20b0c0bb39d44393e9a03fd852c0865b0f540d868df0" readonly -a PRIVATE_HOSTS=(git.learn.hyeonworks.com id.learn.hyeonworks.com storage-admin.learn.hyeonworks.com db-admin.learn.hyeonworks.com grafana.learn.hyeonworks.com) readonly -a PUBLIC_PRIVATE_HOSTS=(storage-admin.learn.hyeonworks.com db-admin.learn.hyeonworks.com grafana.learn.hyeonworks.com) execute=false mutation=false rollback_armed=false temp="" lan_was_active=false tail_was_active=false lan_was_enabled=false tail_was_enabled=false fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } usage() { cat <<'USAGE' 사용법: bash scripts/bootstrap/apply-private-dns.sh bash scripts/bootstrap/apply-private-dns.sh --execute 인자 없이 실행하면 설정, 주소, 공개 DNS, manifest hash만 검사합니다. --execute는 두 dnsmasq 인스턴스와 coredns-custom을 적용합니다. 공유기 DHCP DNS와 Tailscale 관리 화면은 변경하지 않습니다. USAGE } if (( $# == 0 )); then : elif (( $# == 1 )) && [[ "$1" == "-h" || "$1" == "--help" ]]; then usage exit 0 elif (( $# == 1 )) && [[ "$1" == "--execute" ]]; then execute=true else usage >&2 exit 2 fi for cmd in awk dig find ip install journalctl kubectl rg sha256sum sleep ss stat systemctl systemd-analyze; do command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다" done [[ -x /usr/sbin/dnsmasq ]] || fail "/usr/sbin/dnsmasq가 없습니다" [[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요" for file in dnsmasq-lan.conf dnsmasq-tailscale.conf \ hyeonworks-dnsmasq-lan.service hyeonworks-dnsmasq-tailscale.service; do [[ -f "$HOST_SOURCE/$file" && ! -L "$HOST_SOURCE/$file" ]] || fail "후보 파일이 없습니다: $file" done /usr/sbin/dnsmasq --test --conf-file="$HOST_SOURCE/dnsmasq-lan.conf" /usr/sbin/dnsmasq --test --conf-file="$HOST_SOURCE/dnsmasq-tailscale.conf" systemd-analyze verify "$HOST_SOURCE/hyeonworks-dnsmasq-lan.service" \ "$HOST_SOURCE/hyeonworks-dnsmasq-tailscale.service" kubectl kustomize "$K8S_SOURCE" >"/tmp/private-dns-render.$$" trap 'rm -f -- "/tmp/private-dns-render.$$"' EXIT manifest_sha="$(sha256sum "/tmp/private-dns-render.$$" | awk '{print $1}')" ip -4 address show | rg -q -F "$LAN_IP/" || fail "LAN 주소 $LAN_IP가 호스트에 없습니다" ip -4 address show | rg -q -F "$TAIL_IP/" || fail "Tailscale 주소 $TAIL_IP가 호스트에 없습니다" [[ "$(kubectl get node "$TARGET_NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')" == True ]] || \ fail "대상 노드가 Ready가 아닙니다" for name in "${PUBLIC_PRIVATE_HOSTS[@]}"; do public_a="$(dig +short @1.1.1.1 A "$name" | tr -d '[:space:]')" public_aaaa="$(dig +short @1.1.1.1 AAAA "$name" | tr -d '[:space:]')" [[ -z "$public_a" && -z "$public_aaaa" ]] || fail "$name 공개 A/AAAA가 존재합니다" done printf 'Current context: %s\n' "$(kubectl config current-context)" printf 'LAN listener: %s:53\n' "$LAN_IP" printf 'Tail listener: %s:53\n' "$TAIL_IP" printf 'CoreDNS SHA-256: %s\n' "$manifest_sha" printf '공개 private-service A/AAAA: 없음\n' if [[ "$execute" == false ]]; then printf 'DRY RUN PASS: --execute를 지정하지 않아 변경하지 않았습니다.\n' exit 0 fi [[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다" context="$(kubectl config current-context)" api="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" printf 'Type APPLY %s to install private DNS: ' "$context" read -r answer [[ "$answer" == "APPLY $context" ]] || fail "취소했습니다" [[ "$(kubectl config current-context)" == "$context" ]] || fail "context가 바뀌었습니다" [[ "$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" == "$api" ]] || \ fail "API server가 바뀌었습니다" sudo -v managed_listeners_are_exact() { local lan_pid tail_pid line protocol local_address local lan_tcp=0 lan_udp=0 tail_tcp=0 tail_udp=0 local -a listener_lines=() sudo systemctl is-active --quiet hyeonworks-dnsmasq-lan.service || return 1 sudo systemctl is-active --quiet hyeonworks-dnsmasq-tailscale.service || return 1 lan_pid="$(sudo systemctl show hyeonworks-dnsmasq-lan.service --property MainPID --value)" || return 1 tail_pid="$(sudo systemctl show hyeonworks-dnsmasq-tailscale.service --property MainPID --value)" || return 1 [[ "$lan_pid" =~ ^[1-9][0-9]*$ && "$tail_pid" =~ ^[1-9][0-9]*$ ]] || return 1 sudo cmp -s "$HOST_SOURCE/dnsmasq-lan.conf" /etc/dnsmasq-hyeonworks/lan.conf || return 1 sudo cmp -s "$HOST_SOURCE/dnsmasq-tailscale.conf" /etc/dnsmasq-hyeonworks/tailscale.conf || return 1 sudo cmp -s "$HOST_SOURCE/hyeonworks-dnsmasq-lan.service" \ /etc/systemd/system/hyeonworks-dnsmasq-lan.service || return 1 sudo cmp -s "$HOST_SOURCE/hyeonworks-dnsmasq-tailscale.service" \ /etc/systemd/system/hyeonworks-dnsmasq-tailscale.service || return 1 mapfile -t listener_lines < <(sudo ss -H -lnupt '( sport = :53 )') || return 1 for line in "${listener_lines[@]}"; do read -r protocol _ _ _ local_address _ <<<"$line" case "$local_address" in "$LAN_IP:53") [[ "$line" == *"pid=$lan_pid,"* ]] || return 1 [[ "$protocol" == tcp ]] && lan_tcp=$((lan_tcp + 1)) [[ "$protocol" == udp ]] && lan_udp=$((lan_udp + 1)) ;; "$TAIL_IP:53") [[ "$line" == *"pid=$tail_pid,"* ]] || return 1 [[ "$protocol" == tcp ]] && tail_tcp=$((tail_tcp + 1)) [[ "$protocol" == udp ]] && tail_udp=$((tail_udp + 1)) ;; esac done (( lan_tcp == 1 && lan_udp == 1 && tail_tcp == 1 && tail_udp == 1 )) } if sudo ss -H -lntu '( sport = :53 )' | awk -v lan="$LAN_IP:53" -v tail="$TAIL_IP:53" \ '$5 == lan || $5 == tail {found=1} END {exit found ? 0 : 1}'; then if managed_listeners_are_exact; then printf '기존 exact managed DNS listener를 안전한 재적용 대상으로 확인했습니다.\n' else fail "대상 LAN/Tailscale 주소의 53번 포트를 exact managed listener가 아닌 프로세스가 사용 중입니다" fi fi umask 077 temp="$(mktemp -d /tmp/platform-private-dns.XXXXXX)" cm_existed=false sudo systemctl is-active --quiet hyeonworks-dnsmasq-lan.service && lan_was_active=true sudo systemctl is-active --quiet hyeonworks-dnsmasq-tailscale.service && tail_was_active=true [[ "$(sudo systemctl is-enabled hyeonworks-dnsmasq-lan.service 2>/dev/null || true)" == enabled ]] && lan_was_enabled=true [[ "$(sudo systemctl is-enabled hyeonworks-dnsmasq-tailscale.service 2>/dev/null || true)" == enabled ]] && tail_was_enabled=true [[ -e /etc/dnsmasq-hyeonworks ]] && sudo cp -a /etc/dnsmasq-hyeonworks "$temp/etc-dnsmasq" [[ -e /etc/systemd/system/hyeonworks-dnsmasq-lan.service ]] && \ sudo cp -a /etc/systemd/system/hyeonworks-dnsmasq-lan.service "$temp/lan.service" [[ -e /etc/systemd/system/hyeonworks-dnsmasq-tailscale.service ]] && \ sudo cp -a /etc/systemd/system/hyeonworks-dnsmasq-tailscale.service "$temp/tailscale.service" if kubectl -n kube-system get configmap coredns-custom -o yaml >"$temp/coredns-custom.yaml" 2>/dev/null; then cm_existed=true fi restore_service_state() { local unit=$1 was_active=$2 was_enabled=$3 if [[ "$was_enabled" == true ]]; then sudo systemctl enable "$unit" >/dev/null 2>&1 else sudo systemctl disable "$unit" >/dev/null 2>&1 || true fi if [[ "$was_active" == true ]]; then sudo systemctl start "$unit" >/dev/null 2>&1 else sudo systemctl stop "$unit" >/dev/null 2>&1 || true fi } rollback() { set +e printf '\nROLLBACK: private DNS 이전 상태를 복원합니다.\n' >&2 sudo systemctl disable --now hyeonworks-dnsmasq-lan.service \ hyeonworks-dnsmasq-tailscale.service >/dev/null 2>&1 if [[ -d "$temp/etc-dnsmasq" ]]; then sudo rm -rf -- /etc/dnsmasq-hyeonworks sudo cp -a "$temp/etc-dnsmasq" /etc/dnsmasq-hyeonworks else sudo rm -rf -- /etc/dnsmasq-hyeonworks fi for unit in lan tailscale; do target="/etc/systemd/system/hyeonworks-dnsmasq-$unit.service" if [[ -f "$temp/$unit.service" ]]; then sudo install -o root -g root -m 0644 "$temp/$unit.service" "$target" else sudo rm -f -- "$target" fi done sudo systemctl daemon-reload restore_service_state hyeonworks-dnsmasq-lan.service "$lan_was_active" "$lan_was_enabled" restore_service_state hyeonworks-dnsmasq-tailscale.service "$tail_was_active" "$tail_was_enabled" if [[ "$cm_existed" == true ]]; then kubectl apply -f "$temp/coredns-custom.yaml" >/dev/null else kubectl -n kube-system delete configmap coredns-custom --ignore-not-found >/dev/null fi kubectl -n kube-system rollout restart deployment/coredns >/dev/null kubectl -n kube-system rollout status deployment/coredns --timeout=90s >/dev/null rollback_armed=false printf 'ROLLBACK complete.\n' >&2 } finish() { rc=$? trap - EXIT INT TERM if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then rollback fi if [[ -n "$temp" ]]; then case "$temp" in /tmp/platform-private-dns.*) rm -rf -- "$temp" ;; esac fi rm -f -- "/tmp/private-dns-render.$$" exit "$rc" } trap finish EXIT trap 'exit 130' INT trap 'exit 143' TERM rollback_armed=true mutation=true sudo install -d -o root -g root -m 0755 /etc/dnsmasq-hyeonworks sudo install -o root -g root -m 0644 "$HOST_SOURCE/dnsmasq-lan.conf" /etc/dnsmasq-hyeonworks/lan.conf sudo install -o root -g root -m 0644 "$HOST_SOURCE/dnsmasq-tailscale.conf" /etc/dnsmasq-hyeonworks/tailscale.conf sudo install -o root -g root -m 0644 "$HOST_SOURCE/hyeonworks-dnsmasq-lan.service" \ /etc/systemd/system/hyeonworks-dnsmasq-lan.service sudo install -o root -g root -m 0644 "$HOST_SOURCE/hyeonworks-dnsmasq-tailscale.service" \ /etc/systemd/system/hyeonworks-dnsmasq-tailscale.service sudo systemctl daemon-reload sudo systemctl enable --now hyeonworks-dnsmasq-lan.service hyeonworks-dnsmasq-tailscale.service stable=0 for (( attempt=1; attempt<=20; attempt++ )); do if sudo systemctl is-active --quiet hyeonworks-dnsmasq-lan.service \ hyeonworks-dnsmasq-tailscale.service \ && [[ "$(dig +time=1 +tries=1 +short "@$LAN_IP" git.learn.hyeonworks.com A | tail -n1)" == "$LAN_IP" ]] \ && [[ "$(dig +time=1 +tries=1 +short "@$TAIL_IP" git.learn.hyeonworks.com A | tail -n1)" == "$TAIL_IP" ]]; then stable=$((stable + 1)) if (( stable >= 3 )); then break fi else stable=0 fi sleep 0.5 done if (( stable < 3 )); then for unit in hyeonworks-dnsmasq-lan.service hyeonworks-dnsmasq-tailscale.service; do sudo systemctl status "$unit" --no-pager -n 20 >&2 || true sudo journalctl -u "$unit" --no-pager -n 20 >&2 || true done fail "private DNS listener가 안정화되지 않았습니다" fi kubectl apply -f "/tmp/private-dns-render.$$" kubectl -n kube-system rollout restart deployment/coredns kubectl -n kube-system rollout status deployment/coredns --timeout=90s for resolver in "$LAN_IP" "$TAIL_IP"; do for name in "${PRIVATE_HOSTS[@]}"; do [[ "$(dig +short "@$resolver" A "$name" | tail -n1)" == "$resolver" ]] || fail "$name private DNS 검증 실패" done done pod="private-dns-smoke-$(date +%H%M%S)" kubectl -n default run "$pod" --restart=Never --image="$BUSYBOX_IMAGE" \ --labels=platform.hyeonworks.com/transient=true \ --command -- sh -c 'nslookup git.learn.hyeonworks.com >/dev/null && nslookup id.learn.hyeonworks.com >/dev/null && nslookup storage-admin.learn.hyeonworks.com >/dev/null && nslookup db-admin.learn.hyeonworks.com >/dev/null && nslookup grafana.learn.hyeonworks.com >/dev/null' kubectl -n default wait --for=jsonpath='{.status.phase}'=Succeeded "pod/$pod" --timeout=60s kubectl -n default delete "pod/$pod" --wait=true >/dev/null rollback_armed=false printf 'PRIVATE DNS APPLY SUCCESS\n' printf '공유기 DHCP DNS와 Tailscale split DNS는 문서에 따라 별도로 등록하세요.\n'