Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions .github/workflows/docker-publish-multiarch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ jobs:
# `latest` belongs to docker-publish.yml, which moves it on
# every push to main; without this the action's default
# `latest=auto` would have a release take that tag over.
#
# The split of the rest: this workflow owns the semver tags
# and sha-<sha7>, all of them naming the multi-arch manifest
# list. docker-publish.yml owns `latest` and main-<sha7>,
# both naming its linux/amd64 image. No tag name is written
# by both, so a release tag cut on a commit that is also on
# main cannot leave one name pointing at two artefacts.
flavor: latest=false
tags: |
# version tag (0.7.0 -> 0.7.0)
Expand Down
29 changes: 25 additions & 4 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,31 @@ jobs:
python3 src/ros2_medkit_plugins/ros2_medkit_opcua/test/inspect_build_variant.py \
"$RUNNER_TEMP/plugin-from-image.so" --expect read-only

# :latest is created from the inspected digest, so the tag resolves
# to the artifact the step above read, attestation manifest and all.
# Both tags are created from the inspected digest, so either one
# resolves to the artifact the step above read, attestation
# manifest and all. One invocation, so the two tags cannot come to
# name different manifests.
#
# :latest moves on every merge, which leaves a user who pulls it no
# way back to the behaviour they had yesterday - and the defaults
# this image ships are exactly the kind of thing that changes under
# them. main-<sha7> is the immutable reference to pin. The
# multi-arch workflow publishes the semver tags and fires only on a
# release tag; this one runs on every push to main, where there is
# no version number to use.
#
# The `main-` prefix is what keeps the two workflows apart. This
# image is linux/amd64 only, while the multi-arch workflow pushes a
# two-architecture manifest list under sha-<sha7>. A release tag is
# normally cut on a commit that is also on main, so a shared tag
# name would be written by both and resolve to whichever job
# finished last. The prefix names the branch this image came from,
# which is also what a reader of the tag wants to know.
- name: Tag the inspected digest
run: |
set -euo pipefail
image=${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}
docker buildx imagetools create \
-t ${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}:latest \
${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}@${{ steps.build.outputs.digest }}
-t "$image:latest" \
-t "$image:main-${GITHUB_SHA::7}" \
"$image@${{ steps.build.outputs.digest }}"
5 changes: 5 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ jobs:
- name: Generate verification report
run: python3 scripts/generate_verification.py

# Before Sphinx, because Sphinx does not read the contents of a shell
# code block: it would build a page full of examples that answer 401.
- name: Check documented curl examples carry a credential
run: python3 scripts/check_docs_curl_auth.py

- name: Check links
working-directory: docs
run: sphinx-build -b linkcheck . _build/linkcheck
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ corner - and the only trace is a line buried in a log you would have to SSH in t
Start ros2_medkit next to it (no changes to Nav2). The aborted goal becomes a fault:

```bash
curl http://localhost:8080/api/v1/apps/bt_navigator/faults
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/bt_navigator/faults
# → ACTION_NAVIGATE_TO_POSE_ABORTED severity=ERROR source=/bt_navigator status=CONFIRMED
# + a black-box rosbag of the seconds around the failure
```
Expand All @@ -51,7 +51,7 @@ trajectory. Same story: the same action bridge surfaces the aborted move as a fa
`move_group` entity, with the freeze-frame of what the arm was doing, without touching MoveIt.

```bash
curl http://localhost:8080/api/v1/apps/move_group/faults
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/move_group/faults
# → the aborted MoveGroup goal, as a structured fault with its snapshot
```

Expand Down Expand Up @@ -87,6 +87,8 @@ docker run --rm --network host --ipc host \
ghcr.io/selfpatch/ros2_medkit-jazzy:latest \
ros2 launch ros2_medkit_gateway bringup.launch.py
# → REST API live at http://localhost:8080/api/v1/
# The container prints a one-time client_secret on startup; use it to get a
# token. Pass -e MEDKIT_AUTH_DISABLED=1 to run without authentication.
```

Swap `jazzy` for `humble`/`lyrical`; the two `-e` flags forward your shell's `ROS_DOMAIN_ID` and
Expand All @@ -96,7 +98,23 @@ picks them up it is a plain apt install too:

```bash
sudo apt install ros-jazzy-ros2-medkit-gateway # or ros-humble- / ros-lyrical-
ros2 launch ros2_medkit_gateway bringup.launch.py

