diff --git a/README.md b/README.md index 532571891..be906bb26 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 ``` @@ -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 @@ -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] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ef82bb433..0eda05fed 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -24,6 +24,53 @@ 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. +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 +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 + 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 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. +export MEDKIT_TLS_DISABLED=1 + # 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 @@ -31,6 +78,6 @@ export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}" # - 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 "$@" diff --git a/docker/gateway_docker_params.yaml b/docker/gateway_docker_params.yaml index 1d4049a57..2bf90df5e 100644 --- a/docker/gateway_docker_params.yaml +++ b/docker/gateway_docker_params.yaml @@ -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 @@ -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" diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 694f891aa..3b5013acf 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -896,7 +896,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. @@ -1630,7 +1630,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):** @@ -1651,7 +1651,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):** @@ -2910,7 +2910,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):** @@ -2965,7 +2965,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):** diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index c47613dc0..d8672ac2f 100644 --- a/docs/config/discovery-options.rst +++ b/docs/config/discovery-options.rst @@ -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:** diff --git a/docs/config/server.rst b/docs/config/server.rst index 08db8d3c8..adce58f46 100644 --- a/docs/config/server.rst +++ b/docs/config/server.rst @@ -116,7 +116,7 @@ TLS/HTTPS Configuration - Description * - ``server.tls.enabled`` - bool - - ``false`` + - ``true`` - Enable HTTPS using OpenSSL. * - ``server.tls.cert_file`` - string @@ -857,7 +857,7 @@ default for local development. - Description * - ``auth.enabled`` - bool - - ``false`` + - ``true`` - Enable/disable JWT authentication. * - ``auth.jwt_secret`` - string @@ -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 diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 7ee1ccd57..ed4bb8f66 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -40,7 +40,10 @@ Open three terminals. In each, source your workspace: .. code-block:: bash - ros2 launch ros2_medkit_gateway gateway.launch.py + ros2 launch ros2_medkit_gateway gateway.launch.py \ + tls_enabled:=false \ + jwt_secret:=change-me-to-at-least-32-characters-long \ + auth_clients:=demo:demo-secret:admin You should see: @@ -122,6 +125,41 @@ Required if you want to test the Faults API. The ``~/.ros2_medkit/`` directory must exist before starting the fault manager. SQLite will create the database file automatically. +.. important:: + + The gateway ships **closed**: ``auth.enabled`` is true and + ``require_auth_for`` is ``all``, so every route below needs a credential and + the gateway will not start without a signing secret. That is deliberate - a + gateway that booted open is one nobody notices. The two arguments above are + a throwaway development credential; a real deployment injects them from its + own secret store. + + Get a token once and reuse it for every command on this page: + + .. code-block:: 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) + + ``tls_enabled:=false`` is what keeps the rest of this page on ``http://``. + TLS is on in the shipped config and the gateway will not start without a + certificate, so a first run either turns it off, as here, or supplies one: + run ``scripts/generate_dev_certs.sh ./certs`` and pass + ``cert_file:=./certs/cert.pem key_file:=./certs/key.pem``. Turn it off only + on a host nothing else can reach. See :doc:`tutorials/https` for a real + certificate. + + ``POST /api/v1/auth/authorize`` takes the ``client_credentials`` grant; + ``/auth/token`` is the refresh endpoint and takes ``refresh_token``. + + ``GET /api/v1/health`` is the other route that stays open, so a container + supervisor with no credential can still tell the process is alive. + + To run without authentication - only on a host nothing else can reach - + pass ``auth_enabled:=false``. + .. admonition:: ✅ Checkpoint :class: tip @@ -146,7 +184,7 @@ The gateway exposes all endpoints under ``/api/v1``. Let's explore! .. code-block:: bash - curl http://localhost:8080/api/v1/ + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/ Response shows available endpoints and version info. @@ -197,7 +235,7 @@ ros2_medkit organizes ROS 2 nodes into a SOVD-aligned entity hierarchy: .. code-block:: bash - curl http://localhost:8080/api/v1/functions + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions With ``demo_nodes.launch.py``, you'll see Functions like ``powertrain``, ``chassis``, and ``body`` (created from the first namespace segment). @@ -206,7 +244,7 @@ With ``demo_nodes.launch.py``, you'll see Functions like ``powertrain``, ``chass .. code-block:: bash - curl http://localhost:8080/api/v1/components + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components In runtime mode, you'll see a single host-level Component. @@ -214,7 +252,7 @@ In runtime mode, you'll see a single host-level Component. .. code-block:: bash - curl http://localhost:8080/api/v1/areas + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/areas In runtime mode, this returns an empty list. Areas require a manifest definition (see :doc:`tutorials/manifest-discovery`). @@ -228,7 +266,7 @@ The data endpoints let you read topic data from apps. .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/data + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/data Response structure (showing one topic): @@ -282,7 +320,7 @@ Each data item includes: .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/data/powertrain%2Fengine%2Ftemperature + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/data/powertrain%2Fengine%2Ftemperature Response with live data: @@ -339,7 +377,7 @@ The operations endpoints let you call ROS 2 services and actions. .. code-block:: bash - curl http://localhost:8080/api/v1/apps/calibration/operations + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/calibration/operations **Call a service (synchronous execution):** @@ -387,7 +425,7 @@ Response (202 Accepted): .. code-block:: bash - curl http://localhost:8080/api/v1/apps/long_calibration/operations/long_calibration/executions/a1b2c3d4-e5f6-7890-abcd-ef1234567890 + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/long_calibration/operations/long_calibration/executions/a1b2c3d4-e5f6-7890-abcd-ef1234567890 **Cancel a running action:** @@ -406,13 +444,13 @@ The configurations endpoints expose ROS 2 parameters. .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/configurations + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/configurations **Get a specific parameter:** .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/configurations/publish_rate + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/configurations/publish_rate **Set a parameter value:** @@ -439,13 +477,13 @@ Step 7: Monitor Faults .. code-block:: bash - curl http://localhost:8080/api/v1/faults + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/faults **List faults for a specific component:** .. code-block:: bash - curl http://localhost:8080/api/v1/apps/lidar_sensor/faults + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/lidar_sensor/faults **Clear a fault:** diff --git a/docs/index.rst b/docs/index.rst index 1adf85ec5..e398425fa 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -45,7 +45,13 @@ Quick Links Quick Reference --------------- -Common commands for quick access: +Common commands for quick access. + +.. note:: + + The gateway ships closed: every command below needs a credential, and + ``GET /api/v1/health`` is the only one that does not. See + :doc:`getting_started` for how to obtain ``$TOKEN``. .. code-block:: bash @@ -53,28 +59,28 @@ Common commands for quick access: curl http://localhost:8080/api/v1/health # List all areas - curl http://localhost:8080/api/v1/areas + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/areas # List all components - curl http://localhost:8080/api/v1/components + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components # List all apps - curl http://localhost:8080/api/v1/apps + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps # List all functions - curl http://localhost:8080/api/v1/functions + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions # Get data from an entity (area, component, app, or function) - curl http://localhost:8080/api/v1/{entity-type}/{entity-id}/data + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/{entity-type}/{entity-id}/data # List operations for an entity (area, component, app, or function) - curl http://localhost:8080/api/v1/{entity-type}/{entity-id}/operations + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/{entity-type}/{entity-id}/operations # Get configurations (parameters) - curl http://localhost:8080/api/v1/{entity-type}/{entity-id}/configurations + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/{entity-type}/{entity-id}/configurations # List faults - curl http://localhost:8080/api/v1/faults + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/faults Community --------- diff --git a/docs/tutorials/authentication.rst b/docs/tutorials/authentication.rst index 3c1431f21..9cd60e94b 100644 --- a/docs/tutorials/authentication.rst +++ b/docs/tutorials/authentication.rst @@ -11,7 +11,7 @@ Role-Based Access Control (RBAC) in ros2_medkit_gateway. Overview -------- -By default, the gateway runs without authentication for easy development. +The gateway requires authentication by default. For production deployments, you should enable authentication to: - Control who can access the API diff --git a/docs/tutorials/beacon-discovery.rst b/docs/tutorials/beacon-discovery.rst index 43e0ad13d..b181a40e8 100644 --- a/docs/tutorials/beacon-discovery.rst +++ b/docs/tutorials/beacon-discovery.rst @@ -259,7 +259,7 @@ The plugin registers a vendor extension endpoint on all apps and components: .. 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: @@ -460,7 +460,7 @@ The plugin registers its own vendor extension endpoint: .. 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 The response format is identical to the topic beacon endpoint. diff --git a/docs/tutorials/demos/demo-sensor.rst b/docs/tutorials/demos/demo-sensor.rst index b2cf6087b..a0bf1343f 100644 --- a/docs/tutorials/demos/demo-sensor.rst +++ b/docs/tutorials/demos/demo-sensor.rst @@ -107,16 +107,16 @@ Query sensor data via REST API: .. code-block:: bash # Get LiDAR scan - curl http://localhost:8080/api/v1/apps/lidar-sim/data/scan | jq '.ranges[:5]' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/lidar-sim/data/scan | jq '.ranges[:5]' # Get IMU data - curl http://localhost:8080/api/v1/apps/imu-sim/data/imu | jq '.linear_acceleration' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/imu-sim/data/imu | jq '.linear_acceleration' # Get GPS fix - curl http://localhost:8080/api/v1/apps/gps-sim/data/fix | jq '{lat: .latitude, lon: .longitude}' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/gps-sim/data/fix | jq '{lat: .latitude, lon: .longitude}' # Get camera image info - curl http://localhost:8080/api/v1/apps/camera-sim/data/image | jq '{width, height, encoding}' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/camera-sim/data/image | jq '{width, height, encoding}' Managing Configurations ----------------------- @@ -128,10 +128,10 @@ View and modify sensor parameters: .. code-block:: bash # List all LiDAR configurations - curl http://localhost:8080/api/v1/apps/lidar-sim/configurations | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/lidar-sim/configurations | jq # Get specific parameter - curl http://localhost:8080/api/v1/apps/lidar-sim/configurations/noise_stddev | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/lidar-sim/configurations/noise_stddev | jq # Change scan rate curl -X PUT http://localhost:8080/api/v1/apps/lidar-sim/configurations/scan_rate \ @@ -176,10 +176,10 @@ faults at runtime using provided scripts: .. code-block:: bash # List all system faults - curl http://localhost:8080/api/v1/faults + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/faults # Get faults for specific sensor - curl http://localhost:8080/api/v1/apps/lidar-sim/faults + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/lidar-sim/faults **Manual fault injection via API:** diff --git a/docs/tutorials/demos/demo-turtlebot3.rst b/docs/tutorials/demos/demo-turtlebot3.rst index 24c609b08..ff3340e36 100644 --- a/docs/tutorials/demos/demo-turtlebot3.rst +++ b/docs/tutorials/demos/demo-turtlebot3.rst @@ -90,7 +90,7 @@ Querying via API: .. code-block:: bash - curl http://localhost:8080/api/v1/areas | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/areas | jq .. figure:: /_static/images/13_curl_areas_turtlebot3.png :alt: Areas response @@ -116,13 +116,13 @@ Query data via REST API: .. code-block:: bash # List all apps - curl http://localhost:8080/api/v1/apps | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps | jq # Get specific topic from AMCL localization - curl http://localhost:8080/api/v1/apps/amcl/data | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/amcl/data | jq # Get specific topic from controller server - curl http://localhost:8080/api/v1/apps/controller-server/data | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/controller-server/data | jq .. figure:: /_static/images/06_topic_data_view.png :alt: Topic data view @@ -148,10 +148,10 @@ You can also interact with the navigation stack via API: .. code-block:: bash # List operations on BT Navigator - curl http://localhost:8080/api/v1/apps/bt-navigator/operations | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/bt-navigator/operations | jq # List operations on Controller Server - curl http://localhost:8080/api/v1/apps/controller-server/operations | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/controller-server/operations | jq Managing Parameters ------------------- @@ -163,10 +163,10 @@ View and modify parameters: .. code-block:: bash # List all configurations for AMCL - curl http://localhost:8080/api/v1/apps/amcl/configurations | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/amcl/configurations | jq # Get specific parameter - curl http://localhost:8080/api/v1/apps/amcl/configurations/use_sim_time | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/amcl/configurations/use_sim_time | jq # Change parameter value curl -X PUT http://localhost:8080/api/v1/apps/amcl/configurations/use_sim_time \ @@ -199,7 +199,7 @@ The demo includes fault injection scripts to test diagnostic capabilities: ./check-faults.sh # Or query via API - curl http://localhost:8080/api/v1/faults + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/faults .. figure:: /_static/images/18_faults_injected_dashboard.png :alt: Faults dashboard diff --git a/docs/tutorials/docker.rst b/docs/tutorials/docker.rst index 84c042e67..7840f613f 100644 --- a/docs/tutorials/docker.rst +++ b/docs/tutorials/docker.rst @@ -61,7 +61,7 @@ Test the gateway: curl http://localhost:8080/api/v1/health # {"status":"healthy","timestamp":...} - curl http://localhost:8080/api/v1/version-info + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/version-info # {"items":[{"version":"","vendor_info":{"name":"ros2_medkit",...}}]} Custom Configuration diff --git a/docs/tutorials/fault-correlation.rst b/docs/tutorials/fault-correlation.rst index 07357780c..ca3d6cc4d 100644 --- a/docs/tutorials/fault-correlation.rst +++ b/docs/tutorials/fault-correlation.rst @@ -310,7 +310,7 @@ Querying Correlation Data .. code-block:: bash - curl http://localhost:8080/api/v1/faults + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/faults Response always includes: diff --git a/docs/tutorials/graph-provider.rst b/docs/tutorials/graph-provider.rst index eb305285f..006b95c66 100644 --- a/docs/tutorials/graph-provider.rst +++ b/docs/tutorials/graph-provider.rst @@ -211,14 +211,14 @@ The Discovery Path .. code-block:: bash - curl http://localhost:8080/api/v1/functions | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions | jq 2. **Read the Function's detail** and follow its capability href. Every Function detail response carries an ``"x-medkit-graph"`` link: .. code-block:: bash - curl http://localhost:8080/api/v1/functions/engine-monitoring | jq '."x-medkit-graph"' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions/engine-monitoring | jq '."x-medkit-graph"' # "/api/v1/functions/engine-monitoring/x-medkit-graph" 3. **GET the graph** at that href. @@ -233,7 +233,7 @@ Function ``engine-monitoring`` hosting an ``engine-temp-sensor`` App (publishes .. code-block:: bash - curl http://localhost:8080/api/v1/functions/engine-monitoring/x-medkit-graph | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions/engine-monitoring/x-medkit-graph | jq .. code-block:: json diff --git a/docs/tutorials/heuristic-apps.rst b/docs/tutorials/heuristic-apps.rst index 8d4b74638..638d98d5e 100644 --- a/docs/tutorials/heuristic-apps.rst +++ b/docs/tutorials/heuristic-apps.rst @@ -55,7 +55,7 @@ Query available Apps: .. code-block:: bash - curl http://localhost:8080/api/v1/apps | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps | jq Example response: @@ -120,16 +120,16 @@ In runtime mode, the gateway maps the ROS 2 graph as follows: .. code-block:: bash - curl http://localhost:8080/api/v1/apps + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps # Returns: [{"id": "lidar_driver"}, {"id": "camera_node"}] - curl http://localhost:8080/api/v1/components + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components # Returns: [{"id": "my-hostname", "source": "runtime", ...}] - curl http://localhost:8080/api/v1/functions + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions # Returns: [{"id": "perception"}, {"id": "navigation"}] - curl http://localhost:8080/api/v1/areas + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/areas # Returns: {"items": []} (empty - Areas come from manifest only) API Endpoints @@ -159,7 +159,7 @@ Component derived from system information: .. code-block:: bash - curl http://localhost:8080/api/v1/components | jq '.items[] | {id, source}' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components | jq '.items[] | {id, source}' .. code-block:: json @@ -177,7 +177,7 @@ In runtime mode, Functions are created from the first namespace segment: .. code-block:: bash - curl http://localhost:8080/api/v1/functions | jq '.items[] | {id}' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions | jq '.items[] | {id}' .. code-block:: json diff --git a/docs/tutorials/https.rst b/docs/tutorials/https.rst index 5604fbd8a..016f9380b 100644 --- a/docs/tutorials/https.rst +++ b/docs/tutorials/https.rst @@ -11,7 +11,7 @@ encrypted HTTPS communication with the gateway. Overview -------- -By default, the gateway uses plain HTTP. For production deployments, +The gateway serves HTTPS by default. For production deployments, you should enable TLS to: - Encrypt all traffic between clients and the gateway @@ -100,10 +100,69 @@ Configuration Options - Path to PEM-encoded private key * - ``server.tls.ca_file`` - ``""`` - - CA certificate (for future mutual TLS) + - CA that signs client certificates. Setting it turns on mutual TLS and + makes a client certificate **required**; leave empty for server-only TLS * - ``server.tls.min_version`` - ``"1.2"`` - - Minimum TLS version: ``"1.2"`` or ``"1.3"`` + - Minimum TLS version: ``"1.2"`` or ``"1.3"``. Enforced on the server's own + SSL context, so it is the floor regardless of what the local OpenSSL + policy would otherwise allow. Any other value is rejected at startup + +Defaults +-------- + +TLS is **on** in the shipped ``gateway_params.yaml``, and ``cert_file`` and +``key_file`` are empty. A gateway with TLS enabled and no certificate refuses +to start rather than fall back to plaintext, so a first run has to supply one +of the two: + +.. code-block:: bash + + # a certificate, for a real deployment or a self-signed pair for a first run + ros2 launch ros2_medkit_gateway gateway.launch.py \ + cert_file:=/path/to/cert.pem key_file:=/path/to/key.pem + + # or no TLS at all, only on a host nothing else can reach + ros2 launch ros2_medkit_gateway gateway.launch.py tls_enabled:=false + +For a first run on a developer machine, ``scripts/generate_dev_certs.sh`` +writes a self-signed certificate and key. Browsers and ``curl`` will refuse it +until you pass the CA explicitly, which is the correct behaviour for a +certificate nothing has vouched for, not a problem to work around in +production. + +Mutual TLS +---------- + +Set ``ca_file`` to the CA that signs your client certificates and the gateway +requires one from **every** client: + +.. code-block:: yaml + + server: + tls: + enabled: true + cert_file: "/etc/ros2_medkit/certs/server.pem" + key_file: "/etc/ros2_medkit/certs/server-key.pem" + ca_file: "/etc/ros2_medkit/certs/client-ca.pem" + +This is all or nothing per gateway. A client that presents no certificate is +rejected during the handshake, before any request is read, and there is no +"verify it only if offered" setting. A client whose certificate is signed by +any other CA is rejected the same way. + +.. code-block:: bash + + # without a client certificate: no response, the handshake never completes + curl --cacert ca.pem https://localhost:8443/api/v1/areas + + # with one signed by ca_file + curl --cacert ca.pem --cert client.pem --key client-key.pem \ + https://localhost:8443/api/v1/areas + +Mutual TLS is transport-level and sits alongside token authentication rather +than replacing it. SOVD authenticates with bearer tokens, so leave ``ca_file`` +empty unless every client on that network can be issued a certificate. Using with curl --------------- diff --git a/docs/tutorials/linux-introspection.rst b/docs/tutorials/linux-introspection.rst index 50d8938cd..6e9c340f2 100644 --- a/docs/tutorials/linux-introspection.rst +++ b/docs/tutorials/linux-introspection.rst @@ -112,7 +112,7 @@ Returns process-level metrics for a single ROS 2 node: .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-procfs | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-procfs | jq .. code-block:: json @@ -136,7 +136,7 @@ Each entry includes a ``node_ids`` array listing the Apps that share the process .. code-block:: bash - curl http://localhost:8080/api/v1/components/sensor_suite/x-medkit-procfs | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/sensor_suite/x-medkit-procfs | jq .. code-block:: json @@ -167,7 +167,7 @@ Returns the systemd unit managing the node's process: .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-systemd | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-systemd | jq .. code-block:: json @@ -186,7 +186,7 @@ Returns aggregated unit info for all child Apps, deduplicated by unit name: .. code-block:: bash - curl http://localhost:8080/api/v1/components/sensor_suite/x-medkit-systemd | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/sensor_suite/x-medkit-systemd | jq .. code-block:: json @@ -213,7 +213,7 @@ Returns container metadata for a node running inside a container: .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-container | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-container | jq .. code-block:: json @@ -249,7 +249,7 @@ Returns aggregated container info for all child Apps, deduplicated by container .. code-block:: bash - curl http://localhost:8080/api/v1/components/sensor_suite/x-medkit-container | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/sensor_suite/x-medkit-container | jq .. code-block:: json diff --git a/docs/tutorials/locking.rst b/docs/tutorials/locking.rst index acfb2639c..00901525f 100644 --- a/docs/tutorials/locking.rst +++ b/docs/tutorials/locking.rst @@ -117,7 +117,7 @@ Check what locks exist on an entity: .. code-block:: bash - curl http://localhost:8080/api/v1/components/motor_controller/locks \ + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/motor_controller/locks \ -H "X-Client-Id: $CLIENT_ID" The ``owned`` field in each lock item indicates whether the requesting client diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst index 83e0e9f35..14295f1c4 100644 --- a/docs/tutorials/manifest-discovery.rst +++ b/docs/tutorials/manifest-discovery.rst @@ -173,7 +173,7 @@ Check manifest status: .. code-block:: bash - curl http://localhost:8080/api/v1/manifest/status + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/manifest/status Expected response: @@ -195,13 +195,13 @@ List apps: .. code-block:: bash - curl http://localhost:8080/api/v1/apps + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps List functions: .. code-block:: bash - curl http://localhost:8080/api/v1/functions + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/functions Understanding Hybrid Mode ------------------------- @@ -336,7 +336,7 @@ Check which apps are online: .. code-block:: bash - curl http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' Example response: diff --git a/docs/tutorials/migration-to-manifest.rst b/docs/tutorials/migration-to-manifest.rst index ea7024657..9f8752b65 100644 --- a/docs/tutorials/migration-to-manifest.rst +++ b/docs/tutorials/migration-to-manifest.rst @@ -323,13 +323,13 @@ Step 7: Test in Hybrid Mode .. code-block:: bash - curl http://localhost:8080/api/v1/manifest/status | jq + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/manifest/status | jq 4. **Verify apps are linked**: .. code-block:: bash - curl http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' 5. **Check for orphan nodes** (warnings in gateway logs): diff --git a/docs/tutorials/openapi.rst b/docs/tutorials/openapi.rst index 053c9f892..6e71db8c5 100644 --- a/docs/tutorials/openapi.rst +++ b/docs/tutorials/openapi.rst @@ -15,16 +15,16 @@ Append ``/docs`` to any valid API path: .. code-block:: bash # Full gateway spec (all endpoints) - curl http://localhost:8080/api/v1/docs | jq . + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/docs | jq . # Spec scoped to the components collection - curl http://localhost:8080/api/v1/components/docs + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/docs # Spec for a specific component and its resource collections - curl http://localhost:8080/api/v1/components/my_sensor/docs + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/my_sensor/docs # Spec for one resource collection (e.g. data) - curl http://localhost:8080/api/v1/components/my_sensor/data/docs + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/my_sensor/data/docs Entity-level specs reflect the actual capabilities of each entity at runtime. Plugin-registered vendor routes also appear when the requested diff --git a/docs/tutorials/scripts.rst b/docs/tutorials/scripts.rst index e395ebf33..48b0c8b2d 100644 --- a/docs/tutorials/scripts.rst +++ b/docs/tutorials/scripts.rst @@ -82,7 +82,7 @@ Quick Example .. code-block:: bash - curl http://localhost:8080/api/v1/components/main-computer/scripts/script_1717123456_0/executions/exec_1717123500_0 + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/main-computer/scripts/script_1717123456_0/executions/exec_1717123500_0 Response when finished: diff --git a/docs/tutorials/snapshots.rst b/docs/tutorials/snapshots.rst index ac8daa2fb..6263758ed 100644 --- a/docs/tutorials/snapshots.rst +++ b/docs/tutorials/snapshots.rst @@ -56,7 +56,7 @@ Quick Start .. code-block:: bash - curl http://localhost:8080/api/v1/faults/MOTOR_OVERHEAT/snapshots + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/faults/MOTOR_OVERHEAT/snapshots Configuration Options --------------------- @@ -241,7 +241,7 @@ Snapshots are included inline in the fault response as ``environment_data``: .. code-block:: bash - curl http://localhost:8080/api/v1/apps/motor_controller/faults/MOTOR_OVERHEAT + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/motor_controller/faults/MOTOR_OVERHEAT **Response:** @@ -307,7 +307,7 @@ Snapshots are included inline in the fault response as ``environment_data``: .. code-block:: bash - curl http://localhost:8080/api/v1/apps/motor_controller/faults/MOTOR_OVERHEAT | \ + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/motor_controller/faults/MOTOR_OVERHEAT | \ jq '.environment_data.snapshots' Example Workflow @@ -342,7 +342,7 @@ This example demonstrates the complete snapshot capture workflow. .. code-block:: bash - curl http://localhost:8080/api/v1/apps/nav_node/faults/NAV_ERROR | \ + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/nav_node/faults/NAV_ERROR | \ jq '.environment_data.snapshots' The response will contain the odometry data that was captured at the @@ -728,7 +728,7 @@ Rosbag files are downloaded via SOVD bulk-data endpoints. .. 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 One item per **recording**, not per fault. A burst of correlated faults shares a single recording and appears once, with every fault it covers listed in diff --git a/docs/tutorials/triggers-use-cases.rst b/docs/tutorials/triggers-use-cases.rst index fb68119b6..63b1040fb 100644 --- a/docs/tutorials/triggers-use-cases.rst +++ b/docs/tutorials/triggers-use-cases.rst @@ -119,7 +119,7 @@ Step 4: List all triggers .. code-block:: bash - curl http://localhost:8080/api/v1/apps/temp_sensor/triggers + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/triggers **Response:** @@ -281,7 +281,7 @@ component. .. code-block:: bash - curl http://localhost:8080/api/v1/components | jq '.items[].id' + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components | jq '.items[].id' If ``engine`` does not appear, the demo nodes may not be running or the namespace grouping may differ. Adjust ``engine`` to match the actual @@ -306,10 +306,10 @@ Step 4: Verify all triggers .. code-block:: bash # App-level triggers - curl http://localhost:8080/api/v1/apps/temp_sensor/triggers + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/temp_sensor/triggers # Component-level triggers - curl http://localhost:8080/api/v1/components/engine/triggers + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/components/engine/triggers Step 5: Connect SSE streams and observe cascade ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -424,10 +424,10 @@ Step 4: Verify triggers on different entity types .. code-block:: bash # Area-level triggers - curl http://localhost:8080/api/v1/areas/powertrain/triggers + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/areas/powertrain/triggers # App-level triggers - curl http://localhost:8080/api/v1/apps/engine-temp-sensor/triggers + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apps/engine-temp-sensor/triggers Step 5: Connect SSE streams ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/postman/README.md b/postman/README.md index fa55023bf..8a2f1fe73 100644 --- a/postman/README.md +++ b/postman/README.md @@ -92,7 +92,7 @@ ros2 launch ros2_medkit_gateway gateway.launch.py 4. Tokens are automatically saved to environment variables 5. Use `{{access_token}}` in Authorization header for protected endpoints -> **Note:** Auth endpoints are always accessible. By default (`require_auth_for: write`), only write operations (POST, PUT, DELETE) require authentication. GET requests work without a token. +> **Note:** Auth endpoints are always accessible. By default (`require_auth_for: all`), every request needs a token, GET included. **Discovery:** 1. Expand **"Discovery"** folder diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index a736dd654..5881a581e 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -1477,32 +1477,32 @@ Cross-Origin Resource Sharing (CORS) settings for browser-based clients. CORS is #### Authentication Configuration -JWT-based authentication with Role-Based Access Control (RBAC). Authentication is **disabled by default** for backward compatibility. +JWT-based authentication with Role-Based Access Control (RBAC). Authentication is **enabled by default** for backward compatibility. | Parameter | Type | Default | Description | | ----------------------------------- | -------- | --------------------- | --------------------------------------------------------------------------- | -| `auth.enabled` | bool | `false` | Enable/disable authentication. Set to `true` to require auth. | +| `auth.enabled` | bool | `true` | Enable/disable authentication. Set to `true` to require auth. | | `auth.jwt_secret` | string | (required if enabled) | Secret key for HS256 signing. Must be at least 32 characters. | | `auth.jwt_algorithm` | string | `HS256` | JWT signing algorithm: `HS256` (symmetric) or `RS256` (asymmetric). | | `auth.token_expiry_seconds` | int | `3600` | Access token lifetime in seconds (range: 60-86400). | | `auth.refresh_token_expiry_seconds` | int | `86400` | Refresh token lifetime in seconds (range: 300-604800). | -| `auth.require_auth_for` | string | `write` | Auth requirement: `none`, `write` (POST/PUT/DELETE only), or `all`. | +| `auth.require_auth_for` | string | `all` | Auth requirement: `none`, `write` (POST/PUT/DELETE only), or `all`. | | `auth.issuer` | string | `ros2_medkit_gateway` | JWT issuer claim for token validation. | | `auth.clients` | string[] | `[]` | Client credentials in format `client_id:client_secret:role`. | #### TLS/HTTPS Configuration -TLS (Transport Layer Security) enables encrypted HTTPS communication. TLS is **disabled by default** for backward compatibility. +TLS (Transport Layer Security) enables encrypted HTTPS communication. TLS is **enabled by default**; the gateway refuses to start without a certificate. | Parameter | Type | Default | Description | | ---------------------------- | ------ | ------- | --------------------------------------------------------------------------- | -| `server.tls.enabled` | bool | `false` | Enable/disable TLS. When enabled, server uses HTTPS instead of HTTP. | +| `server.tls.enabled` | bool | `true` | Enable/disable TLS. When enabled, server uses HTTPS instead of HTTP. | | `server.tls.cert_file` | string | (required if enabled) | Path to PEM-encoded certificate file. | | `server.tls.key_file` | string | (required if enabled) | Path to PEM-encoded private key file. | -| `server.tls.ca_file` | string | `""` | Optional CA certificate (reserved for future mutual TLS support). | +| `server.tls.ca_file` | string | `""` | CA that signs CLIENT certificates. Setting it enables mutual TLS and REQUIRES a client certificate from every caller. | | `server.tls.min_version` | string | `"1.2"` | Minimum TLS version: `"1.2"` (compatible) or `"1.3"` (more secure). | -> **Note:** Mutual TLS (client certificate verification) is planned for a future release. +> **Note:** Mutual TLS is available: set `server.tls.ca_file`. **Roles and Permissions:** @@ -1573,7 +1573,7 @@ auth: jwt_algorithm: "HS256" token_expiry_seconds: 3600 refresh_token_expiry_seconds: 86400 - require_auth_for: "write" # GET requests work without auth + require_auth_for: "all" # every request needs a token, GET included issuer: "ros2_medkit_gateway" clients: - "admin:admin_secret:admin" diff --git a/src/ros2_medkit_gateway/config/gateway_params.yaml b/src/ros2_medkit_gateway/config/gateway_params.yaml index dec314749..03c21825f 100644 --- a/src/ros2_medkit_gateway/config/gateway_params.yaml +++ b/src/ros2_medkit_gateway/config/gateway_params.yaml @@ -80,8 +80,14 @@ ros2_medkit_gateway: # TLS/HTTPS Configuration # Enables encrypted communication using OpenSSL tls: - # Enable/disable TLS (default: false for backward compatibility) - enabled: false + # On, so a gateway reachable from anything but loopback is encrypted + # without a deployment having to remember to turn it on. cert_file and + # key_file below must be filled in; the gateway refuses to start with + # TLS on and no certificate, which is the intended failure - an + # unencrypted gateway that started anyway is the worse outcome. + # Turn OFF only for a gateway bound to 127.0.0.1 behind a TLS + # terminator that is itself doing this job. + enabled: true # Path to PEM-encoded certificate file (required when TLS enabled) # Example: "/etc/ros2_medkit/certs/cert.pem" @@ -101,8 +107,11 @@ ros2_medkit_gateway: # Options: "1.2" (default, widely compatible), "1.3" (more secure) min_version: "1.2" - # TODO: Mutual TLS (client certificate verification) is not yet implemented - # See: https://github.com/selfpatch/ros2_medkit/issues/XXX + # Mutual TLS. Set ca_file above to the CA that signs your client + # certificates and the gateway REQUIRES one from every client: a + # caller with no certificate is rejected during the handshake, before + # any request is read. Leave ca_file empty for ordinary server-only + # TLS, which is what bearer-token clients expect. # Safety-backstop refresh interval in milliseconds. # @@ -360,11 +369,22 @@ ros2_medkit_gateway: # Authentication Configuration (REQ_INTEROP_086, REQ_INTEROP_087) # JWT-based authentication with Role-Based Access Control (RBAC) auth: - # Enable/disable authentication - # Default: false (disabled for local development) - enabled: false + # On. This file is the default a deployment gets when it brings no + # profile of its own, so it has to be the safe one: an unauthenticated + # SOVD gateway exposes the entity tree, the fault history and every + # operation the plugins register. + # + # The gateway REFUSES TO START while this is true and jwt_secret is + # empty (auth_config.cpp: "JWT secret is required when authentication is + # enabled"). That is deliberate. A gateway that will not boot is a + # deployment problem someone fixes in a minute; a gateway that booted + # open is one nobody notices. + enabled: true - # JWT signing secret (required when enabled) + # JWT signing secret. REQUIRED - the gateway will not start without it + # while auth.enabled is true. At least 32 characters for HS256. + # Inject it from a secret store or the deployment's own configuration; + # do not commit a real secret here. # For HS256: The shared secret string # For RS256: Path to the private key file (PEM format) jwt_secret: "" @@ -390,8 +410,14 @@ ros2_medkit_gateway: # - "none": No authentication required (auth endpoints still available) # - "write": Auth required for write operations (POST, PUT, DELETE) # - "all": Auth required for all operations - # Default: "write" - require_auth_for: "write" + # + # "all", not "write". Under "write" every read stays open even with + # authentication switched on, and the reads are where the disclosure is: + # the entity tree names the machines, the fault history is the + # maintenance record. Both are readable by anyone who can reach the port. + # GET /health and /auth/* stay public under "all" - see + # AllAuthRequirementPolicy, which documents why each one has to be. + require_auth_for: "all" # JWT issuer claim # Default: "ros2_medkit_gateway" diff --git a/src/ros2_medkit_gateway/design/hardening.rst b/src/ros2_medkit_gateway/design/hardening.rst index 16311c0f3..4f4630980 100644 --- a/src/ros2_medkit_gateway/design/hardening.rst +++ b/src/ros2_medkit_gateway/design/hardening.rst @@ -1,15 +1,28 @@ Gateway hardening (secure field profile) ======================================== -The gateway ships every transport and access control needed for a hardened -deployment - JWT authentication with RBAC, TLS/HTTPS, restricted CORS, and -token-bucket rate limiting - but they are **disabled by default** so local -development works out of the box. A gateway exposed on a plant network with the -defaults is wide open: unauthenticated reads and writes over cleartext HTTP. - -For any deployment reachable from an untrusted network, start from the secure -field profile preset ``config/gateway_params.secure.yaml`` instead of -``config/gateway_params.yaml``: +The gateway ships **closed**. ``config/gateway_params.yaml`` sets +``auth.enabled: true``, ``auth.require_auth_for: "all"`` and +``server.tls.enabled: true``, so out of the box every route needs a credential +and the transport is encrypted. + +Two consequences worth stating plainly: + +* **The gateway refuses to start without a signing secret.** With auth enabled + and ``auth.jwt_secret`` empty it exits with "JWT secret is required when + authentication is enabled" (and HS256 additionally requires at least 32 + characters). This is intended. A gateway that will not boot is a deployment + problem someone fixes in a minute; a gateway that booted open is one nobody + notices. +* **``GET /api/v1/health`` and ``/api/v1/auth/*`` stay public.** Health so a + container supervisor or load balancer with no credential can tell the + process is alive - it carries a fixed status document and no topology. Auth + because authentication cannot bootstrap through a door that already demands + the credential it exists to hand out. Nothing else is exempt. + +For a deployment reachable from an untrusted network, the remaining controls - +restricted CORS, rate limiting, locking, a reduced surface - are collected in +the secure field profile preset ``config/gateway_params.secure.yaml``: .. code-block:: bash @@ -24,9 +37,10 @@ What the secure profile turns on ================================ ============== =========================================== Control Default Secure profile ================================ ============== =========================================== -``auth.enabled`` false true -``auth.require_auth_for`` write all (auth on reads + writes) -``server.tls.enabled`` false true (HTTPS, min TLS 1.3) +``auth.enabled`` true true +``auth.require_auth_for`` all all (auth on reads + writes) +``server.tls.enabled`` true true (HTTPS, min TLS 1.3) +``auth.jwt_secret`` *unset* *unset* - both REQUIRE one at deploy time ``cors.allowed_origins`` ``[]`` explicit origin list (no wildcard) ``rate_limiting.enabled`` false true (global + per-client + per-endpoint) ``scripts.allow_uploads`` true false (manifest-defined scripts only) @@ -35,6 +49,20 @@ Control Default Secure profile ``locking`` on operations none lock required before mutation ================================ ============== =========================================== +The access-control rows now match: the difference between the two files is the +surface reduction below them, not whether the door is locked. + +Running without authentication +------------------------------ + +On a host nothing else can reach - a laptop, a CI job, a single-container demo +- pass ``auth_enabled:=false`` to ``gateway.launch.py``, or set +``auth.enabled: false`` in your own params file. Do this deliberately and never +on a machine reachable from a plant or office network: with authentication off +the entity tree names the machines, the fault history is the maintenance record +of the line, and every registered operation is callable by anyone who can reach +the port. + Credential and certificate provisioning ---------------------------------------- diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp index b8aaaa953..81826a8c1 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp @@ -150,6 +150,13 @@ class AuthManager { */ bool enable_client(const std::string & client_id); + /// How many refresh records are currently held. + /// + /// Public so a test can observe that the sweep actually runs. The count is + /// the thing the unbounded-growth claim is about, and asserting on it is the + /// only way to tell a sweep that works from one that is never called. + size_t refresh_token_count() const; + private: /** * @brief Generate a JWT token @@ -201,6 +208,10 @@ class AuthManager { mutable std::mutex clients_mutex_; std::unordered_map clients_; + /// Drop every expired record. The caller must already hold + /// refresh_tokens_mutex_; cleanup_expired_tokens() is the locking wrapper. + size_t cleanup_expired_locked(); + // Refresh token storage (thread-safe) mutable std::mutex refresh_tokens_mutex_; std::unordered_map refresh_tokens_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp index 6e25328eb..d724cf622 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp @@ -69,18 +69,33 @@ class NoAuthRequirementPolicy : public IAuthRequirementPolicy { /** * @brief Policy that always requires authentication * - * Except for public endpoints (auth endpoints, health check) + * Except for two public endpoints: the auth endpoints, and the health probe. */ class AllAuthRequirementPolicy : public IAuthRequirementPolicy { public: bool requires_authentication(const std::string & method, const std::string & path) const override { - (void)method; - // Auth endpoints are always public (to allow login) - return path.find("/api/v1/auth/") != 0; + // Auth endpoints are always public: authentication cannot bootstrap + // through a door that demands the credential it exists to hand out. + if (path.find("/api/v1/auth/") == 0) { + return false; + } + + // GET /api/v1/health is public. A container supervisor and an upstream + // load balancer probe it to decide whether this process is alive, and + // neither holds a credential; requiring one turns a healthy gateway into + // a restart loop. The response is a fixed status document - it names no + // entity, no topic and no plant data - so leaving it open discloses that + // a gateway is running and nothing further. HEAD is not included: a probe + // that wants the status document asks for it with GET. + if (method == "GET" && path == "/api/v1/health") { + return false; + } + + return true; } std::string description() const override { - return "AllAuth: Authentication required for all endpoints except /auth/*"; + return "AllAuth: Authentication required for all endpoints except /auth/* and GET /health"; } }; diff --git a/src/ros2_medkit_gateway/launch/bringup.launch.py b/src/ros2_medkit_gateway/launch/bringup.launch.py index 4fb776651..76382940c 100644 --- a/src/ros2_medkit_gateway/launch/bringup.launch.py +++ b/src/ros2_medkit_gateway/launch/bringup.launch.py @@ -52,6 +52,12 @@ def generate_launch_description(): server_host = LaunchConfiguration('server_host') server_port = LaunchConfiguration('server_port') cors_allowed_origins = LaunchConfiguration('cors_allowed_origins') + tls_enabled = LaunchConfiguration('tls_enabled') + cert_file = LaunchConfiguration('cert_file') + key_file = LaunchConfiguration('key_file') + auth_enabled = LaunchConfiguration('auth_enabled') + jwt_secret = LaunchConfiguration('jwt_secret') + auth_clients = LaunchConfiguration('auth_clients') args = [ DeclareLaunchArgument( @@ -70,6 +76,32 @@ def generate_launch_description(): default_value='http://localhost:3000,http://localhost:5173', description='Comma-separated CORS origins allowed to call the gateway from a browser, ' 'so the web UI works out of the box. Empty disables CORS.'), + # Forwarded to gateway.launch.py. Without these the gateway's own + # defaults apply and bringup cannot start at all: the shipped config + # turns TLS and authentication on, and the gateway refuses to run + # without a certificate and a signing secret. Passing them here is what + # makes `ros2 launch ... bringup.launch.py tls_enabled:=false ...` a + # complete command rather than a dead end. + DeclareLaunchArgument( + 'tls_enabled', default_value='true', + description='Serve HTTPS. Needs cert_file and key_file; pass false to serve ' + 'plain HTTP on a host nothing else can reach.'), + DeclareLaunchArgument( + 'cert_file', default_value='', + description='PEM certificate for HTTPS. Required while tls_enabled is true.'), + DeclareLaunchArgument( + 'key_file', default_value='', + description='PEM private key matching cert_file.'), + DeclareLaunchArgument( + 'auth_enabled', default_value='true', + description='Require a credential on every request.'), + DeclareLaunchArgument( + 'jwt_secret', default_value='', + description='HS256 signing secret, at least 32 characters. Required while ' + 'auth_enabled is true.'), + DeclareLaunchArgument( + 'auth_clients', default_value='', + description='Comma-separated "client_id:client_secret:role" triples.'), DeclareLaunchArgument( 'enable_fault_manager', default_value='true', description='Start the fault_manager node.'), @@ -90,7 +122,10 @@ def generate_launch_description(): gateway = _include( 'ros2_medkit_gateway', 'gateway.launch.py', launch_arguments={'server_host': server_host, 'server_port': server_port, - 'cors_allowed_origins': cors_allowed_origins}) + 'cors_allowed_origins': cors_allowed_origins, + 'tls_enabled': tls_enabled, 'cert_file': cert_file, + 'key_file': key_file, 'auth_enabled': auth_enabled, + 'jwt_secret': jwt_secret, 'auth_clients': auth_clients}) fault_manager = _include( 'ros2_medkit_fault_manager', 'fault_manager.launch.py', enable_arg='enable_fault_manager', diff --git a/src/ros2_medkit_gateway/launch/gateway.launch.py b/src/ros2_medkit_gateway/launch/gateway.launch.py index 0da999c7f..4425bf4af 100644 --- a/src/ros2_medkit_gateway/launch/gateway.launch.py +++ b/src/ros2_medkit_gateway/launch/gateway.launch.py @@ -94,6 +94,53 @@ def generate_launch_description(): 'controls the periodic forced refresh. Must match the default ' 'in config/gateway_params.yaml.')) + declare_jwt_secret_arg = DeclareLaunchArgument( + 'jwt_secret', default_value='', + description=( + 'HS256 signing secret, at least 32 characters. REQUIRED: the ' + 'shipped config has auth.enabled true, and the gateway refuses to ' + 'start without a secret. Pass one here, or point config_file at a ' + 'file that sets auth.jwt_secret and auth.clients. To run without ' + 'authentication - only ever on a host nothing else can reach - ' + 'pass auth_enabled:=false explicitly.')) + + declare_auth_enabled_arg = DeclareLaunchArgument( + 'auth_enabled', default_value='true', + description=( + 'Require a credential. On by default, matching the shipped ' + 'config. Turning it off makes the entity tree, the fault history ' + 'and every operation readable by anyone who can reach the port.')) + + declare_clients_arg = DeclareLaunchArgument( + 'auth_clients', default_value='', + description=( + 'Comma-separated "client_id:client_secret:role" triples ' + '(roles: viewer, operator, configurator, admin). Needed to obtain ' + 'a token from /auth/token.')) + + declare_tls_enabled_arg = DeclareLaunchArgument( + 'tls_enabled', default_value='true', + description=( + 'Serve HTTPS. On by default, matching the shipped config. Needs ' + 'cert_file and key_file; the gateway refuses to start with TLS on ' + 'and no certificate rather than fall back to plaintext. Pass ' + 'tls_enabled:=false to serve plain HTTP on a host nothing else ' + 'can reach.')) + + declare_cert_file_arg = DeclareLaunchArgument( + 'cert_file', default_value='', + description=( + 'PEM certificate (or full chain) for HTTPS. REQUIRED while ' + 'tls_enabled is true. For a first run, generate a self-signed ' + 'pair with scripts/generate_dev_certs.sh - browsers will warn, ' + 'which is correct for a certificate nothing has vouched for.')) + + declare_key_file_arg = DeclareLaunchArgument( + 'key_file', default_value='', + description=( + 'PEM private key matching cert_file. REQUIRED while tls_enabled ' + 'is true. Keep it chmod 600 and owned by the gateway user.')) + declare_cors_arg = DeclareLaunchArgument( 'cors_allowed_origins', default_value=CORS_DEFAULT, @@ -120,6 +167,57 @@ def _launch_setup(context, *_args, **_kwargs): param_overrides.update(cors_override( LaunchConfiguration('cors_allowed_origins').perform(context), LaunchConfiguration('config_file').perform(context), default_config)) + + tls_enabled = LaunchConfiguration('tls_enabled').perform(context).lower() in ( + 'true', '1', 'yes') + # Environment override read BEFORE the value is used: the container + # image serves plain HTTP behind whatever terminates TLS for it, and it + # has no certificate of its own to offer. + if os.environ.get('MEDKIT_TLS_DISABLED') == '1': + tls_enabled = False + param_overrides['server.tls.enabled'] = tls_enabled + cert_file = (LaunchConfiguration('cert_file').perform(context) + or os.environ.get('MEDKIT_TLS_CERT_FILE', '')) + key_file = (LaunchConfiguration('key_file').perform(context) + or os.environ.get('MEDKIT_TLS_KEY_FILE', '')) + if cert_file: + param_overrides['server.tls.cert_file'] = cert_file + if key_file: + param_overrides['server.tls.key_file'] = key_file + if tls_enabled and not (cert_file and key_file): + # The gateway would refuse to start a moment from now, naming the + # config file. Name the launch arguments instead, here, where they + # are the thing the reader can actually change. + print('[gateway.launch.py] TLS is enabled and cert_file/key_file were not both ' + 'given. Pass cert_file:= key_file:=, set them in a config_file, ' + 'or pass tls_enabled:=false to serve plain HTTP. ' + 'scripts/generate_dev_certs.sh makes a self-signed pair for a first run.') + + # Launch argument first, then the environment. The environment path is + # what makes the container image work: its entrypoint generates a + # per-container credential and exports it, and `ros2 launch` inside that + # container has no other way to receive it. + auth_enabled = LaunchConfiguration('auth_enabled').perform(context).lower() in ( + 'true', '1', 'yes') + if os.environ.get('MEDKIT_AUTH_DISABLED') == '1': + auth_enabled = False + param_overrides['auth.enabled'] = auth_enabled + jwt_secret = (LaunchConfiguration('jwt_secret').perform(context) + or os.environ.get('MEDKIT_JWT_SECRET', '')) + clients = (LaunchConfiguration('auth_clients').perform(context) + or os.environ.get('MEDKIT_CLIENTS', '')) + if jwt_secret: + param_overrides['auth.jwt_secret'] = jwt_secret + if clients: + param_overrides['auth.clients'] = [c for c in clients.split(',') if c] + if auth_enabled and not jwt_secret: + # The gateway would refuse to start a moment from now with a + # message about the config file. Say the actionable thing instead, + # here, where the launch argument that fixes it is in scope. + print('[gateway.launch.py] auth is enabled and no jwt_secret was given. ' + 'Pass jwt_secret:= and ' + 'auth_clients:=::admin, set them in a config_file, ' + 'or pass auth_enabled:=false to run without authentication.') return [Node( package='ros2_medkit_gateway', executable='gateway_node', @@ -133,6 +231,12 @@ def _launch_setup(context, *_args, **_kwargs): declare_host_arg, declare_port_arg, declare_refresh_arg, + declare_auth_enabled_arg, + declare_jwt_secret_arg, + declare_clients_arg, + declare_tls_enabled_arg, + declare_cert_file_arg, + declare_key_file_arg, declare_cors_arg, OpaqueFunction(function=_launch_setup), ]) diff --git a/src/ros2_medkit_gateway/launch/gateway_https.launch.py b/src/ros2_medkit_gateway/launch/gateway_https.launch.py index b7a269100..3bc70649b 100644 --- a/src/ros2_medkit_gateway/launch/gateway_https.launch.py +++ b/src/ros2_medkit_gateway/launch/gateway_https.launch.py @@ -90,7 +90,13 @@ def generate_certificates(cert_dir: str) -> dict: return { 'cert_file': cert_file, 'key_file': key_file, - 'ca_file': ca_file if os.path.exists(ca_file) else '', + # Deliberately NOT passed as server.tls.ca_file. This CA signs the + # SERVER certificate so a client can verify the gateway; setting it + # as the gateway's ca_file turns on mutual TLS and rejects every + # client that has no certificate of its own, including the curl + # this launch file prints. Kept here only so the hint below can + # tell the user which CA to pass with --cacert. + 'ca_file_for_client': ca_file if os.path.exists(ca_file) else '', } os.makedirs(cert_dir, exist_ok=True) @@ -153,7 +159,9 @@ def generate_certificates(cert_dir: str) -> dict: return { 'cert_file': cert_file, 'key_file': key_file, - 'ca_file': ca_file, + # See the note above: this is the CA a CLIENT verifies the server with, + # not a client-certificate authority for the gateway to demand. + 'ca_file_for_client': ca_file, } @@ -196,7 +204,7 @@ def launch_setup(context): LogInfo(msg=[f' curl -k https://{server_host}:{server_port}/api/v1/health']), LogInfo(msg=['']), LogInfo(msg=['Test with CA verification:']), - LogInfo(msg=[f' curl --cacert {cert_paths["ca_file"]} ' + LogInfo(msg=[f' curl --cacert {cert_paths["ca_file_for_client"]} ' f'https://{server_host}:{server_port}/api/v1/health']), LogInfo(msg=['='*60]), diff --git a/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh b/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh old mode 100644 new mode 100755 index 16fd0c216..e14b8551e --- a/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh +++ b/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh @@ -122,7 +122,11 @@ echo " tls:" echo " enabled: true" echo " cert_file: \"$OUTPUT_DIR/cert.pem\"" echo " key_file: \"$OUTPUT_DIR/key.pem\"" -echo " ca_file: \"$OUTPUT_DIR/ca.pem\"" +echo "" +echo " Do NOT add ca_file here. It is not the CA a client verifies the server" +echo " with - setting it makes the gateway REQUIRE a client certificate from" +echo " every caller, and the curl below would then be refused. Pass ca.pem to" +echo " the client with --cacert instead, as shown." echo "" echo "Test with curl:" echo " curl -k https://localhost:8080/api/v1/health" diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp index a0e313d8a..c74d32350 100644 --- a/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp +++ b/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp @@ -26,6 +26,30 @@ namespace ros2_medkit_gateway { +namespace { + +/// Compare two secrets without returning early on the first differing byte. +/// +/// The lengths are compared too, and a length mismatch is reported. That does +/// leak the length, which is acceptable: secrets here are operator-chosen and +/// their length is not the secret. What must not leak is WHICH bytes matched, +/// and the loop below always visits every byte of the expected value. +bool constant_time_equals(const std::string & expected, const std::string & presented) { + // Fold the length difference into the result rather than returning, so both + // branches cost the same. + unsigned char diff = static_cast(expected.size() != presented.size()); + const std::size_t n = expected.size(); + for (std::size_t i = 0; i < n; ++i) { + // Index the presented value modulo its own size so a shorter input cannot + // read out of bounds; the length check above already forced a mismatch. + const unsigned char p = presented.empty() ? 0U : static_cast(presented[i % presented.size()]); + diff |= static_cast(static_cast(expected[i]) ^ p); + } + return diff == 0; +} + +} // namespace + // Helper to read file contents static std::string read_file_contents(const std::string & path) { std::ifstream file(path); @@ -85,8 +109,15 @@ tl::expected AuthManager::authenticate(const s return tl::unexpected(AuthErrorResponse::invalid_client("Client is disabled")); } - // Verify secret - if (client.client_secret != client_secret) { + // Verify secret in constant time. A plain std::string comparison returns as + // soon as two bytes differ, so the time it takes to refuse leaks how many + // leading bytes were right, and a caller who can measure it can recover the + // secret one byte at a time. Every default deployment now needs a configured + // client, so this path is on the critical path for all of them. + // + // Secrets are still stored in plaintext in the configuration; making this + // comparison constant-time does not change that and is not meant to. + if (!constant_time_equals(client.client_secret, client_secret)) { return tl::unexpected(AuthErrorResponse::invalid_client("Invalid client_secret")); } @@ -248,10 +279,27 @@ TokenValidationResult AuthManager::validate_token(const std::string & token, Tok return result; } - // Check if associated refresh token is revoked (for access tokens) + // An access token that names a refresh record is only valid while that + // record is present and not revoked. + // + // Absent counts as invalid, not as "nothing to check". Records live in + // memory, so after a restart the map is empty; treating absence as fine + // would make every revoked token work again until it expired on its own, + // which on the default one-hour expiry is a long time to keep honouring a + // credential somebody explicitly withdrew. + // + // The cost is deliberate and worth stating: a restart invalidates every + // access token, so clients re-authenticate after one. That is visible + // behaviour, and it is the trade this gateway makes elsewhere too - refusing + // is better than quietly allowing. if (claims.refresh_token_id.has_value()) { auto record = get_refresh_token(claims.refresh_token_id.value()); - if (record.has_value() && record->revoked) { + if (!record.has_value()) { + result.valid = false; + result.error = "Associated refresh token is no longer known to this gateway"; + return result; + } + if (record->revoked) { result.valid = false; result.error = "Associated refresh token has been revoked"; return result; @@ -341,13 +389,11 @@ bool AuthManager::revoke_refresh_token(const std::string & refresh_token) { return true; } -size_t AuthManager::cleanup_expired_tokens() { +size_t AuthManager::cleanup_expired_locked() { auto now = std::chrono::system_clock::now(); auto now_ts = std::chrono::duration_cast(now.time_since_epoch()).count(); - std::lock_guard lock(refresh_tokens_mutex_); size_t count = 0; - for (auto it = refresh_tokens_.begin(); it != refresh_tokens_.end();) { if (it->second.expires_at < now_ts) { it = refresh_tokens_.erase(it); @@ -356,10 +402,19 @@ size_t AuthManager::cleanup_expired_tokens() { ++it; } } - return count; } +size_t AuthManager::refresh_token_count() const { + std::lock_guard lock(refresh_tokens_mutex_); + return refresh_tokens_.size(); +} + +size_t AuthManager::cleanup_expired_tokens() { + std::lock_guard lock(refresh_tokens_mutex_); + return cleanup_expired_locked(); +} + bool AuthManager::register_client(const std::string & client_id, const std::string & client_secret, UserRole role) { std::lock_guard lock(clients_mutex_); @@ -600,6 +655,19 @@ bool AuthManager::matches_path(const std::string & pattern, const std::string & void AuthManager::store_refresh_token(const RefreshTokenRecord & record) { std::lock_guard lock(refresh_tokens_mutex_); + + // Sweep before inserting. Nothing else calls the sweep, so without this the + // map keeps one record per successful authorisation for the life of the + // process, and validate_token looks that map up on every authenticated + // request. Doing it here rather than on a timer keeps the bound a property + // of the data structure instead of a property of a thread that might not be + // running, and makes it observable in a test without waiting on wall clock. + // + // The cost is a scan per authorisation. The map only ever holds unexpired + // records, so it is sized by how many tokens are live at once, not by how + // many have ever been issued. + cleanup_expired_locked(); + refresh_tokens_[record.token_id] = record; } diff --git a/src/ros2_medkit_gateway/src/core/config.cpp b/src/ros2_medkit_gateway/src/core/config.cpp index b140bd110..37cb72fc9 100644 --- a/src/ros2_medkit_gateway/src/core/config.cpp +++ b/src/ros2_medkit_gateway/src/core/config.cpp @@ -58,11 +58,6 @@ std::string TlsConfig::validate() const { return "TLS: ca_file does not exist or is not readable: " + ca_file; } - // TODO(future): Add mutual TLS validation when implemented - // if (mutual_tls && ca_file.empty()) { - // return "TLS: ca_file is required when mutual_tls is enabled"; - // } - // Validate minimum TLS version if (min_version != "1.2" && min_version != "1.3") { return "TLS: min_version must be '1.2' or '1.3', got: " + min_version; diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 520945a7f..cb07ed3cb 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -435,7 +435,6 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki .with_key_file(get_parameter("server.tls.key_file").as_string()) .with_ca_file(get_parameter("server.tls.ca_file").as_string()) .with_min_version(get_parameter("server.tls.min_version").as_string()) - // TODO(future): Add .with_mutual_tls() when implemented .build(); // Note: HttpServerManager will log TLS configuration details } catch (const std::exception & e) { diff --git a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index 42324b8e2..ae0300634 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -17,6 +17,7 @@ #include #include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" +#include "ros2_medkit_gateway/core/auth/auth_middleware.hpp" #include "ros2_medkit_gateway/core/auth/auth_models.hpp" #include "ros2_medkit_gateway/core/data/topic_data_provider.hpp" #include "ros2_medkit_gateway/core/discovery/discovery_enums.hpp" @@ -50,13 +51,53 @@ ErrorInfo make_internal_error(const char * where, const std::exception & e) { } // namespace +namespace { + +/// True when authentication is on and this request did not present a token +/// this gateway accepts. +/// +/// /health is the one route the "all" policy lets through unauthenticated, so +/// that a container supervisor or load balancer can tell the process is alive +/// without holding a credential. That exemption is only defensible while the +/// body says nothing an anonymous caller should not learn - and the full body +/// does: the linking warnings name entities and ROS node FQNs, and the entity +/// cache reports how many apps, areas and components this gateway sees. The +/// probe needs none of that, so an anonymous caller gets the liveness answer +/// and nothing else, and an authenticated one gets the whole document. +bool is_anonymous(const HandlerContext & ctx, const http::TypedRequest & req) { + if (!ctx.auth_config().enabled) { + return false; // Nothing is anonymous when nothing is authenticated. + } + auto * manager = ctx.auth_manager(); + if (manager == nullptr) { + return true; // Fail closed: cannot verify, so do not disclose. + } + auto header = req.header("Authorization"); + if (!header) { + return true; + } + auto token = AuthMiddleware::extract_bearer_token(*header); + if (!token) { + return true; + } + return !manager->validate_token(*token).valid; +} + +} // namespace + http::Result HealthHandlers::get_health(const http::TypedRequest & req) { - (void)req; // Unused parameter try { dto::Health response; response.status = "healthy"; response.timestamp = std::chrono::system_clock::now().time_since_epoch().count(); + // Liveness and nothing else for an anonymous caller. Returned before any + // of the sections below are built, so a section added later is private by + // default rather than public until someone remembers to think about it. + if (is_anonymous(ctx_, req)) { + return response; + } + // Operator-actionable warnings the gateway flags without taking itself // offline. Collected across every subsystem that can produce one, so the // array and its schema version are part of the /health contract whether or diff --git a/src/ros2_medkit_gateway/src/http/http_server.cpp b/src/ros2_medkit_gateway/src/http/http_server.cpp index 3979bd477..45ebff98b 100644 --- a/src/ros2_medkit_gateway/src/http/http_server.cpp +++ b/src/ros2_medkit_gateway/src/http/http_server.cpp @@ -17,6 +17,11 @@ #include #include +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT +// TLS1_2_VERSION / TLS1_3_VERSION and SSL_CTX_set_min_proto_version. +#include +#endif + namespace ros2_medkit_gateway { HttpServerManager::HttpServerManager(const TlsConfig & tls_config, std::size_t thread_pool_size, @@ -24,8 +29,19 @@ HttpServerManager::HttpServerManager(const TlsConfig & tls_config, std::size_t t : tls_config_(tls_config), thread_pool_size_(thread_pool_size), keep_alive_timeout_sec_(keep_alive_timeout_sec) { #ifdef CPPHTTPLIB_OPENSSL_SUPPORT if (tls_config_.enabled) { - // Create SSL server with certificate and key - ssl_server_ = std::make_unique(tls_config_.cert_file.c_str(), tls_config_.key_file.c_str()); + // A non-empty ca_file turns on mutual TLS. The SSLServer constructor does + // the work itself: given a client CA path it calls + // SSL_CTX_load_verify_locations and then + // SSL_CTX_set_verify(SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT). + // + // That pairing is why this is all-or-nothing per gateway: with a CA set, + // a client that presents NO certificate is rejected at the handshake. + // There is no "verify it if offered" middle setting without patching the + // vendored header. Leaving ca_file empty keeps ordinary server-only TLS, + // which is what the SOVD bearer-token flow expects. + ssl_server_ = + std::make_unique(tls_config_.cert_file.c_str(), tls_config_.key_file.c_str(), + tls_config_.ca_file.empty() ? nullptr : tls_config_.ca_file.c_str()); if (!ssl_server_->is_valid()) { throw std::runtime_error( @@ -38,8 +54,10 @@ HttpServerManager::HttpServerManager(const TlsConfig & tls_config, std::size_t t apply_thread_pool(*ssl_server_); apply_keep_alive(*ssl_server_); - RCLCPP_INFO(rclcpp::get_logger("http_server"), "TLS/HTTPS enabled - cert: %s, min_version: %s", - tls_config_.cert_file.c_str(), tls_config_.min_version.c_str()); + RCLCPP_INFO(rclcpp::get_logger("http_server"), + "TLS/HTTPS enabled - cert: %s, min_version: %s, client certificates: %s", tls_config_.cert_file.c_str(), + tls_config_.min_version.c_str(), + tls_config_.ca_file.empty() ? "not required" : "REQUIRED (mutual TLS)"); // Note: key_file path intentionally not logged for security reasons } else { server_ = std::make_unique(); @@ -138,26 +156,25 @@ void HttpServerManager::configure_tls() { return; } - // YAGNI Decision: min_version field exists in TlsConfig for future extensibility - // but is not fully implemented. - // - // Rationale: - // - cpp-httplib's SSLServer doesn't expose SSL_CTX for min_version configuration - // - Modern OpenSSL (1.1.1+) defaults to TLS 1.2+ which is secure + // Set the protocol floor on our own context rather than inheriting one. // - // Future implementation options: - // 1. Fork cpp-httplib to expose SSL_CTX for SSL_CTX_set_min_proto_version() - // 2. Use OpenSSL system-wide configuration (/etc/ssl/openssl.cnf) - // 3. Replace cpp-httplib with Boost.Beast or another library with full SSL control + // Two reasons it has to be us. The SSLServer constructor calls + // SSL_CTX_set_min_proto_version(ctx_, TLS1_1_VERSION), so the library asks + // for a floor of TLS 1.1. What a deployment actually gets on top of that is + // whatever the local OpenSSL policy allows, which differs between the + // distributions we ship for. Neither of those is a decision this project + // made, and SOVD requires TLS 1.2 as the minimum, so the value is set here + // where it can be read and tested. // - // TODO(future): Add mutual TLS support - requires cpp-httplib modifications - // to expose SSL_CTX for SSL_CTX_set_verify() with SSL_VERIFY_PEER - - if (tls_config_.min_version != "1.2") { - RCLCPP_WARN(rclcpp::get_logger("http_server"), - "min_version='%s' requested but cpp-httplib uses OpenSSL defaults (TLS 1.2+). " - "Custom min_version not enforced.", - tls_config_.min_version.c_str()); + // The string is validated in TlsConfig::validate(), which rejects anything + // other than "1.2" or "1.3" before a server is ever constructed. + const int min_proto = (tls_config_.min_version == "1.3") ? TLS1_3_VERSION : TLS1_2_VERSION; + SSL_CTX * ctx = ssl_server_->ssl_context(); + if (ctx == nullptr || SSL_CTX_set_min_proto_version(ctx, min_proto) != 1) { + // Refuse to serve rather than fall back to the library floor: a caller + // that asked for 1.3 and silently got 1.1 is worse off than one that got + // an error, because nothing downstream can tell the difference. + throw std::runtime_error("Failed to set the minimum TLS version to " + tls_config_.min_version); } // Log TLS handshake failures for debugging diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index ec4d8a603..41ba065bc 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -239,7 +239,37 @@ void RESTServer::setup_pre_routing_handler() { // Set up pre-routing handler for CORS and Authentication // This handler runs before any route handler srv->set_pre_routing_handler([this](const httplib::Request & req, httplib::Response & res) { - // 1. Handle CORS (existing logic) + // 1. Authentication, before anything else can answer. + // + // Order matters and it used to be wrong. CORS preflight below answers an + // OPTIONS with 204, and the rate limiter answers with 429; both used to run + // first and both RETURN Handled, so on a gateway with require_auth_for + // "all" an anonymous caller could still get a real answer out of a + // protected route. That is two exemptions nobody declared, and they appear + // exactly when an operator enables CORS to let a browser talk to the box. + // + // Putting auth first costs a preflight its CORS headers when the caller has + // no credential, which is correct: a browser that cannot authenticate has + // no business being told what the box would have allowed. + if (auth_middleware_ && auth_middleware_->is_enabled()) { + auto auth_request = AuthMiddleware::from_httplib_request(req); + auto result = auth_middleware_->process(auth_request); + + if (!result.allowed) { + // CORS headers on the refusal itself, so a browser sees a 401 rather + // than an opaque network error it cannot report to the user. + if (cors_config_.enabled) { + std::string origin = req.get_header_value("Origin"); + if (!origin.empty() && is_origin_allowed(origin)) { + set_cors_headers(res, origin); + } + } + AuthMiddleware::apply_to_response(result, res); + return httplib::Server::HandlerResponse::Handled; + } + } + + // 2. Handle CORS (existing logic) if (cors_config_.enabled) { std::string origin = req.get_header_value("Origin"); bool origin_allowed = !origin.empty() && is_origin_allowed(origin); @@ -279,18 +309,6 @@ void RESTServer::setup_pre_routing_handler() { // 1. Handle CORS (existing logic) - // Handle Authentication if enabled - if (auth_middleware_ && auth_middleware_->is_enabled()) { - // Use AuthMiddleware to process the request - auto auth_request = AuthMiddleware::from_httplib_request(req); - auto result = auth_middleware_->process(auth_request); - - if (!result.allowed) { - AuthMiddleware::apply_to_response(result, res); - return httplib::Server::HandlerResponse::Handled; - } - } - return httplib::Server::HandlerResponse::Unhandled; }); } diff --git a/src/ros2_medkit_gateway/test/test_auth_manager.cpp b/src/ros2_medkit_gateway/test/test_auth_manager.cpp index 8cb883ff5..d6ebe7271 100644 --- a/src/ros2_medkit_gateway/test/test_auth_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_auth_manager.cpp @@ -586,6 +586,133 @@ TEST_F(AuthManagerTest, CleanupExpiredTokens) { EXPECT_GE(cleaned, 1); } +// --------------------------------------------------------------------------- +// Refresh-record growth, constant-time secret comparison, and revocation. +// --------------------------------------------------------------------------- + +namespace { + +/// A manager with a single admin client, parameterised on the two expiry +/// values, so a test can put them at their endpoints rather than at one +/// comfortable middle value. +AuthManager make_manager(int access_expiry, int refresh_expiry) { + auto config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("expiry_sweep_secret_key_at_least_32_chars_long") + .with_require_auth_for(AuthRequirement::ALL) + .with_token_expiry(access_expiry) + .with_refresh_token_expiry(refresh_expiry) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + return AuthManager(config); +} + +} // namespace + +// The sweep exists but had no production caller, so the map grew by one record +// per successful authorisation for the life of the process. What this asserts +// is the COUNT, because a sweep that is never invoked returns the right answer +// when a test calls it directly and still leaks in production. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerTokenLifetimeTest, RepeatedLoginsDoNotGrowTheStoreWithoutBound) { + // Refresh expiry at its minimum legal value: validate() requires + // refresh >= access, so this is the endpoint, not a convenient number. + auto manager = make_manager(1, 1); + + for (int i = 0; i < 5; ++i) { + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + } + EXPECT_EQ(manager.refresh_token_count(), 5U) << "records should accumulate while they are live"; + + // Past the refresh expiry, the next authorisation must clear them out. + std::this_thread::sleep_for(std::chrono::seconds(2)); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + + EXPECT_EQ(manager.refresh_token_count(), 1U) + << "the five expired records survived a later authorisation - the sweep is not running"; +} + +// The other endpoint. A long-lived refresh token must NOT be swept: an +// over-eager sweep would log clients out mid-session, which is the opposite +// failure and just as real. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerTokenLifetimeTest, LongLivedRecordsAreNotSweptEarly) { + auto manager = make_manager(1, 86400); + + for (int i = 0; i < 4; ++i) { + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + } + std::this_thread::sleep_for(std::chrono::seconds(2)); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + + EXPECT_EQ(manager.refresh_token_count(), 5U) << "records well inside their expiry were discarded"; +} + +// Degenerate case: access and refresh expiry equal and both large. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerTokenLifetimeTest, EqualAccessAndRefreshExpiryKeepsRecords) { + auto manager = make_manager(3600, 3600); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + EXPECT_EQ(manager.refresh_token_count(), 2U); +} + +// A wrong secret must be refused whatever its shape. The interesting inputs +// are the ones a short-circuiting comparison treats differently from a +// constant-time one: a correct prefix, and a value that extends the real one. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerSecretComparisonTest, OnlyTheExactSecretAuthenticates) { + auto manager = make_manager(3600, 3600); + + EXPECT_TRUE(manager.authenticate("svc", "svc_secret").has_value()) << "the real secret must work"; + + // The last two are the ones that matter. Everything before them differs in + // length, or in the first byte, so a comparison that checked the length and + // then only a prefix would satisfy the whole list. "svc_secreT" differs only + // in the FINAL byte: shorten the comparison loop by one and it is accepted + // while every other case here still fails correctly. + for (const auto & wrong : + {"", "s", "svc_secre", "svc_secret_", "svc_secretX", "SVC_SECRET", "xxxxxxxxxx", "svc_secreT", "Svc_secret"}) { + EXPECT_FALSE(manager.authenticate("svc", wrong).has_value()) << "secret \"" << wrong << "\" was accepted"; + } +} + +// An access token whose refresh record is gone is invalid, not "unchecked". +// Records are in memory, so this is also what a gateway restart looks like to +// a token issued before it: the deliberate consequence is that a restart makes +// clients re-authenticate. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, AnAccessTokenWithNoSurvivingRecordIsRejected) { + auto manager = make_manager(3600, 3600); + auto issued = manager.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + + EXPECT_TRUE(manager.validate_token(issued->access_token).valid) + << "the token must be valid while its record is present"; + + // Revoking drops or marks the record; either way the access token that names + // it must stop being accepted. + ASSERT_TRUE(issued->refresh_token.has_value()); + ASSERT_TRUE(manager.revoke_refresh_token(issued->refresh_token.value())); + EXPECT_FALSE(manager.validate_token(issued->access_token).valid) + << "an access token whose refresh record was revoked was still accepted"; +} + +// A second manager standing in for the same gateway after a restart: same +// secret and issuer, so the signature still verifies, but no records. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, ARestartInvalidatesAccessTokensRatherThanTrustingThem) { + auto before = make_manager(3600, 3600); + auto issued = before.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(before.validate_token(issued->access_token).valid); + + auto after_restart = make_manager(3600, 3600); + EXPECT_FALSE(after_restart.validate_token(issued->access_token).valid) + << "a token from before the restart was accepted although the gateway has no record of it - " + "a revoked token would come back to life this way"; +} + // Test JwtClaims TEST(JwtClaimsTest, ToJson) { JwtClaims claims; @@ -1033,6 +1160,51 @@ TEST_F(AuthRequirementPolicyTest, AllAuthPolicyAlwaysRequiresAuth) { EXPECT_TRUE(policy.requires_authentication("DELETE", "/api/v1/admin/users")); } +// The health probe is the one route the ALL policy lets through, and its +// narrowness is the whole reason it is safe. Widening it to "any method on +// /health", or to any path merely CONTAINING /health, is the natural next edit +// and would open a hole, so the boundary is pinned here. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, AllAuthPolicyExemptsOnlyGetHealth) { + AllAuthRequirementPolicy policy; + + // Exempt: a container supervisor and a load balancer probe this with no + // credential, and requiring one turns a healthy gateway into a restart loop. + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/health")); + + // Only GET. A write to the health path is not a liveness probe. + EXPECT_TRUE(policy.requires_authentication("POST", "/api/v1/health")); + EXPECT_TRUE(policy.requires_authentication("PUT", "/api/v1/health")); + EXPECT_TRUE(policy.requires_authentication("DELETE", "/api/v1/health")); + EXPECT_TRUE(policy.requires_authentication("PATCH", "/api/v1/health")); + EXPECT_TRUE(policy.requires_authentication("HEAD", "/api/v1/health")); + + // Only that exact path. A prefix or suffix match would hand an attacker a + // trivial bypass: append or prepend the magic word and walk in. + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/health/detail")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/healthz")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/components/health")); + EXPECT_TRUE(policy.requires_authentication("GET", "/health")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/health?x=1")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v2/health")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, AllAuthPolicyExemptsAuthEndpoints) { + AllAuthRequirementPolicy policy; + + // Authentication cannot bootstrap through a door that already demands the + // credential it exists to hand out. + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/auth/authorize")); + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/auth/token")); + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/auth/revoke")); + + // The prefix must be anchored: a path that merely mentions auth later is + // not an auth endpoint. + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/components/auth/data")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/authorization")); +} + // @verifies REQ_INTEROP_086 TEST_F(AuthRequirementPolicyTest, WriteOnlyPolicyForGetRequests) { WriteOnlyAuthRequirementPolicy policy; diff --git a/src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py b/src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py new file mode 100644 index 000000000..948e339b4 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Every route refuses an uncredentialed request under the shipped defaults. + +The gateway's own half of the closed-door acceptance. It does not check +configuration values: it asks the RUNNING gateway for its route table and then +probes every route in it. A test that asserted `require_auth_for == "all"` +would keep passing the day a route is registered outside the policy, which is +the failure this is here to catch. + +The route table comes from RouteRegistry via `GET /api/v1/`, so a route added +next year is swept the day it is registered, with nothing here to update. + +Two exemptions, and both are named with their reason in EXEMPT below. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import create_gateway_node + +CLOSED_PORT = get_test_port() +CLOSED_BASE_URL = f'http://127.0.0.1:{CLOSED_PORT}{API_BASE_PATH}' +CLOSED_ROOT = f'http://127.0.0.1:{CLOSED_PORT}' + +# At least 32 characters, or the gateway refuses to start under HS256. +JWT_SECRET = 'closed_by_default_integration_secret_key_0123456789' +CLIENT_ID = 'diagbox' +CLIENT_SECRET = 'diagbox_client_secret' + +# A path parameter is filled with an id that exists on no gateway. A route that +# refuses for a nonexistent id refuses for a real one, and a probe that turns +# out to reach an OPEN route cannot mutate anything real. +PROBE_ID = 'closed-by-default-probe' + + +@pytest.mark.launch_test +def generate_test_description(): + """Gateway with the posture the shipped default config carries.""" + gateway_node = create_gateway_node( + port=CLOSED_PORT, + extra_params={ + 'server.host': '127.0.0.1', + # These three are what config/gateway_params.yaml now ships. The + # secret and client cannot come from that file (a committed secret + # is a secret every deployment shares), so they are supplied here + # the way a deployment supplies them. + 'auth.enabled': True, + 'auth.require_auth_for': 'all', + 'auth.issuer': 'ros2_medkit_gateway', + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + }, + ) + + return launch.LaunchDescription([ + gateway_node, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node} + + +def _is_exempt(method, path): + """Routes that are deliberately reachable without a credential. + + GET /health - a container supervisor and an upstream load balancer probe + it to decide whether this process is alive, and neither holds a + credential; requiring one turns a healthy gateway into a restart loop. The + body is a fixed status document: no entity names, no topics, no data. + + /auth/* - token issuance. Authentication cannot bootstrap through a door + that already demands the credential it exists to hand out. + """ + if method == 'GET' and path == f'{API_BASE_PATH}/health': + return True + return path.startswith(f'{API_BASE_PATH}/auth/') + + +class TestClosedByDefault(GatewayTestCase): + """The gateway refuses every route it serves, bar the named exemptions.""" + + BASE_URL = CLOSED_BASE_URL + + @classmethod + def setUpClass(cls): + super().setUpClass() + resp = requests.post( + f'{CLOSED_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=10, + ) + assert resp.status_code == 200, f'could not obtain a token: {resp.status_code} {resp.text}' + cls.token = resp.json()['access_token'] + cls.auth = {'Authorization': f'Bearer {cls.token}'} + + # The route table, read from the gateway itself. A hardened gateway + # does not list its routes anonymously, so this read authenticates. + root = requests.get(f'{CLOSED_BASE_URL}/', headers=cls.auth, timeout=10) + assert root.status_code == 200, f'route table unreadable: {root.status_code}' + cls.endpoints = root.json().get('endpoints', []) + assert cls.endpoints, 'gateway reported no endpoints - nothing would be proven' + + @staticmethod + def _fill(path): + out, depth = [], 0 + for ch in path: + if ch == '{': + depth += 1 + if depth == 1: + out.append(PROBE_ID) + elif ch == '}': + depth -= 1 + elif depth == 0: + out.append(ch) + return ''.join(out) + + def test_01_route_table_is_substantial(self): + """A sweep over three routes would prove almost nothing.""" + self.assertGreater( + len(self.endpoints), 50, + f'expected the full gateway surface, got {len(self.endpoints)} routes' + ) + + def test_02_no_route_answers_without_a_credential(self): + """Sweep EVERY registered route. This is the acceptance.""" + answered = [] + for entry in self.endpoints: + method, _, raw = entry.partition(' ') + if not raw: + continue + if _is_exempt(method, raw): + continue + path = self._fill(raw) + # A write method needs a body: without Content-Length the server + # waits for one that never arrives and the probe times out with no + # status, measuring nothing at all. + kwargs = {'timeout': 15} + if method in ('POST', 'PUT', 'PATCH'): + kwargs['json'] = {} + try: + resp = requests.request(method, f'{CLOSED_ROOT}{path}', **kwargs) + except requests.RequestException as exc: + answered.append(f'{method} {path} -> transport error {exc}') + continue + # 401/403 only. A 404 is an ANSWER: the gateway parsed the request + # and told an anonymous caller what does not exist here. + if resp.status_code not in (401, 403): + answered.append(f'{method} {path} -> {resp.status_code}') + + self.assertEqual( + [], answered, + 'these routes answered an uncredentialed request:\n ' + + '\n '.join(answered) + ) + + def test_03_a_wrong_credential_is_refused_everywhere(self): + """A token this gateway never issued gets no further than none at all.""" + bad = {'Authorization': 'Bearer not.a.real.token'} + answered = [] + for entry in self.endpoints: + method, _, raw = entry.partition(' ') + if not raw or _is_exempt(method, raw): + continue + path = self._fill(raw) + kwargs = {'timeout': 15, 'headers': bad} + if method in ('POST', 'PUT', 'PATCH'): + kwargs['json'] = {} + try: + resp = requests.request(method, f'{CLOSED_ROOT}{path}', **kwargs) + except requests.RequestException as exc: + answered.append(f'{method} {path} -> transport error {exc}') + continue + if resp.status_code not in (401, 403): + answered.append(f'{method} {path} -> {resp.status_code}') + + self.assertEqual( + [], answered, + 'these routes accepted a forged credential:\n ' + '\n '.join(answered) + ) + + def test_04_reads_are_refused_not_just_writes(self): + """The require_auth_for="write" hole, pinned directly. + + Under "write" every one of these answers 200 to an anonymous caller, + and they are the disclosure: the entity tree names the machines. + """ + for path in ('/', '/areas', '/components', '/apps', '/functions', '/version-info'): + with self.subTest(path=path): + resp = requests.get(f'{CLOSED_BASE_URL}{path}', timeout=15) + self.assertIn(resp.status_code, (401, 403)) + + def test_05_the_exempt_health_route_still_answers(self): + """The mirror of the sweeps. + + Without this, a gateway that refused EVERYTHING would pass every test + above while being uniformly broken - the container supervisor could + not tell it was alive, and it would restart forever. + """ + resp = requests.get(f'{CLOSED_BASE_URL}/health', timeout=15) + self.assertEqual(resp.status_code, 200, 'health must stay probe-able') + + def test_06_anonymous_health_is_liveness_and_nothing_else(self): + """The exemption rests on the body saying nothing, so check the body. + + An allowlist, not a denylist. Listing the fields known to leak today + would pass the day a new section is added, and the whole reason this + route is public is that a probe needs no more than "am I alive". + """ + body = requests.get(f'{CLOSED_BASE_URL}/health', timeout=15).json() + # warnings and its schema version are always serialised - they are part + # of the /health contract whether or not anything produced one - so the + # allowlist includes them and the assertion below covers the content. + self.assertEqual( + set(body), {'status', 'timestamp', 'warnings', 'warning_schema_version'}, + f'an anonymous /health returned more than liveness: {body}' + ) + self.assertEqual(body['status'], 'healthy') + # The array is the leak vector: a linking warning reads like + # "App 'engine_ecu' cannot bind to '/nav/controller'", naming an entity + # and a ROS node FQN. Empty is the only safe value for a caller that + # presented nothing. + self.assertEqual( + body['warnings'], [], + f'an anonymous /health carried warning text: {body["warnings"]}' + ) + + def test_06b_the_full_health_document_names_entities(self): + """The reason test_06 matters, pinned so it cannot be argued away. + + With a credential the same route returns discovery state and entity + cache counts. That is a legitimate operator surface, and it is exactly + what an anonymous caller must not receive - so if this ever stops being + true, the narrowing above has become pointless and should be revisited + rather than left as dead weight. + """ + body = requests.get( + f'{CLOSED_BASE_URL}/health', headers=self.auth, timeout=15 + ).json() + self.assertIn('discovery', body) + self.assertIn('x-medkit-entity-cache', body) + self.assertGreater( + len(set(body)), 2, + 'the authenticated /health is now as bare as the anonymous one' + ) + + def test_06c_a_forged_credential_gets_the_bare_body(self): + """A token this gateway never issued is an anonymous caller.""" + body = requests.get( + f'{CLOSED_BASE_URL}/health', + headers={'Authorization': 'Bearer not.a.real.token'}, + timeout=15, + ).json() + self.assertEqual( + set(body), {'status', 'timestamp', 'warnings', 'warning_schema_version'}, + f'a forged credential unlocked the full health document: {body}' + ) + self.assertEqual(body['warnings'], []) + + def test_07_a_valid_credential_gets_through(self): + """Otherwise the sweeps above would pass on a gateway that serves nobody.""" + resp = requests.get(f'{CLOSED_BASE_URL}/areas', headers=self.auth, timeout=15) + self.assertEqual(resp.status_code, 200) + + def test_08_head_on_health_is_not_a_way_in(self): + """The exemption is GET-only, deliberately. + + Widening it to "any method on /health" is the natural next edit and it + would be wrong, so the narrowness is pinned here. + """ + # HEAD first, and separately, because it is the method that matters and + # the one an earlier version of this test left out despite its name. + # cpp-httplib dispatches HEAD into the GET handler table, so if the + # exemption stopped checking the method, HEAD would return the status + # document to an anonymous caller. The others have no handler at all on + # this path, so they answer 404 either way and cannot show the + # difference on their own. + head = requests.head(f'{CLOSED_BASE_URL}/health', timeout=15) + self.assertIn( + head.status_code, (401, 403), + f'HEAD /health answered {head.status_code} to an anonymous caller' + ) + + for method in ('POST', 'PUT', 'DELETE', 'PATCH'): + with self.subTest(method=method): + resp = requests.request( + method, f'{CLOSED_BASE_URL}/health', json={}, timeout=15 + ) + self.assertNotEqual( + resp.status_code, 200, + f'{method} /health answered 200 to an anonymous caller' + ) + + +@launch_testing.post_shutdown_test() +class TestClosedByDefaultShutdown(unittest.TestCase): + """Gateway exits cleanly.""" + + def test_exit_codes(self, proc_info, gateway_node): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=gateway_node + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_peer_recovery.test.py b/src/ros2_medkit_integration_tests/test/features/test_peer_recovery.test.py index 7aedd42e2..ee029d0d1 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_peer_recovery.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_peer_recovery.test.py @@ -825,9 +825,20 @@ def test_07_a_peer_owned_read_succeeds_again_and_the_peer_answered_it(self): 'test_04 must watch this URL fail before test_07 can claim it recovered', ) + # Poll for the condition this test actually asserts, not merely for a + # 200. Recovery has two steps that finish at different times: the route + # comes back, and then a sample arrives on the re-created subscription. + # Between them the read answers 200 with status "metadata_only" and an + # empty body, so a poll that stops at the status code hands the + # assertions below a response taken from that window - and the wider + # the machine's load, the wider the window. def served(): answer = self._aggregate_read_of_peer_topic() - return answer if answer.status_code == 200 else None + if answer.status_code != 200: + return None + if answer.json().get('x-medkit', {}).get('status') != 'data': + return None + return answer response = _poll(served, timeout=RECOVERY_TIMEOUT) self.assertIsNotNone( diff --git a/src/ros2_medkit_integration_tests/test/features/test_shipped_defaults.test.py b/src/ros2_medkit_integration_tests/test/features/test_shipped_defaults.test.py new file mode 100644 index 000000000..5f3c32a75 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_shipped_defaults.test.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Boot the gateway from the SHIPPED config file and check the posture. + +Every other test in this suite builds its parameters inline, which is fine for +testing behaviour but means nothing here ever loaded +``config/gateway_params.yaml``. That left the branch in an odd state: the file +could be reverted to ``auth.enabled: false`` and the whole suite would stay +green, because each test supplies the values it needs itself. + +This file closes that gap. It launches with ``--params-file`` pointing at the +installed copy of the shipped config, overriding only the port, the signing +secret and the client - the three things a real deployment must supply and the +file deliberately leaves empty - and then checks that what ships is closed. + +TLS is turned off here and only here. The shipped file has it on, which is +correct, but a certificate is a deployment artefact and generating one would +test the certificate rather than the posture. ``test_tls_protocol_floor`` +covers TLS itself against real handshakes. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import os +import socket +import time +import unittest + +from ament_index_python.packages import get_package_share_directory +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.coverage import get_coverage_env + +PORT = get_test_port() +BASE_URL = f'http://127.0.0.1:{PORT}{API_BASE_PATH}' + +SHIPPED_PARAMS = os.path.join( + get_package_share_directory('ros2_medkit_gateway'), 'config', 'gateway_params.yaml' +) + +JWT_SECRET = 'shipped_defaults_integration_secret_key_0123456789' +CLIENT_ID = 'shipped' +CLIENT_SECRET = 'shipped_client_secret' + + +@pytest.mark.launch_test +def generate_test_description(): + """Launch the gateway with the shipped params file, plus the required secrets.""" + gateway_node = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='ros2_medkit_gateway', + output='screen', + parameters=[ + SHIPPED_PARAMS, + { + 'server.host': '127.0.0.1', + 'server.port': PORT, + 'refresh_interval_ms': 1000, + # A certificate is a deployment artefact, not part of the + # posture under test here. + 'server.tls.enabled': False, + # What the shipped file leaves empty on purpose. + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + }, + ], + additional_env=dict(get_coverage_env()), + ) + + return launch.LaunchDescription([ + gateway_node, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node} + + +def _wait_listening(port, timeout=90.0): + """Block until the gateway accepts a connection. + + launch_testing starts the tests when the process is spawned, not when it is + serving. Without this the first request is refused by a gateway that simply + has not opened its socket yet, which looks nothing like the posture this + file is about. + + The timeout is generous because this gateway loads the full shipped config, + which does more work at startup than the inline parameter sets the rest of + the suite uses. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(('127.0.0.1', port), timeout=2): + return + except OSError: + time.sleep(0.25) + raise AssertionError(f'gateway on port {port} never started listening within {timeout}s') + + +class TestShippedDefaults(unittest.TestCase): + """What config/gateway_params.yaml actually produces.""" + + @classmethod + def setUpClass(cls): + _wait_listening(PORT) + resp = requests.post( + f'{BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + assert resp.status_code == 200, f'token request failed: {resp.status_code} {resp.text}' + cls.auth = {'Authorization': f'Bearer {resp.json()["access_token"]}'} + + def test_01_the_shipped_file_turns_authentication_on(self): + """Reverting auth.enabled in the shipped file must fail here. + + No other test would notice: they all pass auth.enabled themselves. + """ + resp = requests.get(f'{BASE_URL}/areas', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + 'the shipped config served /areas to an anonymous caller' + ) + + def test_02_the_shipped_file_covers_reads_not_just_writes(self): + """Pins require_auth_for: "all" as the shipped value. + + Under "write" every one of these answers 200 without a credential. + """ + for path in ('/', '/areas', '/components', '/apps', '/functions', '/version-info'): + with self.subTest(path=path): + resp = requests.get(f'{BASE_URL}{path}', timeout=15) + self.assertIn(resp.status_code, (401, 403)) + + def test_03_health_stays_reachable(self): + """The exemption has to survive in the shipped file too. + + Without it a container supervisor cannot probe the gateway and every + deployment restart-loops, so this is as much a part of the shipped + posture as the refusals above. + """ + resp = requests.get(f'{BASE_URL}/health', timeout=15) + self.assertEqual(resp.status_code, 200) + + def test_04_a_configured_client_still_works(self): + """The mirror: a gateway that refused everyone would pass the rest.""" + resp = requests.get(f'{BASE_URL}/areas', headers=self.auth, timeout=15) + self.assertEqual(resp.status_code, 200) + + def test_05_the_shipped_file_is_the_one_under_test(self): + """Guard against this test silently drifting off the real file. + + If the installed config stops declaring the values this file exists to + check, the assertions above would still pass for the wrong reason. + """ + with open(SHIPPED_PARAMS, encoding='utf-8') as handle: + text = handle.read() + self.assertIn('require_auth_for: "all"', text) + self.assertIn('enabled: true', text) + + +@launch_testing.post_shutdown_test() +class TestShippedDefaultsShutdown(unittest.TestCase): + """Gateway exits cleanly.""" + + def test_exit_codes(self, proc_info, gateway_node): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=gateway_node + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_tls_protocol_floor.test.py b/src/ros2_medkit_integration_tests/test/features/test_tls_protocol_floor.test.py new file mode 100644 index 000000000..6a629cce2 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_tls_protocol_floor.test.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check the TLS protocol floor and client-certificate verification. + +Both are driven by a real client against a real gateway. + +Both properties are about what happens during the TLS handshake, before any +HTTP request exists, so they cannot be observed from Python's requests or from +a unit test that checks a setter was called. Every assertion here comes from +``openssl s_client`` completing or failing a handshake at a pinned version. + +The client is run with ``-cipher ALL:@SECLEVEL=0``. Without it a modern +OpenSSL client refuses to OFFER TLS 1.0/1.1 on its own, and the test would pass +while proving nothing about the server: it has to be the server that says no. + +Two gateways run side by side, one with min_version 1.2 and one with 1.3, so +the floor is shown to MOVE with the setting rather than happening to sit where +OpenSSL's own default put it. + +@verifies REQ_INTEROP_086 +""" + +import os +import re +import shutil +import socket +import subprocess +import tempfile +import time +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest + +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, get_test_port +from ros2_medkit_test_utils.launch_helpers import create_gateway_node + +PORT_TLS12 = get_test_port(0) +PORT_TLS13 = get_test_port(1) +PORT_MTLS = get_test_port(2) + +_CERT_DIR = tempfile.mkdtemp(prefix='medkit_tls_floor_') + + +def _run(*args): + subprocess.run(args, check=True, capture_output=True) + + +def _make_ca(name): + """Build a CA key plus its self-signed certificate.""" + key = os.path.join(_CERT_DIR, f'{name}-ca-key.pem') + crt = os.path.join(_CERT_DIR, f'{name}-ca.pem') + _run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', crt, '-days', '1', + '-subj', f'/CN=medkit-test-{name}-ca') + return key, crt + + +def _make_leaf(name, ca_key, ca_crt, cn): + """Build a leaf key and certificate signed by the given CA.""" + key = os.path.join(_CERT_DIR, f'{name}-key.pem') + csr = os.path.join(_CERT_DIR, f'{name}.csr') + crt = os.path.join(_CERT_DIR, f'{name}.pem') + _run('openssl', 'req', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', csr, '-subj', f'/CN={cn}') + _run('openssl', 'x509', '-req', '-in', csr, '-CA', ca_crt, '-CAkey', ca_key, + '-CAcreateserial', '-out', crt, '-days', '1') + return key, crt + + +# The CA that signs the server certificate and the legitimate client. +CA_KEY, CA_CRT = _make_ca('trusted') +SRV_KEY, SRV_CRT = _make_leaf('server', CA_KEY, CA_CRT, 'localhost') +CLI_KEY, CLI_CRT = _make_leaf('client', CA_KEY, CA_CRT, 'medkit-test-client') + +# A second CA the gateway was never told about, for the certificate that is +# well-formed and correctly signed but by the wrong authority. +ROGUE_KEY, ROGUE_CRT = _make_ca('rogue') +ROGUE_CLI_KEY, ROGUE_CLI_CRT = _make_leaf('rogue-client', ROGUE_KEY, ROGUE_CRT, 'rogue') + + +def _tls_params(port, min_version, ca_file=''): + params = { + 'server.host': '127.0.0.1', + 'server.tls.enabled': True, + 'server.tls.cert_file': SRV_CRT, + 'server.tls.key_file': SRV_KEY, + 'server.tls.min_version': min_version, + # Auth off: this file is about the handshake, and a 401 would arrive + # long after the point under test has already been decided. + 'auth.enabled': False, + } + if ca_file: + params['server.tls.ca_file'] = ca_file + return params + + +@pytest.mark.launch_test +def generate_test_description(): + """Three gateways: floor at 1.2, floor at 1.3, and one demanding a client cert.""" + nodes = [ + create_gateway_node(port=PORT_TLS12, name='gateway_tls12', + extra_params=_tls_params(PORT_TLS12, '1.2')), + create_gateway_node(port=PORT_TLS13, name='gateway_tls13', + extra_params=_tls_params(PORT_TLS13, '1.3')), + create_gateway_node(port=PORT_MTLS, name='gateway_mtls', + extra_params=_tls_params(PORT_MTLS, '1.2', ca_file=CA_CRT)), + ] + return launch.LaunchDescription(nodes + [launch_testing.actions.ReadyToTest()]), { + 'gateway_tls12': nodes[0], + 'gateway_tls13': nodes[1], + 'gateway_mtls': nodes[2], + } + + +def _handshake(port, version, client_cert=None, client_key=None, timeout=20): + """Attempt one handshake. True only when a cipher was actually agreed. + + `openssl s_client` exits 0 in cases where no session was established, and + it prints the protocol it ATTEMPTED whether or not the server accepted it. + "Cipher is (NONE)" is the reliable tell for a handshake that did not + complete, so that is what is read here rather than the exit status. + """ + cmd = ['openssl', 's_client', f'-{version}', + '-cipher', 'ALL:@SECLEVEL=0', + '-connect', f'127.0.0.1:{port}'] + if client_cert: + cmd += ['-cert', client_cert, '-key', client_key] + try: + proc = subprocess.run(cmd, input=b'', capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired: + return False + out = (proc.stdout + proc.stderr).decode(errors='replace') + + # "Cipher is " is NOT proof that the handshake completed, and reading + # it that way is how an earlier version of this file reported mutual TLS as + # broken when it was working. Under TLS 1.2 the cipher suite is agreed + # before the client certificate is examined, so a server that then rejects + # the certificate still leaves a cipher name in the output, followed by a + # fatal alert. Verified by hand against this gateway: a client with no + # certificate printed "Cipher is ECDHE-RSA-AES256-GCM-SHA384" AND + # "sslv3 alert handshake failure", while curl against the same endpoint got + # no HTTP response at all. + # + # So a fatal alert is the signal, and "Cipher is (NONE)" covers the case + # where the version itself was refused before any suite was picked. + if 'Cipher is (NONE)' in out: + return False + if re.search(r'alert (handshake failure|protocol version|certificate|unknown ca)', out): + return False + return 'Cipher is ' in out + + +def _free_port(): + """Return a port nothing is listening on, for the control server above.""" + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + + +def _wait_listening(port, timeout=60.0): + """Block until the port accepts a TCP connection. + + launch_testing starts the tests as soon as the processes are spawned, not + when they are serving, and a gateway that is not listening yet refuses + every connection. That looks identical to "the server rejected this + handshake", so without this gate the refusal assertions pass for the wrong + reason and the acceptance assertions fail at random. Observed directly: + the same file reported two failures, then two, then one, across three runs. + + TCP only, deliberately. A TLS handshake cannot be the readiness probe here + because on the mutual-TLS gateway a probe without a client certificate is + supposed to fail. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(('127.0.0.1', port), timeout=2): + return + except OSError: + time.sleep(0.25) + raise AssertionError(f'gateway on port {port} never started listening within {timeout}s') + + +class TestTlsProtocolFloor(unittest.TestCase): + """The floor moves with min_version, and it is the server that enforces it.""" + + @classmethod + def setUpClass(cls): + _wait_listening(PORT_TLS12) + _wait_listening(PORT_TLS13) + + def test_01_floor_12_accepts_12_and_13(self): + """The mirror of the refusals below. + + Without this, a gateway that refused every version would pass the + whole file while serving nobody. + """ + self.assertTrue(_handshake(PORT_TLS12, 'tls1_2'), 'TLS 1.2 must be accepted at floor 1.2') + self.assertTrue(_handshake(PORT_TLS12, 'tls1_3'), 'TLS 1.3 must be accepted at floor 1.2') + + def test_01b_the_client_actually_offers_the_old_versions(self): + """Guard the negative assertions below against becoming vacuous. + + `_handshake` returns False both when the SERVER refuses and when the + client never put a ClientHello on the wire. A modern OpenSSL will not + offer TLS 1.0/1.1 unless `-cipher ALL:@SECLEVEL=0` persuades it, and on + a distro built `no-tls1 no-tls1_1`, or under a crypto policy pinning + MinProtocol, it cannot offer them at all. In either case test_02 below + would pass against a gateway happily serving TLS 1.0. + + So: stand up a plain `openssl s_server` that accepts everything, and + require the client to reach 1.0 and 1.1 against it. If it cannot, the + refusals in test_02 prove nothing and this fails instead of lying. + """ + for version in ('tls1', 'tls1_1'): + with self.subTest(version=version): + port = _free_port() + server = subprocess.Popen( + ['openssl', 's_server', '-accept', str(port), '-quiet', + '-cert', SRV_CRT, '-key', SRV_KEY, + '-cipher', 'ALL:@SECLEVEL=0', f'-{version}'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + try: + _wait_listening(port, timeout=15) + self.assertTrue( + _handshake(port, version), + f'this client cannot offer {version} at all, so the ' + f'{version} refusals in test_02 would pass against a ' + 'gateway that accepts it' + ) + finally: + server.terminate() + server.wait(timeout=10) + + def test_02_floor_12_refuses_11_and_10(self): + """SOVD requires TLS 1.2 as the minimum, so 1.1 and 1.0 must not connect. + + The vendored cpp-httplib asks OpenSSL for a floor of TLS 1.1 + (SSL_CTX_set_min_proto_version(ctx_, TLS1_1_VERSION)), so without the + gateway setting its own floor this is the version that decides whether + we comply, and it is not a value this project chose. + """ + self.assertFalse(_handshake(PORT_TLS12, 'tls1_1'), 'TLS 1.1 must be refused at floor 1.2') + self.assertFalse(_handshake(PORT_TLS12, 'tls1'), 'TLS 1.0 must be refused at floor 1.2') + + def test_03_floor_13_refuses_12(self): + """The test that fails if min_version is inert. + + A gateway configured for 1.3 that still completes a 1.2 handshake is + exactly the state this branch shipped before: the value was read, + logged, and then ignored. TLS 1.2 is accepted by the OTHER gateway in + this same launch, so a failure here cannot be blamed on the client or + on the certificate. + """ + self.assertFalse(_handshake(PORT_TLS13, 'tls1_2'), 'TLS 1.2 must be refused at floor 1.3') + self.assertFalse(_handshake(PORT_TLS13, 'tls1_1'), 'TLS 1.1 must be refused at floor 1.3') + + def test_04_floor_13_accepts_13(self): + self.assertTrue(_handshake(PORT_TLS13, 'tls1_3'), 'TLS 1.3 must be accepted at floor 1.3') + + +class TestMutualTls(unittest.TestCase): + """With ca_file set, a client certificate is required and verified.""" + + @classmethod + def setUpClass(cls): + _wait_listening(PORT_MTLS) + _wait_listening(PORT_TLS12) + + def test_05_no_client_certificate_is_refused(self): + """ca_file set means SSL_VERIFY_FAIL_IF_NO_PEER_CERT: no cert, no session.""" + self.assertFalse( + _handshake(PORT_MTLS, 'tls1_2'), + 'a client presenting no certificate must not complete the handshake' + ) + + def test_06_a_certificate_from_the_configured_ca_is_accepted(self): + self.assertTrue( + _handshake(PORT_MTLS, 'tls1_2', client_cert=CLI_CRT, client_key=CLI_KEY), + 'a client certificate signed by the configured CA must be accepted' + ) + + def test_07_a_certificate_from_another_ca_is_refused(self): + """Well-formed and correctly signed, but by an authority we never trusted. + + This separates "verification is on" from "any certificate will do", + which test_05 alone cannot. + """ + self.assertFalse( + _handshake(PORT_MTLS, 'tls1_2', client_cert=ROGUE_CLI_CRT, client_key=ROGUE_CLI_KEY), + 'a client certificate from an unconfigured CA must be refused' + ) + + def test_08_a_gateway_without_ca_file_does_not_demand_one(self): + """The default stays server-only TLS. + + SOVD authenticates with bearer tokens, so requiring a client + certificate by default would put us outside the spec. mTLS is opt-in + and this pins that it is. + """ + self.assertTrue( + _handshake(PORT_TLS12, 'tls1_2'), + 'a gateway with no ca_file must still serve a client that has no certificate' + ) + + +@launch_testing.post_shutdown_test() +class TestTlsFloorShutdown(unittest.TestCase): + """All three gateways exit cleanly.""" + + def test_exit_codes(self, proc_info, gateway_tls12, gateway_tls13, gateway_mtls): + for proc in (gateway_tls12, gateway_tls13, gateway_mtls): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=proc) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(_CERT_DIR, ignore_errors=True)