fix: enforce 1:1 compose path mapping for Pilot agent mounts (#1516)

* fix: enforce 1:1 compose path mapping for Pilot agent mounts

Pilot enrollment now generates validated 1:1 bind mounts so every
agent path maps to a unique compose directory. Persisted agent paths
reconcile during startup to catch drift. Unsafe relative-bind redeploys
are blocked before container removal to prevent path escapes.

- Add composePathMapping utility with strict path validation
- Generate COMPOSE_DIR and validated mounts during Pilot enrollment
- Reconcile persisted agent paths during startup bootstrap
- Block redeploy when a relative-bind mount would escape the compose root
- Default Pilot UI path to /opt/docker/sencho
- Update multi-node and pilot-agent documentation
- Add regression tests for enrollment, bootstrap, compose-service,
  and environment-check paths

* fix: update E2E enrollment regexes for YAML-quoted token values
This commit is contained in:
Anso
2026-06-29 14:35:50 -04:00
committed by GitHub
parent 9ff678a7bb
commit 41dc339c26
14 changed files with 481 additions and 53 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ On the control instance, click your avatar in the top-right and choose **Setting
| **Name** | A display name (e.g. `staging-vps`, `media-box`). Visible in the switcher and across the UI. |
| **Type** | Set to **Remote**. |
| **Mode** | Set to **Pilot Agent**. |
| **Compose Directory** | The root directory where compose stack folders live on the remote host. Defaults to `/app/compose`; change it if the remote uses a different volume mount. |
| **Compose Directory** | The absolute directory where compose stack folders live on the remote host. Pilot Agent defaults to `/opt/docker/sencho` and mounts the directory at the same path inside the agent so relative stack bind mounts remain host-correct. |
Click **Add node**.
+14 -8
View File
@@ -44,7 +44,7 @@ You do not have to pick one mode for the whole fleet. Mix and match per node.
Conceptually, the agent reverses the usual client/server direction.
1. The agent container starts on the remote host with three environment variables: a mode flag, the control instance URL, and a short-lived enrollment token.
1. The agent container starts on the remote host with its mode, control instance URL, short-lived enrollment token, and compose directory configured.
2. It dials `wss://<control-instance>/api/pilot/tunnel` and holds the WebSocket open for as long as the container runs.
3. For every request the control instance needs to make against that node (listing containers, deploying a stack, streaming logs, opening a console, forwarding mesh traffic), frames are multiplexed over that single connection.
4. On the agent side, those frames are demultiplexed and replayed locally against the host's Docker socket and filesystem.
@@ -78,7 +78,7 @@ Pilot mode narrows the operator surface in a couple of places where parity with
| Requirement | Detail |
|---|---|
| **Remote host** | Docker installed and a user that can run containers. About 1 GB free on the volume that backs `/app/data` and whatever you point `/app/compose` at. |
| **Remote host** | Docker installed and a user that can run containers. About 1 GB free on the volume that backs `/app/data` and the selected compose directory. |
| **Network from remote → control instance** | Outbound TCP to the control instance's HTTPS port (whatever your reverse proxy or Sencho exposes). Nothing inbound. |
| **Network at the control instance** | The path from the remote to the control instance must allow WebSocket upgrades end-to-end. If you terminate TLS at a reverse proxy in front of Sencho, that proxy needs `Upgrade: websocket` passthrough. |
| **Operator role** | Admin on the control instance. The Settings → Nodes panel is admin-only. |
@@ -114,7 +114,7 @@ When you submit the Add Node form with Mode set to Pilot Agent, the control inst
The enrollment token is one-time and short-lived by design. If a token is intercepted or leaked, the window of risk is small and the slot is consumed on first use.
The generated compose file looks like this, with the three pilot env vars already baked in:
The generated compose file looks like this, with the Pilot connection and compose path already configured:
```yaml
name: sencho-agent
@@ -126,11 +126,14 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- sencho-agent-data:/app/data
- /opt/docker/sencho:/app/compose
- type: bind
source: /opt/docker/sencho
target: /opt/docker/sencho
environment:
SENCHO_MODE: pilot
SENCHO_PRIMARY_URL: https://sencho.example.com
SENCHO_ENROLL_TOKEN: <short-lived token>
COMPOSE_DIR: /opt/docker/sencho
volumes:
sencho-agent-data:
@@ -146,7 +149,7 @@ Deploying through Compose also lets the control instance push over-the-air updat
### First connect
The agent boots, reads the three env vars, and dials `wss://<control-instance>/api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header.
The agent boots, reads its configuration, and dials `wss://<control-instance>/api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header.
The control instance verifies the token, marks the enrollment slot consumed, and replies with a `hello` frame followed by a control frame that carries a **long-lived tunnel JWT** (365-day expiry). The agent writes that token to its data volume at `/app/data/pilot.jwt`. The original enrollment token is now useless: the agent does not need it again, and the control instance refuses to accept it a second time.
@@ -218,13 +221,16 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- sencho-agent-data:/app/data
- /opt/docker/sencho:/app/compose
- type: bind
source: /opt/docker/sencho
target: /opt/docker/sencho
- /etc/ssl/internal-ca.pem:/etc/ssl/internal-ca.pem:ro
environment:
SENCHO_MODE: pilot
SENCHO_PRIMARY_URL: https://sencho.internal.example.com
SENCHO_ENROLL_TOKEN: <short-lived token>
SENCHO_PILOT_CA_FILE: /etc/ssl/internal-ca.pem
COMPOSE_DIR: /opt/docker/sencho
volumes:
sencho-agent-data:
@@ -258,7 +264,7 @@ These environment variables are read by the **agent** container at boot.
| `SENCHO_ENROLL_TOKEN` | First boot only | none | The 15-minute enrollment token issued by the control instance. Ignored on subsequent boots once `pilot.jwt` is on disk. |
| `SENCHO_PILOT_CA_FILE` | Optional | unset | Absolute path inside the container to a PEM bundle. Use when your control instance's TLS chain is rooted in a private CA. |
| `DATA_DIR` | Optional | `/app/data` | Where the persisted `pilot.jwt` is stored. Override only if you are mounting a different volume layout. |
| `COMPOSE_DIR` | Optional | `/app/compose` | Root directory on the agent host where compose stack folders live. The control instance reads and writes stacks under this path. |
| `COMPOSE_DIR` | Optional | `/app/compose` | Root directory where compose stack folders live. Enrollment sets it to the absolute path selected for the node and mounts that path identically on the host and in the agent. |
One variable is read on the **control instance** side, not the agent:
@@ -322,7 +328,7 @@ The generic node-connectivity issues (a node showing Offline, a pilot agent stuc
</Accordion>
<Accordion title="Stacks tab on a pilot node shows nothing or fails to list">
The agent's compose directory is missing or not writable. Verify the host path you mounted at `/app/compose` exists, is owned by a user the agent container can write as, and matches the Compose Directory configured for the node in Settings → Nodes. If the host path has moved, edit the node and update the Compose Directory field.
The agent's compose directory is missing or not writable. Verify the selected absolute path exists, is owned by a user the agent container can write as, and is mounted at the same path on the host and inside the agent. The Compose Directory in Settings → Nodes controls the generated enrollment file.
</Accordion>
<Accordion title="I changed the Mode on a pilot node in Edit and now it is broken">