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()