# The gateway ships closed: it requires a credential and refuses to start
# without a signing secret. Supply one, and either a certificate or
# tls_enabled:=false on a host nothing else can reach.
ros2 launch ros2_medkit_gateway bringup.launch.py \
tls_enabled:=false \
jwt_secret:=change-me-to-at-least-32-characters-long \
auth_clients:=demo:demo-secret:admin
```

Every `curl` below then needs a token:

```bash
TOKEN=$(curl -s http://localhost:8080/api/v1/auth/authorize \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","client_id":"demo","client_secret":"demo-secret"}' \
| jq -r .access_token)
```

> [!TIP]
Expand Down
90 changes: 89 additions & 1 deletion docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,101 @@ source "${COLCON_WS}/install/setup.bash"
# Default to FastDDS (can be overridden via RMW_IMPLEMENTATION env var)
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}"

# Bootstrap credentials so the image can ship closed AND still start.
#
# The packaged params file turns authentication on, matching the gateway's own
# default, and the gateway refuses to start without a signing secret. An image
# that therefore needed a secret before `docker run` would do anything is a
# quickstart nobody completes, and the usual reaction is to turn auth off and
# leave it off. So: if the operator supplied nothing, generate a secret and an
# admin client for this container and print the credential once.
#
# Supply MEDKIT_JWT_SECRET and MEDKIT_CLIENTS to pin your own, or
# MEDKIT_AUTH_DISABLED=1 to run open on a host nothing else can reach.

# The params file the node will actually read, if any. `docker run` can replace
# the default CMD, so this cannot be assumed: an operator who mounts their own
# file has already decided the auth configuration, and generating a credential
# on top of it would override the one their clients hold.
PARAMS_FILE=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--params-file" ]; then PARAMS_FILE="$arg"; fi
prev="$arg"
done

# True when that file names its own signing secret, which is the marker that
# the operator brought their own credentials rather than wanting generated ones.
operator_supplied_auth() {
[ -n "$PARAMS_FILE" ] && [ -r "$PARAMS_FILE" ] && grep -qE '^[[:space:]]*jwt_secret[[:space:]]*:' "$PARAMS_FILE"
}

AUTH_ARGS=()
if [ "${MEDKIT_AUTH_DISABLED:-0}" = "1" ]; then
AUTH_ARGS+=(-p auth.enabled:=false)
echo "ros2_medkit: MEDKIT_AUTH_DISABLED=1 - starting WITHOUT authentication." >&2
echo " Every route is readable by anyone who can reach this port." >&2
elif operator_supplied_auth; then
echo "ros2_medkit: ${PARAMS_FILE} carries its own auth.jwt_secret - using it as-is," >&2
echo " generating nothing and overriding nothing." >&2
else
if [ -z "${MEDKIT_JWT_SECRET:-}" ]; then
# Per container, and not persisted: a restart issues a new one, which is
# correct for a credential nobody chose and nobody stored.
MEDKIT_JWT_SECRET="$(head -c 32 /dev/urandom | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')"
MEDKIT_CLIENT_SECRET="$(head -c 24 /dev/urandom | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')"
MEDKIT_CLIENTS="${MEDKIT_CLIENTS:-medkit:${MEDKIT_CLIENT_SECRET}:admin}"
echo "=============================================================" >&2
echo "ros2_medkit: generated a one-time admin credential for this" >&2
echo " container. It changes on every restart." >&2
echo "" >&2
echo " client_id: medkit" >&2
echo " client_secret: ${MEDKIT_CLIENT_SECRET}" >&2
echo "" >&2
echo " curl -s http://localhost:8080/api/v1/auth/authorize \\" >&2
echo " -H 'Content-Type: application/json' \\" >&2
echo " -d '{\"grant_type\":\"client_credentials\",\"client_id\":\"medkit\",\"client_secret\":\"${MEDKIT_CLIENT_SECRET}\"}'" >&2
echo "" >&2
echo " Set MEDKIT_JWT_SECRET and MEDKIT_CLIENTS to pin your own." >&2
echo "=============================================================" >&2
fi
# Asserted here, not left to the params file. `docker run <img> --ros-args -p
# server.port:=9090` REPLACES the default CMD, so the params file is never
# loaded, and the node's compiled-in default for auth.enabled is false - the
# image would print an admin credential and then serve every route to anyone.
# Saying it on the command line means the image is closed however it is
# started, and it is a no-op when the params file is loaded and agrees.
AUTH_ARGS+=(-p auth.enabled:=true)
if [ -z "$PARAMS_FILE" ]; then
# Same reasoning one level down: with no params file the compiled-in
# require_auth_for is "write", which leaves every read open. A file, even a
# mounted one, is the operator's statement about this and is left alone.
AUTH_ARGS+=(-p auth.require_auth_for:=all)
fi
AUTH_ARGS+=(-p "auth.jwt_secret:=${MEDKIT_JWT_SECRET}")
[ -n "${MEDKIT_CLIENTS:-}" ] && AUTH_ARGS+=(-p "auth.clients:=[${MEDKIT_CLIENTS}]")
fi
# Exported so the other dispatch branch works too: `docker run <img> ros2 launch
# ... bringup.launch.py` execs a command instead of the node, so it never sees
# AUTH_ARGS. gateway.launch.py falls back to these variables.
export MEDKIT_JWT_SECRET MEDKIT_CLIENTS MEDKIT_AUTH_DISABLED
# The image serves plain HTTP; see gateway_docker_params.yaml for why.
#
# Only when the operator has not brought a config of their own. This is the
# image's DEFAULT, not a policy: `docker run <img> ros2 launch ...
# gateway.launch.py config_file:=/mnt/secure.yaml` with TLS on in that file must
# get TLS, and an unconditional export here silently served it plaintext.
if [ -z "$PARAMS_FILE" ] && ! printf '%s\n' "$@" | grep -q '^config_file:='; then
export MEDKIT_TLS_DISABLED=1
fi

