From dba756f3a68fc5e4f64ca8bd17449deef3700d7a Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sun, 4 Oct 2026 23:15:56 -0700 Subject: [PATCH] Keep ROS traffic off the LAN; load the firewall at boot DDS has no authentication, and /camera/* and /vision/faces carry video and recognised names. Discovery now defaults to this machine only (ROBOT_ROS_DISCOVERY=SUBNET to opt in; a ${VAR:-default} on ROS's own variable never applied, since its setup script already exports SUBNET), and the firewall drops domain 42's DDS ports on every interface but usb0 and loopback. The detector's startup log prints a count of enrolled people, not their names. robot-firewall.service loads the rules before networking at every boot: they had only been loaded at runtime and were gone after each reboot, which has to be fixed before the PipeWire network server opens port 4713. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs --- pi/install-firewall.sh | 12 ++++++++++++ pi/nftables-robot.nft | 4 ++++ pi/robot-env.sh | 7 +++++-- pi/robot-firewall.service | 15 +++++++++++++++ .../hermes_vision/face_detector_node.py | 4 ++-- 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100755 pi/install-firewall.sh create mode 100644 pi/robot-firewall.service diff --git a/pi/install-firewall.sh b/pi/install-firewall.sh new file mode 100755 index 0000000..1c7ac73 --- /dev/null +++ b/pi/install-firewall.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Load the robot firewall at every boot, before networking. Run as root from this directory. +# Re-runnable. Do this BEFORE enabling the PipeWire network server (4713 on all interfaces). +set -euo pipefail +cd "$(dirname "$0")" +install -D -m 644 nftables-robot.nft /usr/local/etc/robot-firewall.nft +nft -c -f /usr/local/etc/robot-firewall.nft # refuse to install a broken ruleset +install -m 644 robot-firewall.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable robot-firewall.service +systemctl restart robot-firewall.service +nft list table inet robot diff --git a/pi/nftables-robot.nft b/pi/nftables-robot.nft index c52aa84..5fb5979 100644 --- a/pi/nftables-robot.nft +++ b/pi/nftables-robot.nft @@ -1,6 +1,9 @@ #!/usr/sbin/nft -f # The robot's audio (4713), MCP (8765) and face webhook (8766) ports are reachable only over the # USB link, and only from the host at 10.55.0.1. Every other interface, Wi-Fi included, is dropped. +# ROS 2's DDS has no authentication, and /camera/* and /vision/faces carry video and recognised +# names, so its ports (domain 42: 17900-18149 with Fast DDS's default offsets) are dropped on every +# interface but usb0 and loopback too. # SSH and everything else are left alone: that policy is the owner's call. table inet robot @@ -12,5 +15,6 @@ table inet robot { iif "lo" accept iifname "usb0" ip saddr 10.55.0.1 tcp dport { 4713, 8765, 8766 } accept tcp dport { 4713, 8765, 8766 } drop + iifname != { "lo", "usb0" } udp dport 17900-18149 drop } } diff --git a/pi/robot-env.sh b/pi/robot-env.sh index cbd508c..d56aa29 100644 --- a/pi/robot-env.sh +++ b/pi/robot-env.sh @@ -3,11 +3,14 @@ # - the Raspberry Pi libcamera fork ahead of ROS's own copy (same soname, but ROS's crashes in the # Pi image plugin: see CONTEXT.md) # - ~/venv's site-packages first: Blinka/Adafruit for the display, OpenCV 4.10 for YuNet -# - a fixed ROS domain and subnet-only discovery, matching the host-side podman shell +# - a fixed ROS domain (42), discovery on this machine only. DDS has no authentication and the +# camera and recognised names travel on it: set ROBOT_ROS_DISCOVERY=SUBNET only to +# reach it from a host over the USB link (the firewall drops DDS ports on every other interface) source /opt/ros/jazzy/setup.bash [ -f "$HOME/camera_ws/install/setup.bash" ] && source "$HOME/camera_ws/install/setup.bash" [ -f "$HOME/ros_ws/install/setup.bash" ] && source "$HOME/ros_ws/install/setup.bash" export LD_LIBRARY_PATH="/opt/libcamera-rpi/lib/aarch64-linux-gnu:${LD_LIBRARY_PATH:-}" export PYTHONPATH="$HOME/venv/lib/python3.12/site-packages:${PYTHONPATH:-}" export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-42}" -export ROS_AUTOMATIC_DISCOVERY_RANGE="${ROS_AUTOMATIC_DISCOVERY_RANGE:-SUBNET}" +# ROS's own setup script exports SUBNET, so a ${VAR:-default} on the ROS variable would never apply +export ROS_AUTOMATIC_DISCOVERY_RANGE="${ROBOT_ROS_DISCOVERY:-LOCALHOST}" diff --git a/pi/robot-firewall.service b/pi/robot-firewall.service new file mode 100644 index 0000000..4c4acbe --- /dev/null +++ b/pi/robot-firewall.service @@ -0,0 +1,15 @@ +[Unit] +Description=Robot firewall: audio, MCP and webhook ports only over the USB link +Before=network-pre.target +Wants=network-pre.target +DefaultDependencies=no +After=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/sbin/nft -f /usr/local/etc/robot-firewall.nft +ExecStop=/usr/sbin/nft delete table inet robot + +[Install] +WantedBy=multi-user.target diff --git a/ros/hermes_vision/hermes_vision/face_detector_node.py b/ros/hermes_vision/hermes_vision/face_detector_node.py index 644d9a9..18b03b2 100644 --- a/ros/hermes_vision/hermes_vision/face_detector_node.py +++ b/ros/hermes_vision/hermes_vision/face_detector_node.py @@ -41,9 +41,9 @@ class FaceDetectorNode(Node): if self.debug else None) self.create_subscription(CompressedImage, topic, self._on_image, 1) self._next = 0.0 - who = ', '.join(self.recognizer.enrolled) if self.recognizer else 'off' + n = len(self.recognizer.enrolled) if self.recognizer else None # a count, not names: logs persist self.get_logger().info(f'face detector up: {self.detector.backend}, {1 / self.period:g} Hz, {topic}; ' - f'recognising: {who or "nobody enrolled yet"}') + f'recognition: {"off" if n is None else f"{n} person(s) enrolled"}') def _on_image(self, msg: CompressedImage) -> None: now = time.monotonic()