Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions .github/workflows/ros_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,28 @@ jobs:

- name: Run build and tests inside container
run: |
docker run --rm dicom_to_ros:ci bash -c "
source /opt/ros/jazzy/setup.bash &&
colcon build &&
colcon test --event-handlers=console_direct+ &&
colcon test-result
"
docker run --rm \
--workdir /ros2_ws \
-v ${{ github.workspace }}/dicom_interfaces:/ros2_ws/src/dicom_interfaces \
-v ${{ github.workspace }}/dicom_to_ros:/ros2_ws/src/dicom_to_ros \
-v keystore_ci:/ros2_ws/keystore \
-e ROS_SECURITY_ENABLE=true \
-e ROS_SECURITY_STRATEGY=Enforce \
-e ROS_SECURITY_KEYSTORE=/ros2_ws/keystore \
dicom_to_ros:ci bash -c "
set -e
echo '--- Generating Keystore ---'
./src/dicom_to_ros/dicom_to_ros/security/generate_keystore.sh

echo '--- Building Workspace ---'
source /opt/ros/jazzy/setup.bash
colcon build --symlink-install

echo '--- Running Tests ---'
source ./install/setup.bash
colcon test --event-handlers=console_direct+
colcon test-result
"

volumes:
keystore_ci:
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ build/
install/
log/

# ROS 2 Security
keystore/
sros2_keystore/

# Environment variables
.env

# IDE / Editor
.vscode/
.idea/
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ The `docker/` directory runs the ROS 2 DICOM listener and all processing nodes.
git clone https://github.com/Ekumen-OS/dicom_to_ros.git
cd dicom_to_ros

export UID=$(id -u)
export GID=$(id -g)
docker compose -f docker/docker-compose.yml up --build -d
```
Expand All @@ -182,6 +183,60 @@ The pipeline starts immediately and listens for DICOM C-STORE requests on port `

---

## Security

This project supports **ROS 2 Security (SROS2)** to protect sensitive patient data transmitted over the network. When enabled, SROS2 provides authentication, encryption, and access control for all internal topics, including the raw `/dicom_interfaces/Dicom` message and the derived `/dicom_study_info` topic.

### Enabling Security

Security is enabled by default in the provided `docker-compose.yml` file via environment variables. On the first run, a script generates a security keystore containing the necessary keys and certificates for all pipeline nodes. This keystore is persisted in a Docker volume named `keystore`.

> **Note:** The initial key generation is a one-time process. If the container is stopped during this first run, the security volume may be left in a corrupted state. If this happens, you will need to manually remove the Docker volume (`docker volume rm <project>_keystore`) before restarting the container.

### Accessing Secured Topics from Your Application

By default, only the internal `dicom_to_ros` nodes can communicate. To grant your own ROS 2 node access to a secured topic (e.g., `/dicom_study_info`):

1. **Add a Profile to the Policy:** Open `dicom_to_ros/dicom_to_ros/security/permissions.xml` and add a new `<profile>` for your node inside the `<profiles>` block. You must grant it permissions to the system topics (`/rosout`, `/parameter_events`) and services (`~/*`) in addition to any data topics.

*Example for a node named `my_subscriber` that needs to read study info:*
```xml
<profile ns="/dicom_to_ros" node="my_subscriber">
<topics subscribe="ALLOW">
<topic>/dicom_to_ros/dicom_study_info</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>~/*</service>
</services>
</profile>
```
2. **Regenerate the Keystore:** The security artifacts must be regenerated to include your new node. Bring down the pipeline and remove the old keystore volume.
```bash
docker compose -f docker/docker-compose.yml down -v
```
3. **Restart the Pipeline:** The entrypoint script will automatically generate a new keystore that includes keys and permissions for your node.
```bash
docker compose -f docker/docker-compose.yml up -d
```
4. **Launch Your Node:** When you launch your application, ensure it joins the correct namespace and enclave.
```python
# Example in a Python launch file
Node(
package='my_package',
executable='my_subscriber_node',
name='my_subscriber',
namespace='/dicom_to_ros',
ros_arguments=['--enclave', '/dicom_to_ros']
)
```

---

## Demo

For a more detailed demo, please check the [dicom_to_ros_demo](dicom_to_ros_demo/README.md) folder.
Expand Down
62 changes: 62 additions & 0 deletions dicom_to_ros/dicom_to_ros/security/generate_keystore.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/bin/bash
set -e

SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )

# This script generates the ROS 2 security keystore and keys for all nodes
# in the dicom_to_ros pipeline.


KEYSTORE_DIR="/ros2_ws/keystore"

# Check if the final permissions file exists and if the policy has not changed.
POLICY_FILE="$SCRIPT_DIR/permissions.xml"
CHECKSUM_FILE="$KEYSTORE_DIR/permissions.md5"

