- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉) - tools/harden_pi.sh: 하드닝 스크립트 갱신 - docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서 - docs/claude-config: IoT 납품·검증 스킬/룰 참조본 - review/: 프로젝트 리뷰·UI v2 목업
142 lines
7.2 KiB
Bash
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 /home/pi/raspberry_pi4_pms7003/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)"
|