mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
fix(mesh): trust central for cross-node dial auth and regenerate overrides at boot (#1014)
Two bugs in the same Phase D follow-up surface, fixed together because they both block declaring B-verify complete on the production fleet. Cross-node mesh dials returned `denied` at the agent. The pilot's `tcp_open` handler in `agent.ts::resolveMeshTarget` consulted the local SQLite `mesh_stacks` table, which is no longer written to under the post-Phase D control plane (state lives only on central). Drop the check. The pilot tunnel JWT (scope `pilot_tunnel`, signed with central's `auth_jwt_secret`) authenticates the caller; the same trust model already applies to filesystem ops, exec, and container control over the same tunnel. Threat-model trade-off: a leaked `pilot_tunnel` JWT or compromised central can now dial any compose-managed service on the pilot. Containers without `com.docker.compose.project` + `com.docker.compose.service` labels remain unreachable via this path. `MeshService.start()` did not regenerate compose override files at boot. After a Sencho restart with missing overrides on disk, meshed user containers had no `extra_hosts` / `networks: [sencho_mesh]` injection until each stack was opted out and back in. Add `regenerateAllOverrides()` that walks every `mesh_stacks` row and re-pushes via `pushOverrideToNode`. Best-effort: per-stack failures log to the mesh activity buffer; `MeshService.start()` is fire-and-forget at startup so a slow remote node does not delay boot. Tests: - `pilot-agent-mesh-resolve.test.ts` (new): mocks dockerode and proves `resolveMeshTarget` no longer returns `denied` with an empty `mesh_stacks` table. - `mesh-service.test.ts`: three new cases for `regenerateAllOverrides` - fan-out across the fleet, skip when `senchoIp` is null, log per-stack warning on push failure without throwing.
This commit is contained in:
+10
-15
@@ -562,9 +562,11 @@ export class PilotAgent {
|
||||
|
||||
// --- Sencho Mesh TCP dispatch (tunnel -> Compose service container) ---
|
||||
//
|
||||
// PR 1 rejects every tcp_open with mesh_not_enabled; the dial path is
|
||||
// exercised by tests via setMeshResolver but never lit in production until
|
||||
// PR 2 wires Dockerode resolution gated by the local mesh_stacks table.
|
||||
// Central is the sole authority for mesh opt-in (state lives in central's
|
||||
// SQLite mesh_stacks table). The pilot resolves a target by Compose
|
||||
// container labels and dials directly. The tunnel JWT (scope
|
||||
// 'pilot_tunnel') gates the WS upgrade itself, so any tcp_open frame on
|
||||
// an open tunnel is trusted to originate from central.
|
||||
|
||||
private async onTcpOpen(frame: Extract<ReturnType<typeof decodeJsonFrame>, { t: 'tcp_open' }>): Promise<void> {
|
||||
const ws = this.ws;
|
||||
@@ -724,11 +726,11 @@ export class PilotAgent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a mesh target by consulting the local mesh_stacks opt-in table
|
||||
* and Compose container labels. Refuses if the target stack is not opted
|
||||
* in on this node (defense-in-depth: the primary is trusted, but we also
|
||||
* gate at the agent so a leaked tunnel token cannot reach unauthorized
|
||||
* services).
|
||||
* Resolves a mesh target by Compose container labels and returns the
|
||||
* container's first usable IP. Central has already validated that the
|
||||
* target stack is opted in before issuing the dial; the tunnel JWT
|
||||
* (scope 'pilot_tunnel') authenticates the caller, so this handler
|
||||
* does no per-stack gating of its own.
|
||||
*/
|
||||
private async resolveMeshTarget(
|
||||
stack: string,
|
||||
@@ -736,15 +738,8 @@ export class PilotAgent {
|
||||
port: number,
|
||||
): Promise<MeshResolveResult> {
|
||||
try {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
const dockerodeMod = await import('dockerode');
|
||||
const Docker = (dockerodeMod as { default: new (opts?: unknown) => { listContainers: (opts?: unknown) => Promise<unknown[]> } }).default;
|
||||
const db = DatabaseService.getInstance();
|
||||
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
if (!db.isMeshStackEnabled(localNodeId, stack)) {
|
||||
return { ok: false, err: 'denied' };
|
||||
}
|
||||
const docker = new Docker();
|
||||
const containers = (await docker.listContainers({
|
||||
filters: { label: [`com.docker.compose.project=${stack}`, `com.docker.compose.service=${service}`] },
|
||||
|
||||
Reference in New Issue
Block a user