232 lines
9.8 KiB
Markdown
232 lines
9.8 KiB
Markdown
# tines host hardening — before a robot leaves the lab
|
||
|
||
Date: 2026-10-19
|
||
Status: draft v1. Not yet reviewed.
|
||
Fixes: findings F10–F12 in `THREAT_MODEL.md` (assessed at `3b1a644`)
|
||
Depends on: `2026-10-19-tines-fleet-fixes.md`, which adds the poll loop,
|
||
the trim/flush hooks and the log format this spec builds on
|
||
|
||
## Intent
|
||
|
||
The hosts that run tines on a robot (`tines`, `tines-ros2`, and the
|
||
`tines-bt` and `tines-store` crates under them) currently trust three
|
||
things they shouldn't:
|
||
- that storage never fills,
|
||
- that every local process is friendly,
|
||
- that every participant on the ROS2 domain is friendly.
|
||
|
||
This spec bounds every resource a peer, a local process or a ROS2
|
||
publisher can grow, and limits who can act as the robot. It's the bar a
|
||
robot must meet before it operates anywhere other than a trusted lab.
|
||
|
||
## Why separately from 1.0
|
||
|
||
Everything here is in host crates, which stay 0.x with their own versions
|
||
(1.0 spec, D4). None of it changes the wire or core's API. Tying it to the
|
||
core 1.0 release would make one wait on the other for no benefit. The bar
|
||
is deployment readiness, not a version number.
|
||
|
||
## Goals
|
||
|
||
1. **Nothing grows without bound** (F10): store size, log size, channels,
|
||
and per-request allocations.
|
||
2. **Only authorised local users can act as the robot** through the
|
||
service's IPC (F11), and no client can block the others.
|
||
3. **The ROS2 bridge doesn't feed on itself** (F12): it can be restricted
|
||
to a trusted DDS scope, can run read-only, and rate-limits what it
|
||
accepts.
|
||
4. **Hosts run with least privilege**, under a provided systemd unit.
|
||
|
||
## Non-goals
|
||
|
||
- Authenticating peers or signing observations (1.1). These limits make
|
||
floods *bounded*; they don't make them *impossible*.
|
||
- Confidentiality of the radio link (threat model F15).
|
||
- Core changes. Per-peer behaviour in the exchange (eviction, summary rate
|
||
limits) is in the 1.0 spec §10.
|
||
|
||
## Constraints
|
||
|
||
- No changes to tines-core's API or wire format.
|
||
- Defaults must be safe without configuration: a robot that gets an
|
||
untuned config must still be bounded.
|
||
- Every limit is configurable, has a documented default, and is reported
|
||
(counter or log line) when it bites. A silent limit looks like a bug.
|
||
|
||
## The how
|
||
|
||
### 1. F10 — bounded growth
|
||
|
||
1. **Default trim policy.** Both hosts call `trim` on the poll loop's
|
||
schedule (fleet fixes §2), every `trim_interval_ms` (default 10 s), with
|
||
a config `TrimPolicy` whose defaults are always on:
|
||
- `max_count`: default 50,000 observations (about 50 MB worst case at
|
||
the 1 KiB payload cap; size it per robot).
|
||
- `max_age_ms`: default 7 days.
|
||
- `keep_per_topic`: unset by default.
|
||
|
||
The fleet spec's sizing guidance applies: custody traffic must outlive
|
||
its lifetime. The host docs say how to size `max_count` for that.
|
||
2. **Store full stops being silent.** `LogStore` gains an optional hard cap
|
||
(`max_records`, defaulting to 2 × `max_count`). Above it, `insert`
|
||
returns `StoreError::Full`. With the fleet fix for F1, a full store no
|
||
longer acks, so custody stays with the sender. That's the safe
|
||
direction, and it's the point of having a cap.
|
||
3. **Log compaction.** When dead records (trimmed or superseded) exceed
|
||
`compact_ratio` of the file (default 0.5), and at most once per
|
||
`compact_interval_ms`, `LogStore` writes the live records and current
|
||
release flags to a new file. It fsyncs the file, renames it over the
|
||
old one, and fsyncs the directory. A crash during compaction leaves the
|
||
old log intact. Releases written as records (fleet fixes §4) are
|
||
carried over.
|
||
4. **Bounded channels.**
|
||
- tines-bt inbound: a bounded `mpsc` of `BT_INBOUND_CAP` frames (default
|
||
256). When full, the oldest is dropped and counted. A frame is
|
||
droppable; the protocol tolerates loss.
|
||
- tines-bt outbound: a bounded channel. When full, `send` returns
|
||
`SendError::Busy`, which core already retries via `on_send_error`.
|
||
- tines-ros2 inbound: bounded (default 256), dropping the oldest with a
|
||
counter.
|
||
5. **Per-request allocation.** The IPC frame cap drops from 1 MiB to
|
||
16 KiB. The largest valid request is a hex-encoded 64-byte topic plus a
|
||
1 KiB payload in JSON, about 2.3 KiB.
|
||
6. **Load.** `LogStore::open` reads the log in a stream instead of
|
||
`fs::read` of the whole file, so memory use at open is bounded by the
|
||
index, not the file size.
|
||
|
||
### 2. F11 — local IPC
|
||
|
||
1. **Socket location and mode.**
|
||
- Default path `$RUNTIME_DIRECTORY/tines.sock`, provided by systemd's
|
||
`RuntimeDirectory=tines` with mode `0750`.
|
||
- The socket is created `0660`, owned by the service user and a
|
||
configurable `ipc_group` (default `tines`).
|
||
- Only members of that group can connect.
|
||
2. **Peer credentials.** On accept, read `SO_PEERCRED`. Allow if the uid is
|
||
the service uid, or the gid or any supplementary group is `ipc_group`,
|
||
or the uid is in the config `ipc_allow_uids`. Anything else is closed
|
||
with a logged refusal. This is defence in depth on top of the file
|
||
mode.
|
||
3. **Read-only clients.** Config `ipc_readonly_uids` and
|
||
`ipc_readonly_gid` get `Belief`, `Status` and `Events` only. `Observe`
|
||
requires the full grant. Most consumers only read, and shouldn't be
|
||
able to speak as the robot.
|
||
4. **Don't unlink arbitrary paths.** Remove the configured path only if
|
||
it's a socket (`symlink_metadata` → file type socket). Otherwise fail
|
||
with an error.
|
||
5. **Concurrency and timeouts.**
|
||
- Serve each client on its own thread, up to `ipc_max_clients`
|
||
(default 8); beyond that, accept and close.
|
||
- Each connection has read and write timeouts (`ipc_timeout_ms`,
|
||
default 5 s).
|
||
- An idle client is closed. No client can block another.
|
||
6. **Framing errors close the connection.** An oversized length is
|
||
rejected and the connection is closed, so the stream can't be left
|
||
desynchronised. Today it returns an error and keeps reading.
|
||
7. **`accept` errors** (for example `EMFILE`) are logged and retried with
|
||
backoff; they no longer end the daemon.
|
||
8. **Events.** `Events` stays a destructive drain (one consumer), and the
|
||
docs say so. Fan-out to several consumers is a later feature if it's
|
||
needed.
|
||
|
||
### 3. F12 — the ROS2 bridge
|
||
|
||
1. **No self-echo.** The bridge must not re-observe its own publications.
|
||
- Prefer the middleware option that ignores local publications, if
|
||
rclrs exposes it.
|
||
- Otherwise, filter in the callback by publisher GID if available, or
|
||
else drop an inbound message whose payload equals the last payload
|
||
the bridge published on that topic within `echo_window_ms` (default
|
||
1 s).
|
||
- **Test:** publish once, poll several times; the store gains exactly
|
||
one observation.
|
||
2. **Read-only mode.** Config `bridge_inbound` (default `false`). When
|
||
false, the bridge creates publishers only. ROS2 consumers can read
|
||
tines state, but nothing on the domain can make the robot say anything.
|
||
Enabling inbound is an explicit decision per deployment.
|
||
3. **Rate limits.** When inbound is enabled, a per-topic token bucket
|
||
applies (`inbound_rate_per_s`, default 2; burst 4). Excess messages are
|
||
dropped and counted. A bridged topic should carry facts, not telemetry
|
||
streams.
|
||
4. **DDS scope.**
|
||
- The docs and the sample unit set a dedicated `ROS_DOMAIN_ID` and
|
||
`ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST` unless the deployment needs
|
||
the ROS graph off-box.
|
||
- For off-box graphs, the docs describe enabling SROS2: an enclave for
|
||
the `tines` node, with governance that allows only listed
|
||
participants to publish on `/tines/**`.
|
||
- This spec documents SROS2 setup; it doesn't automate it.
|
||
5. **Receipts topic.** `/tines/receipts` reveals peer NodeIds and
|
||
observation ids. It stays on, but can be disabled
|
||
(`publish_receipts`, default `true`) for deployments that bridge
|
||
off-box without SROS2.
|
||
|
||
### 4. Least privilege and the systemd unit
|
||
|
||
Provide `packaging/systemd/tines.service` (standalone) and a ROS2 launch
|
||
note. The unit:
|
||
|
||
```ini
|
||
[Service]
|
||
User=tines
|
||
Group=tines
|
||
SupplementaryGroups=bluetooth
|
||
StateDirectory=tines # log lives here, 0700
|
||
RuntimeDirectory=tines # socket lives here
|
||
RuntimeDirectoryMode=0750
|
||
Restart=on-failure
|
||
NoNewPrivileges=yes
|
||
ProtectSystem=strict
|
||
ProtectHome=yes
|
||
PrivateTmp=yes
|
||
RestrictAddressFamilies=AF_UNIX AF_BLUETOOTH
|
||
CapabilityBoundingSet=
|
||
```
|
||
|
||
- The service never runs as root.
|
||
- BlueZ access goes through D-Bus policy for the `bluetooth` group. The
|
||
docs list the bluetoothd configuration the target images need.
|
||
- A panic exits non-zero, and `Restart=on-failure` brings the service back
|
||
(fleet fixes §2).
|
||
|
||
## Acceptance
|
||
|
||
- Each limit has a test that drives it past its cap: the counter or log
|
||
fires, and memory or disk stays bounded:
|
||
- trim,
|
||
- the store cap,
|
||
- compaction (including a crash mid-compaction),
|
||
- both BT channels,
|
||
- the ROS2 inbound channel and rate limit,
|
||
- the IPC frame cap.
|
||
- **IPC tests:**
|
||
- wrong uid refused;
|
||
- read-only uid can't `Observe`;
|
||
- an idle client doesn't block a second client;
|
||
- an oversized frame closes the connection;
|
||
- a non-socket path isn't unlinked.
|
||
- **ROS2 tests:** the self-echo test, and that read-only mode creates no
|
||
subscriptions. These run in the existing ROS2 Docker job (`Dockerfile.ros2`).
|
||
- The systemd unit passes `systemd-analyze verify` and runs the service on
|
||
the Pi image.
|
||
- `THREAT_MODEL.md` marks F10–F12 mitigated and updates §5's
|
||
"Bounded memory" row.
|
||
|
||
## Left to the builder
|
||
|
||
- Exact defaults where marked; they get tuned once real robots are
|
||
measured.
|
||
- Whether rclrs exposes local-publication filtering (and so which echo
|
||
filter is used).
|
||
- Compaction triggers beyond the ratio, for example on open.
|
||
|
||
## Sequencing
|
||
|
||
Follows the fleet fixes. Within this spec, the order is:
|
||
1. channels and the IPC cap (small, independent);
|
||
2. trim defaults and the store cap;
|
||
3. compaction;
|
||
4. IPC permissions, credentials and concurrency;
|
||
5. ROS2;
|
||
6. the systemd unit.
|