@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/package.json +4 -2
- package/src/agent/agent.ts +72 -39
- package/src/agent/capabilities/desktop/atspi-helper.ts +1 -1
- package/src/agent/capabilities/desktop/csharp-helper.ts +1 -1
- package/src/agent/capabilities/desktop/linux-driver.ts +1 -1
- package/src/agent/capabilities/desktop/macos-driver.ts +1 -1
- package/src/agent/capabilities/desktop/swift-helper.ts +1 -1
- package/src/agent/capabilities/filesystem.ts +7 -7
- package/src/agent/capabilities/shell.ts +29 -19
- package/src/agent/cli.ts +156 -22
- package/src/agent/config.ts +21 -1
- package/src/agent/security/command-validator.ts +7 -39
- package/src/agent/security/path-validator.ts +16 -19
- package/src/agent/security/permission-guard.ts +2 -1
- package/src/client/cli.test.ts +758 -0
- package/src/client/cli.ts +716 -0
- package/src/client/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/relay.ts +50 -6
- package/src/server/routes.ts +15 -1
- package/src/server/server.ts +22 -22
- package/src/server/ws-handler.ts +76 -12
- package/src/shared/crypto.ts +2 -3
- package/src/shared/index.ts +2 -0
- package/src/shared/types.ts +30 -1
package/src/index.ts
CHANGED
package/src/server/relay.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
package/src/server/routes.ts
CHANGED
|
@@ -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(
|
|
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);
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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, {
|
|
47
|
+
const wsHandlers = createWsHandlers(relay, {
|
|
48
|
+
heartbeat,
|
|
49
|
+
onAuthenticate: config?.onAuthenticate,
|
|
50
|
+
});
|
|
46
51
|
|
|
47
52
|
const app = new Hono();
|
|
48
53
|
|
|
49
|
-
|
|
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
|
-
|
|
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 (!
|
|
71
|
-
return new Response(JSON.stringify({ error: 'Missing
|
|
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
|
|
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
|
|
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);
|
package/src/server/ws-handler.ts
CHANGED
|
@@ -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
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|
package/src/shared/crypto.ts
CHANGED
|
@@ -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',
|
|
14
|
+
export function deriveSigningKey(token: string, secret: string): string {
|
|
15
|
+
return createHmac('sha256', secret)
|
|
17
16
|
.update(token)
|
|
18
17
|
.digest('hex');
|
|
19
18
|
}
|
package/src/shared/index.ts
CHANGED
package/src/shared/types.ts
CHANGED
|
@@ -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
|
}
|