#!/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 /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)"