From d149ed68e5e1d68470c869424e6762d6e312edc2 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 21 May 2026 11:18:28 +0200 Subject: [PATCH] feat(harden): nftables default-drop firewall + first-boot password rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two image-side hardening pieces both small enough to ship together. deploy/nftables/nftables.conf — single ruleset installed at /etc/nftables.conf. Default-drop input. Allowed: loopback, established/related, ratelimited ICMP, kiosk local API :18090 from RFC1918 / RFC4193 / link-local sources only. SSH stays gated by sshd-disabled (image build sets enable-ssh: 0 and 01-run-chroot masks it); the firewall rule for :22 is left commented in for triage scenarios. Forward dropped. Output left wide open — kiosk needs to dial out to arbitrary RTSP cameras + the BF server (which may live on the public internet) without explicit allowlisting. deploy/systemd/betterframe-firstboot.{service,sh} — runs once per device before betterframe-kiosk starts. Generates a 24-char unambiguous-glyph password, applies via chpasswd, stores at /etc/betterframe/admin-password (0400 root), and prints a banner to tty1 so an HDMI-attached operator can transcribe it during the boot window before cage takes over the screen. Marker at /var/lib/betterframe/.firstboot-complete prevents re-run on subsequent boots. Without this, every kiosk built from the same image shipped with bfadmin:betterframe — a single password leak compromises the entire fleet. Future follow-up: post the rotated password (encrypted with cluster_key) to the BF server via heartbeat so admin UI can surface it. Not in this commit; the local file + tty banner are the only retrieval paths today. --- .github/workflows/build.yml | 8 ++ deploy/nftables/nftables.conf | 75 ++++++++++++++++++ .../00-install-packages/00-packages | 1 + .../01-install-kiosk/01-run-chroot.sh | 15 ++++ deploy/systemd/betterframe-firstboot.service | 19 +++++ deploy/systemd/betterframe-firstboot.sh | 76 +++++++++++++++++++ 6 files changed, 194 insertions(+) create mode 100644 deploy/nftables/nftables.conf create mode 100644 deploy/systemd/betterframe-firstboot.service create mode 100755 deploy/systemd/betterframe-firstboot.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 107f631..cefc058 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -197,6 +197,14 @@ jobs: deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/rauc-system.conf cp deploy/rauc/betterframe-rauc-boot.sh \ deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + # Firewall ruleset + cp deploy/nftables/nftables.conf \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/nftables.conf + # First-boot password rotation + cp deploy/systemd/betterframe-firstboot.service \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + cp deploy/systemd/betterframe-firstboot.sh \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ # CA cert is operator-supplied — generated locally via # scripts/gen-rauc-signing-keys.sh and committed at # deploy/rauc/ca-cert.pem. Without it the image installs but diff --git a/deploy/nftables/nftables.conf b/deploy/nftables/nftables.conf new file mode 100644 index 0000000..9779d6e --- /dev/null +++ b/deploy/nftables/nftables.conf @@ -0,0 +1,75 @@ +#!/usr/sbin/nft -f +# BetterFrame kiosk firewall. Default-drop inbound; only LAN can reach the +# kiosk local HTTP API. Outbound stays wide open — kiosk needs to pull RTSP +# from arbitrary cameras + reach the BF server (which may be on the public +# internet) without explicit allowlisting. +# +# Reload after edits: sudo nft -f /etc/nftables.conf + +flush ruleset + +table inet betterframe { + # --- helper sets ---------------------------------------------------------- + set rfc1918_v4 { + type ipv4_addr + flags interval + elements = { + 10.0.0.0/8, + 172.16.0.0/12, + 192.168.0.0/16, + 169.254.0.0/16 # link-local; on by default for DHCP fallback discovery + } + } + set rfc4193_v6 { + type ipv6_addr + flags interval + elements = { + fc00::/7, + fe80::/10 # link-local + } + } + + # --- input chain ---------------------------------------------------------- + chain input { + type filter hook input priority filter; policy drop; + + # Sanity: existing flows always pass. + ct state established,related accept + ct state invalid drop + + # Loopback — needed for any IPC, healthchecks, local-only services. + iifname "lo" accept + + # ICMP for diagnostics. Rate-limited so we don't help amplification. + ip protocol icmp limit rate 4/second accept + ip6 nexthdr icmpv6 limit rate 4/second accept + + # SSH never opens at the firewall — image build sets `enable-ssh: 0` and + # disables/masks sshd. If you intentionally re-enable it for triage, + # uncomment the next line and lock it down further: + # tcp dport 22 ip saddr @rfc1918_v4 accept + + # Kiosk local HTTP API (:18090) — LAN-only. Lets house-side automation + # hit `/local/layout/` + `/local/snapshot/` via the local_key. + # Public-internet traffic is dropped here so an exposed-port misconfig + # downstream (e.g. an upstream NAT/UPnP slip) doesn't open it to the + # world. + tcp dport 18090 ip saddr @rfc1918_v4 accept + tcp dport 18090 ip6 saddr @rfc4193_v6 accept + + # Everything else: drop (policy already covers this; explicit for clarity + # so future edits can insert above this comment without changing policy). + # counter drop # uncomment when debugging which packets are getting blocked + } + + # --- forward / output ----------------------------------------------------- + chain forward { + type filter hook forward priority filter; policy drop; + } + + chain output { + # Kiosk is the initiator for everything (RTSP pulls, BF server REST, + # firmware downloads). Allow all outbound. + type filter hook output priority filter; policy accept; + } +} diff --git a/deploy/pi-gen/stage-betterframe-client/00-install-packages/00-packages b/deploy/pi-gen/stage-betterframe-client/00-install-packages/00-packages index dc77fe1..bd71df9 100644 --- a/deploy/pi-gen/stage-betterframe-client/00-install-packages/00-packages +++ b/deploy/pi-gen/stage-betterframe-client/00-install-packages/00-packages @@ -17,3 +17,4 @@ wlr-randr ca-certificates rauc dosfstools +nftables diff --git a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh index fad0241..85f9aeb 100755 --- a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh +++ b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh @@ -45,6 +45,21 @@ else fi install -m 755 /tmp/bf-files/betterframe-rauc-boot.sh /usr/local/sbin/betterframe-rauc-boot.sh +# --- Firewall: default-drop inbound, LAN-only kiosk local API --- +# Replaces any default nftables config Debian ships. +install -m 644 /tmp/bf-files/nftables.conf /etc/nftables.conf +systemctl enable nftables.service + +# --- First-boot password rotation --- +# Replaces the image-default bfadmin password with a per-device random one +# the first time the kiosk boots. Stored at /etc/betterframe/admin-password +# (0400 root) AND printed to tty1 banner once. See script for details. +install -m 644 /tmp/bf-files/betterframe-firstboot.service \ + /etc/systemd/system/betterframe-firstboot.service +install -m 755 /tmp/bf-files/betterframe-firstboot.sh \ + /usr/local/sbin/betterframe-firstboot.sh +systemctl enable betterframe-firstboot.service + # Default env file — operator may edit on first boot to point at their server. cat > /etc/default/betterframe-kiosk <<'EOF' # Runtime env for betterframe-kiosk. Edit and `systemctl restart betterframe-kiosk`. diff --git a/deploy/systemd/betterframe-firstboot.service b/deploy/systemd/betterframe-firstboot.service new file mode 100644 index 0000000..5356bb8 --- /dev/null +++ b/deploy/systemd/betterframe-firstboot.service @@ -0,0 +1,19 @@ +[Unit] +Description=BetterFrame first-boot provisioning (rotate default password) +Documentation=https://github.com/BetterCorp/BetterFrame +ConditionPathExists=!/var/lib/betterframe/.firstboot-complete +DefaultDependencies=no +After=local-fs.target +Before=getty.target multi-user.target betterframe-kiosk.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/betterframe-firstboot.sh +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=no + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/betterframe-firstboot.sh b/deploy/systemd/betterframe-firstboot.sh new file mode 100755 index 0000000..2abb59a --- /dev/null +++ b/deploy/systemd/betterframe-firstboot.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Runs ONCE per device on first boot, before betterframe-kiosk starts. +# Purpose: replace the image's baked-in bfadmin password with a per-device +# random one so a leaked image (or shared install) doesn't share creds +# across kiosks. +# +# Outputs: +# /etc/betterframe/admin-password (mode 0400, root) — plaintext for now +# /run/betterframe/firstboot-banner — printed to tty1 so an HDMI-attached +# operator can read the new password +# once. Cleared on next boot. +# /var/lib/betterframe/.firstboot-complete — marker, blocks re-run. +# +# Future: post the password (encrypted with cluster_key) to the BF server +# via the heartbeat so admins can fetch it from the kiosk detail page. +# Until that's wired the file + tty banner are the only ways out, and the +# operator is expected to read the banner once or pull the file via the +# HDMI-attached console. +set -euo pipefail + +MARKER_DIR="/var/lib/betterframe" +MARKER="${MARKER_DIR}/.firstboot-complete" +PASSWORD_FILE="/etc/betterframe/admin-password" +BANNER_DIR="/run/betterframe" +BANNER="${BANNER_DIR}/firstboot-banner" + +if [ -f "$MARKER" ]; then + exit 0 +fi + +# 32 chars of base64 ≈ 24 bytes entropy. tr away ambiguous glyphs so the +# operator transcribing from a TV screen has a fighting chance. +NEW_PW="$(LC_ALL=C tr -dc 'A-HJ-NP-Za-km-z2-9' < /dev/urandom | head -c 24)" + +mkdir -p "$MARKER_DIR" +mkdir -p "$(dirname "$PASSWORD_FILE")" +mkdir -p "$BANNER_DIR" + +# Apply the new password atomically. +printf 'bfadmin:%s\n' "$NEW_PW" | chpasswd + +# Persist for first-time retrieval. 0400 root-only so bfkiosk + bfadmin +# can't peek at each other's creds. Operator who needs it can `sudo cat`. +umask 077 +printf '%s\n' "$NEW_PW" > "$PASSWORD_FILE" +chmod 0400 "$PASSWORD_FILE" +chown root:root "$PASSWORD_FILE" + +# Banner printed to tty1 so the operator can read it once before the kiosk +# takes over the screen. cage masks /dev/tty1 when betterframe-kiosk +# starts, so the banner is only visible during the boot window. +cat > "$BANNER" < /dev/tty1 || true +fi + +# Marker last so a crash mid-script doesn't leave us with a half-applied +# state that blocks the next boot's rerun. +touch "$MARKER" + +exit 0