Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions apps/api/src/routes/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,26 @@ nodesRoutes.get('/:id/logs/stream', async (c) => {
}
}

// Proxy the WebSocket upgrade to the VM agent
const headers = new Headers(c.req.raw.headers);
headers.delete('x-sam-node-id');
// Proxy only WebSocket handshake headers to the VM agent. Browser/control-plane
// credentials such as Cookie or Authorization must not be forwarded because
// VM-agent diagnostic auth gives Authorization precedence over the query token.
const clientHeaders = c.req.raw.headers;
const headers = new Headers();
for (const name of [
'Upgrade',
'Connection',
'Sec-WebSocket-Key',
'Sec-WebSocket-Version',
'Sec-WebSocket-Protocol',
'Sec-WebSocket-Extensions',
'Origin',
]) {
const value = clientHeaders.get(name);
if (value) {
headers.set(name, value);
}
}
headers.set('Authorization', `Bearer ${token}`);
headers.set('X-SAM-Node-Id', nodeId);

return fetchNodeAgent(
Expand Down
55 changes: 54 additions & 1 deletion apps/api/tests/unit/routes/node-observability-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type { Env } from '../../../src/env';
const mockRequireNodeOwnership = vi.fn();
const mockGetNodeLogsFromNode = vi.fn();
const mockListNodeContainersFromNode = vi.fn();
const mockFetchNodeAgent = vi.fn();
const mockGetNodeAgentRequestTimeoutMs = vi.fn();
const mockSignNodeManagementToken = vi.fn();

vi.mock('../../../src/middleware/auth', () => ({
requireAuth: () => vi.fn((_c: any, next: any) => next()),
Expand All @@ -18,6 +21,8 @@ vi.mock('../../../src/middleware/node-auth', () => ({
}));

vi.mock('../../../src/services/node-agent', () => ({
fetchNodeAgent: (...args: unknown[]) => mockFetchNodeAgent(...args),
getNodeAgentRequestTimeoutMs: (...args: unknown[]) => mockGetNodeAgentRequestTimeoutMs(...args),
getNodeLogsFromNode: (...args: unknown[]) => mockGetNodeLogsFromNode(...args),
listNodeContainersFromNode: (...args: unknown[]) => mockListNodeContainersFromNode(...args),
getNodeSystemInfoFromNode: vi.fn(),
Expand All @@ -40,7 +45,7 @@ vi.mock('../../../src/services/nodes', () => ({
}));

vi.mock('../../../src/services/jwt', () => ({
signNodeManagementToken: vi.fn(),
signNodeManagementToken: (...args: unknown[]) => mockSignNodeManagementToken(...args),
}));

vi.mock('../../../src/services/limits', () => ({
Expand All @@ -67,6 +72,12 @@ describe('node observability log routes', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRequireNodeOwnership.mockResolvedValue({ id: 'node-1', status: 'running', userId: 'user-1' });
mockGetNodeAgentRequestTimeoutMs.mockReturnValue(30_000);
mockSignNodeManagementToken.mockResolvedValue({
token: 'node-management-token',
expiresAt: '2026-08-23T05:00:00.000Z',
});
mockFetchNodeAgent.mockResolvedValue(new Response('proxied', { status: 200 }));
});

it('returns docker container entries from the node agent proxy', async () => {
Expand Down Expand Up @@ -105,4 +116,46 @@ describe('node observability log routes', () => {
expect(body.containers).toHaveLength(1);
expect(body.containers[0].name).toBe('web-1');
});

it('proxies log stream with node-management auth and strips client auth material', async () => {
const response = await createApp().request(
'/api/nodes/node-1/logs/stream?source=docker&level=debug&token=client-supplied-token',
{
headers: {
Authorization: 'Bearer user-api-token',
Cookie: 'better-auth.session_token=user-session',
Upgrade: 'websocket',
Connection: 'Upgrade',
'Sec-WebSocket-Key': 'websocket-upgrade-key-placeholder',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Protocol': 'sam.logs',
'Sec-WebSocket-Extensions': 'permessage-deflate',
Origin: 'https://app.example.com',
},
},
{
BASE_DOMAIN: 'example.com',
VM_AGENT_PROTOCOL: 'https',
VM_AGENT_PORT: '8443',
} as Env,
);

expect(response.status).toBe(200);
expect(mockSignNodeManagementToken).toHaveBeenCalledWith('user-1', 'node-1', null, expect.anything());
expect(mockFetchNodeAgent).toHaveBeenCalledTimes(1);

const [, , vmUrl, init] = mockFetchNodeAgent.mock.calls[0];
const parsedVmUrl = new URL(vmUrl as string);
expect(parsedVmUrl.pathname).toBe('/logs/stream');
expect(parsedVmUrl.searchParams.get('token')).toBe('node-management-token');
expect(parsedVmUrl.searchParams.get('source')).toBe('docker');
expect(parsedVmUrl.searchParams.get('level')).toBe('debug');

const forwardedHeaders = (init as { headers: Headers }).headers;
expect(forwardedHeaders.get('Authorization')).toBe('Bearer node-management-token');
expect(forwardedHeaders.get('Cookie')).toBeNull();
expect(forwardedHeaders.get('X-SAM-Node-Id')).toBe('node-1');
expect(forwardedHeaders.get('Upgrade')).toBe('websocket');
expect(forwardedHeaders.get('Sec-WebSocket-Protocol')).toBe('sam.logs');
});
});
7 changes: 5 additions & 2 deletions apps/www/src/content/docs/docs/reference/vm-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ GET /containers

The `/debug-package` endpoint bundles cloud-init logs, journald, Docker logs, system info, events/metrics databases, provisioning timings, and network config into a single downloadable archive — the fastest way to diagnose a node without SSH.

Node-wide diagnostics require a node-scoped management token issued by the control plane. Workspace browser sessions and workspace-scoped management tokens are not accepted for these routes because a single node can host multiple workspaces. User-facing node observability should go through the control-plane `/api/nodes/{nodeId}/...` proxy routes, which verify node ownership and sign the node-scoped token for the VM Agent.

## Subsystems

### PTY Manager
Expand Down Expand Up @@ -229,11 +231,12 @@ Responses are serialized via `orderedPipe` to prevent token reordering from conc

### JWT Validator

Validates workspace JWTs using the API's JWKS endpoint:
Validates workspace and node-management JWTs using the API's JWKS endpoint:

- Fetches public keys from `/.well-known/jwks.json`
- Caches keys with periodic refresh
- Extracts workspace ID and user ID from claims
- Enforces workspace claims on workspace-scoped routes
- Enforces node-scoped management tokens on node-wide diagnostic routes

## Configuration

Expand Down
70 changes: 40 additions & 30 deletions packages/vm-agent/internal/server/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import (
)

func (s *Server) handleListNodeEvents(w http.ResponseWriter, r *http.Request) {
// Accept browser-facing auth: workspace request auth (any workspace on this node
// proves node ownership) or management token via Authorization header / ?token= query param.
if !s.requireNodeEventAuth(w, r) {
return
}
Expand Down Expand Up @@ -64,48 +62,60 @@ func (s *Server) handleListWorkspaceEvents(w http.ResponseWriter, r *http.Reques
})
}

// requireNodeEventAuth authenticates node-level event requests.
// Accepts:
// 1. Node management token via Authorization header (control-plane proxy)
// 2. Node management token via ?token= query parameter (browser direct call)
// 3. Any valid workspace session cookie for a workspace on this node (browser)
// requireNodeEventAuth authenticates node-wide diagnostic requests.
//
// These routes expose node-wide observability state and raw diagnostic artifacts
// for every workspace on a node. They must therefore require a node-scoped
// management token minted by the control plane/operator. Workspace browser
// cookies and workspace-scoped management tokens are intentionally rejected.
func (s *Server) requireNodeEventAuth(w http.ResponseWriter, r *http.Request) bool {
// Try management token from Authorization header first (existing pattern).
// Authorization takes precedence. A malformed/replayed bearer token must not
// silently fall through to any weaker credential on the same request.
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
if authHeader != "" {
if !strings.HasPrefix(authHeader, "Bearer ") {
writeError(w, http.StatusUnauthorized, "invalid Authorization header")
return false
}
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if token != "" {
claims, err := s.jwtValidator.ValidateNodeManagementToken(token, "")
if err == nil {
routedNode := s.routedNodeID(r)
if routedNode == "" || routedNode == s.config.NodeID {
_ = claims
return true
}
}
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return false
}
return s.requireNodeScopedManagementToken(w, r, token)
}

// Try management token from ?token= query parameter (browser direct call).
// WebSocket control-plane proxying uses ?token= because browsers cannot set
// custom Authorization headers during a WebSocket upgrade.
queryToken := strings.TrimSpace(r.URL.Query().Get("token"))
if queryToken != "" {
claims, err := s.jwtValidator.ValidateNodeManagementToken(queryToken, "")
if err == nil {
_ = claims
return true
}
}

// Try workspace session cookie — any valid workspace session for this node proves access.
session := s.sessionManager.GetSessionFromRequest(r)
if session != nil && session.Claims != nil && session.Claims.Workspace != "" {
return true
return s.requireNodeScopedManagementToken(w, r, queryToken)
}

writeError(w, http.StatusUnauthorized, "authentication required")
return false
}

func (s *Server) requireNodeScopedManagementToken(w http.ResponseWriter, r *http.Request, token string) bool {
claims, err := s.jwtValidator.ValidateNodeManagementToken(token, "")
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid management token")
return false
}
if claims.Workspace != "" {
writeError(w, http.StatusForbidden, "node-scoped management token required")
return false
}

routedNode := s.routedNodeID(r)
if routedNode != "" && routedNode != s.config.NodeID {
writeError(w, http.StatusForbidden, "node route mismatch")
return false
}

return true
}

func parseEventLimit(raw string) int {
if raw == "" {
return 100
Expand Down
Loading
Loading