@kortix/agent-tunnel 0.1.1 → 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 +1 -1
- package/src/agent/agent.ts +19 -9
- 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/cli.ts +156 -22
- package/src/client/cli.test.ts +10 -10
- package/src/client/cli.ts +3 -3
- package/src/server/ws-handler.ts +5 -0
- package/src/shared/crypto.ts +2 -3
package/package.json
CHANGED
package/src/agent/agent.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { TunnelConfig } from './config';
|
|
|
3
3
|
import { CapabilityRegistry, type RpcHandler } from './capabilities/index';
|
|
4
4
|
import { PermissionGuard } from './security/permission-guard';
|
|
5
5
|
import type { LocalPermission } from './security/permission-guard';
|
|
6
|
-
import {
|
|
6
|
+
import { signMessage, verifyMessageSignature } from '../shared/crypto';
|
|
7
7
|
|
|
8
8
|
interface JsonRpcRequest {
|
|
9
9
|
jsonrpc: '2.0';
|
|
@@ -54,7 +54,7 @@ export class TunnelAgent {
|
|
|
54
54
|
private uptimeInterval: ReturnType<typeof setInterval> | null = null;
|
|
55
55
|
|
|
56
56
|
// HMAC signature verification
|
|
57
|
-
private signingKey: string;
|
|
57
|
+
private signingKey: string | null = null;
|
|
58
58
|
private lastNonce = 0;
|
|
59
59
|
private responseNonce = 0;
|
|
60
60
|
|
|
@@ -62,7 +62,6 @@ export class TunnelAgent {
|
|
|
62
62
|
this.config = config;
|
|
63
63
|
this.registry = registry;
|
|
64
64
|
this.permissionGuard = new PermissionGuard();
|
|
65
|
-
this.signingKey = deriveSigningKey(config.token);
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
connect(): void {
|
|
@@ -116,12 +115,11 @@ export class TunnelAgent {
|
|
|
116
115
|
this.uptime = 0;
|
|
117
116
|
this.lastNonce = 0;
|
|
118
117
|
this.responseNonce = 0;
|
|
118
|
+
this.signingKey = null;
|
|
119
119
|
this.uptimeInterval = setInterval(() => { this.uptime++; }, 1000);
|
|
120
120
|
|
|
121
121
|
// Send auth handshake as first message (token never in URL)
|
|
122
122
|
this.send({ type: 'auth', token: this.config.token });
|
|
123
|
-
|
|
124
|
-
log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
|
|
125
123
|
});
|
|
126
124
|
|
|
127
125
|
this.ws.addEventListener('message', (event) => {
|
|
@@ -150,7 +148,7 @@ export class TunnelAgent {
|
|
|
150
148
|
}
|
|
151
149
|
|
|
152
150
|
private async handleMessage(raw: string): Promise<void> {
|
|
153
|
-
let msg:
|
|
151
|
+
let msg: any;
|
|
154
152
|
try {
|
|
155
153
|
msg = JSON.parse(raw);
|
|
156
154
|
} catch {
|
|
@@ -158,6 +156,18 @@ export class TunnelAgent {
|
|
|
158
156
|
return;
|
|
159
157
|
}
|
|
160
158
|
|
|
159
|
+
// Handle auth_ok — server sends signing key after successful auth
|
|
160
|
+
if (msg.type === 'auth_ok' && msg.signingKey) {
|
|
161
|
+
this.signingKey = msg.signingKey;
|
|
162
|
+
log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!this.signingKey) {
|
|
167
|
+
log(`${c.yellow}!${c.reset}`, `Message received before auth completed`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
161
171
|
if (!this.verifyIncomingSignature(msg, raw)) {
|
|
162
172
|
if ('id' in msg && msg.id) {
|
|
163
173
|
this.sendSignedError(msg.id, -32000, 'Invalid message signature');
|
|
@@ -232,7 +242,7 @@ export class TunnelAgent {
|
|
|
232
242
|
const { _sig, _nonce, ...payloadObj } = msg as any;
|
|
233
243
|
const payload = JSON.stringify(payloadObj);
|
|
234
244
|
|
|
235
|
-
if (!verifyMessageSignature(this.signingKey
|
|
245
|
+
if (!verifyMessageSignature(this.signingKey!, payload, nonce, sig)) {
|
|
236
246
|
log(`${c.red}✗${c.reset}`, `Invalid HMAC signature`);
|
|
237
247
|
return false;
|
|
238
248
|
}
|
|
@@ -278,7 +288,7 @@ export class TunnelAgent {
|
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
private sendSigned(data: Record<string, unknown>): void {
|
|
281
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
291
|
+
if (this.ws?.readyState === WebSocket.OPEN && this.signingKey) {
|
|
282
292
|
const nonce = ++this.responseNonce;
|
|
283
293
|
const payload = JSON.stringify(data);
|
|
284
294
|
const sig = signMessage(this.signingKey, payload, nonce);
|
|
@@ -313,7 +323,7 @@ export class TunnelAgent {
|
|
|
313
323
|
platform: platform(),
|
|
314
324
|
arch: arch(),
|
|
315
325
|
osVersion: release(),
|
|
316
|
-
agentVersion: '0.1.
|
|
326
|
+
agentVersion: '0.1.2',
|
|
317
327
|
},
|
|
318
328
|
},
|
|
319
329
|
});
|
|
@@ -4,7 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
|
|
6
6
|
const HELPER_VERSION = 'v1';
|
|
7
|
-
const BIN_DIR = join(homedir(), '.
|
|
7
|
+
const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
|
|
8
8
|
const HELPER_PATH = join(BIN_DIR, `atspi-helper-${HELPER_VERSION}.py`);
|
|
9
9
|
|
|
10
10
|
const PYTHON_SOURCE = `#!/usr/bin/env python3
|
|
@@ -4,7 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
|
|
6
6
|
const HELPER_VERSION = 'v1';
|
|
7
|
-
const BIN_DIR = join(homedir(), '.
|
|
7
|
+
const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
|
|
8
8
|
const HELPER_PATH = join(BIN_DIR, `desktop-helper-win-${HELPER_VERSION}.exe`);
|
|
9
9
|
|
|
10
10
|
const CSHARP_SOURCE = `
|
|
@@ -32,7 +32,7 @@ import type {
|
|
|
32
32
|
} from './types';
|
|
33
33
|
|
|
34
34
|
function tmpPath(): string {
|
|
35
|
-
return join(tmpdir(), `
|
|
35
|
+
return join(tmpdir(), `tunnel-ss-${randomBytes(6).toString('hex')}.png`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function exec(cmd: string, args: string[]): Promise<string> {
|
|
@@ -32,7 +32,7 @@ import type {
|
|
|
32
32
|
} from './types';
|
|
33
33
|
|
|
34
34
|
function tmpPath(ext: string = '.png'): string {
|
|
35
|
-
return join(tmpdir(), `
|
|
35
|
+
return join(tmpdir(), `tunnel-ss-${randomBytes(6).toString('hex')}${ext}`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function exec(cmd: string, args: string[], timeoutMs = 15000): Promise<string> {
|
|
@@ -4,7 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
|
|
6
6
|
const HELPER_VERSION = 'v4';
|
|
7
|
-
const BIN_DIR = join(homedir(), '.
|
|
7
|
+
const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
|
|
8
8
|
const HELPER_PATH = join(BIN_DIR, `desktop-helper-${HELPER_VERSION}`);
|
|
9
9
|
|
|
10
10
|
const SWIFT_SOURCE = `
|
package/src/agent/cli.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { loadConfig } from './config';
|
|
2
|
+
import { loadConfig, type TunnelConfig } from './config';
|
|
3
3
|
import { TunnelAgent } from './agent';
|
|
4
4
|
import { CapabilityRegistry } from './capabilities/index';
|
|
5
5
|
import { createFilesystemCapability } from './capabilities/filesystem';
|
|
6
6
|
import { createShellCapability } from './capabilities/shell';
|
|
7
7
|
import { createDesktopCapability } from './capabilities/desktop';
|
|
8
8
|
import { hostname, platform, arch, release } from 'os';
|
|
9
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
9
13
|
|
|
10
14
|
const c = {
|
|
11
15
|
reset: '\x1b[0m',
|
|
@@ -114,30 +118,14 @@ async function printStartup(config: { tunnelId: string; apiUrl: string }, capabi
|
|
|
114
118
|
console.log('');
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
const config = loadConfig({
|
|
119
|
-
token: flags.token,
|
|
120
|
-
tunnelId: flags['tunnel-id'],
|
|
121
|
-
apiUrl: flags['api-url'],
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
if (!config.token) {
|
|
125
|
-
console.error(`${c.red}${c.bold} error${c.reset} --token is required`);
|
|
126
|
-
process.exit(1);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (!config.tunnelId) {
|
|
130
|
-
console.error(`${c.red}${c.bold} error${c.reset} --tunnel-id is required`);
|
|
131
|
-
process.exit(1);
|
|
132
|
-
}
|
|
133
|
-
|
|
121
|
+
function startAgent(config: TunnelConfig): void {
|
|
134
122
|
const registry = new CapabilityRegistry();
|
|
135
123
|
registry.register(createFilesystemCapability(config));
|
|
136
124
|
registry.register(createShellCapability(config));
|
|
137
125
|
registry.register(createDesktopCapability());
|
|
138
126
|
|
|
139
127
|
clearScreen();
|
|
140
|
-
|
|
128
|
+
printStartup(config, registry.getCapabilityNames(), '0.1.2');
|
|
141
129
|
|
|
142
130
|
const agent = new TunnelAgent(config, registry);
|
|
143
131
|
agent.connect();
|
|
@@ -152,6 +140,152 @@ async function commandConnect(flags: Record<string, string>): Promise<void> {
|
|
|
152
140
|
process.on('SIGINT', shutdown);
|
|
153
141
|
}
|
|
154
142
|
|
|
143
|
+
function openBrowser(url: string): void {
|
|
144
|
+
try {
|
|
145
|
+
const plat = platform();
|
|
146
|
+
if (plat === 'darwin') execSync(`open "${url}"`);
|
|
147
|
+
else if (plat === 'win32') execSync(`start "" "${url}"`);
|
|
148
|
+
else execSync(`xdg-open "${url}"`);
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const CONFIG_DIR = join(homedir(), '.agent-tunnel');
|
|
153
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
154
|
+
|
|
155
|
+
function saveCredentials(tunnelId: string, token: string, apiUrl: string): void {
|
|
156
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
157
|
+
let existing: Record<string, unknown> = {};
|
|
158
|
+
if (existsSync(CONFIG_FILE)) {
|
|
159
|
+
try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
|
|
160
|
+
}
|
|
161
|
+
writeFileSync(CONFIG_FILE, JSON.stringify({ ...existing, tunnelId, token, apiUrl }, null, 2));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
|
|
165
|
+
console.log('');
|
|
166
|
+
console.log(` ${c.cyan}◆${c.reset} ${c.bold}Device Authorization${c.reset}`);
|
|
167
|
+
console.log('');
|
|
168
|
+
|
|
169
|
+
// Step 1: Create device auth request
|
|
170
|
+
let deviceCode: string;
|
|
171
|
+
let deviceSecret: string;
|
|
172
|
+
let verificationUrl: string;
|
|
173
|
+
let expiresAt: string;
|
|
174
|
+
let pollIntervalMs: number;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch(`${config.apiUrl}/device-auth`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
180
|
+
body: JSON.stringify({ machineHostname: hostname() }),
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
const text = await res.text().catch(() => '');
|
|
184
|
+
console.error(` ${c.red}✗${c.reset} Failed to create device auth request: ${res.status} ${text.slice(0, 200)}`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
const data = await res.json();
|
|
188
|
+
deviceCode = data.deviceCode;
|
|
189
|
+
deviceSecret = data.deviceSecret;
|
|
190
|
+
verificationUrl = data.verificationUrl;
|
|
191
|
+
expiresAt = data.expiresAt;
|
|
192
|
+
pollIntervalMs = data.pollIntervalMs || 2000;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(` ${c.red}✗${c.reset} Failed to reach API at ${config.apiUrl}`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Step 2: Display code and open browser
|
|
200
|
+
console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${deviceCode}${c.reset}`);
|
|
201
|
+
console.log('');
|
|
202
|
+
console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
|
|
203
|
+
console.log(` ${c.cyan}${verificationUrl}${c.reset}`);
|
|
204
|
+
console.log('');
|
|
205
|
+
|
|
206
|
+
openBrowser(verificationUrl);
|
|
207
|
+
|
|
208
|
+
// Step 3: Poll for approval
|
|
209
|
+
const expiresAtMs = new Date(expiresAt).getTime();
|
|
210
|
+
|
|
211
|
+
while (true) {
|
|
212
|
+
const remaining = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000));
|
|
213
|
+
if (remaining <= 0) {
|
|
214
|
+
console.log(`\n ${c.red}✗${c.reset} Authorization expired. Please try again.`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const min = Math.floor(remaining / 60);
|
|
219
|
+
const sec = remaining % 60;
|
|
220
|
+
process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${min}:${sec.toString().padStart(2, '0')}${c.reset} `);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status?secret=${deviceSecret}`);
|
|
224
|
+
if (res.ok) {
|
|
225
|
+
const data = await res.json();
|
|
226
|
+
|
|
227
|
+
if (data.status === 'approved' && data.tunnelId && data.token) {
|
|
228
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
229
|
+
console.log(` ${c.green}●${c.reset} ${c.bold}Authorized!${c.reset}`);
|
|
230
|
+
console.log('');
|
|
231
|
+
|
|
232
|
+
// Save credentials
|
|
233
|
+
saveCredentials(data.tunnelId, data.token, config.apiUrl);
|
|
234
|
+
console.log(` ${c.dim}Credentials saved to ${CONFIG_FILE}${c.reset}`);
|
|
235
|
+
console.log('');
|
|
236
|
+
|
|
237
|
+
// Connect with received credentials
|
|
238
|
+
const fullConfig = loadConfig({
|
|
239
|
+
token: data.token,
|
|
240
|
+
tunnelId: data.tunnelId,
|
|
241
|
+
apiUrl: config.apiUrl,
|
|
242
|
+
});
|
|
243
|
+
startAgent(fullConfig);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (data.status === 'denied') {
|
|
248
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
249
|
+
console.log(` ${c.red}✗${c.reset} Authorization denied.`);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (data.status === 'expired') {
|
|
254
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
255
|
+
console.log(` ${c.red}✗${c.reset} Authorization expired. Please try again.`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
|
|
261
|
+
await sleep(pollIntervalMs);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function commandConnect(flags: Record<string, string>): Promise<void> {
|
|
266
|
+
const config = loadConfig({
|
|
267
|
+
token: flags.token,
|
|
268
|
+
tunnelId: flags['tunnel-id'],
|
|
269
|
+
apiUrl: flags['api-url'],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// If both token and tunnelId are provided, connect directly
|
|
273
|
+
if (config.token && config.tunnelId) {
|
|
274
|
+
startAgent(config);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// If neither is provided, use device auth flow
|
|
279
|
+
if (!config.token && !config.tunnelId) {
|
|
280
|
+
await commandConnectDeviceAuth(config);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Partial — error
|
|
285
|
+
console.error(`${c.red}${c.bold} error${c.reset} Provide both --token and --tunnel-id, or neither (for device auth)`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
|
|
155
289
|
async function commandStatus(flags: Record<string, string>): Promise<void> {
|
|
156
290
|
const config = loadConfig({
|
|
157
291
|
token: flags.token,
|
|
@@ -192,13 +326,13 @@ function showHelp(): void {
|
|
|
192
326
|
console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx @kortix/agent-tunnel <command> [options]${c.reset}`);
|
|
193
327
|
console.log('');
|
|
194
328
|
console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
|
|
195
|
-
console.log(` ${c.cyan}connect${c.reset} Connect
|
|
329
|
+
console.log(` ${c.cyan}connect${c.reset} Connect via device auth (opens browser)`);
|
|
196
330
|
console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
|
|
197
331
|
console.log(` ${c.cyan}help${c.reset} Show this help message`);
|
|
198
332
|
console.log('');
|
|
199
333
|
console.log(`${c.gray} ── Options ─────────────────────────────────────────${c.reset}`);
|
|
200
|
-
console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset}
|
|
201
|
-
console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(
|
|
334
|
+
console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} Skip device auth, connect directly`);
|
|
335
|
+
console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(required with --token)${c.reset}`);
|
|
202
336
|
console.log(` ${c.white}--api-url${c.reset} ${c.dim}<url>${c.reset} API URL ${c.dim}(default: http://localhost:8080)${c.reset}`);
|
|
203
337
|
console.log('');
|
|
204
338
|
console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
|
package/src/client/cli.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Strategy: spin up a lightweight mock HTTP server that simulates the
|
|
5
5
|
* tunnel relay API, then invoke the CLI as a child process via `bun run`
|
|
6
|
-
* with
|
|
6
|
+
* with TUNNEL_API_URL pointing at the mock. Assert on JSON stdout,
|
|
7
7
|
* stderr, and exit codes.
|
|
8
8
|
*
|
|
9
9
|
* Run: bun test src/client/cli.test.ts
|
|
@@ -267,9 +267,9 @@ function runCli(
|
|
|
267
267
|
const child = spawn("bun", cliArgs, {
|
|
268
268
|
env: {
|
|
269
269
|
...process.env,
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
TUNNEL_API_URL: `http://localhost:${mockPort}`,
|
|
271
|
+
TUNNEL_TOKEN: "test-token",
|
|
272
|
+
TUNNEL_ID: "",
|
|
273
273
|
...envOverrides,
|
|
274
274
|
},
|
|
275
275
|
cwd: dirname(dirname(CLI_PATH)),
|
|
@@ -669,7 +669,7 @@ describe("Agent Tunnel CLI", () => {
|
|
|
669
669
|
|
|
670
670
|
test("permission denied returns structured response", async () => {
|
|
671
671
|
const r = await runCli("fs_read", '{"path":"/etc/passwd"}', {
|
|
672
|
-
|
|
672
|
+
TUNNEL_API_URL: `http://localhost:${permPort}`,
|
|
673
673
|
});
|
|
674
674
|
// CLI should output permission-required JSON (not crash)
|
|
675
675
|
expect(r.json).not.toBeNull();
|
|
@@ -683,7 +683,7 @@ describe("Agent Tunnel CLI", () => {
|
|
|
683
683
|
const r = await runCli(
|
|
684
684
|
"shell",
|
|
685
685
|
'{"command":"rm","args":["-rf","/"]}',
|
|
686
|
-
{
|
|
686
|
+
{ TUNNEL_API_URL: `http://localhost:${permPort}` }
|
|
687
687
|
);
|
|
688
688
|
expect(r.json!.success).toBe(false);
|
|
689
689
|
expect(r.json!.permissionRequired).toBe(true);
|
|
@@ -695,7 +695,7 @@ describe("Agent Tunnel CLI", () => {
|
|
|
695
695
|
describe("server unreachable", () => {
|
|
696
696
|
test("status with dead server returns error JSON", async () => {
|
|
697
697
|
const r = await runCli("status", undefined, {
|
|
698
|
-
|
|
698
|
+
TUNNEL_API_URL: "http://localhost:1",
|
|
699
699
|
});
|
|
700
700
|
expect(r.exitCode).toBe(1);
|
|
701
701
|
expect(r.json).not.toBeNull();
|
|
@@ -705,7 +705,7 @@ describe("Agent Tunnel CLI", () => {
|
|
|
705
705
|
|
|
706
706
|
test("fs_read with dead server returns error JSON", async () => {
|
|
707
707
|
const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
|
|
708
|
-
|
|
708
|
+
TUNNEL_API_URL: "http://localhost:1",
|
|
709
709
|
});
|
|
710
710
|
expect(r.exitCode).toBe(1);
|
|
711
711
|
expect(r.json!.success).toBe(false);
|
|
@@ -738,7 +738,7 @@ describe("Agent Tunnel CLI", () => {
|
|
|
738
738
|
|
|
739
739
|
test("status with no connections returns empty list", async () => {
|
|
740
740
|
const r = await runCli("status", undefined, {
|
|
741
|
-
|
|
741
|
+
TUNNEL_API_URL: `http://localhost:${emptyPort}`,
|
|
742
742
|
});
|
|
743
743
|
expect(r.exitCode).toBe(0);
|
|
744
744
|
expect(r.json!.success).toBe(true);
|
|
@@ -748,7 +748,7 @@ describe("Agent Tunnel CLI", () => {
|
|
|
748
748
|
|
|
749
749
|
test("fs_read with no connections returns error", async () => {
|
|
750
750
|
const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
|
|
751
|
-
|
|
751
|
+
TUNNEL_API_URL: `http://localhost:${emptyPort}`,
|
|
752
752
|
});
|
|
753
753
|
expect(r.exitCode).toBe(1);
|
|
754
754
|
expect(r.json!.success).toBe(false);
|
package/src/client/cli.ts
CHANGED
|
@@ -56,15 +56,15 @@ function getEnv(key: string): string | undefined {
|
|
|
56
56
|
const FALLBACK_API_URL = "http://localhost:8008";
|
|
57
57
|
|
|
58
58
|
function getApiBase(): string {
|
|
59
|
-
const raw = getEnv("
|
|
59
|
+
const raw = getEnv("TUNNEL_API_URL") || FALLBACK_API_URL;
|
|
60
60
|
const url = raw.startsWith("http") ? raw : FALLBACK_API_URL;
|
|
61
61
|
return url.replace(/\/+$/, "");
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const client = new TunnelClient({
|
|
65
65
|
apiUrl: `${getApiBase()}/v1/tunnel`,
|
|
66
|
-
token: getEnv("
|
|
67
|
-
tunnelId: getEnv("
|
|
66
|
+
token: getEnv("TUNNEL_TOKEN") || "",
|
|
67
|
+
tunnelId: getEnv("TUNNEL_ID"),
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
package/src/server/ws-handler.ts
CHANGED
|
@@ -81,6 +81,11 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
|
|
|
81
81
|
return;
|
|
82
82
|
}
|
|
83
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
|
+
|
|
84
89
|
relay.registerAgent(tunnelId, pending.ws, result.signingKey, result.metadata);
|
|
85
90
|
if (heartbeat) {
|
|
86
91
|
heartbeat.register(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
|
}
|