#!/bin/bash # ============================================================================= # harden_pi.sh — Raspberry Pi OS (Bookworm) 최소 하드닝 (센서 노드 공통) # # 정책 (2026-07 확정): # - SSH 완전 비활성 — 이 시스템은 outbound 전용(Pi → 서버 HTTPS POST). # 유지보수는 현장 물리 접근(모니터+키보드). 재활성이 필요하면 SD 카드의 # boot 파티션에 빈 파일 `ssh` 를 만들고 부팅한다(물리 접근 필요 — 의도됨). # - 인바운드 전부 차단(nftables), 아웃바운드는 허용(apt/NTP/서버 POST). # - 자동 보안 패치(unattended-upgrades) 켬, 자동 리부팅은 끔(센서 공백 방지). # # 실행 순서(중요): SSH 차단은 "맨 마지막" 단계다. 그 앞의 실패하기 쉬운 단계 # (패키지 설치·방화벽·권한)에서 문제가 나면 set -e 로 SSH 가 살아있는 상태에서 # 중단되어 원격 디버깅이 가능하다. 모든 앞 단계가 성공해야만 SSH 를 끊는다. # # 폐쇄망(air-gapped): 같은 폴더에 vendor.sh 산출물 debs/ 가 있으면 nftables· # unattended-upgrades 도 오프라인(--no-download)으로 설치한다. 없으면 온라인 apt. # (vendor.sh 가 이 두 패키지도 미리 받아 debs/ 에 포함한다.) # # 실행: 모듈 설치(install.sh)와 검증(test_connection.py)이 전부 끝난 "마지막"에. # sudo bash harden_pi.sh # ⚠️ SSH 로 접속해 실행 중이면 마지막 단계에서 세션이 끊기고 재접속 불가. # ============================================================================= set -euo pipefail [ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash harden_pi.sh"; exit 1; } # debs/ 를 스크립트 위치 기준으로 찾도록 cwd 를 스크립트 폴더로 이동(절대경로 로직엔 무관). cd "$(dirname "$0")" 2>/dev/null || true # 오프라인 번들(debs/) 유무 판정 — install.sh 와 동일한 규칙. OFFLINE_DEBS=0 if compgen -G "./debs/*.deb" >/dev/null 2>&1; then OFFLINE_DEBS=1 echo "[INFO] 오프라인 번들(debs/) 감지 → 패키지 설치는 네트워크 미사용(--no-download)" fi # 필요 패키지 확보: 이미 설치돼 있으면 skip, 아니면 오프라인 debs/ → 온라인 apt 순. # 설치 후 재검증하여 실패 시 즉시 중단(SSH 아직 살아있음). ensure_pkgs() { local missing=() p for p in "$@"; do dpkg -s "$p" >/dev/null 2>&1 || missing+=("$p"); done if [ "${#missing[@]}" -eq 0 ]; then echo " (${*} 이미 설치됨 — skip)" elif [ "${OFFLINE_DEBS}" = 1 ]; then echo " 오프라인 debs/ 에서 설치 (네트워크 미사용): ${missing[*]}" # --no-install-recommends: vendor.sh 가 Recommends 를 번들에 안 담으므로(--no-recommends) # 설치 때도 Recommends 를 요구하지 않게 맞춘다(폐쇄망 결정성 — 네트워크 폴백 방지). apt-get install -y --no-install-recommends --no-download ./debs/*.deb else echo " 온라인 apt 설치: ${missing[*]}" # 단독 온라인 실행 대비(로컬 apt 목록이 비어있을 수 있음). install.sh 뒤면 no-op. apt-get update -q || true apt-get install -y -q "${missing[@]}" fi for p in "$@"; do dpkg -s "$p" >/dev/null 2>&1 || { echo "[ERROR] '$p' 설치 실패. 폐쇄망이면 vendor.sh(debs/)에 포함해 재배포하거나," >&2 echo " 온라인이면 네트워크를 확인하세요. (SSH 는 아직 차단되지 않음 — 복구 가능)" >&2 exit 1 } done } echo "[1/6] 방화벽(nftables) — 인바운드 전부 차단..." ensure_pkgs nftables cat > /etc/nftables.conf <<'NFT' #!/usr/sbin/nft -f # 센서 노드 방화벽 — outbound 전용 장비. 인바운드는 루프백/응답 트래픽만. flush ruleset table inet filter { chain input { type filter hook input priority 0; policy drop; iif "lo" accept ct state established,related accept # 현장 진단용 ping 허용 (원치 않으면 아래 2줄 삭제) ip protocol icmp icmp type echo-request accept ip6 nexthdr ipv6-icmp accept } chain forward { type filter hook forward priority 0; policy drop; } chain output { type filter hook output priority 0; policy accept; } } NFT systemctl enable --now nftables nft -f /etc/nftables.conf echo "[2/6] 자동 보안 패치(unattended-upgrades)..." ensure_pkgs unattended-upgrades # Raspberry Pi OS 는 기본 Origins 패턴(Debian 전용)에 안 걸린다 — Raspbian/RPF origin 추가. cat > /etc/apt/apt.conf.d/51unattended-upgrades-raspi <<'APT' Unattended-Upgrade::Origins-Pattern { "origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; "origin=Raspbian,codename=${distro_codename},label=Raspbian"; "origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation"; }; // 자동 리부팅 금지 — 커널 패치 후에도 센서 공백을 만들지 않는다(수동 리부팅). Unattended-Upgrade::Automatic-Reboot "false"; APT cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT' APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; APT systemctl enable --now unattended-upgrades 2>/dev/null || true echo "[3/6] 로컬 권한 조이기..." # 시크릿 파일 소유자 전용 (API_KEY 포함). 못 찾으면 조용히 넘기지 말고 경고. SECRET_FOUND=0 for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py; do if [ -f "$f" ]; then chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; SECRET_FOUND=1; fi done [ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py 를 표준 경로에서 못 찾음 — 배치 경로가 다르면" \ "수동으로 잠그세요: chmod 600 <경로>/config_local.py (API_KEY 노출 방지)" # 콘솔 자동 로그인 비활성 (물리 콘솔도 로그인 요구) if command -v raspi-config >/dev/null 2>&1; then raspi-config nonint do_boot_behaviour B1 || true fi echo "[4/6] 불필요 리스닝/주변 서비스 비활성..." for svc in avahi-daemon.service avahi-daemon.socket bluetooth.service hciuart.service \ triggerhappy.service triggerhappy.socket cups.service cups.socket \ cups-browsed.service ModemManager.service; do systemctl disable --now "$svc" 2>/dev/null || true done echo "[5/6] 사전 검증 캡처 (SSH 차단 전 — 방화벽/리스닝 상태)..." echo "-- 리스닝 소켓 (비어 있거나 로컬 전용이어야 함):" ss -lntup || true echo "-- nftables input chain (policy drop 확인):" # || true: pipefail 하에서 head 가 파이프를 먼저 닫아 nft 가 SIGPIPE(141) 로 죽어도 # 이 검증 단계가 중단되지 않게 한다(SSH 차단 직전 단계라 오탐 중단 방지). nft list chain inet filter input | head -12 || true echo "[6/6] SSH 완전 비활성 (마지막 — 되돌리기 어려운 단계)..." # 여기까지 왔다면 앞의 모든 단계가 성공한 것. 이제 SSH 를 끊는다. # boot 파티션 `ssh` 파일에 의한 재활성 경로(sshswitch)는 남겨둔다 — 물리 접근 # 없이는 사용할 수 없고, 현장 복구 수단으로 유용하다. systemctl disable --now ssh.service ssh.socket 2>/dev/null || true echo "-- ssh 상태 (enabled 가 아니어야 함):" systemctl is-enabled ssh.service 2>&1 || true echo "" echo "완료. 위 [5/6]·[6/6] 출력을 캡처해 보안 증적으로 보관할 것." echo "리부팅 권장: sudo reboot (이후 ss -lntup 재확인)"