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
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
38 changes: 34 additions & 4 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,40 @@ 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.
# What the image serves, checked before it is named. The
# entrypoint's dispatch and the image's config layering are shell
# and Dockerfile, which no colcon test reaches. The digest checked
# here is the one the tag step below points at.
- name: Check the image's auth posture
run: |
scripts/smoke_image_auth_posture.sh \
${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}@${{ steps.build.outputs.digest }}

# 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 }}"
42 changes: 39 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy built workspace from builder (builder uses /root/ws, runtime uses /home/medkit/ws)
COPY --from=builder /root/ws/install/ ${COLCON_WS}/install/

# Default config - can be overridden via volume mount
COPY docker/gateway_docker_params.yaml /etc/ros2_medkit/params.yaml
# Three layers, applied left to right by the entrypoint, each one able to
# override the one before it:
#
# base.yaml the file the package ships, so the image and a source
# install share one posture, so the two cannot drift
# container.yaml the two values a container needs on top of it
# params.yaml the mount point, and the last word among the files
#
# The image's own copy of params.yaml repeats container.yaml, so an unmounted
# container is exactly the second layer. Mounting a file replaces that copy and
# nothing else: base.yaml still supplies every key the mounted file leaves out,
# and the mounted file wins wherever it speaks - server.host and
# refresh_interval_ms included. rclcpp applies the merged node entries in order
# of first appearance, and the entrypoint puts these files before the caller's
# arguments, so a `-p` the caller passes wins over the files; had the image put
# those two keys in front of the files as `-p` arguments, a mounted file could
# set neither.
#
# For the closed profile, point --params-file at
# config/gateway_params.secure.yaml inside the image.
COPY src/ros2_medkit_gateway/config/gateway_params.yaml /etc/ros2_medkit/base.yaml
COPY docker/container_params.yaml /etc/ros2_medkit/container.yaml
COPY docker/container_params.yaml /etc/ros2_medkit/params.yaml

# When running via ros2 run (as this container does), plugin .so paths must be
# configured explicitly via plugins.<name>.path parameters in the params file.
Expand All @@ -188,4 +209,19 @@ USER medkit
EXPOSE 8080

ENTRYPOINT ["/entrypoint.sh"]
CMD ["--ros-args", "--params-file", "/etc/ros2_medkit/params.yaml"]
# Empty, and explicitly so: the base image sets a CMD of its own, and inheriting
# it would send `docker run <image>` down the entrypoint's "exec a command"
# branch when it should start the gateway.
#
# The three config layers live in the entrypoint, which puts them in front of
# whatever arguments a caller passes. Here they would be part of the CMD, and a
# caller passing arguments replaces the CMD - so `docker run <image> --ros-args
# -p server.port:=9090` would drop all three and bind loopback inside the
# container.
#
# To run the closed profile, point --params-file at the packaged file:
# docker run <image> --ros-args --params-file \
# /home/medkit/ws/install/ros2_medkit_gateway/share/ros2_medkit_gateway/config/gateway_params.secure.yaml
# That profile enables TLS, so the container also needs a certificate and key
# (server.tls.cert_file / server.tls.key_file) or it refuses to start.
CMD []
21 changes: 21 additions & 0 deletions docker/container_params.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# The two values a container needs that a host install does not.
#
# Bind every interface, because the port is published and the gateway is reached
# through it. Refresh faster, because a container's graph turns over as sibling
# containers come and go.
#
# A file, and placed where the mount point can still beat it. rclcpp applies
# the merged node entries in order of first appearance, and every `-p` joins the
# entry the first `-p` created; the entrypoint passes these layers first and the
# caller's arguments after them. As `-p` arguments these two keys would sit
# above the mount point and a mounted /etc/ros2_medkit/params.yaml could set
# neither.
#
# CORS names no origin here. A published image allowing development origins is a
# setting nobody chose; a deployment that runs the web UI next to the gateway
# names its own origin in the file it mounts.
ros2_medkit_gateway:
ros__parameters:
server:
host: "0.0.0.0"
refresh_interval_ms: 2000
45 changes: 41 additions & 4 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,50 @@ 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}"

# Closing the image is opt-in, through the environment.
#
# The packaged params file is config/gateway_params.yaml, the same one a source
# install gets, and it leaves authentication off. So `docker run <image>` is the
# gateway a reader of the quickstart expects, and the web UI - which sends no
# Authorization header - talks to it.
#
# MEDKIT_JWT_SECRET, MEDKIT_CLIENTS and MEDKIT_AUTH_DISABLED are passed through
# and nothing here interprets them. The gateway node reads them itself when it
# reads its parameters, so the rule holds on every path out of this script -
# the `ros2 run` below, `docker run <img> ros2 launch ... bringup.launch.py`,
# and `docker run <img> bash` followed by anything. A copy of the rule here
# would cover only the first, and the exec paths would quietly run on different
# terms.
#
# Exported, which is the step that matters: a variable passed with `docker run
# -e` is already in this shell's environment, and `export` is what carries it
# into the environment of what this script execs.
export MEDKIT_JWT_SECRET MEDKIT_CLIENTS MEDKIT_AUTH_DISABLED