if [ -f "$KEYSTORE_DIR/enclaves/dicom_to_ros/permissions.p7s" ]; then
if [ -f "$CHECKSUM_FILE" ] && md5sum -c --status "$CHECKSUM_FILE"; then
echo "Complete and up-to-date keystore found. Skipping generation."
exit 0
else
echo "Permissions policy has changed or checksum is missing. Regenerating keystore..."
rm -rf "$KEYSTORE_DIR"/*
fi
fi

# If the keystore is incomplete, check if the directory is not empty.
# An incomplete keystore in a non-empty directory represents a corrupted state.
if [ -d "$KEYSTORE_DIR" ] && [ "$(ls -A $KEYSTORE_DIR)" ]; then
echo "############################################################################" >&2
echo "## ERROR: Incomplete or Corrupted Security Keystore Detected! ##" >&2
echo "############################################################################" >&2
echo "This can happen if the container was stopped during initial key generation." >&2
echo "" >&2
echo "To fix this, please run the following commands from your host machine:" >&2
echo " 1. docker compose -f docker/docker-compose.yml down" >&2
echo " 2. docker volume rm <project-name>_keystore" >&2
echo " (e.g., 'docker volume rm docker_keystore' if you ran compose from the 'docker' directory)" >&2
echo " 3. Restart the container." >&2
echo "############################################################################" >&2
exit 1
fi

echo "Empty keystore volume detected. Generating fresh security artifacts..."

TMP_KEYSTORE_DIR=$(mktemp -d)
ros2 security create_keystore "$TMP_KEYSTORE_DIR"

# Move the generated CA and initial files into the actual keystore volume
mv "$TMP_KEYSTORE_DIR"/* "$KEYSTORE_DIR"/
rm -rf "$TMP_KEYSTORE_DIR"

ENCLAVE_NAME="/dicom_to_ros"

echo "Creating enclave and generating keys for all nodes from policy file..."

ros2 security create_enclave "$KEYSTORE_DIR" "$ENCLAVE_NAME"

echo "Creating signed permissions for enclave: $ENCLAVE_NAME"
ros2 security create_permission "$KEYSTORE_DIR" "$ENCLAVE_NAME" "$SCRIPT_DIR/permissions.xml"

# Store a checksum of the policy file to detect future changes.
md5sum "$POLICY_FILE" > "$CHECKSUM_FILE"
155 changes: 155 additions & 0 deletions dicom_to_ros/dicom_to_ros/security/permissions.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8"?>
<policy version="0.2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="ros_security_policy.xsd">
<enclaves>
<enclave path="/dicom_to_ros">
<!--
This profile grants permissions to the core nodes of the dicom_to_ros pipeline.
It follows a principle of least privilege, where each node only has access
to the topics it needs to function.
-->
<profiles>
<profile ns="/dicom_to_ros" node="dicom_server">
<topics publish="ALLOW">
<topic>/dicom_interfaces/Dicom</topic>
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<topics subscribe="ALLOW">
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>~/*</service>
</services>
</profile>

<profile ns="/dicom_to_ros" node="dicom2studyinfo">
<topics subscribe="ALLOW">
<topic>/dicom_interfaces/Dicom</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/dicom_to_ros/dicom_study_info</topic>
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>~/*</service>
</services>
</profile>

<profile ns="/dicom_to_ros" node="dicom2img">
<topics subscribe="ALLOW">
<topic>/dicom_interfaces/Dicom</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/dicom_to_ros/dicom_image</topic>
<topic>/dicom_to_ros/dicom_camera_info</topic>
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>~/*</service>
</services>
</profile>

<profile ns="/dicom_to_ros" node="dicom2video">
<topics subscribe="ALLOW">
<topic>/dicom_interfaces/Dicom</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/dicom_to_ros/dicom_video_frames</topic>
<topic>/dicom_to_ros/dicom_video_camera_info</topic>
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>~/*</service>
</services>
</profile>

<profile ns="/dicom_to_ros" node="dicom2pcl">
<topics subscribe="ALLOW">
<topic>/dicom_interfaces/Dicom</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/dicom_to_ros/dicom_point_cloud</topic>
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>~/*</service>
</services>
</profile>

<profile ns="/dicom_to_ros" node="dicom2tf">
<topics subscribe="ALLOW">
<topic>/dicom_interfaces/Dicom</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/tf</topic>
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>~/*</service>
</services>
</profile>

<!-- Allow RViz's dynamically generated hidden TF nodes -->
<!-- 1. RViz Main Node -->
<profile ns="/dicom_to_ros" node="rviz2">
<topics subscribe="ALLOW">
<topic>/dicom_to_ros/dicom_image</topic>
<topic>/dicom_to_ros/dicom_video_frames</topic>
<topic>/dicom_to_ros/dicom_point_cloud</topic>
<topic>/tf</topic>
<topic>/tf_static</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/rosout</topic>
<topic>/parameter_events</topic>

<topic>/initialpose</topic>
<topic>/goal_pose</topic>
<topic>/clicked_point</topic>

<topic>/dicom_to_ros/initialpose</topic>
<topic>/dicom_to_ros/goal_pose</topic>
<topic>/dicom_to_ros/clicked_point</topic>
</topics>
<services reply="ALLOW">
<service>/*</service>
</services>
<services request="ALLOW">
<service>/*</service>
</services>
</profile>

<!-- 2. RViz Hidden TF Nodes -->
<profile ns="/dicom_to_ros" node="transform_listener_impl_*">
<topics subscribe="ALLOW">
<topic>/tf</topic>
<topic>/tf_static</topic>
<topic>/parameter_events</topic>
</topics>
<topics publish="ALLOW">
<topic>/rosout</topic>
<topic>/parameter_events</topic>
</topics>
<services reply="ALLOW">
<service>/*</service>
</services>
<services request="ALLOW">
<service>/*</service>
</services>
</profile>
</profiles>

</enclave>
</enclaves>
</policy>
Loading