POSA_LEAKSMS/tools/harden_pi.sh
cw2007 a72ea18130 i18n(pi): Raspberry Pi 번들 소스 한글 → 영어 (콘솔 mojibake 해소)
헤드리스 Pi 콘솔/journalctl에서 한글이 깨져 보이는 문제로, Pi 번들(02/03/04)에 들어가는 모든 소스의 사람-대상 텍스트를 영어로 전환. 코드·식별자·설정키·경로·숫자·hex·서명 스킴·f-string 플레이스홀더는 불변.

- raspberry_pi4/, raspberry_pi4_sht30/: 로그·주석·문서·센서명·위치 영어화
- tools/make_deploy_bundles.py: 생성 템플릿(config_local.py/setup.sh/SETUP.md/PI_META 위치) 영어화
- tools/harden_pi.sh, tools/set_network.sh: echo/주석 영어화
- WIRING.html: 영어화 + SHT30 전압 경고 정정 반영
- 서버 번들(01, php/)은 한글 유지(브라우저 대시보드, Pi에서 안 깨짐)
- 전 파이썬/셸 문법검사 통과, JSON 본문 서명 무영향
2026-07-10 10:44:13 +09:00

142 lines
7.2 KiB
Bash

#!/bin/bash
# =============================================================================
# harden_pi.sh — Raspberry Pi OS (Bookworm) minimal hardening (common to sensor nodes)
#
# Policy (finalized 2026-07):
# - SSH fully disabled — this system is outbound-only (Pi → server HTTPS POST).
# Maintenance is via on-site physical access (monitor + keyboard). To re-enable,
# create an empty file `ssh` in the SD card's boot partition and boot (physical access required — intended).
# - All inbound blocked (nftables); outbound allowed (apt/NTP/server POST).
# - Automatic security patches (unattended-upgrades) on; automatic reboot off (avoids sensor gaps).
#
# Execution order (important): disabling SSH is the "very last" step. If a problem occurs in an
# earlier failure-prone step (package install / firewall / permissions), set -e aborts while
# SSH is still alive, allowing remote debugging. SSH is cut only after all prior steps succeed.
#
# Air-gapped: if the vendor.sh output debs/ is in the same folder, nftables and
# unattended-upgrades are also installed offline (--no-download). Otherwise, online apt.
# (vendor.sh pre-downloads these two packages too and includes them in debs/.)
#
# Run: at the very "last" moment, after module install (install.sh) and verification (test_connection.py) are all done.
# sudo bash harden_pi.sh
# ⚠️ If running over an SSH connection, the session drops at the last step and cannot reconnect.
# =============================================================================
set -euo pipefail
[ "$(id -u)" -eq 0 ] || { echo "[ERROR] Run with sudo: sudo bash harden_pi.sh"; exit 1; }
# Move cwd to the script folder so debs/ is found relative to the script location (irrelevant to absolute-path logic).
cd "$(dirname "$0")" 2>/dev/null || true
# Detect presence of offline bundle (debs/) — same rule as install.sh.
OFFLINE_DEBS=0
if compgen -G "./debs/*.deb" >/dev/null 2>&1; then
OFFLINE_DEBS=1
echo "[INFO] Offline bundle (debs/) detected → package install uses no network (--no-download)"
fi
# Ensure required packages: skip if already installed, otherwise offline debs/ → online apt, in that order.
# Re-verify after install and abort immediately on failure (SSH still alive).
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 " (${*} already installed — skip)"
elif [ "${OFFLINE_DEBS}" = 1 ]; then
echo " Installing from offline debs/ (no network): ${missing[*]}"
# --no-install-recommends: since vendor.sh does not put Recommends in the bundle (--no-recommends),
# match that at install time by not requiring Recommends (air-gapped determinism — prevents network fallback).
apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " Online apt install: ${missing[*]}"
# In case of a standalone online run (local apt list may be empty). No-op when run after install.sh.
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' install failed. If air-gapped, include it in vendor.sh (debs/) and redeploy," >&2
echo " or if online, check the network. (SSH is not yet disabled — recovery possible)" >&2
exit 1
}
done
}
echo "[1/6] Firewall (nftables) — block all inbound..."
ensure_pkgs nftables
cat > /etc/nftables.conf <<'NFT'
#!/usr/sbin/nft -f
# Sensor node firewall — outbound-only device. Inbound is loopback/response traffic only.
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
# Allow ping for on-site diagnostics (delete the 2 lines below if not wanted)
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] Automatic security patches (unattended-upgrades)..."
ensure_pkgs unattended-upgrades
# Raspberry Pi OS isn't covered by the default Origins pattern (Debian-only) — add Raspbian/RPF origins.
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";
};
// No automatic reboot — avoid creating a sensor gap even after kernel patches (manual reboot).
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] Tightening local permissions..."
# Secret files owner-only (contains API_KEY). If not found, warn instead of silently skipping.
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 not found at standard paths — if the deploy path differs," \
"lock it manually: chmod 600 <path>/config_local.py (prevent API_KEY exposure)"
# Disable console auto-login (physical console requires login too)
if command -v raspi-config >/dev/null 2>&1; then
raspi-config nonint do_boot_behaviour B1 || true
fi
echo "[4/6] Disabling unnecessary listening/peripheral services..."
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] Pre-verification capture (before SSH disable — firewall/listening state)..."
echo "-- Listening sockets (should be empty or local-only):"
ss -lntup || true
echo "-- nftables input chain (verify policy drop):"
# || true: under pipefail, even if head closes the pipe first and nft dies with SIGPIPE(141),
# keep this verification step from aborting (prevents a false-positive abort right before SSH disable).
nft list chain inet filter input | head -12 || true
echo "[6/6] Fully disable SSH (last — hard-to-reverse step)..."
# If we got here, all prior steps succeeded. Now cut SSH.
# Leave the re-enable path via the boot partition `ssh` file (sshswitch) in place — it can't be
# used without physical access, and is useful as an on-site recovery mechanism.
systemctl disable --now ssh.service ssh.socket 2>/dev/null || true
echo "-- ssh status (should not be enabled):"
systemctl is-enabled ssh.service 2>&1 || true
echo ""
echo "Done. Capture the [5/6]·[6/6] output above and keep it as a security record."
echo "Reboot recommended: sudo reboot (then re-check ss -lntup)"