@kortix/agent-tunnel 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export type {
28
28
  TunnelRelayConfig,
29
29
  HeartbeatConfig,
30
30
  TunnelServerConfig,
31
+ TunnelAuthMessage,
32
+ AuthResult,
31
33
  TunnelCapability,
32
34
  TunnelMethod,
33
35
  TunnelErrorCodeValue,
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'events';
2
- import { signMessage } from '../shared/crypto';
2
+ import { signMessage, verifyMessageSignature } from '../shared/crypto';
3
3
  import {
4
4
  type JsonRpcRequest,
5
5
  type JsonRpcResponse,
@@ -16,6 +16,7 @@ interface AgentConnection {
16
16
  ws: WebSocket;
17
17
  signingKey: string;
18
18
  nonce: number;
19
+ lastResponseNonce: number;
19
20
  connectedAt: number;
20
21
  metadata?: Record<string, unknown>;
21
22
  }
@@ -27,6 +28,8 @@ export class TunnelRelay extends EventEmitter {
27
28
  private pendingRPCs = new Map<string, PendingRPC>();
28
29
  private config: Required<TunnelRelayConfig>;
29
30
 
31
+ onAuthorizeRPC?: (tunnelId: string, method: string, params: Record<string, unknown>) => Promise<boolean>;
32
+
30
33
  constructor(config?: TunnelRelayConfig) {
31
34
  super();
32
35
  this.config = {
@@ -47,11 +50,21 @@ export class TunnelRelay extends EventEmitter {
47
50
  ): void {
48
51
  const existing = this.agents.get(tunnelId);
49
52
  if (existing) {
53
+ for (const [requestId, pending] of this.pendingRPCs) {
54
+ if (pending.tunnelId === tunnelId) {
55
+ clearTimeout(pending.timer);
56
+ pending.reject(new TunnelRelayError(
57
+ TunnelErrorCode.NOT_CONNECTED,
58
+ 'Agent connection replaced',
59
+ ));
60
+ this.pendingRPCs.delete(requestId);
61
+ }
62
+ }
50
63
  try { existing.ws.close(1000, 'replaced by new connection'); } catch {}
51
64
  this.emitEvent('connection:replaced', { tunnelId });
52
65
  }
53
66
 
54
- this.agents.set(tunnelId, { ws, signingKey, nonce: 0, connectedAt: Date.now(), metadata });
67
+ this.agents.set(tunnelId, { ws, signingKey, nonce: 0, lastResponseNonce: 0, connectedAt: Date.now(), metadata });
55
68
  this.emitEvent('agent:connect', { tunnelId, metadata });
56
69
  console.log(`[tunnel-relay] Agent registered: ${tunnelId} (total: ${this.agents.size})`);
57
70
  }
@@ -87,7 +100,6 @@ export class TunnelRelay extends EventEmitter {
87
100
  for (const [tunnelId, conn] of this.agents) {
88
101
  result.set(tunnelId, {
89
102
  tunnelId,
90
- signingKey: conn.signingKey,
91
103
  connectedAt: conn.connectedAt,
92
104
  metadata: conn.metadata,
93
105
  });
@@ -100,7 +112,7 @@ export class TunnelRelay extends EventEmitter {
100
112
  }
101
113
 
102
114
  handleAgentMessage(tunnelId: string, raw: string | Buffer): void {
103
- let msg: JsonRpcResponse;
115
+ let msg: any;
104
116
  try {
105
117
  msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
106
118
  } catch {
@@ -108,8 +120,30 @@ export class TunnelRelay extends EventEmitter {
108
120
  return;
109
121
  }
110
122
 
111
- if ('method' in msg && (msg as any).method === 'tunnel.pong') {
112
- this.emitEvent('message:pong', { tunnelId, params: (msg as any).params });
123
+ // Verify HMAC signature on ALL messages from agent (including pong)
124
+ const agent = this.agents.get(tunnelId);
125
+ if (agent && msg._sig !== undefined && msg._nonce !== undefined) {
126
+ if (msg._nonce <= agent.lastResponseNonce) {
127
+ console.warn(`[tunnel-relay] Replay detected from agent ${tunnelId}: nonce ${msg._nonce} <= ${agent.lastResponseNonce}`);
128
+ return;
129
+ }
130
+
131
+ const { _sig, _nonce, ...payloadObj } = msg;
132
+ const payload = JSON.stringify(payloadObj);
133
+
134
+ if (!verifyMessageSignature(agent.signingKey, payload, _nonce, _sig)) {
135
+ console.warn(`[tunnel-relay] Invalid signature from agent ${tunnelId}`);
136
+ return;
137
+ }
138
+
139
+ agent.lastResponseNonce = _nonce;
140
+ } else if (agent) {
141
+ console.warn(`[tunnel-relay] Unsigned message from agent ${tunnelId}, discarding`);
142
+ return;
143
+ }
144
+
145
+ if ('method' in msg && msg.method === 'tunnel.pong') {
146
+ this.emitEvent('message:pong', { tunnelId, params: msg.params });
113
147
  return;
114
148
  }
115
149
 
@@ -162,6 +196,16 @@ export class TunnelRelay extends EventEmitter {
162
196
  );
163
197
  }
164
198
 
199
+ if (this.onAuthorizeRPC) {
200
+ const allowed = await this.onAuthorizeRPC(tunnelId, method, params);
201
+ if (!allowed) {
202
+ throw new TunnelRelayError(
203
+ TunnelErrorCode.PERMISSION_DENIED,
204
+ `RPC ${method} denied for tunnel ${tunnelId}`,
205
+ );
206
+ }
207
+ }
208
+
165
209
  const requestId = crypto.randomUUID();
166
210
  const timeoutMs = options?.timeoutMs ?? this.config.rpcTimeoutMs;
167
211
 
@@ -3,9 +3,22 @@ import type { TunnelRelay } from './relay';
3
3
  import { TunnelRelayError } from './relay';
4
4
  import { TunnelErrorCode } from '../shared/types';
5
5
 
6
- export function createTunnelRouter(relay: TunnelRelay): Hono {
6
+ export function createTunnelRouter(
7
+ relay: TunnelRelay,
8
+ onAuthorizeHTTP?: (req: Request) => Promise<boolean>,
9
+ ): Hono {
7
10
  const router = new Hono();
8
11
 
12
+ if (onAuthorizeHTTP) {
13
+ router.use('*', async (c, next) => {
14
+ const authorized = await onAuthorizeHTTP(c.req.raw);
15
+ if (!authorized) {
16
+ return c.json({ error: 'Unauthorized' }, 403);
17
+ }
18
+ await next();
19
+ });
20
+ }
21
+
9
22
  router.get('/connections', (c) => {
10
23
  const agents = relay.getConnectedAgents();
11
24
  const list = Array.from(agents.values()).map((a) => ({
@@ -51,6 +64,7 @@ export function createTunnelRouter(relay: TunnelRelay): Hono {
51
64
 
52
65
  const httpStatus = errorCode === TunnelErrorCode.NOT_CONNECTED ? 502
53
66
  : errorCode === TunnelErrorCode.TIMEOUT ? 504
67
+ : errorCode === TunnelErrorCode.PERMISSION_DENIED ? 403
54
68
  : 500;
55
69
 
56
70
  return c.json({ error: errorMessage, code: errorCode }, httpStatus);
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Usage:
5
5
  * import { startTunnelServer } from 'agent-tunnel';
6
- * const server = startTunnelServer({ port: 8080 });
6
+ * const server = startTunnelServer({ port: 8080, onAuthenticate: ... });
7
7
  *
8
- * That's it. Agents connect via: ws://localhost:8080/ws?token=xxx&tunnelId=yyy
8
+ * Agents connect via: ws://localhost:8080/ws?tunnelId=yyy
9
+ * Then send { type: "auth", token: "tnl_xxx" } as the first message.
9
10
  * Your app calls relay.relayRPC(tunnelId, method, params) to reach the agent.
10
11
  */
11
12
 
@@ -14,17 +15,13 @@ import { TunnelRelay } from './relay';
14
15
  import { HeartbeatManager } from './heartbeat';
15
16
  import { createTunnelRouter } from './routes';
16
17
  import { createWsHandlers } from './ws-handler';
17
- import { deriveSigningKey } from '../shared/crypto';
18
18
  import type { TunnelServerConfig } from '../shared/types';
19
19
 
20
20
  export interface TunnelServer {
21
- /** Hono app — mount additional routes if needed. */
22
21
  app: Hono;
23
- /** The relay instance — call relayRPC() to reach connected agents. */
24
22
  relay: TunnelRelay;
25
23
  heartbeat: HeartbeatManager;
26
24
  wsHandlers: ReturnType<typeof createWsHandlers>;
27
- /** Stop the server, heartbeat, and close all connections. */
28
25
  stop: () => void;
29
26
  }
30
27
 
@@ -33,7 +30,7 @@ export interface TunnelServer {
33
30
  * - HTTP routes for connections list + RPC
34
31
  * - WebSocket upgrades on /ws for agent connections
35
32
  * - Heartbeat ping/pong
36
- * - HMAC signing key derivation from tokens
33
+ * - HMAC signing key derivation via onAuthenticate hook
37
34
  *
38
35
  * Requires Bun runtime for WebSocket server support.
39
36
  */
@@ -41,57 +38,60 @@ export function startTunnelServer(config?: TunnelServerConfig): TunnelServer {
41
38
  const port = config?.port ?? parseInt(process.env.PORT || '8080', 10);
42
39
 
43
40
  const relay = new TunnelRelay(config?.relay);
41
+
42
+ if (config?.onAuthorizeRPC) {
43
+ relay.onAuthorizeRPC = config.onAuthorizeRPC;
44
+ }
45
+
44
46
  const heartbeat = new HeartbeatManager(relay, config?.heartbeat);
45
- const wsHandlers = createWsHandlers(relay, { heartbeat });
47
+ const wsHandlers = createWsHandlers(relay, {
48
+ heartbeat,
49
+ onAuthenticate: config?.onAuthenticate,
50
+ });
46
51
 
47
52
  const app = new Hono();
48
53
 
49
- // Mount tunnel routes (GET /connections, POST /rpc/:tunnelId, etc.)
50
- const tunnelRouter = createTunnelRouter(relay);
54
+ const tunnelRouter = createTunnelRouter(relay, config?.onAuthorizeHTTP);
51
55
  app.route('/', tunnelRouter);
52
56
 
53
- // Health check
54
57
  app.get('/health', (c) => c.json({ status: 'ok', connections: relay.getConnectedCount() }));
55
58
 
56
- // Start heartbeat
59
+ relay.on('message:pong', (data: { tunnelId: string }) => {
60
+ heartbeat.recordPong(data.tunnelId);
61
+ });
62
+
57
63
  heartbeat.start();
58
64
 
59
- // Start Bun server with WS support
60
65
  const bunServer = Bun.serve({
61
66
  port,
62
67
  fetch(req, server) {
63
68
  const url = new URL(req.url);
64
69
 
65
- // WS upgrade on /ws
66
70
  if (url.pathname === '/ws') {
67
- const token = url.searchParams.get('token');
68
71
  const tunnelId = url.searchParams.get('tunnelId');
69
72
 
70
- if (!token || !tunnelId) {
71
- return new Response(JSON.stringify({ error: 'Missing token or tunnelId' }), {
73
+ if (!tunnelId) {
74
+ return new Response(JSON.stringify({ error: 'Missing tunnelId' }), {
72
75
  status: 400,
73
76
  headers: { 'Content-Type': 'application/json' },
74
77
  });
75
78
  }
76
79
 
77
- const signingKey = deriveSigningKey(token);
78
-
79
80
  const success = server.upgrade(req, {
80
- data: { tunnelId, signingKey } as any,
81
+ data: { tunnelId } as any,
81
82
  });
82
83
  if (success) return undefined;
83
84
 
84
85
  return new Response('WebSocket upgrade failed', { status: 500 });
85
86
  }
86
87
 
87
- // All other requests → Hono
88
88
  return app.fetch(req);
89
89
  },
90
90
 
91
91
  websocket: {
92
92
  idleTimeout: 0,
93
93
  open(ws: any) {
94
- wsHandlers.onOpen(ws.data.tunnelId, ws, ws.data.signingKey);
94
+ wsHandlers.onOpen(ws.data.tunnelId, ws);
95
95
  },
96
96
  message(ws: any, message: string | Buffer) {
97
97
  wsHandlers.onMessage(ws.data.tunnelId, message);
@@ -1,13 +1,16 @@
1
1
  import type { TunnelRelay } from './relay';
2
2
  import type { HeartbeatManager } from './heartbeat';
3
+ import type { TunnelAuthMessage, AuthResult } from '../shared/types';
3
4
 
4
5
  export interface WsHandlerOptions {
5
6
  heartbeat?: HeartbeatManager;
6
7
  maxMessageSize?: number;
8
+ onAuthenticate?: (tunnelId: string, token: string) => Promise<AuthResult | null>;
9
+ authTimeoutMs?: number;
7
10
  }
8
11
 
9
12
  export interface WsHandlers {
10
- onOpen(tunnelId: string, ws: WebSocket, signingKey: string, metadata?: Record<string, unknown>): void;
13
+ onOpen(tunnelId: string, ws: WebSocket): void;
11
14
  onMessage(tunnelId: string, message: string | Buffer): void;
12
15
  onClose(tunnelId: string): void;
13
16
  }
@@ -15,36 +18,97 @@ export interface WsHandlers {
15
18
  export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): WsHandlers {
16
19
  const maxMessageSize = opts?.maxMessageSize ?? 5 * 1024 * 1024;
17
20
  const heartbeat = opts?.heartbeat;
21
+ const onAuthenticate = opts?.onAuthenticate;
22
+ const authTimeoutMs = opts?.authTimeoutMs ?? 10_000;
23
+
24
+ const pendingConnections = new Map<string, { ws: WebSocket; timer: ReturnType<typeof setTimeout> }>();
18
25
 
19
26
  return {
20
- onOpen(tunnelId: string, ws: WebSocket, signingKey: string, metadata?: Record<string, unknown>) {
21
- relay.registerAgent(tunnelId, ws, signingKey, metadata);
22
- if (heartbeat) {
23
- heartbeat.register(tunnelId);
24
- }
27
+ onOpen(tunnelId: string, ws: WebSocket) {
28
+ const timer = setTimeout(() => {
29
+ pendingConnections.delete(tunnelId);
30
+ try { ws.close(4001, 'auth timeout'); } catch {}
31
+ }, authTimeoutMs);
32
+
33
+ pendingConnections.set(tunnelId, { ws, timer });
25
34
  },
26
35
 
27
- onMessage(tunnelId: string, message: string | Buffer) {
36
+ async onMessage(tunnelId: string, message: string | Buffer) {
37
+ const msgStr = typeof message === 'string' ? message : message.toString('utf-8');
28
38
  const msgSize = typeof message === 'string' ? message.length : (message as Buffer).byteLength;
39
+
29
40
  if (msgSize > maxMessageSize) {
30
41
  console.warn(`[tunnel-ws] Oversized message from ${tunnelId}: ${msgSize} bytes (limit: ${maxMessageSize})`);
42
+ const pending = pendingConnections.get(tunnelId);
43
+ const ws = pending?.ws;
44
+ if (ws) {
45
+ try { ws.close(4002, 'message too large'); } catch {}
46
+ }
31
47
  return;
32
48
  }
33
49
 
34
- try {
35
- const parsed = JSON.parse(typeof message === 'string' ? message : message.toString('utf-8'));
36
- if (parsed.method === 'tunnel.pong') {
50
+ const pending = pendingConnections.get(tunnelId);
51
+ if (pending) {
52
+ let authMsg: TunnelAuthMessage;
53
+ try {
54
+ authMsg = JSON.parse(msgStr);
55
+ } catch {
56
+ try { pending.ws.close(4001, 'invalid auth message'); } catch {}
57
+ clearTimeout(pending.timer);
58
+ pendingConnections.delete(tunnelId);
59
+ return;
60
+ }
61
+
62
+ if (authMsg.type !== 'auth' || !authMsg.token) {
63
+ try { pending.ws.close(4001, 'expected auth message'); } catch {}
64
+ clearTimeout(pending.timer);
65
+ pendingConnections.delete(tunnelId);
66
+ return;
67
+ }
68
+
69
+ clearTimeout(pending.timer);
70
+ pendingConnections.delete(tunnelId);
71
+
72
+ if (!onAuthenticate) {
73
+ try { pending.ws.close(4001, 'no authenticator configured'); } catch {}
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const result = await onAuthenticate(tunnelId, authMsg.token);
79
+ if (!result) {
80
+ try { pending.ws.close(4001, 'authentication failed'); } catch {}
81
+ return;
82
+ }
83
+
84
+ // Send signing key to agent so it never needs the server secret
85
+ try {
86
+ pending.ws.send(JSON.stringify({ type: 'auth_ok', signingKey: result.signingKey }));
87
+ } catch {}
88
+
89
+ relay.registerAgent(tunnelId, pending.ws, result.signingKey, result.metadata);
37
90
  if (heartbeat) {
38
- heartbeat.recordPong(tunnelId);
91
+ heartbeat.register(tunnelId);
39
92
  }
93
+ } catch (err) {
94
+ console.error(`[tunnel-ws] Auth error for ${tunnelId}:`, err);
95
+ try { pending.ws.close(4001, 'authentication error'); } catch {}
40
96
  }
41
- } catch {
97
+
98
+ return;
42
99
  }
43
100
 
44
101
  relay.handleAgentMessage(tunnelId, message);
45
102
  },
46
103
 
47
104
  onClose(tunnelId: string) {
105
+ const pending = pendingConnections.get(tunnelId);
106
+ if (pending) {
107
+ clearTimeout(pending.timer);
108
+ pendingConnections.delete(tunnelId);
109
+ return;
110
+ }
111
+
48
112
  relay.unregisterAgent(tunnelId);
49
113
  if (heartbeat) {
50
114
  heartbeat.unregister(tunnelId);
@@ -1,6 +1,5 @@
1
1
  import { createHash, createHmac, timingSafeEqual, randomBytes } from 'crypto';
2
2
 
3
- const SIGNING_KEY_CONTEXT = 'kortix-tunnel-signing-v1';
4
3
  const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
5
4
 
6
5
  function randomAlphanumeric(length: number): string {
@@ -12,8 +11,8 @@ function randomAlphanumeric(length: number): string {
12
11
  return result;
13
12
  }
14
13
 
15
- export function deriveSigningKey(token: string): string {
16
- return createHmac('sha256', SIGNING_KEY_CONTEXT)
14
+ export function deriveSigningKey(token: string, secret: string): string {
15
+ return createHmac('sha256', secret)
17
16
  .update(token)
18
17
  .digest('hex');
19
18
  }
@@ -25,6 +25,8 @@ export type {
25
25
  TunnelRelayConfig,
26
26
  HeartbeatConfig,
27
27
  TunnelServerConfig,
28
+ TunnelAuthMessage,
29
+ AuthResult,
28
30
  TunnelCapability,
29
31
  TunnelMethod,
30
32
  TunnelErrorCodeValue,
@@ -41,6 +41,7 @@ export const TunnelErrorCode = {
41
41
  NOT_CONNECTED: -32004,
42
42
  EXPIRED: -32005,
43
43
  RATE_LIMITED: -32006,
44
+ AUTH_FAILED: -32007,
44
45
  } as const;
45
46
 
46
47
  export type TunnelErrorCodeValue = (typeof TunnelErrorCode)[keyof typeof TunnelErrorCode];
@@ -128,9 +129,9 @@ export interface RelayRpcOptions {
128
129
  timeoutMs?: number;
129
130
  }
130
131
 
132
+ /** Public agent info — does NOT expose signing key. */
131
133
  export interface AgentInfo {
132
134
  tunnelId: string;
133
- signingKey: string;
134
135
  connectedAt: number;
135
136
  metadata?: Record<string, unknown>;
136
137
  }
@@ -157,8 +158,36 @@ export interface HeartbeatConfig {
157
158
  maxMissed?: number;
158
159
  }
159
160
 
161
+ /** Auth handshake message sent by agent as first WS message. */
162
+ export interface TunnelAuthMessage {
163
+ type: 'auth';
164
+ token: string;
165
+ }
166
+
167
+ /** Result returned by onAuthenticate hook on success. */
168
+ export interface AuthResult {
169
+ signingKey: string;
170
+ metadata?: Record<string, unknown>;
171
+ }
172
+
160
173
  export interface TunnelServerConfig {
161
174
  port?: number;
162
175
  relay?: TunnelRelayConfig;
163
176
  heartbeat?: HeartbeatConfig;
177
+ /**
178
+ * Called when an agent sends its auth handshake.
179
+ * Return { signingKey, metadata } to accept, or null to reject.
180
+ * If not provided, all connections are rejected.
181
+ */
182
+ onAuthenticate?: (tunnelId: string, token: string) => Promise<AuthResult | null>;
183
+ /**
184
+ * Called before relaying an RPC to the agent.
185
+ * Return false to deny. If not provided, all RPCs are allowed.
186
+ */
187
+ onAuthorizeRPC?: (tunnelId: string, method: string, params: Record<string, unknown>) => Promise<boolean>;
188
+ /**
189
+ * Called before handling HTTP requests to relay routes (/connections, /rpc).
190
+ * Return false to deny. If not provided, routes are open.
191
+ */
192
+ onAuthorizeHTTP?: (req: Request) => Promise<boolean>;
164
193
  }