# Dispatch on the first argument:
# - empty, or starts with "-" (the default CMD "--ros-args --params-file ..."
# or an override like --ros-args -p server.port:=9090): run the gateway node
# directly, so `docker run <img>` and arg-only overrides keep working.
# - a full command (e.g. `ros2 launch ros2_medkit_gateway bringup.launch.py`
# or `bash`): exec it as-is, so the image can launch the whole bringup stack.
if [ -z "$1" ] || [ "${1#-}" != "$1" ]; then
exec ros2 run ros2_medkit_gateway gateway_node "$@"
exec ros2 run ros2_medkit_gateway gateway_node "$@" "${AUTH_ARGS[@]}"
fi
exec "$@"
21 changes: 21 additions & 0 deletions docker/gateway_docker_params.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ ros2_medkit_gateway:
server:
host: "0.0.0.0"
port: 8080
# TLS stays OFF in the image, unlike config/gateway_params.yaml. A
# container has no certificate of its own and is almost always fronted by
# something that terminates TLS; requiring one here would mean the image
# could not start at all. Terminate TLS at your ingress, or mount a
# certificate and set server.tls.* yourself.
tls:
enabled: false
refresh_interval_ms: 2000
# The web UI runs as a separate origin (its own host/port), so the
# documented "run the web UI next to the gateway" path needs CORS. Without
Expand All @@ -17,3 +24,17 @@ ros2_medkit_gateway:
allowed_origins:
- "http://localhost:3000"
- "http://localhost:5173"

# Authentication. On, matching config/gateway_params.yaml, so the published
# image has the same posture as the source default rather than a quietly
# weaker one.
#
# jwt_secret and clients are NOT set here on purpose: a secret baked into a
# published image is a secret every user of that image shares. The
# entrypoint generates one per container and prints the credential, or
# takes MEDKIT_JWT_SECRET / MEDKIT_CLIENTS from the environment. Run with
# MEDKIT_AUTH_DISABLED=1 to serve without authentication.
auth:
enabled: true
require_auth_for: "all"
issuer: "ros2_medkit_gateway"
32 changes: 16 additions & 16 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ Read and publish data from ROS 2 topics. Item ids follow

.. code-block:: bash

curl http://localhost:8080/api/v1/components/temp_sensor/data/powertrain%2Fengine%2Ftemperature
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/temp_sensor/data/powertrain%2Fengine%2Ftemperature

``PUT /api/v1/components/{id}/data/{topic_path}``
Publish to a topic.
Expand All @@ -958,7 +958,7 @@ Read and publish data from ROS 2 topics. Item ids follow

.. code-block:: bash

curl -X PUT http://localhost:8080/api/v1/components/brake_actuator/data/chassis%2Fbrakes%2Fcommand \
curl -X PUT -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/brake_actuator/data/chassis%2Fbrakes%2Fcommand \
-H "Content-Type: application/json" \
-d '{"data": 50.0}'

Expand Down Expand Up @@ -1030,15 +1030,15 @@ Execute Operations

.. code-block:: bash

curl -X POST http://localhost:8080/api/v1/components/calibration/operations/calibrate/executions \
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/calibration/operations/calibrate/executions \
-H "Content-Type: application/json" \
-d '{}'

**Action Example (asynchronous):**

.. code-block:: bash

curl -X POST http://localhost:8080/api/v1/components/calibration/operations/long_calibration/executions \
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/calibration/operations/long_calibration/executions \
-H "Content-Type: application/json" \
-d '{"order": 10}'

