fix(mesh): accept node_proxy at the proxy-tunnel WS upgrade (#1050)

The mesh proxy-tunnel WS handler was gated on full-admin api_token scope
only, so node_proxy JWTs (the credential the Add Remote Node dialog tells
operators to generate via Settings -> Nodes -> Generate Token) were
silently rejected with HTTP 403. Operators followed the dialog's
instructions, enrolled the node cleanly, saw reachableMode='proxy' with
no negative badge, opted a stack into mesh, watched the redeploy
complete, and only then discovered no bytes flow.

node_proxy already authorizes every /api/* surface on the remote
(deploy, exec, host console, filesystem), which is a strict superset of
what the mesh proxy-tunnel does. Gating mesh more strictly was theatre,
not security, and it created a UX trap with no in-product signal pointing
at the scope mismatch.

Change the upgrade dispatcher to accept any machine-to-machine
credential: node_proxy JWT or full-admin api_token. Session cookies and
restricted api_token scopes (read-only, deploy-only) remain rejected
through the same paths as before. Adds positive/negative coverage in
upgrade-order.test.ts for all four credential shapes so the gate stays
pinned against future re-tightening. Updates user-facing copy in the
mesh docs and Settings -> API Tokens description so the documented path
matches the actual code.

If we ever introduce a demoted node_proxy variant (audit-only, read-only
fleet member), the right move is differentiated node_proxy scope claims
inside the tunnel JWT (tracker F-A3), not requiring a separate token
type for mesh.
This commit is contained in:
Anso
2026-05-14 23:26:41 -04:00
committed by GitHub
parent 4747b14d2a
commit 946db9df52
6 changed files with 96 additions and 16 deletions
+9 -6
View File
@@ -16,14 +16,17 @@ import { rejectUpgrade as reject } from './reject';
* Mesh proxy-tunnel ingress.
*
* The remote side of a Phase C proxy-mode mesh tunnel. Central dials
* `WSS <api_url>/api/mesh/proxy-tunnel` using the long-lived `api_token`
* as a Bearer credential; this handler upgrades the connection and wires
* the shared `TcpStreamSwitchboard` to handle `tcp_open` / `tcp_open_ack`
* / `tcp_open_reverse` / `tcp_close` and `TcpData` frames.
* `WSS <api_url>/api/mesh/proxy-tunnel` using the fleet credential as a
* Bearer header (either the `node_proxy` JWT generated by the remote's
* Settings → Nodes → Generate Token flow, or a `full-admin` api_token
* for non-fleet integrations); this handler upgrades the connection and
* wires the shared `TcpStreamSwitchboard` to handle `tcp_open` /
* `tcp_open_ack` / `tcp_open_reverse` / `tcp_close` and `TcpData` frames.
*
* Auth + scope gating happens in `upgradeHandler.ts` before this handler
* runs (require `full-admin` api_token scope). The handler itself trusts
* the upgrade; the WS credential is the only trust boundary.
* runs: it accepts node_proxy and full-admin api_token, and rejects
* session cookies plus restricted api_token scopes. The handler itself
* trusts the upgrade; the WS credential is the only trust boundary.
*
* Bidirectional: when the tunnel opens, the handler registers itself as
* the reverse-dialer on the local MeshService so meshed containers on
+8 -5
View File
@@ -33,7 +33,7 @@ function parseCookies(req: IncomingMessage): Record<string, string> {
* 1. `/api/pilot/tunnel` -> handlePilotTunnel (own auth, own wss)
* 2. shared cookie/Bearer auth + JWT verify (rejects unauthenticated)
* 3. API token scope gate (read-only / deploy-only restricted to logs + notifications)
* 4. `/api/mesh/proxy-tunnel` -> handleMeshProxyTunnel (requires full-admin api_token scope)
* 4. `/api/mesh/proxy-tunnel` -> handleMeshProxyTunnel (machine-to-machine: node_proxy or full-admin api_token)
* 5. `/ws/notifications` local -> handleNotificationsWs
* 6. remote nodeId path -> handleRemoteForwarder
* 7. `/api/stacks/:name/logs` -> handleLogsWs
@@ -123,11 +123,14 @@ export function attachUpgrade(
}
// Mesh proxy-tunnel ingress: a sibling Sencho is dialing this node
// to carry mesh TCP traffic. Require an api_token Bearer with the
// full-admin scope; mesh manipulates traffic and must not be
// reachable under a session cookie or a node_proxy JWT.
// to carry mesh TCP traffic. Accept any machine-to-machine credential:
// node_proxy JWT (the token enrolled nodes carry) or a full-admin
// api_token. Session cookies fall through to a 403 here because their
// decoded scope is undefined (isProxyToken=false, wsApiTokenScope=null).
// Restricted api_token scopes (read-only, deploy-only) are blocked
// earlier by the scope gate above before this branch is reached.
if (pathname === '/api/mesh/proxy-tunnel') {
if (wsApiTokenScope !== 'full-admin') {
if (!isProxyToken && wsApiTokenScope !== 'full-admin') {
return reject(socket, 403, 'Forbidden');
}
await handleMeshProxyTunnel(req, socket, head);