mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
74ae2ce0c698ee506a0b4c835aecc1eeb40518a2
1172 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
74ae2ce0c6 |
fix: harden atomic deployment rollback (#1029)
* fix: harden atomic deployment rollback * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories * fix: sanitize error objects in console.error to prevent log injection |
||
|
|
69b6ac1f3b |
fix: harden stack file explorer operations (#1028)
* fix: harden stack file explorer operations * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories |
||
|
|
19cdb3681d |
fix: harden blueprint deployment guardrails (#1027)
* fix: harden blueprint deployment guardrails * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories |
||
|
|
eed55f1637 |
chore(main): release 0.76.7 (#1026)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
b8af40d2b4 |
fix(mesh): enumerate stacks on remote pilot nodes for opt-in sheet (#1025)
The mesh opt-in sheet showed "No stacks deployed on this node yet" for every remote pilot because GET /api/mesh/nodes/:nodeId/stacks called FileSystemService.getInstance(nodeId).getStacks() unconditionally, which always reads central's own filesystem regardless of which node the operator targeted. Fix dispatches through the proxy chain when the targeted node is remote, mirroring the C-3 pattern that already governs every other mesh endpoint needing data from a remote Sencho: - New endpoint GET /api/mesh/local-stacks (Admiral-gated) returns the bare stacks list from THIS Sencho's own filesystem. Mirrors the precedent set by /api/mesh/local-services/:stackName. - New MeshService.listLocalStacks() and MeshService.listStacksOnNode() helpers; the latter dispatches local vs remote and degrades to an empty list on transport failure (no proxy target, non-2xx response, malformed body). - Refactored route delegates the stack-name list to listStacksOnNode; central's mesh_stacks DB is still authoritative for the opt-in flag set per C-3. Tests cover the local path, the remote-OK path with header forwarding, non-2xx, no-target (pilot tunnel down), malformed bodies, and unknown node ids. |
||
|
|
b941fe5732 |
chore(main): release 0.76.6 (#1024)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
41a1df279d |
fix(mesh): fix pilot handleAccept dispatch by deriving self nodeId from enrollment token (#1023)
On a pilot node, handleAccept compared target.nodeId (from central's perspective via pilotAliasOverlay) against NodeRegistry.getDefaultNodeId() which always returns 1. This inverted same-node vs cross-node dispatch: - central's alias (nodeId=1) matched localNodeId=1 -> openSameNode on pilot (no container -> silent close, 0 bytes) - pilot's own alias (nodeId=14) != 1 -> openCrossNode -> tunnel loop Fix: add resolveSelfCentralNodeId() that decodes the nodeId claim from SENCHO_ENROLL_TOKEN (present on every pilot; payload decode only, no signature verification). Returns getDefaultNodeId() on central (env unset). Cached in selfCentralNodeId at start(); handleAccept reads the cache. After this fix the reverse direction (pilot-prober -> central echo) routes through openCrossNode (reverse tunnel) and the same-node path (pilot-prober -> pilot echo) routes through openSameNode (local Dockerode dial). Closes BUG-3 banner data-path regression. Tests: 5 new cases covering resolveSelfCentralNodeId token extraction, fallback paths, and handleAccept dispatch routing on a simulated pilot. |
||
|
|
4d4856536b |
chore(main): release 0.76.5 (#1022)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
6ac7da978c |
fix(mesh): push alias port map to pilot so reverse-direction forwarder binds listeners (#1021)
* fix(mesh): apply D-1 pushed override on pilot deploys via file-presence fallback When isMeshStackEnabled returns false, ensureStackOverride now checks for an override file already on disk before returning null. On pilot nodes the mesh_stacks table is intentionally empty (opt-in state lives on central per the C-3 design), so the DB gate blocked the override that central had already pushed via applyLocalOverride. File-presence is safe as the fallback because removeOverrideFromNode sends a DELETE to the pilot when a stack is opted out, so a stale file cannot survive past opt-out. Also aligns removeStackOverride to use path.basename consistently with all other override-path construction sites in the same file. Adds two tests: pilot node with a pushed file returns the path; pilot node with no file returns null. * fix(mesh): push alias port map to pilot so reverse-direction forwarder binds listeners Pilots have no mesh_stacks rows by design (C-3), so refreshAliasCache() produced an empty aliasByPort map and syncForwarderListeners() bound zero ports. Reverse-direction TCP probes (pilot prober -> central alias) failed with connection refused at the OS level because no listener existed. Fix: extend the D-1 override push payload to include the full MeshGlobalAlias records (host + port + routing metadata). The pilot stores these in a new pilotAliasOverlay map keyed by stack name. refreshAliasCache() merges the overlay after the (empty) DB loop, populating aliasByPort correctly. syncForwarderListeners() then binds the alias ports immediately after the push completes. removeLocalOverride() clears the overlay entry and re-syncs on opt-out. The two populations (DB rows on central, pilotAliasOverlay on pilots) are mutually exclusive by the C-3 invariant, so the first-write-wins port collision policy in refreshAliasCache is safe. |
||
|
|
d1c538c790 |
chore(main): release 0.76.4 (#1020)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
c12b3ba995 |
fix(mesh): apply D-1 pushed override on pilot deploys via file-presence fallback (#1019)
When isMeshStackEnabled returns false, ensureStackOverride now checks for an override file already on disk before returning null. On pilot nodes the mesh_stacks table is intentionally empty (opt-in state lives on central per the C-3 design), so the DB gate blocked the override that central had already pushed via applyLocalOverride. File-presence is safe as the fallback because removeOverrideFromNode sends a DELETE to the pilot when a stack is opted out, so a stale file cannot survive past opt-out. Also aligns removeStackOverride to use path.basename consistently with all other override-path construction sites in the same file. Adds two tests: pilot node with a pushed file returns the path; pilot node with no file returns null. |
||
|
|
4c83d4e800 |
chore(main): release 0.76.3 (#1018)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
2d9e4d0480 |
fix(mesh): derive override services from compose file and surface cross-node dispatch (#1017)
* fix(mesh): derive override services from compose file and surface cross-node dispatch
Three fixes for issues found during Phase B end-to-end verification:
1. Override generator no longer queries Dockerode for services. Deploys
removed all containers in the project right before composeArgs ran,
so docker.listContainers({label: project=...}) returned [] and the
generator wrote services: {} which broke meshed user containers.
New helper getDeclaredStackServiceNames reads the local stack's
compose file via FileSystemService + YAML.parse and returns the
top-level services keys. Used from ensureStackOverride and
applyLocalOverride. Defensive fallback: when the read returns []
AND a previous override on disk has a non-empty services map,
keep the file and emit mesh.override.preserved.
2. Boot regen race against the pilot tunnel. regenerateAllOverrides
ran ~1s before the first pilot tunnel-up event, so pushes to
pilot-mode nodes failed with "no proxy target". MeshService.start
now also runs regenerateOverridesForNode(nodeId) inside the
tunnel-up listener, covering both the boot-race window and
mid-runtime tunnel reconnects. Idempotent.
3. openCrossNode emitted no activity log when a cross-node dial got
stuck waiting for tcp_open_ack. The forwarder accepted the TCP
handshake (so nc -v reported open) but logged nothing between
accept and close. Now logs route.dispatch on entry and emits
tunnel.fail after PROBE_TIMEOUT_MS if the agent never acks.
Timer is cleared on every tcpStream and src lifecycle event.
Tests: 8 new in mesh-service.test.ts cover the helper, the BUG-1
override write path, the defensive preservation fallback, the
tunnel-up regen handler, and both cross-node dispatch logging
behaviors. Full vitest suite green (1998 tests pass).
* fix(mesh): satisfy CodeQL path-injection model in new override-read paths
Two new fs.readFile sites added in the previous commit failed the
CodeQL check on the PR even though both paths were guarded by
isValidStackName + isPathWithinBase. The file's existing
applyLocalOverride pattern uses path.basename(stackName) before the
join (commented as "Recognized by CodeQL's path-injection model");
apply the same sanitizer to the new code paths.
- getDeclaredStackServiceNames: wrap stackName in path.basename when
building composePath.
- readExistingOverrideServiceNames: take (dir, stackName) instead of
a pre-built filePath so the sanitizer lives next to the read sink.
- ensureStackOverride: build the override file path with
path.basename(stackName) for symmetry with applyLocalOverride.
Runtime behavior unchanged (path.basename is a no-op for any name
that passed isValidStackName). Tests still green.
|
||
|
|
3c0bbe00d1 |
chore(main): release 0.76.2 (#1015)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
7cca68f84a |
fix(mesh): operator-triggered override regen and boot lifecycle hardening (#1016)
Audit-driven follow-up to the F6+F7 fix. Closes the High-severity findings
from the mesh-dial-path audit without expanding into the architectural
follow-ups (JWT TTL, TLS enforcement, per-stack JWT scope) which need
their own design discussions.
Changes:
1. New `POST /api/mesh/regen-overrides` Admiral-gated endpoint that calls
`MeshService.regenerateAllOverrides()` and returns a structured
`MeshRegenSummary { regenerated, failures, skipped, reason? }`. Lets
the operator rerun boot regen after fixing a remote node that was
offline at central startup, instead of opt-out + opt-in for every
meshed stack on that node. Audit-log row carries the outcome
(success / partial / skipped / error).
2. `regenerateAllOverrides` now aggregates per-stack outcomes into a
single summary log row (`mesh override regen complete: N succeeded,
M failed across K node(s)`) with `failedNodeIds` in `details`. Per-
stack warnings still emitted. When skipped because `senchoIp` is
null, emits an explicit warn instead of silently no-oping.
3. `MeshService.start()` now wraps `refreshAliasCache` and
`syncForwarderListeners` in their own try/catch so a throw from
either step is logged and the boot continues to override regen.
The closing log line surfaces data-plane state ("MeshService started
(data plane ok)" or "(data plane unavailable (<reason>))") so an
operator grepping startup output sees a half-init mesh immediately.
4. `sanitizeForLog` wrap on four previously unsanitized `err.message`
writes (cross-node tcpStream error handler + three probe error
paths). Container IPs and local socket details no longer leak into
the activity buffer or the probe response body.
5. Updated three existing F6 regression tests for the new return shape
and aggregated summary message. Added two new tests: version-skew
404-push handling, and concurrent opt-in during regen.
|
||
|
|
0947a80cda |
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. |
||
|
|
fc05a818d0 |
chore(main): release 0.76.1 (#1013)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
693f9b4495 |
fix(fleet): drop tier gate from stack and container list endpoints (#1012)
Community tier opened the Fleet Overview drilldown (node card → stack list → container list) but two read-only metadata endpoints in fleet.ts kept their requirePaid gate. Stack names still rendered because they ride on the un-gated /api/fleet/overview payload, but expanding a stack always re-fetched containers and hit the orphan gate, surfacing as a "Failed to load containers for X" toast on every node card on Community. Drop requirePaid from GET /node/:nodeId/stacks and GET /node/:nodeId/stacks/:stackName/containers. Both handlers stay behind authMiddleware, validate inputs, and proxy to the per-node api_token for remote nodes. Paid mutations (bulk update, scheduled snapshots, label bulk actions, Fleet Actions cards) keep their gates in fleetActions.ts. Add positive Community-tier tests in fleet.test.ts and clear the stale "Requires Skipper or Admiral license" line from the OpenAPI descriptions. |
||
|
|
412d3696c8 |
ci: drop sencho-mesh sidecar publish job (#1011)
The shared sencho_mesh Docker network replaced host-mode mesh, so the sidecar image is no longer built or distributed. Remove the obsolete push_mesh_sidecar job from the release workflow. |
||
|
|
e5e55d7f10 |
chore(main): release 0.76.0 (#1008)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
23bbee4f45 |
feat(mesh): replace host-mode with shared sencho_mesh Docker network (#1009)
* feat(mesh): replace host-mode with shared sencho_mesh Docker network Phase D of the mesh redesign: drop the operator's `network_mode: host` requirement and the `host-gateway` extra_hosts pattern that did not work on cloud iptables-restrictive distros (OCI, etc.) or Docker Desktop. Each Sencho creates a shared `sencho_mesh` Docker bridge network on boot (default subnet 172.30.0.0/24, override via SENCHO_MESH_SUBNET), pins itself at `<network>+2`, and attaches every meshed user service to the same bridge. Compose overrides now emit IP-based `extra_hosts` plus a top-level `networks` block declaring `sencho_mesh` external. Override delivery: central renders for local stacks; for remote stacks it sends the fleet alias list to the remote's new `PUT /api/mesh/local- override/:stackName` endpoint, which renders against the remote's OWN local senchoIp and writes under its OWN DATA_DIR. Each node may use a different subnet without coordination beyond the env var. Opt-in / opt-out now trigger an automatic redeploy of the affected stack via the existing deploy code path (local: ComposeService; remote: HTTP POST through proxyFetch). The frontend opt-in sheet shows a confirmation modal (ConfirmModal) before the mutation. Failed redeploys emit both a mesh activity event and a durable audit-log row. Hardening: - Reserve port 1852 at opt-in (prevents user containers from racing the Sencho API listener). - ensureMeshNetwork refuses to continue if `sencho_mesh` exists with a mismatched subnet rather than silently routing to the wrong IP. - Idempotent network connect/disconnect helpers in DockerController. - optInStack rolls back the DB row if the just-inserted stack's override push fails (no half-states surviving across calls). - regenerateOverridesForNode runs in parallel and skips the just- pushed stack on opt-in. Operator template: drop `network_mode: host`, restore `ports: ["1852:1852"]`. Mesh now works identically on Linux LAN, OCI, and Docker Desktop without firewall changes. Docs: rewrite docs/features/sencho-mesh.mdx around the shared bridge network, document SENCHO_MESH_SUBNET, surface the host-network-service opt-in restriction, and cross-link with the Pilot Agent docs. BREAKING CHANGE: the operator's `docker-compose.yml` no longer uses `network_mode: host`. After upgrading, redeploy any meshed stacks once so they pick up the new IP-based override and join `sencho_mesh`. * fix(mesh): wrap stackName with path.basename in local-override fs ops CodeQL flagged js/path-injection on the new applyLocalOverride and removeLocalOverride methods because they are publicly reachable and its data-flow model does not recognize isValidStackName / isPathWithinBase as sanitizers. The validation IS sufficient (the allowlist regex blocks path separators, the path-prefix check blocks escape), but path.basename is a model CodeQL recognizes and is purely defensive: for any input that already passes isValidStackName, basename is the identity. |
||
|
|
ccad5c925b |
feat(nodes): hide hub-only views when active node is remote (#1007)
* feat(nodes): hide hub-only views when active node is remote Fleet, Schedules, Audit, Logs, and Auto-Update operate on hub-owned state (node registry, fleet schedules, centralized audit, fleet-wide log aggregation, fleet-wide update preview). When the active node is remote, proxying those surfaces would show that remote's own disconnected state instead of the hub's. Hide them from the nav strip and force-redirect to Home if one was open during the node switch. Backend hubOnlyGuard middleware sits between nodeContextMiddleware and the remote proxy and rejects /api/scheduled-tasks, /api/audit-log, and /api/notification-routes with 403 + HUB_ONLY_ENDPOINT when nodeId resolves to a remote, closing the script-bypass path the UI gating cannot reach. Settings sub-sections were already gated via the hiddenOnRemote registry; this extends the same model to top-level views. * docs(nodes): note hub-only visibility on Fleet, Schedules, Audit, Logs, Auto-Update Each of the five hub-only feature pages now points readers to the canonical "What top-level views show when a remote node is active" section in multi-node.mdx, so users landing directly on a feature page understand why the nav item disappears when they switch to a remote node. |
||
|
|
7ad9381ede |
chore(deps-dev): bump fast-uri from 3.1.0 to 3.1.2 (#1006)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
1c20739e90 |
chore(main): release 0.75.1 (#1005)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
10a469ecb4 |
fix(mesh): bind every alias port on every node, not just local-owned (#1004)
syncForwarderListeners filtered the bind set to ports owned by the local node. That broke cross-node mesh routing end to end: meshed containers' extra_hosts: <alias>:host-gateway entries resolve to the SOURCE node's gateway, so the source node is where the inbound TCP connection lands. With the filter, the source node never bound the remote alias's port and the connection went nowhere. The architecture model documented in docs/internal/architecture/ mesh.md (data flow step 3) is explicit: monolith's Sencho process accepts the connection on its MeshForwarder listener bound to port 5432 — where monolith is the source and opsix is the target. Every meshed node binds every alias port. handleAccept then resolves the alias and dispatches to openSameNode (when target.nodeId equals the local node) or openCrossNode (otherwise). Both branches were already correct; only the bind filter was wrong. Fleet-wide port collisions remain blocked at opt-in time (optInStack checks aliasByPort), so binding every alias port is unambiguous. New vitest case asserts the want-set includes both local-owned and remote-owned ports. Discovered while running PR 0 verification on v0.75.0 against Local + sencho-pilot-test: connections from a Local prober to the pilot's alias port hit Local's host with no listener. |
||
|
|
e4411f3247 |
chore(main): release 0.75.0 (#1002)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
567e524450 |
feat(mesh): bidirectional routing via tcp_open_reverse and central relay (#1003)
* feat(mesh): bidirectional routing via tcp_open_reverse and central relay
Phase B of the mesh redesign. Adds the reverse-direction protocol so a
pilot's MeshForwarder can route cross-node traffic to central or to
another pilot. Central relays pilot-to-pilot streams transparently;
pilots keep their existing single outbound WS to central.
Protocol additions (backend/src/pilot/protocol.ts):
- TcpOpenReverseFrame { s, targetNodeId, stack, service, port } sent
agent to primary. Reuses existing tcp_open_ack, tcp_close, and
TcpData binary frames for the response and byte plane.
- AGENT_REVERSE_ID_BASE = 0x40000001 splits the 32-bit id space so
agent-allocated reverse stream ids never collide with primary-
allocated forward ids on the same tunnel.
- StreamIdAllocator now wraps to its configured start (not always 1)
so an allocator parameterized with the agent base stays in the
agent half across the wrap.
Agent (backend/src/pilot/agent.ts):
- New ReverseTcpStreamHandle exported class, EventEmitter facade
matching PilotTunnelBridge.TcpStream's surface (write, end,
destroy plus open, data, error, close events).
- Public openMeshTcpStream(target) allocates a reverse id, sends
tcp_open_reverse, returns the handle.
- onTcpOpenAckReverse handles inbound ack, dispatches open or
error+close to the matching handle.
- TcpData and tcp_close binary/JSON paths route ids in the reverse
range to reverseTcpStreams; existing primary-allocated paths
unchanged.
- streamCount, cleanupAfterDisconnect, onStreamIdle include reverse
streams. The allocator is reset to a fresh base on disconnect so
long-lived agents that reconnect many times do not drift up the id
space.
- startPilotAgent registers the agent as MeshService's reverse
dialer via lazy import.
Bridge (backend/src/services/PilotTunnelBridge.ts):
- Two new StreamState kinds: reverse_local (target = central, dial
Dockerode container IP) and reverse_relay (target = another pilot,
open a forward TcpStream on the target pilot's bridge).
- handleTcpOpenReverse validates the agent-id range and stream cap,
then dispatches to acceptReverseLocal or acceptReverseRelay via
lazy imports of MeshService, NodeRegistry, and PilotTunnelManager
(avoids module cycles).
- acceptReverseLocal calls MeshService.resolveContainerIp (now
public), opens net.createConnection, splices bytes through the
tunnel. Pre-connect error handler is removed inside connect to
avoid double-firing with the mid-stream error path.
- acceptReverseRelay opens a forward TcpStream on the target
bridge, splices bytes between the two tunnels.
- Existing tcp_close and TcpData handlers extended for the new
state kinds. teardownStream extended.
MeshService (backend/src/services/MeshService.ts):
- resolveContainerIp made public so the bridge's local-target path
can dial the same shape.
- New reverseDialer field plus setReverseDialer setter. Pilot mode
registers the agent at boot; central mode leaves it null.
- New private dialMeshTcpStream dispatcher: pilot side uses the
reverse dialer, central side uses PilotTunnelManager.getBridge.
- openCrossNode refactored to call the dispatcher and use the
active-stream record's id for activity logging.
- New exported MeshTcpStreamLike interface that both
PilotTunnelBridge.TcpStream and ReverseTcpStreamHandle satisfy
structurally; ReverseMeshDialer is the contract for the setter.
Tests (3 files, 16 new cases, 172 total pass):
- pilot-protocol-tcp.test.ts: tcp_open_reverse round-trip; agent
base invariant.
- pilot-agent-reverse-stream.test.ts: openMeshTcpStream allocation,
ws-not-open, frame shape, write encoding, end emits tcp_close,
ack-success, ack-failure, low-id ack ignored, inbound TcpData
routing.
- pilot-bridge-reverse.test.ts: id-range validation, no-target
failure, successful local dial against a real upstream server.
Together with PR #1000 (Phase A) and PR #1001 (deps), Phase B
completes the central-pilot-pilot mesh routing matrix. Phase C
(mesh over proxy-mode remotes) is the planned follow-up.
* test(pilot): drop unused encodeJsonFrame import
Lint failed on pilot-agent-reverse-stream.test.ts after the test
changed from constructing tcp_open_reverse frames inline to driving
the agent's frame-dispatch path with synthetic objects. The import
is no longer referenced; ESLint's no-unused-vars rule rejected it.
|
||
|
|
f599110386 |
feat(mesh): collapse sidecar into Sencho process via in-process forwarder (#1000)
The separate saelix/sencho-mesh sidecar container is gone. The
forwarder logic that previously lived in mesh-sidecar/src/forwarder.ts
moves into the Sencho process as backend/src/services/MeshForwarder.ts,
a thin per-port net.Server lifecycle wrapper. MeshService implements
the host interface and owns resolve plus splice; MeshForwarder owns
listener boilerplate. One container per node, no separate image to
publish, no control WebSocket.
Operator-facing change: the Sencho container now runs in
network_mode: host so the forwarder can bind alias ports on the host
network where meshed containers' extra_hosts host-gateway entries
point. Without host network mode the listeners would land in the
container's namespace and inbound traffic from peers would never
reach them. The 1852:1852 port publish becomes a no-op under host
mode and is commented out in the operator template.
Same-node forward path now dials the target container's bridge IP
via Dockerode (preferring the compose default network for
deterministic selection across daemon versions) instead of 127.0.0.1.
The legacy 127.0.0.1 path only worked when the target service
published its port to the host; the IP path works regardless.
Cross-node mesh routing in this phase is central -> pilot direction
only via PilotTunnelManager.openTcpStream. Pilot -> central and
pilot <-> pilot via central relay land in Phase B with the
tcp_open_reverse frame.
Deletions:
- mesh-sidecar/ package entirely (Dockerfile, package, sources, tests)
- backend/src/websocket/meshControl.ts
- MeshService sidecar lifecycle: spawnSidecar, stopSidecar,
isSidecarRunning, mintSidecarToken, verifySidecarToken,
attachSidecarSocket, handleSidecarResolve, sendSidecar
- POST /api/mesh/nodes/:id/sidecar/restart route
- /api/mesh/control WS dispatch in upgradeHandler
Type cleanup: 'sidecar' literal removed from MeshActivitySource and
MeshProbeResult.where (also the frontend mirror). MeshNodeStatus
sidecarRunning becomes localForwarderListening (boolean | null) so
non-local nodes get a null instead of an unconditional false; the
honest semantic is "this view only knows the local forwarder state;
remote forwarder status lands in Phase B." MeshNodeDiagnostic
sidecar object becomes forwarder { listening, listenerCount }.
Frontend MeshDiagnosticsSheet drops the restart-sidecar action and
sidecar liveness card; surfaces forwarder state plus a "runs
in-process; no separate container" caption.
Resolves audit findings C-1 (data plane non-functional), C-2 (sidecar
control WS not loopback-enforced), C-4 (sidecar lifecycle Dockerode-
on-remote, PR #999 closed), and C-5 (saelix/sencho-mesh:latest
unreachable). C-3 (PR #992) is unchanged. M-12 (PR #994) is
unchanged.
|
||
|
|
b5463e1771 |
chore(deps): bump fast-uri to 3.1.1+ to clear GHSA-q3j6-qgpj-74h6 (#1001)
GitHub published a high-severity advisory against fast-uri <= 3.1.0 (path traversal via percent-encoded dot segments, GHSA-q3j6-qgpj-74h6) which CI's npm audit step now flags. The vulnerability is not exploitable in Sencho's usage: The dependency chain is composerize@1.7.5 -> composeverter@1.7.5 -> ajv@8.18.0 -> fast-uri@3.1.0. Composerize is consumed only by POST /api/convert/ in routes/convert.ts, which parses CLI-style docker run flags (not URLs) and returns YAML. ajv's use of fast-uri is for internal JSON-Schema $ref/$id resolution, not user input. The CVE requires attacker-controlled URLs flowing into fast-uri.normalize() or equal() for path-based policy bypass; that flow does not exist in Sencho. Even though the vulnerability is not exploitable, npm audit blocks CI regardless of exploitability assessment (it does not read VEX). This commit is a lockfile-only bump produced by npm audit fix. ajv@8.18.0's range already accepts fast-uri >= 3.1.1 so no package.json change is needed. Verification: npm audit reports 0 vulnerabilities post-fix; backend test suite (1921 tests, 5 skipped) is green; tsc --noEmit is clean. |
||
|
|
1803512f70 |
feat: drop stack labels and network topology to Community tier (#995)
* feat(labels): drop tier gate to Community for organization endpoints Stack Labels CRUD and per-stack assignment are now Community-tier features: list, create, update, delete labels, and assign labels to a single stack. The two automation surfaces stay Skipper+: per-label bulk deploy / stop / restart (POST /api/labels/:id/action) and the Fleet Actions tab's bulk-assign card (POST /api/fleet-actions/labels/bulk-assign). Tier story is now organize free, automate paid. Add a route-level test that proves the CRUD endpoints succeed on a Community license while the bulk-action endpoint still returns 403. Update overview, licensing, and stack-labels docs to reflect the new tier placement and to note that bulk actions on a label still require Skipper or Admiral. * feat(topology): drop tier gate to Community for network topology view The Resources tab's Networks > Topology view is now available on every tier. Drops requirePaid from GET /api/system/networks/topology, removes the isPaid wrapper around the List | Topology toggle in ResourcesView, and removes the PaidGate around the topology graph. CapabilityGate stays in place so a node running on a build without the network-topology capability still renders its lock card instead of the graph. Add a route-level test that proves the endpoint returns 200 on a Community license. Update licensing and resources docs to reflect the new tier placement. * fix(labels): expose Settings > Labels tab on Community tier The settings registry entry for the Labels tab still carried tier: 'skipper', which kept the tab hidden in the Settings sidebar even though the underlying CRUD endpoints now serve Community. Drop the tier flag so Community users can discover and reach the section that backs the already-Community-tier label endpoints. |
||
|
|
0f3380897a |
chore(main): release 0.74.4 (#998)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
3d1cf0f8f1 |
fix(pilot): generate auth_jwt_secret on pilot-agent boot (#997)
Pilot-agent hosts never run the first-run setup wizard, so the wizard path that normally generates auth_jwt_secret (routes/auth.ts) never fires. Without that secret, the agent-side loopback auth helper added in PR #990 (pilot/agent.ts::getLoopbackAuthHeader) returned null at mint time, no Authorization header was attached on the forwarded loopback request, and the local Sencho's authMiddleware rejected every proxied call with 401 "Authentication required". This was reproducible end-to-end on v0.74.1, .2, and .3. Add ensurePilotJwtSecret() to bootstrap/startup, called early in startServer (before LicenseService.initialize, which only reads system_state and does not touch global_settings, so the order is safe). When SENCHO_MODE=pilot and global_settings.auth_jwt_secret is empty, generate 64 random bytes (hex-encoded, matching the wizard's pattern in routes/auth.ts) and persist. Subsequent boots see the persisted value and no-op. Outside pilot mode the function is a no-op since the wizard owns the lifecycle. The helper is exported so the regression test can exercise the real function rather than maintaining a hand-mirrored copy. Three vitest cases cover: first-boot generation, no-op on persisted secret, no-op in non-pilot mode. Together with PR #989 (proxy-side bridge dispatch), PR #990 (agent-side loopback auth injection), PR #992 (cross-node control plane via HTTP proxy), and PR #994 (local-node mesh-reachable diagnostic), this completes the central, bridge, agent, local-Sencho HTTP path for pilot-agent-mode nodes. |
||
|
|
ff4be9019a |
chore(main): release 0.74.3 (#996)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
34581e55e7 |
fix(mesh): treat local-node aliases as always reachable in diagnostics (#994)
MeshService.getRouteDiagnostic called PilotTunnelManager.hasActiveTunnel unconditionally for the alias's target node. Local nodes never establish pilot tunnels because they do not need them (mesh same-node traffic uses the localhost fast path), so the call always returned false and the diagnostic flipped to state: 'tunnel down' even on a working local route. The route detail sheet and node card consequently rendered every local mesh alias as a destructive 'tunnel down' pill. Added a private isMeshReachable helper that returns true for any local-type node and otherwise delegates to hasActiveTunnel. Used it in both getRouteDiagnostic and getStatus, replacing the inline ternary in getStatus that already had the right shape but lived as duplicated logic. The helper falls back to false when the node row is missing (orphaned alias from a partial cascade), matching the pre-existing conservative behavior. Verified live: GET /api/mesh/aliases/echo.audit-mesh-prod.Local.sencho/diagnostic previously returned state='tunnel down' on a healthy local route. |
||
|
|
f7ff7955ee |
chore(main): release 0.74.2 (#993)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
e176ae9f17 |
fix(mesh): inspect remote stacks via HTTP proxy not Dockerode (#992)
MeshService.inspectStackServices used DockerController.getInstance(nodeId)
to enumerate compose-labeled containers, but NodeRegistry.getDocker
explicitly throws for any node with type='remote' by design. The throw
was silently caught and an empty array returned, so opt-in for any
remote node failed with a misleading "no running services" error and
refreshAliasCache only ever populated LOCAL aliases. Cross-node mesh
routing was therefore impossible end-to-end regardless of pilot-tunnel
state.
Split the inspector. inspectLocalStackServices keeps the Dockerode
listContainers path and always queries the local Docker daemon; it is
public so the new route can call it. inspectStackServices is now a
dispatcher: local nodes fall through to the Dockerode path, remote
nodes (proxy mode and pilot-agent) HTTP-fetch
/api/mesh/local-services/:stackName against the URL resolved by
NodeRegistry.getProxyTarget, with the persisted node_proxy Bearer
token and license tier headers attached. The remote's MeshService
enumerates its own LOCAL Docker daemon and returns the
{service, ports[]} envelope.
refreshAliasCache now inspects every opted-in stack in parallel via
Promise.allSettled so a slow or unreachable remote does not stall the
60-second loop. The new /api/mesh/local-services/:stackName route is
gated by requireAdmiral plus isValidStackName and always queries the
caller's own Sencho instance.
Together with PR #989 (proxy-side bridge dispatch) and PR #990
(agent-side loopback auth), this makes the central, bridge, agent,
local-Sencho mesh control plane functional end-to-end for both
proxy-mode and pilot-agent remotes.
|
||
|
|
ee6e761f70 |
chore(main): release 0.74.1 (#991)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
a6d3e5d052 |
fix(pilot): inject loopback auth on agent-side HTTP/WS forwarding (#990)
After the central proxy forwards a request through the pilot tunnel, the agent receives an http_req or ws_open frame and opens a local loopback request to its own Sencho server. The forwarder previously copied the inbound headers verbatim with no Authorization header, so the local authMiddleware rejected every proxied call with 401 Authentication required. The central cannot sign the missing token because each Sencho instance has its own auth_jwt_secret generated at first-run setup. The agent process runs in the same Node process as the local Sencho server and shares the local DatabaseService, so it can mint a fresh pilot_tunnel-scoped JWT signed with the local secret. Token TTL is 5 minutes with a refresh-on-use 4-minute cache to avoid jwt.sign on every request. The header is built once per request via buildLoopbackHeaders and applied to both onHttpReq and onWsOpen so the two forwarder paths stay in lockstep. The local authMiddleware accepts the scope through its existing pilot_tunnel branch with no special-case bypass. Together with the proxy-side change in the previous commit, this completes the central, bridge, agent, local-Sencho HTTP path for pilot-agent-mode nodes. |
||
|
|
269ba2909b |
chore(main): release 0.74.0 (#980)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
94ce7c71d2 |
fix(proxy): route pilot-agent HTTP via PilotTunnelBridge loopback (#989)
The remote-node HTTP proxy resolved targets by reading nodes.api_url and
nodes.api_token directly from the database. Both fields are empty for
pilot-agent nodes by design, which produced a misleading 503 ("no API URL
or token configured. Update it in Settings, Nodes.") for any API call
targeting a healthy pilot-agent: stack creation, log retrieval, and every
other resource a pilot-agent should serve.
NodeRegistry.getProxyTarget already encapsulates the correct dispatch.
For proxy mode it returns the persisted api_url and api_token. For
pilot-agent it returns the loopback URL of the active PilotTunnelBridge
with an empty token, since the bridge re-authenticates implicitly via the
pre-verified tunnel socket.
Switch all three lookup sites in remoteNodeProxy to this helper, cache
the resolved target on req.proxyTarget so the http-proxy router and
proxyReq callbacks do not re-resolve, and split the 503 message so
pilot-agent operators see "Pilot tunnel to X is disconnected" instead of
the proxy-mode hint.
|
||
|
|
c9452337e1 |
test(pilot): in-process simulation of mid-tunnel disconnects (#987)
* test(pilot): in-process simulation of mid-tunnel disconnects The PR #979 hardening added per-stream idle timers, drain handling, paused-request maps, and a tcpAwaitingDrain set on the bridge. None of those cleanup paths had an end-to-end test driving them through a real disconnect. A future refactor that inverts the order of clearIdleTimer + streams.delete (or that misses one of the aux maps) would leak per-tunnel memory in a way unit tests cannot catch. Spin up a real http.Server with attachUpgrade and a real ws.WebSocket client (same self-contained pattern as pilot-tunnel-integration.test.ts). Four cases: - HTTP request mid-flight: open a real loopback HTTP request, wait for the bridge to forward http_req to the agent, terminate the agent WS, assert the loopback request resolves with 502 (the teardownStream path) and the manager marks the node Offline. - TCP stream with bytes outstanding: openTcpStream from the bridge, ack open from the test agent, write some bytes, terminate the tunnel, assert the TcpStream emits 'close' and the bridge is gone from the manager. - Reconnect with the long-lived token after a clean close does NOT bump tunnels_replaced (no live tunnel to replace). - Reconnect WHILE the prior tunnel is still live DOES bump tunnels_replaced. Inverse case proves the counter wiring is correct in both directions. Self-contained: no shared helper modules, no dependencies on other in-flight pilot test files. Can land independently of PRs #982, #983, or #985. * test(pilot): address PR E code-review on disconnect tests Two small findings: - Defensive: attach a no-op 'error' listener on the TcpStream in test #2 after the 'open' event has fired. teardownStream only emits 'error' for unaccepted streams today, so the listener is not exercised; the listener exists so a future refactor that delays accepted=true past 'open' cannot crash the worker on an unhandled 'error' event. - Removed a stale cross-file reference in the header comment that pointed at pilot-tunnel-integration.test.ts; that file lives in a separate in-flight branch and does not exist on main. No behavior change. All 4 disconnect tests pass. * chore(test): drop unused PROTOCOL_VERSION import The import was leftover from an earlier draft and ESLint flagged it as no-unused-vars (error level for symbols not prefixed with _), failing the lint job in CI. |
||
|
|
3112f58a88 |
fix(templates): align App Store deploy gate with stack:create permission (#986)
The POST /api/templates/deploy handler gated on requireAdmin, while every
other create-stack endpoint in routes/stacks.ts uses
requirePermission('stack:create'). Per the role table in
middleware/permissions.ts, node-admin holds stack:create — so a node-admin
could create stacks the regular way but got 403 ADMIN_REQUIRED from the
App Store. The cockpit's Deploy button is gated on can('stack:create'),
so the button looked enabled and the click silently failed.
Swap the gate to requirePermission(req, res, 'stack:create'). Admin still
passes through the global bypass; node-admin now passes via the role
permissions table; deployer, viewer, and auditor stay denied. Cache-refresh
on the same router keeps requireAdmin since cache invalidation has no
per-resource scope.
Adds backend/src/__tests__/templates-deploy-rbac.test.ts with six
parameterised supertest cases (one per role plus an unauthenticated
case) so the matrix is locked in. The two passing-role cases assert
the request clears the gate (status !== 403, code !== PERMISSION_DENIED)
without depending on Docker being available in the test environment.
|
||
|
|
8f13a7faf3 |
fix(pilot): stop silently swallowing fs errors in agent token helpers (#985)
* fix(pilot): stop silently swallowing fs errors in agent token helpers
The pilot-agent audit found that both filesystem-touching helpers in
the agent process discarded every fs error class:
- readPersistedToken caught all errors and returned null. ENOENT
(normal first boot) and EACCES / EIO / EROFS (a real disk or
volume-permission failure) were indistinguishable; the latter
silently fell back to "no persisted token", and on a node where
the enrollment token had already been consumed the agent would
enter a re-enrollment loop with no log signal pointing at the disk.
- persistToken caught all errors and logged at console.warn. The
operator saw the next-boot loop with the same diagnostic gap.
Replace both with errno-aware handling:
- readPersistedToken calls fs.readFileSync directly (no TOCTOU race
against existsSync), treats ENOENT as silent, and logs every other
errno at ERROR with the path.
- persistToken logs at ERROR (not WARN) with the failing errno and an
explicit "next agent restart will require re-enrollment until the
volume is writable" message. The agent still continues with the
in-memory token so the current session is unaffected.
Both helpers are now exported (marked @internal) so the new test file
can mock fs and assert the error-class-vs-log-level matrix.
12 unit cases in pilot-agent-fs-errors.test.ts cover ENOENT,
EACCES, EIO, EROFS, ENOSPC, missing errno, empty file, and the
happy path. Mock pattern follows backend/src/__tests__/filesystem.test.ts.
* fix(pilot): address code-review on the fs-error branch
Three findings from the review pass:
- Em dash in a test description (Directive 18). Rewritten as
"does not throw, so the in-memory token stays usable for the
current session".
- persistToken still had an existsSync + mkdirSync pair around
the token-write. mkdirSync({ recursive: true }) is idempotent on
existing directories, so the existsSync was redundant and added
a TOCTOU window where the directory could be removed between the
probe and the write. Dropped the existsSync; the test that
previously primed mockExistsSync now asserts mockExistsSync is
NOT called, locking the TOCTOU removal.
- The two new exports used the @internal JSDoc tag, but this
repo's existing pattern for "public-by-convention-for-tests"
helpers (e.g. RegistryService.ts:481) is plain prose
"Exposed for unit tests." Switched to that style.
No behavior change beyond the TOCTOU removal.
|
||
|
|
86abd901f6 |
feat(app-store): show inline port-conflict message on deploy sheet (#984)
* feat(app-store): show inline port-conflict message on deploy sheet
Replace the hover-only cursor tooltip on the Advanced tab with an inline
"in use by <stack>" message rendered next to the port input. The
pulsating warning dot stays as the live-data indicator.
Add a Port-conflict warning rail to the Essentials tab that lists each
conflicting default port and the application using it. The rail replaces
the "Deploy with defaults" hint while conflicts exist, and its left
accent pulses to echo the live-data signal.
Update docs/features/app-store.mdx to describe the new visible behavior
on both tabs.
* docs(app-store): drop docs hunk from this PR
The deploy-sheet docs are being rewritten on the docs/v1-refresh branch
(PR #966). To avoid a merge conflict between the two PRs, the prose
update for the inline port-conflict messaging now lives there as a
follow-up commit on top of
|
||
|
|
e380ee7b16 |
test(pilot): in-process integration test and Playwright E2E (#983)
* test(pilot): in-process integration test for the tunnel handshake
Layered unit tests cover the protocol decoder, the bridge cap, the
manager, and the DB-layer enrollment lifecycle. None exercise the
glue: the WebSocket dispatch order in upgradeHandler.ts, the actual
hello / enroll_ack round-trip, the long-lived token swap. A
regression that moves /api/pilot/tunnel below the auth gate, breaks
the hello / enroll_ack ordering, or changes the token shape would not
be caught today.
Spin up a real http.Server with attachUpgrade wired and a real
ws.WebSocket client. Three tests:
- Enroll-ack happy path: connect with an enrollment token, receive
hello + ctrl enroll_ack, assert the manager has registered the
tunnel.
- Replay rejection on the wire: connect, complete enroll-ack, close,
reconnect with the same enrollment token, assert HTTP 401 from the
upgrade handshake.
- Long-lived token reconnect: capture the enroll_ack token, close,
reconnect with the long-lived pilot_tunnel JWT, assert hello but
no enroll_ack and the tunnel is registered.
The test does not mock the agent side beyond the framing protocol.
The goal is to confirm the wires connect end-to-end, not to drive a
full request round-trip (which would require a synthetic agent bridge
and adds little marginal coverage over the existing bridge unit tests).
This file also establishes the in-process WS-pair test pattern for
any future WebSocket integration work; no such pattern existed in the
suite before.
* test(e2e): cover operator-side pilot-agent enrollment in Playwright
Backend integration is covered by pilot-tunnel-integration.test.ts
and the vitest enrollment suites. The browser-only surfaces (mode
selector default, enrollment dialog, docker run code block,
regenerate affordance on an existing pilot-mode node) had no
automated coverage; a typo in the mode label, a styling regression
that hid the docker-run code, or a backend response shape change
that broke the rendered command would all ship unchecked today.
Two specs reusing the existing loginAs helper and the same
Settings -> Nodes navigation pattern from nodes.spec.ts:
- Create flow: open Add Node, switch type to Remote, confirm pilot
mode is the default (api_url field stays absent), submit, assert
the enrollment dialog renders a docker run command containing
SENCHO_MODE=pilot, SENCHO_PRIMARY_URL=, and a JWT-shaped
SENCHO_ENROLL_TOKEN=. Cleanup deletes the row.
- Regenerate flow: create a pilot node, capture the first token,
open the row's edit dialog, click Regenerate enrollment token,
assert the second token differs from the first. Cleanup deletes
the row.
Out of scope for this E2E: simulating an agent connecting to flip the
row to Online. The integration test covers the wire side; this spec
keeps focus on the operator-visible UI.
Each test name-suffixes with Date.now() so re-runs do not collide on
the UNIQUE name constraint and so leftover rows from a partial run
get distinct names instead of stacking on the same row.
* fix(pilot): address PR B code-review findings
Three code-review findings:
- High: NodeManager row action buttons were icon-only with no
accessible name. Added aria-label="Edit node",
aria-label="Delete node", aria-label="Test connection" so the
Playwright E2E can target them by role/name (currently the
accessible name is rendered into a Radix tooltip portal that
Playwright cannot resolve as the button's name).
- High: Replaced UI-driven cleanup in the E2E with API-based
deletion via page.request, run in both beforeEach (sweep
leftovers) and afterEach (sweep this test's row even on failure).
Targets every node whose name starts with the test prefix so a
crashed previous run cannot affect the next.
- Medium: Replaced two fixed 50ms sleeps in the integration test
with vi.waitFor polling on hasActiveTunnel(nodeId) === false,
eliminating the race between the WS close hop chain and the next
test step on slow CI runners.
Plus two cleanup items:
- The integration test's afterAll now removes 'tunnel-up' /
'tunnel-down' listeners from the manager singleton so this
file's runs do not leak listeners into other test files in the
same Vitest worker.
- Tightened the WS error swallowing in the rejection-path test:
only swallow the expected "Unexpected server response" error
shape; surface anything else (ECONNREFUSED etc.) instead of
silently masking it.
No em dashes added (Directive 18 verified clean).
|
||
|
|
f5a52e44dc |
refactor(pilot): narrow Mesh handle, prune dead code, add replay-route test (#982)
* refactor(pilot): narrow PilotTunnelManager.getBridge to MeshTunnelHandle
The manager handed out the entire PilotTunnelBridge to its only consumer,
MeshService, which let any current or future caller reach into transport
internals: loopback URL, per-stream maps, the close API, the underscored
_writeTcpData / _closeTcpStream, the diagnostic helpers. None of that
was load-bearing for Mesh.
Introduce a MeshTunnelHandle interface alongside TcpStream in
PilotTunnelBridge.ts that exposes only the two methods Mesh actually
calls (openTcpStream and getBufferedAmount), have PilotTunnelBridge
declare implements MeshTunnelHandle, and change getBridge to return
the interface. MeshService continues to compile unchanged because its
existing call sites only touch the narrowed surface.
A future alternative transport (a stub for tests, a different routing
strategy) now has a one-method-and-a-getter contract to satisfy
instead of the full bridge.
* refactor(pilot): drop unused tunnel manager and bridge surface
Three public methods predating the hardening pass had zero callers
across the entire codebase (verified via grep across backend, frontend,
e2e):
- PilotTunnelManager.touch(nodeId): never invoked. The pilot_last_seen
timestamp is updated by the manager itself on registerTunnel and by
the persistence layer on heartbeat events.
- PilotTunnelManager.listActive(): never invoked. The metrics endpoint
added in PR #979 returns its own per-node breakdown via
getMetricsSnapshot, which is the canonical observability surface.
- PilotTunnelBridge.listTcpStreams() and the supporting
TcpStreamSummary interface: never invoked. The Mesh diagnostics
sheet that would have consumed it is not wired and would use
getMetricsSnapshot if/when it ships.
Removing them tightens the public surface and prevents accidental new
dependencies on speculative future-proofing.
* test(pilot): cover enrollment replay rejection at the route layer
The DB-level test in pilot-enrollment.test.ts already verifies that
consumePilotEnrollment is one-shot. That guards the persistence layer
but not the route handler: a refactor of handlePilotTunnel that swaps
the order of consume vs upgrade, or that grants the WebSocket
upgrade before checking the consume result, would silently break the
security invariant while DB-layer tests stay green.
Drive handlePilotTunnel directly with a stub IncomingMessage and
Duplex socket. Six cases:
- Already-consumed enrollment row -> 401.
- Token whose hash matches no row -> 401.
- Row whose expires_at has passed -> 401.
- Missing Authorization header -> 401.
- JWT signed with a wrong secret -> 401.
- pilot_tunnel JWT for an unknown node -> 404.
The stub captures HTTP/1.1 status writes so the test asserts the
exact rejection lands on the wire, not just that the function
returned without throwing.
* docs(debug): warn future committers off per-frame isDebugEnabled calls
Code-review feedback on PR #979 noted that isDebugEnabled is fine in
the cadences it has today (per-tunnel, per-request, error paths) but
is fragile against a future commit that drops it into a per-frame
WebSocket loop. The function does a try/catch + Node require cache
lookup + a method call into DatabaseService on every invocation;
acceptable at hundreds of calls per second, expensive at thousands.
Add a comment block above the function spelling out the acceptable
and unacceptable cadences and the snapshot-outside-the-loop
mitigation, so the next person to add a diag log there sees the
constraint.
* fix(pilot): address PR A code-review findings
Code review on this branch surfaced four items:
- Em-dash directive (CLAUDE.md Directive 18) violations in four
comment sites; replaced with colons or restructured.
- debug.ts perf comment claimed try/catch frame setup as the cost
driver. V8 inlines those; the load-bearing cost is the require
lookup and singleton dispatch. Reword.
- Test file header described coverage as 'replay rejection at the
route layer' but the file now also covers missing-header,
wrong-secret, expired-row, never-stored, and unknown-node
rejection paths. Widen the JSDoc and the describe label to match.
- Stub-cast comment in the replay test now explicitly lists the
IncomingMessage and Duplex surface the stub satisfies, so a
future commit that grows handlePilotTunnel's surface (rate
limiting, socket options) updates both stubs instead of
silently no-opping against them.
No behavior change.
|
||
|
|
4867234eac |
chore(resources): remove Largest 5 and Recently changed cards from Volumes tab (#981)
The two landing cards above the volumes table did not earn their space and made the Volumes view feel cluttered. Drop the cards, the unused TabLanding component, and the helpers that only fed it. |
||
|
|
0be479100c |
docs: v1 docs refresh (#966)
* docs(introduction): rewrite intro page and refresh screenshots
Rewrite the Getting Started introduction to reflect the current product:
adds the Mesh, Blueprints, Pilot, Fleet, Resources concepts up front;
restructures capability sections around Stacks, Fleet (now including
Fleet Actions), Mesh, Blueprints, Monitoring, Resources, Security,
Automation, and Pilot/Remote ops; cross-links every claim to the
matching feature page.
Replaces the dashboard hero shot with a fresh capture against the
redesigned cockpit chrome and adds three inline shots (running stack
with anatomy and logs, fleet command deck, resources hub with
reclaim header). All screenshots taken at 1920x900, dark theme,
with node names, usernames, IPs, and home paths neutralized.
Drops outdated claims: stale "190+ templates" count, the
"viewer accounts" RBAC summary, and the "atomic" deployment label
that did not match the actual rollback mechanism.
* docs(introduction): add Federation and Fleet Secrets
Federation has shipped (Admiral) with cordon and pin policy as
operator overrides on the blueprint reconciler. Fleet Secrets is
landing as a Skipper+ tab for versioned env-var bundles encrypted
at rest, with diff preview and target push.
Mirror those in the Run-one-machine-or-many bullet list and in the
Tiers paragraph so the introduction matches the current product.
* docs(introduction): re-shoot screenshots against v0.72.0 production
Re-capture all four introduction screenshots from the upgraded
production node so the fleet view shows the current full tab strip
(Overview / Snapshots / Status / Deployments / Traffic / Federation
/ Fleet Actions / Secrets) instead of the older Overview / Snapshots
/ Status only. Same 1920x900 dark-theme capture and the same PII
scrub applied (node names, usernames, IPs, home paths neutralized).
* docs(quickstart): rewrite around v0.72.0 cockpit and add screenshots
Replaces the bare install snippet with a five-minute walkthrough that
matches the redesigned UI. Leads with a docker-compose.yml block (the
bare docker run command is collapsed in an Accordion), keeps the 1:1
path rule, and adds two new sections that show the user what happens
on first boot.
Adds two screenshots at 1920x900 dark theme:
- setup-cold-start.png: the Cold start card with Username, Password,
Confirm password fields, and the Initialize console button.
- dashboard.png: the post-sign-in dashboard captured against v0.72.0
with the full top nav (Home, Fleet, Resources, App Store, Logs,
Auto-Update, Console, Audit, Schedules) and the populated Stack
health table sorted by load.
Where-to-next now uses CardGroup cols=2 to match the introduction
page's pattern.
* docs(configuration): align env var reference with current backend
Bring docs/getting-started/configuration.mdx up to date with the
v0.72.0 backend:
- Remove PORT from the optional env vars table. The listen port is
hardcoded to 1852 in backend/src/helpers/constants.ts and is never
read from the environment. Replace it with a Listen port section
that explains the fixed port and host-port remapping.
- Document API_RATE_LIMIT (default 200) and API_POLLING_RATE_LIMIT
(default 300), both applied in production only.
- Note the /app/compose fallback default for COMPOSE_DIR while still
pointing readers at the 1:1 path rule.
- Point the SSO env var section at the new SSO Quickstart page and
keep the SSO feature reference as the deeper dive.
- Tighten First boot and cross-link to Quickstart so the screenshot
is not duplicated.
- Add a Where to next CardGroup matching the refreshed Introduction
and Quickstart pages.
Drop stale PORT=1852 and JWT_SECRET=your-secure-jwt-secret-here
lines from .env.example so the example no longer contradicts the
docs (PORT is hardcoded, JWT_SECRET is auto-generated and persisted
to the database during initial setup).
* docs(configuration): replace em-dash-substitute hyphens in prose
Three sentences used ` - ` (space-hyphen-space) as an em-dash
substitute. Replaced with the punctuation that fits each case:
- "How Sencho organizes your compose directory": semicolon between
the two related clauses.
- Data directory Warning: parentheses around the parenthetical
insertion.
- 1:1 path rule explanation: comma before the contrastive clause.
Heading slugs and YAML code-block comments are unchanged.
* docs(sso): refresh setup guide and drop misleading "one-click" wording
Brings the SSO Setup Guide in line with how SSO actually works in the
current build, and corrects misleading copy across both SSO docs pages.
- Setup Guide: explains the env-var-seeds-once / DB-is-authoritative
config model up front, replacing per-section "Restart Sencho"
wording that implied a restart was always required.
- Setup Guide: promotes the per-provider Test Connection button out
of the LDAP-only paragraph into a generic intro callout, and adds
a self-signed LDAPS tip.
- Setup Guide: notes that all OIDC providers accept a *_DISPLAY_NAME
override for the login button label, and adds a commented LDAP TLS
toggle to the full compose example.
- Setup Guide: adds two screenshots of the redesigned Settings > SSO
panel (overview + LDAP card expanded with form).
- Both pages: replaces "one-click presets" / "one-click configuration"
with "preset providers". The Skipper-tier presets still require an
OAuth app provisioned in the provider's console; what they actually
buy is provider-aware defaults and a branded login button. The old
wording overpromised.
* docs(features-overview): regroup catalog and add 17 missing features
Restructure the Features Overview into the same six groups the docs
sidebar uses (Stacks & Deployments, Observability, Fleet & Multi-Node,
Security & Identity, Automation, Platform) plus a short Reference tail.
Add catalog entries for 17 shipped features that the previous overview
never mentioned: Stack Activity, Stack File Explorer, Deploy Progress,
Deploy Enforcement, Blueprints, Git Sources, Global Search, Pilot Agent,
Sencho Mesh, Fleet Federation, Fleet Actions, Fleet Sync, Fleet Secrets,
Two-Factor Authentication, CVE Suppressions, Auto-Heal Policies, Stack
Sidebar.
Fix two factual inaccuracies:
- Fleet View blurb wrongly gated search, sort, filter, and stack
drill-down behind Skipper. The deep-dive is explicit that those are
available on every tier; only the bulk Update All action inside the
Node Updates modal is paid.
- Auto-update entry was titled and described as a scheduling system.
The deep-dive page is the Auto-Update Readiness board (risk tags,
changelog previews, rollback targets); the scheduler lives under
Scheduled Operations.
Add three hero screenshots captured from production at 1920x900,
illustrating the redesigned cockpit visual language: Home dashboard,
stack anatomy, and fleet topology.
* docs(stack-management): refresh page around v0.72.0 cockpit and add screenshots
Updates the Stack Management page to match the current UI: sidebar with
filter chips and label groups, bulk mode, restructured kebab menu,
two-tab anatomy panel, and three-source New stack dialog (Empty, From
Git, From Docker Run).
Adds sections for Filter chips, Pinned and label groups, and Bulk mode.
Restructures the Stack context menu around the inspect, organize,
lifecycle, and destructive groups with their keyboard shortcuts.
Documents the From Git tab and cross-links Git Sources for the full
sync flow.
Replaces every existing screenshot with fresh captures from the current
UI and adds eight new captures for the new sections. Cross-links Stack
Activity, Stack Labels, Stack File Explorer, Compose Editor, Atomic
Deployments, Scheduled Operations, Auto-Heal Policies, Auto-Update
Policies, and Alerts and Notifications for features documented on
their own pages.
* docs(stack-activity): refresh page around v1 cockpit and recapture screenshots
Realign the page with the current Anatomy panel tab strip and the
StackActivityTimeline component:
- Frame the Activity tab as a sibling of Anatomy under the right-hand
panel, with files/edit actions belonging to the strip.
- Expand the category guidance: list the five iconized categories and
call out that other stack-scoped notifications (deploy failure,
available image updates, auto-heal triggers, monitor alerts, scan
findings) flow into the timeline with a generic icon.
- Tighten the actor-attribution rule to match the component: omitted
for events without an actor and for system-driven events.
- Add the day-format example to the relative-time row.
- Recapture both screenshots from a populated stack (Today + Yesterday
+ Earlier with three distinct icons) and an empty stack.
- Convert troubleshooting blurbs to H3 for anchor links and consistency
with the v1-refresh sibling pages.
* docs(stack-activity): wrap troubleshooting entries in Accordion blocks
Match the foldable troubleshooting pattern established by
docs/features/deploy-progress.mdx so the page stays compact and
readers can scan to their issue. This is the canonical formatting
for the /features section's troubleshooting blurbs going forward.
* docs(editor): refresh page around v1 cockpit and recapture screenshots
Rewrites the page around the dual-mode right panel (Anatomy by default,
Monaco when the user clicks edit), the redesigned Command Center action
bar, the new container row layout with status badges and live stats, the
Structured / Raw terminal logs toggle, the Git Source toolbar button, and
the opt-in diff preview. Drops the obsolete persistent embedded terminal
section. Preserves the #diff-preview-before-save and #log-viewer anchors
referenced from settings.mdx and global-observability.mdx. Replaces
editor-overview.png and container-exec-modal.png with fresh captures
against v0.72.0 production at 1920x900 dark theme; renames container
-actions.png to containers-list.png; adds command-center.png and
editor-edit-mode.png. PII scrubbed (host paths normalized) and the
compose-diff-preview/diff-modal.png shared asset is left untouched after
visual diff against the live UI showed no chrome change.
* docs(features): wrap troubleshooting accordions in AccordionGroup
Wraps the loose <Accordion> blocks in editor.mdx and stack-activity.mdx
in a single <AccordionGroup> to match the troubleshooting design used
by fleet-federation.mdx. Structural only; no content changes.
* docs(stack-file-explorer): refresh page around v1 explorer and add screenshots
Full rewrite to match the live two-pane Files tab. Replaces the false
'Edit' toolbar flow with the read-only chip + always-on Save model,
fixes the protected-files list (5 names) and dedicated-tab redirect
list (3 names), drops the fabricated 100 MB download cap, and corrects
the upload claim to single file at a time.
Adds sections for New File, Rename, Permissions, the type-to-confirm
protected-file delete flow, the non-empty folder delete confirmation,
the 500-entry tree display cap, and the symlink rendering. Restructures
troubleshooting around <AccordionGroup> + <Accordion> to match
fleet-federation.mdx, and adds two new entries (display cap, 403 on
Community admin write).
Ships nine screenshots captured against v0.72.0 production at 1920x900
in dark theme: overview, two-pane layout, protected-tree-marker,
viewer-edit-mode, new-file-dialog, context-menu-folder, context-menu-file,
permissions-dialog, delete-protected-confirm.
* docs(deploy-progress): rewrite around current modal + capture v1 screenshots
Aligns the page with the v0.72.0 implementation and standardizes the
troubleshooting layout with the rest of the docs refresh.
Setting and gating
- Renames the Settings field to "Deploy progress modal" and quotes the
current helper text verbatim. Documents that the toggle is off by
default, lives under Settings > Appearance > Display, is stored in
localStorage, and syncs across tabs in the same browser.
Modal anatomy
- Names every visible UI string: header verbs, status indicator (with
the "closes in <n>s" countdown that was previously undocumented),
empty-body strings, footer toggle that flips between "Raw output" and
"Hide raw", and the destructive border on ERR rows vs the softer warn
tint on WARN rows.
- Replaces the vague "after a few seconds" with the actual 4-second
auto-close timer; documents hover-to-pause and the
leave-hover-restarts-the-countdown behavior.
- Documents the truncated error message in the failed-state header and
the manual close-only requirement.
- Notes that the pill is portal-mounted and survives navigation.
Stage badges
- Keeps the 9-badge table but adds an honest note that most lines render
as LOG because the badges are gated on Compose's "[+]" progress
prefix, which Compose only emits in TTY mode and Sencho spawns it
without one.
Entry points
- Splits the supported actions into the four that produce a populated
structured-log body (Deploy, Update, Install, Git Apply) and the two
that bypass compose and finish with 0 lines (Restart, Stop). Drops the
"Down" claim from the user-facing list since no UI control currently
triggers it; mentions the down route as an automation surface only.
Troubleshooting
- Wraps the existing accordions in an <AccordionGroup> matching the
pattern used by the editor and stack-activity refreshes. Adds two new
entries: one explaining the Restart/Stop "0 lines" outcome, one
explaining the LOG-everywhere case for non-TTY compose output.
Screenshots (six PNGs in docs/images/deploy-progress/, 1920x900, dark
theme, captured against the upgraded production node)
- setting-toggle.png: the Display section with the toggle enabled.
- modal-streaming.png: a real update in flight at 19s, 554 lines.
- modal-succeeded.png: succeeded state with the live closes-in
countdown visible.
- modal-raw-output.png: structured rows with the Raw output panel
expanded beneath.
- pill.png: minimized pill anchored bottom-center on a stack editor
view.
- modal-failed.png: failed state with the truncated error in the
header and ERR rows highlighted.
* docs(resources): refresh Resources Hub page for v1 redesign and feature additions
Rewrite the page to match the shipping UI and replace stale screenshots
with fresh captures of the redesigned chrome.
- Document the admin-only Reclaim hero and clarify the per-tile Sencho-only
vs. All Docker (includes external) split in Quick Clean.
- Add coverage of the Scan history toolbar button and the per-row severity
badge plus shield-icon scan dropdown in the Images tab; cross-link to the
vulnerability scanning page.
- Correct the Volumes column list (no Size column; size lives on the Largest
5 landing card) and call out admin gates on browse and delete.
- Spell out the List/Topology view-mode toggle and that Create Network is
admin-only and List-mode only.
- Rewrite the Unmanaged tab section around the project-grouped layout, the
Select all + Purge Selected (N) admin-only multi-select, and the empty
state copy.
- Replace screenshots: resources-reclaim, networks-list, create-network,
network-inspect, network-topology, network-topology-toggle. Add fresh
resources-volumes-tab and resources-unmanaged-tab captures.
* docs(app-store): refresh page around v1 deploy sheet, scan integration, and registry settings
Rewrites the App Store reference to match the current cockpit:
- Documents the weekly-rotated featured banner picked from the top-5 by GitHub stars and the star-descending grid sort.
- Adds the deploy-sheet structure (breadcrumb, meta line, About panel with Read more) and splits the Advanced tab into Ports, Volumes, Environment variables, Custom variables, and Security subsections.
- Documents the Trivy-gated Security checkbox, atomic vs non-atomic deploys by tier, and the rollback semantics driven by error class.
- Adds a Watching the deploy section linking to the deploy-progress modal.
- Rewrites the Custom registry section against the new two-panel settings layout (Default + Custom) with the URL validation rule and the using default / using custom hint.
- Adds a four-entry Accordion troubleshooting block in the house style.
- Replaces three screenshots and adds two (Advanced tab, Settings registry panel) captured against the production node.
Permissions wording aligns with current backend (admin only); the broader stack:create gate will land in a follow-up fix branch.
* docs(app-store): describe inline port-conflict messaging on deploy sheet
Update the deploy-sheet section to match the visible port-conflict
behavior: the Essentials tab surfaces a Port-conflict warning that
replaces the defaults hint when any default port is already bound, and
the Advanced tab shows an inline "in use by {stack}" message next to
the container port instead of a hover-only tooltip. Refresh the
screenshot alt-text and the troubleshooting Accordion to match.
* docs(app-store): align permissions note with stack:create gate
Pairs with the backend gate swap in fix/templates-deploy-rbac (#986)
which moves POST /api/templates/deploy from requireAdmin to
requirePermission('stack:create'). Updates the Note block under
'Watching the deploy' so the docs match the new behavior: admin and
node-admin can deploy templates from the App Store; viewer, deployer,
and auditor cannot.
|
||
|
|
8cbeb8708e |
chore(deps): bump the all-npm-backend group across 1 directory with 8 updates (#943)
Bumps the all-npm-backend group with 8 updates in the /backend directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.15.2` | `1.16.0` | | [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) | `3.0.5` | `4.0.0` | | [yaml](https://github.com/eemeli/yaml) | `2.8.3` | `2.8.4` | | [zod](https://github.com/colinhacks/zod) | `4.3.6` | `4.4.3` | | [eslint](https://github.com/eslint/eslint) | `10.2.1` | `10.3.0` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.1` | `8.59.2` | | [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1038.0` | `3.1043.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1038.0` | `3.1043.0` | Updates `axios` from 1.15.2 to 1.16.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0) Updates `http-proxy-middleware` from 3.0.5 to 4.0.0 - [Release notes](https://github.com/chimurai/http-proxy-middleware/releases) - [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/master/CHANGELOG.md) - [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v3.0.5...v4.0.0) Updates `yaml` from 2.8.3 to 2.8.4 - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.8.3...v2.8.4) Updates `zod` from 4.3.6 to 4.4.3 - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.3) Updates `eslint` from 10.2.1 to 10.3.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.3.0) Updates `typescript-eslint` from 8.59.1 to 8.59.2 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.2/packages/typescript-eslint) Updates `@aws-sdk/client-ecr` from 3.1038.0 to 3.1043.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1043.0/clients/client-ecr) Updates `@aws-sdk/client-s3` from 3.1038.0 to 3.1043.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1043.0/clients/client-s3) --- updated-dependencies: - dependency-name: axios dependency-version: 1.16.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: http-proxy-middleware dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-npm-backend - dependency-name: yaml dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: zod dependency-version: 4.4.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: eslint dependency-version: 10.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: typescript-eslint dependency-version: 8.59.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: "@aws-sdk/client-ecr" dependency-version: 3.1043.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1043.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Anso <dev@saelix.com> |
||
|
|
3d9489648e |
fix(pilot): harden outbound reverse-tunnel against resource exhaustion (#979)
* fix(pilot): cap tunnel frame size, concurrent streams, and stream idle time
Pilot tunnels carry every HTTP, WS, and Mesh-TCP byte for a remote node
through a single multiplexed WebSocket. A buggy or compromised peer that
sent oversized frames, opened streams in a tight loop, or left streams
parked indefinitely could exhaust gateway memory.
Three protocol-level limits applied symmetrically on both ends:
- MAX_FRAME_SIZE_BYTES (8 MB): set as the ws maxPayload on the
gateway-side WebSocketServer and the agent-side WebSocket client,
plus a defense-in-depth length check in decodeBinaryFrame and
decodeJsonFrame.
- MAX_STREAMS_PER_TUNNEL (1024): bridge refuses new loopback HTTP /
upgrade / TCP allocations with 503 once at the cap; the agent
rejects new incoming http_req / ws_open / tcp_open with the
appropriate error frame.
- STREAM_IDLE_TIMEOUT_MS (10 min): every stream gets a per-stream
timer refreshed on each inbound or outbound activity. Expiry tears
down the local half and notifies the peer.
The constants are colocated in pilot/protocol.ts so any future agent
build picks them up via the protocol module.
* fix(pilot): rate-limit pilot enrollment endpoints to 10 per minute
Pilot enrollment mints a JWT and writes a pilot_enrollments row, both
privileged operations that should not share the global API budget
(200/min). Add a dedicated express-rate-limit instance keyed by user or
IP at 10/min in production (100/min in dev so the local enrollment
test loop is not throttled).
The limiter is applied directly on POST /api/nodes/:id/pilot/enroll.
On POST /api/nodes the limiter's skip function reads the parsed body
and exempts proxy-mode creates; only requests that resolve to
mode=pilot_agent count against the enrollment budget.
* fix(pilot): cap concurrent pilot tunnels system-wide
The PilotTunnelManager held an unbounded Map of bridges. A reconnect
storm, runaway enrollment, or operator misconfiguration could grow the
map without limit, even though every tunnel still carries a valid JWT.
Cap at 256 concurrent tunnels per primary instance:
- Soft warning at 128 (logged once per crossing).
- Hard refusal at 256: registerTunnel throws PilotTunnelCapacityError
and the upgrade handler closes the WebSocket with 1013 (Try Again
Later) so the agent backs off rather than tight-looping.
A node that already has a registered tunnel does not consume a new
slot when it reconnects; the existing bridge is closed first.
* fix(pilot): release backpressure as soon as the tunnel buffer drains
Previously the bridge paused HTTP request bodies when the tunnel
WebSocket's bufferedAmount climbed above 4 MB but never resumed them
explicitly; the next data event re-checked the threshold, and TCP
streams only saw a 'drain' fan-out on the 30 s ping cycle. Slow-consumer
peers held buffered bytes for tens of seconds longer than needed.
Replace both with an on-demand drain check: when at least one stream
is paused, sample bufferedAmount every 100 ms; once it drops below the
high-water mark, resume every paused request and emit 'drain' to every
accepted TCP stream, then stop the timer. Dormant when no stream is
paused, so steady-state cost is zero.
* fix(pilot): trust internal CAs via SENCHO_PILOT_CA_FILE
Self-hosted deployments often terminate TLS with an internal CA. The
only previous escape hatch was NODE_TLS_REJECT_UNAUTHORIZED=0, which
disables verification across every outbound connection in the agent
process and is the wrong shape of fix.
Add SENCHO_PILOT_CA_FILE: when set, the agent reads the file as a PEM
bundle and passes it to ws as the `ca` option for the tunnel
WebSocket. rejectUnauthorized remains true. Failure to read the file
exits with a clear error so the operator does not silently fall back
to the default trust store.
There is no flag to disable TLS verification entirely; that would
defeat the credential trust model.
* fix(pilot): gate frame-rate-adjacent logs behind developer_mode
The agent's malformed-frame warning fires per inbound frame and could
flood logs under attack or against a buggy primary. The bridge swallowed
parse errors silently, leaving operators blind to protocol drift, and
the manager had no signal at all on tunnel registration.
Route per-frame and per-tunnel diagnostic logs through the existing
isDebugEnabled() helper (same pattern used by middleware/auth, RBAC,
and websocket/logs). Production stays quiet by default; the toggle is
the existing developer_mode setting in global_settings — no new env
var, no new dependency.
* feat(pilot): expose per-tunnel metrics via /api/system/pilot-tunnels
Operator support had no signal beyond raw logs to answer "is this
tunnel flapping?" or "is one bad node hiding behind the aggregate?".
Add an in-process counter set covering tunnels_total, tunnels_replaced,
tunnels_rejected_capacity, enroll_acks, frame_decode_errors, plus a
per-node array carrying the connectedAt and bufferedAmount so a single
tunnel sitting on a stuck buffer remains visible.
Counters live in services/PilotMetrics.ts; the gateway has no shared
in-process metrics facility today, so this is a per-feature pattern
documented as such for future consolidation. Counters are strictly
process-local — no telemetry, no export, no phone-home — consistent
with Sencho's privacy posture.
Surfaced read-only via GET /api/system/pilot-tunnels behind admin auth,
mirroring the existing cache-stats endpoint.
* fix(pilot): tighten reconnect backoff and log handling
Three small follow-ups to the hardening pass:
- Sanitize the primary URL on the agent's connect log so a malicious
SENCHO_PRIMARY_URL with embedded control characters cannot inject
fake log lines.
- Move the reconnect-backoff reset from the 'open' callback to the
'hello' frame handler. A peer that always rejects the handshake
(incompatible version, consumed enrollment token) used to reset
the backoff on every TCP-level connect and tight-loop reconnects;
now the reset waits for a clean protocol round-trip.
- Document the StreamIdAllocator wrap behavior so future readers
can see the relationship between the 2^31 wrap and the
MAX_STREAMS_PER_TUNNEL cap without re-deriving it.
* test(pilot): add coverage for enrollment, rate limiter, and tunnel caps
Adds two test files exercising the hardening pass surfaces that were
previously uncovered:
- pilot-enrollment.test.ts: end-to-end through POST /api/nodes
(pilot mode), POST /api/nodes/:id/pilot/enroll, the SHA256
token-hash persistence, replay protection, expired enrollments,
and the rate-limit header wiring (enrollment limiter on pilot
paths, global limiter elsewhere).
- pilot-bridge-limits.test.ts: oversize-frame rejection at both
binary and JSON decoders, and the per-tunnel concurrent stream
cap as observed via openTcpStream returning null and the loopback
HTTP server returning 503 once the bridge is at capacity.
Bridge cap test fills the slot map with TCP stream handles rather than
real HTTP requests so the OS socket pool stays out of the picture.
* test(pilot): cover manager metrics snapshot and capacity-error shape
Adds direct coverage for:
- PilotTunnelCapacityError exposing the limit so the upgrade
handler can format a useful close-frame reason.
- PilotMetrics.snapshot returning a defensive copy (callers must not
mutate the live counter set).
- getMetricsSnapshot returning the open count, per-node breakdown,
and counter set in a stable shape, with tunnels_total bumping on
a successful registerTunnel.
The manager test seeds a real pilot-mode node row first because
updateNode throws on missing rows; this models the production path
where a node is created before its enrollment is consumed.
* docs(pilot): document tunnel limits, custom CA, and new failure modes
Refresh the Pilot Agent feature doc to cover the hardening surfaces:
- New 'Self-signed primary TLS certs' section walking through the
SENCHO_PILOT_CA_FILE env var with an example docker run command.
- New 'Resource limits' section listing the per-tunnel and
system-wide ceilings so operators know what the wire enforces.
- Four new troubleshooting entries: WebSocket close 1013 (system
cap), loopback 503 (stream cap), close 1002 with protocol error
(frame size / malformed), and HTTP 429 on enrollment.
Also expand .env.example with a new pilot-agent block covering
SENCHO_MODE, SENCHO_PRIMARY_URL, SENCHO_ENROLL_TOKEN, and
SENCHO_PILOT_CA_FILE so operators do not have to read code or the
feature doc to discover the agent-side config surface.
* fix(pilot): address code-review findings on the hardening pass
Critical:
- Bridge: TCP backpressure now starts the drain timer and tracks
streams awaiting drain, so a TcpStream caller waiting on 'drain'
no longer hangs when no HTTP request is in flight.
- Bridge: every direct streams.delete call now goes through the
removeStream helper so per-stream idle timers are cleared
consistently and pausedReqs / tcpAwaitingDrain stay aligned.
- Bridge close(): resume any paused IncomingMessage before clearing
the map so a parser does not stay stuck across teardown.
High:
- Protocol: decodeJsonFrame now compares Buffer.byteLength(raw,
'utf8') against the cap; raw.length (UTF-16 code units) let
multi-byte payloads sneak ~3x the byte budget through.
- Agent: the optional CA bundle is read once at construction and
cached on the instance, so a missing or rotated SENCHO_PILOT_CA_FILE
no longer process-exits on every reconnect attempt.
Medium / Low:
- Bridge limits test: assert mockWs.sent.length to confirm slot
allocations actually serialized a tcp_open frame and that a
rejected (cap+1) attempt did not consume a stream id.
- Manager: defer the tunnels_replaced increment until after the
cap check passes, so a rejected reconnect does not double-count
as both replacement and capacity rejection.
- Manager: clarify the protocol comment about MAX_FRAME_SIZE_BYTES
enforcement layering (ws maxPayload is authoritative; decoder
check is for tests and defense-in-depth).
- Limiter: defensive `if (!req.body) return false` so a future
refactor that delays body parsing cannot silently skip the
enrollment limiter.
Deferred (documented as follow-ups, not in this branch):
- M2: narrowing public surface of PilotTunnelManager.getBridge to a
MeshTunnelHandle interface (cross-cutting refactor).
- H4: end-to-end replay test driving the upgrade handler twice
with the same enrollment JWT (needs WS test harness).
|