Expand Down Expand Up @@ -1244,7 +1244,7 @@ Request Transition

.. code-block:: bash

curl -X PUT http://localhost:8080/api/v1/apps/temp_sensor/status/restart
curl -X PUT -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/status/restart

.. _configuration-endpoints:

Expand Down Expand Up @@ -1313,7 +1313,7 @@ Manage ROS 2 node parameters.

.. code-block:: bash

curl -X PUT http://localhost:8080/api/v1/components/temp_sensor/configurations/publish_rate \
curl -X PUT -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/temp_sensor/configurations/publish_rate \
-H "Content-Type: application/json" \
-d '{"data": 20.0}'

Expand Down Expand Up @@ -1735,7 +1735,7 @@ List available bulk-data categories for an entity. Returns the union of rosbag c

.. code-block:: bash

curl http://localhost:8080/api/v1/apps/motor_controller/bulk-data
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/motor_controller/bulk-data

**Response (200 OK):**

Expand All @@ -1756,7 +1756,7 @@ List all bulk-data items in a category for the entity.

.. code-block:: bash

curl http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags

**Response (200 OK):**

Expand Down Expand Up @@ -1832,10 +1832,10 @@ declared on the 206 only - the 200 can never carry it.

.. code-block:: bash

curl -O -J http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/fault_MOTOR_OVERHEAT_1738664999000
curl -H "Authorization: Bearer $TOKEN" -O -J http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/fault_MOTOR_OVERHEAT_1738664999000

# One byte range
curl -H 'Range: bytes=0-1023' \
curl -H "Authorization: Bearer $TOKEN" -H 'Range: bytes=0-1023' \
http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/550e8400-e29b-41d4-a716-446655440000

**Response Codes:**
Expand Down Expand Up @@ -1880,7 +1880,7 @@ used for uploads.

.. code-block:: bash

curl -X POST http://localhost:8080/api/v1/components/motor_controller/bulk-data/calibration \
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/motor_controller/bulk-data/calibration \
-F "file=@calibration_data.bin;type=application/octet-stream" \
-F "description=Motor calibration parameters v2.1" \
-F 'metadata={"version": "2.1", "author": "engineer_01"}'
Expand Down Expand Up @@ -1926,7 +1926,7 @@ fault manager and cannot be deleted via this endpoint.

.. code-block:: bash

curl -X DELETE http://localhost:8080/api/v1/components/motor_controller/bulk-data/calibration/calibration_1739612345000000000_ab12cd34
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/motor_controller/bulk-data/calibration/calibration_1739612345000000000_ab12cd34

**Response Codes:**

Expand Down Expand Up @@ -2682,7 +2682,7 @@ Trigger Events (SSE Stream)

.. code-block:: bash

curl -N http://localhost:8080/api/v1/apps/temp_sensor/triggers/trig_001/events
curl -H "Authorization: Bearer $TOKEN" -N http://localhost:8080/api/v1/apps/temp_sensor/triggers/trig_001/events

- **404** ``resource-not-found`` - Trigger not found or expired
- **503** ``service-unavailable`` - Maximum SSE client limit reached
Expand Down Expand Up @@ -3203,7 +3203,7 @@ topic (push-based).

.. code-block:: bash

curl http://localhost:8080/api/v1/apps/engine_temp_sensor/x-medkit-topic-beacon
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/engine_temp_sensor/x-medkit-topic-beacon

**Response (200 OK):**

Expand Down Expand Up @@ -3258,7 +3258,7 @@ polling ROS 2 node parameters matching a configured prefix (pull-based).

.. code-block:: bash

curl http://localhost:8080/api/v1/apps/engine_temp_sensor/x-medkit-param-beacon
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/engine_temp_sensor/x-medkit-param-beacon

**Response (200 OK):**

Expand Down Expand Up @@ -3582,7 +3582,7 @@ runs - on any path, including paths that do not exist:

.. code-block:: bash

$ curl -i -H 'Range: furlongs=1-2' http://localhost:8080/api/v1/health
$ curl -H "Authorization: Bearer $TOKEN" -i -H 'Range: furlongs=1-2' http://localhost:8080/api/v1/health
HTTP/1.1 416 Range Not Satisfiable

The body is the usual ``GenericError`` shape, which is why the document
Expand Down
2 changes: 1 addition & 1 deletion docs/config/discovery-options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ The ``x-medkit-topic-beacon`` vendor endpoint exposes current beacon state:

.. code-block:: bash

curl http://localhost:8080/api/v1/apps/engine_temp_sensor/x-medkit-topic-beacon
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/engine_temp_sensor/x-medkit-topic-beacon

**Example Response:**

Expand Down
Loading
Loading