Skip to content
Draft
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
22 changes: 21 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,33 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Short commit sha
id: shortsha
run: echo "value=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"

- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: ROS_DISTRO=${{ matrix.ros_distro }}
tags: ${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}:latest
# Two tags, and the second one is the point. `:latest` moves
# on every merge, so a user who pulls it has 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. `sha-<short>` gives them something immutable to
# pin. The release workflow publishes the semver tags; this
# one runs on every push to main, where there is no version
# number to use.
#
# Short form, to match the sha- tags the release workflow
# already publishes (docker/metadata-action's
# `type=sha,format=short`). Two spellings of the same idea in
# one registry is how a user ends up unable to find the image
# they pinned.
tags: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}:latest
${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}:sha-${{ steps.shortsha.outputs.value }}
cache-from: type=gha,scope=${{ matrix.ros_distro }}
# mode=min, not max: max exports every intermediate build
# stage, which for three distros held ~3.1 GB of the repo's
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"
10 changes: 5 additions & 5 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 Down Expand Up @@ -1719,7 +1719,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 @@ -1740,7 +1740,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 @@ -3187,7 +3187,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 @@ -3242,7 +3242,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
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
39 changes: 36 additions & 3 deletions docs/config/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ TLS/HTTPS Configuration
- Description
* - ``server.tls.enabled``
- bool
- ``false``
- ``true``
- Enable HTTPS using OpenSSL.
* - ``server.tls.cert_file``
- string
Expand Down Expand Up @@ -857,7 +857,7 @@ default for local development.
- Description
* - ``auth.enabled``
- bool
- ``false``
- ``true``
- Enable/disable JWT authentication.
* - ``auth.jwt_secret``
- string
Expand All @@ -881,7 +881,7 @@ default for local development.
- Refresh token validity period in seconds (24 hours). Must be >= ``token_expiry_seconds``.
* - ``auth.require_auth_for``
- string
- ``"write"``
- ``"all"``
- When to require authentication: ``"none"`` (auth endpoints still available), ``"write"`` (POST/PUT/DELETE only), or ``"all"`` (every request).
* - ``auth.issuer``
- string
Expand All @@ -891,6 +891,10 @@ default for local development.
- string[]
- ``[]``
- Pre-configured clients as ``"client_id:client_secret:role"`` strings.
* - ``auth.public_routes``
- string[]
- ``[]``
- Routes answered with no credential, each written ``"METHOD /path"``. Layers over ``require_auth_for`` and only ever removes a requirement. Matched exactly, no wildcards. Every entry is logged at ``WARN`` on startup, and a malformed entry stops the gateway.

.. note::

Expand Down Expand Up @@ -922,6 +926,35 @@ Example:
token_expiry_seconds: 3600
clients: ["admin:REPLACE_WITH_STRONG_SECRET:admin", "viewer:REPLACE_WITH_STRONG_SECRET:viewer"]

Opening a route to uncredentialed callers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Under ``require_auth_for: "all"`` the only routes answered without a credential
are ``/api/v1/auth/*``, because authentication cannot bootstrap through a door
that demands the credential it hands out. Health is not special and is refused
like everything else.

When something that cannot hold a credential has to reach a route, name it:

.. code-block:: yaml

auth:
public_routes: ["GET /api/v1/health"]

Matching is exact. ``GET /api/v1/health`` opens that method on that path and
nothing else - not ``HEAD``, not ``/api/v1/healthz``, not the subtree. Wildcards
are rejected rather than accepted and matched literally, and a malformed entry
stops the gateway rather than being dropped silently.

An anonymous caller on such a route gets a reduced body: ``GET /health`` answers
with ``status`` and ``timestamp`` only, plus ``x-medkit-reduced: true`` so a
monitor can tell a withheld answer from a clean one. A credential still returns
the whole document.

Most liveness probes need no entry at all. A ``401`` already proves the process
is up and answering HTTP, so a probe that accepts ``200``, ``401`` and ``403``
works against any auth configuration and leaves nothing open. Prefer that.

See :doc:`/tutorials/authentication` for a complete setup tutorial.

Plugin Framework
Expand Down
Loading
Loading