# 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.
# - empty, or starts with "-" (an override like --ros-args -p
# server.port:=9090): run the gateway node with the image's three config
# layers first and the caller's arguments after them.
# - 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.
#
# The three --params-file arguments belong here and not in the Dockerfile CMD,
# because a caller passing arguments REPLACES the CMD. As part of the CMD they
# would be dropped by any `docker run <img> --ros-args ...`, leaving a gateway
# on the packaged loopback bind and reachable from nothing outside the
# container. Supplied here, an override changes the one key it names.
#
# The caller's arguments follow, and their own --ros-args opens a second group.
# rclcpp applies the merged node entries in order of first appearance, and the
# caller's `-p` is the first one on this command line, so its entry comes after
# these files and wins over them - which is what lets an override change the
# key it names while the layers supply everything else.
if [ -z "$1" ] || [ "${1#-}" != "$1" ]; then
exec ros2 run ros2_medkit_gateway gateway_node "$@"
exec ros2 run ros2_medkit_gateway gateway_node \
--ros-args \
--params-file /etc/ros2_medkit/base.yaml \
--params-file /etc/ros2_medkit/container.yaml \
--params-file /etc/ros2_medkit/params.yaml \
"$@"
fi
exec "$@"
Comment thread
bburda marked this conversation as resolved.
19 changes: 0 additions & 19 deletions docker/gateway_docker_params.yaml

This file was deleted.

25 changes: 23 additions & 2 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2869,16 +2869,37 @@ Endpoint limits can also be overridden with patterns:
Response Headers
~~~~~~~~~~~~~~~~

When rate limiting is enabled, the gateway includes the following HTTP response headers on every check:
When rate limiting is enabled, the gateway includes the following HTTP response
headers on answers to callers the limiter may speak to - a caller whose
credential it accepted, or any caller on a route that needs none:

- ``X-RateLimit-Limit``: The effective RPM limit applied.
- ``X-RateLimit-Remaining``: Number of requests remaining in the current minute window.
- ``X-RateLimit-Reset``: Unix epoch time (in seconds) when the limit bucket resets.

They are withheld from a refusal the gateway makes before it has accepted
anybody. The allowance, the reset time and the retry delay are limiter state,
and a ``401`` for a missing credential or the ``429`` described below carries
none of it.

Rejection (429 Too Many Requests)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If a request exceeds the available tokens, it is rejected with an HTTP 429 status code and a ``Retry-After`` header indicating the number of seconds to wait before retrying.
If a request exceeds the available tokens, it is rejected with an HTTP 429 status code. On a route that needs no credential the answer also carries a ``Retry-After`` header with the number of seconds to wait before retrying; on a protected route it does not, whatever credential the caller holds.

One refusal is narrower. On a protected route, a caller whose allowance is gone
and who presented an ``Authorization`` header is answered ``429`` **before** the
token is verified, so an over-limit caller costs the gateway no signature check.
Nothing about that caller has been verified at that point, so the answer carries
no ``Retry-After``, no ``X-RateLimit-*``, and an empty ``parameters`` object. A
caller presenting no header at all keeps the ordinary ``401``.

The credential makes no difference here: the limiter refuses before it is
examined, so a client holding a perfectly good token gets the same bare
``429``. **On a protected route there is no retry hint**, which is what a
closed-profile client has to be built around. Pace from the
``X-RateLimit-Reset`` on the last answer that carried one - any ``2xx`` does.
On a protected route no ``Retry-After`` arrives.

**Example Response:**

Expand Down
15 changes: 15 additions & 0 deletions docs/config/aggregation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@ for peer communication.
``false`` (default), auth tokens are **never** sent to peers - this
prevents token leakage to untrusted or mDNS-discovered peers. Only
enable when all peers are trusted and share the same JWT configuration.

Sharing the JWT configuration means the signing secret, the algorithm
and the issuer, and also ``auth.token_expiry_seconds`` and
``auth.refresh_token_expiry_seconds``. A peer records a revocation for a
token it did not issue and holds it for that token's refresh expiry plus
its OWN access expiry, so a peer configured with a shorter access expiry
drops the record while the issuer's tokens are still inside theirs, and
the revocation lapses there. The refresh expiry matters the same way: a
peer keeps a foreign record for at most its own refresh lifetime, so an
issuer with a longer one can go on refreshing a token the peer has
already forgotten.

The role a forwarded token grants is the peer's own: each gateway reads
``sub`` against its ``auth.clients`` and applies the role listed there,
so the ``role`` claim in the token does not travel.
* - ``aggregation.peer_auth_header``
- string
- ``""``
Expand Down
Loading
Loading