@goodandready/dsh-remote-workspace 0.1.0 → 0.2.0
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/docs/design/DESIGN.md +60 -51
- package/docs/plans/findings.md +14 -2
- package/docs/plans/progress.md +11 -10
- package/docs/plans/task_plan.md +16 -19
- package/lib/client.js +1766 -966
- package/lib/index.js +79 -18
- package/lib/mirror-sync-service.js +73 -16
- package/lib/remote-fs-service.js +58 -7
- package/lib/routes.js +291 -57
- package/lib/ssh-service.js +188 -17
- package/lib/tools.js +25 -11
- package/lib/tunnel-service.js +85 -32
- package/lib/vault-service.js +184 -0
- package/package.json +1 -1
package/lib/ssh-service.js
CHANGED
|
@@ -3,13 +3,33 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Utility to strip ANSI escape sequences from terminal output for clean LLM ingestion
|
|
8
|
+
*/
|
|
9
|
+
export function stripAnsi(str) {
|
|
10
|
+
if (!str) return '';
|
|
11
|
+
// Match standard ANSI escape codes, OSC sequences, and CSI codes
|
|
12
|
+
return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export class SshService {
|
|
7
|
-
|
|
16
|
+
/**
|
|
17
|
+
* @param {any} ctx
|
|
18
|
+
* @param {import('./vault-service.js').VaultService} [vaultService]
|
|
19
|
+
*/
|
|
20
|
+
constructor(ctx, vaultService = null) {
|
|
8
21
|
this.ctx = ctx;
|
|
22
|
+
this.vaultService = vaultService;
|
|
9
23
|
/** @type {Map<string, Client>} */
|
|
10
24
|
this.connections = new Map();
|
|
11
25
|
/** @type {Map<string, any>} */
|
|
12
26
|
this.sftpSessions = new Map();
|
|
27
|
+
/** @type {Map<string, any>} */
|
|
28
|
+
this.terminalSessions = new Map();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setVaultService(vault) {
|
|
32
|
+
this.vaultService = vault;
|
|
13
33
|
}
|
|
14
34
|
|
|
15
35
|
/**
|
|
@@ -20,27 +40,35 @@ export class SshService {
|
|
|
20
40
|
return profile.privateKey;
|
|
21
41
|
}
|
|
22
42
|
if (profile.privateKeyPath) {
|
|
23
|
-
let resolvedPath = profile.privateKeyPath;
|
|
43
|
+
let resolvedPath = profile.privateKeyPath.trim();
|
|
24
44
|
if (resolvedPath.startsWith('~')) {
|
|
25
45
|
resolvedPath = path.join(os.homedir(), resolvedPath.slice(1));
|
|
26
46
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
47
|
+
try {
|
|
48
|
+
if (fs.existsSync(resolvedPath)) {
|
|
49
|
+
return fs.readFileSync(resolvedPath, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
} catch (_) {}
|
|
30
52
|
}
|
|
31
53
|
return undefined;
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
/**
|
|
35
|
-
* Connect to remote machine with pooling and
|
|
57
|
+
* Connect to remote machine with pooling, keep-alive, and auto-reconnect
|
|
36
58
|
*/
|
|
37
|
-
async getConnection(
|
|
59
|
+
async getConnection(rawProfile) {
|
|
60
|
+
const profile = this.vaultService ? this.vaultService.hydrateProfile(rawProfile) : rawProfile;
|
|
38
61
|
const connId = profile.id;
|
|
62
|
+
|
|
39
63
|
const existing = this.connections.get(connId);
|
|
40
64
|
if (existing && existing._sock && !existing._sock.destroyed) {
|
|
41
65
|
return existing;
|
|
42
66
|
}
|
|
43
67
|
|
|
68
|
+
// Clean any broken reference
|
|
69
|
+
this.connections.delete(connId);
|
|
70
|
+
this.sftpSessions.delete(connId);
|
|
71
|
+
|
|
44
72
|
return new Promise((resolve, reject) => {
|
|
45
73
|
const conn = new Client();
|
|
46
74
|
const connConfig = {
|
|
@@ -53,7 +81,7 @@ export class SshService {
|
|
|
53
81
|
};
|
|
54
82
|
|
|
55
83
|
if (profile.authType === 'password') {
|
|
56
|
-
connConfig.password = profile.password;
|
|
84
|
+
connConfig.password = profile.password || '';
|
|
57
85
|
} else if (profile.authType === 'agent') {
|
|
58
86
|
connConfig.agent = process.env.SSH_AUTH_SOCK;
|
|
59
87
|
} else {
|
|
@@ -92,6 +120,9 @@ export class SshService {
|
|
|
92
120
|
* Test connection and measure ping / detect OS
|
|
93
121
|
*/
|
|
94
122
|
async testConnection(profile) {
|
|
123
|
+
if (profile.id) {
|
|
124
|
+
this.disconnect(profile.id);
|
|
125
|
+
}
|
|
95
126
|
const start = Date.now();
|
|
96
127
|
const conn = await this.getConnection(profile);
|
|
97
128
|
const latency = Date.now() - start;
|
|
@@ -99,19 +130,32 @@ export class SshService {
|
|
|
99
130
|
return new Promise((resolve) => {
|
|
100
131
|
conn.exec('uname -s -r -m || ver', (err, stream) => {
|
|
101
132
|
if (err) {
|
|
102
|
-
return resolve({
|
|
133
|
+
return resolve({
|
|
134
|
+
success: true,
|
|
135
|
+
latency,
|
|
136
|
+
latencyMs: latency,
|
|
137
|
+
os: 'Unknown',
|
|
138
|
+
remoteOs: 'Unknown'
|
|
139
|
+
});
|
|
103
140
|
}
|
|
104
141
|
let output = '';
|
|
105
142
|
stream.on('data', (d) => { output += d.toString(); });
|
|
106
143
|
stream.on('close', () => {
|
|
107
|
-
|
|
144
|
+
const osStr = output.trim() || 'Unknown';
|
|
145
|
+
resolve({
|
|
146
|
+
success: true,
|
|
147
|
+
latency,
|
|
148
|
+
latencyMs: latency,
|
|
149
|
+
os: osStr,
|
|
150
|
+
remoteOs: osStr
|
|
151
|
+
});
|
|
108
152
|
});
|
|
109
153
|
});
|
|
110
154
|
});
|
|
111
155
|
}
|
|
112
156
|
|
|
113
157
|
/**
|
|
114
|
-
* Obtain active SFTP wrapper
|
|
158
|
+
* Obtain active SFTP wrapper with connection reuse
|
|
115
159
|
*/
|
|
116
160
|
async getSftp(profile) {
|
|
117
161
|
const connId = profile.id;
|
|
@@ -129,38 +173,161 @@ export class SshService {
|
|
|
129
173
|
}
|
|
130
174
|
|
|
131
175
|
/**
|
|
132
|
-
* Execute command on remote host
|
|
176
|
+
* Execute command on remote host with PTY, streaming, timeout, and ANSI sanitization
|
|
177
|
+
* @param {any} profile
|
|
178
|
+
* @param {string} command
|
|
179
|
+
* @param {string|object} [optionsOrCwd] - cwd string or options object
|
|
180
|
+
* @param {function} [legacyOnData]
|
|
133
181
|
*/
|
|
134
|
-
async exec(profile, command,
|
|
182
|
+
async exec(profile, command, optionsOrCwd = null, legacyOnData = null) {
|
|
183
|
+
let cwd = null;
|
|
184
|
+
let onData = legacyOnData;
|
|
185
|
+
let pty = false;
|
|
186
|
+
let timeoutMs = 0;
|
|
187
|
+
let cleanAnsi = false;
|
|
188
|
+
|
|
189
|
+
if (typeof optionsOrCwd === 'string') {
|
|
190
|
+
cwd = optionsOrCwd;
|
|
191
|
+
} else if (optionsOrCwd && typeof optionsOrCwd === 'object') {
|
|
192
|
+
cwd = optionsOrCwd.cwd || null;
|
|
193
|
+
onData = optionsOrCwd.onData || legacyOnData;
|
|
194
|
+
pty = Boolean(optionsOrCwd.pty);
|
|
195
|
+
timeoutMs = optionsOrCwd.timeout ? optionsOrCwd.timeout * 1000 : 0;
|
|
196
|
+
cleanAnsi = Boolean(optionsOrCwd.cleanAnsi);
|
|
197
|
+
}
|
|
198
|
+
|
|
135
199
|
const conn = await this.getConnection(profile);
|
|
136
200
|
const shellCmd = cwd ? `cd ${JSON.stringify(cwd)} && ${command}` : command;
|
|
137
201
|
|
|
202
|
+
const execOptions = {};
|
|
203
|
+
if (pty) {
|
|
204
|
+
execOptions.pty = {
|
|
205
|
+
rows: typeof pty === 'object' ? (pty.rows || 24) : 24,
|
|
206
|
+
cols: typeof pty === 'object' ? (pty.cols || 80) : 80,
|
|
207
|
+
term: typeof pty === 'object' ? (pty.term || 'xterm-256color') : 'xterm-256color'
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
138
211
|
return new Promise((resolve, reject) => {
|
|
139
|
-
|
|
212
|
+
let timer = null;
|
|
213
|
+
|
|
214
|
+
conn.exec(shellCmd, execOptions, (err, stream) => {
|
|
140
215
|
if (err) return reject(err);
|
|
141
216
|
let stdout = '';
|
|
142
217
|
let stderr = '';
|
|
218
|
+
|
|
219
|
+
if (timeoutMs > 0) {
|
|
220
|
+
timer = setTimeout(() => {
|
|
221
|
+
try {
|
|
222
|
+
stream.signal('SIGTERM');
|
|
223
|
+
stream.close();
|
|
224
|
+
} catch (_) {}
|
|
225
|
+
const errTimeout = new Error(`Command timed out after ${timeoutMs / 1000}s: ${command}`);
|
|
226
|
+
errTimeout.code = 'ETIMEDOUT';
|
|
227
|
+
errTimeout.stdout = cleanAnsi ? stripAnsi(stdout) : stdout;
|
|
228
|
+
errTimeout.stderr = cleanAnsi ? stripAnsi(stderr) : stderr;
|
|
229
|
+
reject(errTimeout);
|
|
230
|
+
}, timeoutMs);
|
|
231
|
+
}
|
|
232
|
+
|
|
143
233
|
stream.on('data', (data) => {
|
|
144
234
|
const str = data.toString();
|
|
145
235
|
stdout += str;
|
|
146
236
|
if (onData) onData({ type: 'stdout', data: str });
|
|
147
237
|
});
|
|
148
|
-
|
|
238
|
+
|
|
239
|
+
stream.stderr?.on('data', (data) => {
|
|
149
240
|
const str = data.toString();
|
|
150
241
|
stderr += str;
|
|
151
242
|
if (onData) onData({ type: 'stderr', data: str });
|
|
152
243
|
});
|
|
244
|
+
|
|
153
245
|
stream.on('close', (code, signal) => {
|
|
154
|
-
|
|
246
|
+
if (timer) clearTimeout(timer);
|
|
247
|
+
resolve({
|
|
248
|
+
code: code ?? 0,
|
|
249
|
+
signal,
|
|
250
|
+
stdout: cleanAnsi ? stripAnsi(stdout) : stdout,
|
|
251
|
+
stderr: cleanAnsi ? stripAnsi(stderr) : stderr
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Create an interactive PTY terminal session for Web UI
|
|
260
|
+
*/
|
|
261
|
+
async createTerminalSession(profile, options = {}) {
|
|
262
|
+
const conn = await this.getConnection(profile);
|
|
263
|
+
const cols = options.cols || 80;
|
|
264
|
+
const rows = options.rows || 24;
|
|
265
|
+
const term = options.term || 'xterm-256color';
|
|
266
|
+
const sessionId = `term_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
267
|
+
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
conn.shell({ term, cols, rows }, (err, stream) => {
|
|
270
|
+
if (err) return reject(err);
|
|
271
|
+
|
|
272
|
+
const session = {
|
|
273
|
+
id: sessionId,
|
|
274
|
+
profileId: profile.id,
|
|
275
|
+
stream,
|
|
276
|
+
createdAt: Date.now(),
|
|
277
|
+
buffer: [],
|
|
278
|
+
subscribers: new Set(),
|
|
279
|
+
write(data) {
|
|
280
|
+
try { stream.write(data); } catch (_) {}
|
|
281
|
+
},
|
|
282
|
+
resize(c, r) {
|
|
283
|
+
try { stream.setWindow(r, c, 0, 0); } catch (_) {}
|
|
284
|
+
},
|
|
285
|
+
close() {
|
|
286
|
+
try { stream.end(); } catch (_) {}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
stream.on('data', (d) => {
|
|
291
|
+
const chunk = d.toString('utf8');
|
|
292
|
+
session.buffer.push(chunk);
|
|
293
|
+
if (session.buffer.length > 500) session.buffer.shift();
|
|
294
|
+
for (const sub of session.subscribers) {
|
|
295
|
+
try { sub(chunk); } catch (_) {}
|
|
296
|
+
}
|
|
155
297
|
});
|
|
298
|
+
|
|
299
|
+
stream.on('close', () => {
|
|
300
|
+
this.terminalSessions.delete(sessionId);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
this.terminalSessions.set(sessionId, session);
|
|
304
|
+
resolve(session);
|
|
156
305
|
});
|
|
157
306
|
});
|
|
158
307
|
}
|
|
159
308
|
|
|
309
|
+
getTerminalSession(sessionId) {
|
|
310
|
+
return this.terminalSessions.get(sessionId);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
closeTerminalSession(sessionId) {
|
|
314
|
+
const session = this.terminalSessions.get(sessionId);
|
|
315
|
+
if (session) {
|
|
316
|
+
session.close();
|
|
317
|
+
this.terminalSessions.delete(sessionId);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
invalidate(profileId) {
|
|
324
|
+
this.disconnect(profileId);
|
|
325
|
+
}
|
|
326
|
+
|
|
160
327
|
disconnect(profileId) {
|
|
161
328
|
const conn = this.connections.get(profileId);
|
|
162
329
|
if (conn) {
|
|
163
|
-
conn.end();
|
|
330
|
+
try { conn.end(); } catch (_) {}
|
|
164
331
|
this.connections.delete(profileId);
|
|
165
332
|
this.sftpSessions.delete(profileId);
|
|
166
333
|
}
|
|
@@ -172,5 +339,9 @@ export class SshService {
|
|
|
172
339
|
}
|
|
173
340
|
this.connections.clear();
|
|
174
341
|
this.sftpSessions.clear();
|
|
342
|
+
for (const [id, term] of this.terminalSessions.entries()) {
|
|
343
|
+
try { term.close(); } catch (_) {}
|
|
344
|
+
}
|
|
345
|
+
this.terminalSessions.clear();
|
|
175
346
|
}
|
|
176
347
|
}
|
package/lib/tools.js
CHANGED
|
@@ -11,21 +11,34 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
11
11
|
// 1. remote_exec
|
|
12
12
|
ctx.tools.register({
|
|
13
13
|
name: 'remote_exec',
|
|
14
|
-
description: 'Execute bash/shell command on the active remote workspace host',
|
|
14
|
+
description: 'Execute bash/shell command on the active remote workspace host with timeout and ANSI cleaning',
|
|
15
15
|
parameters: {
|
|
16
16
|
command: { type: 'string', required: true, description: 'Command line to execute on remote server' },
|
|
17
|
-
cwd: { type: 'string', description: 'Working directory on remote server (defaults to remote workspace)' }
|
|
17
|
+
cwd: { type: 'string', description: 'Working directory on remote server (defaults to remote workspace)' },
|
|
18
|
+
timeout: { type: 'number', description: 'Execution timeout in seconds (default 60s, 0 to disable)' },
|
|
19
|
+
pty: { type: 'boolean', description: 'Allocate pseudo-terminal for colored or interactive outputs' },
|
|
20
|
+
cleanAnsi: { type: 'boolean', description: 'Strip ANSI escape codes from output for clean LLM context (default true)' }
|
|
18
21
|
},
|
|
19
22
|
output: genericOutput,
|
|
20
23
|
async execute(args) {
|
|
21
24
|
const profile = getActiveProfile();
|
|
22
25
|
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
23
26
|
const dir = args.cwd || profile.remoteWorkspace;
|
|
24
|
-
const
|
|
27
|
+
const timeout = args.timeout !== undefined ? args.timeout : 60;
|
|
28
|
+
const cleanAnsi = args.cleanAnsi !== undefined ? args.cleanAnsi : true;
|
|
29
|
+
|
|
30
|
+
const res = await sshService.exec(profile, args.command, {
|
|
31
|
+
cwd: dir,
|
|
32
|
+
timeout,
|
|
33
|
+
pty: Boolean(args.pty),
|
|
34
|
+
cleanAnsi
|
|
35
|
+
});
|
|
36
|
+
|
|
25
37
|
return {
|
|
26
38
|
exitCode: res.code,
|
|
27
39
|
stdout: res.stdout,
|
|
28
|
-
stderr: res.stderr
|
|
40
|
+
stderr: res.stderr,
|
|
41
|
+
signal: res.signal
|
|
29
42
|
};
|
|
30
43
|
}
|
|
31
44
|
});
|
|
@@ -33,7 +46,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
33
46
|
// 2. remote_fs
|
|
34
47
|
ctx.tools.register({
|
|
35
48
|
name: 'remote_fs',
|
|
36
|
-
description: 'Perform file operations (read, write, stat, list, mkdir, remove) on remote workspace',
|
|
49
|
+
description: 'Perform file operations (read, write, stat, list, mkdir, remove) on remote workspace with caching',
|
|
37
50
|
parameters: {
|
|
38
51
|
action: {
|
|
39
52
|
type: 'string',
|
|
@@ -42,13 +55,14 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
42
55
|
},
|
|
43
56
|
path: { type: 'string', required: true, description: 'Absolute or workspace-relative remote path' },
|
|
44
57
|
content: { type: 'string', description: 'Content for write action' },
|
|
45
|
-
recursive: { type: 'boolean', description: 'Recursive flag for remove action' }
|
|
58
|
+
recursive: { type: 'boolean', description: 'Recursive flag for remove action' },
|
|
59
|
+
bypassCache: { type: 'boolean', description: 'Bypass in-memory cache for fresh results' }
|
|
46
60
|
},
|
|
47
61
|
output: genericOutput,
|
|
48
62
|
async execute(args) {
|
|
49
63
|
const profile = getActiveProfile();
|
|
50
64
|
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
51
|
-
const { action, path: targetPath, content, recursive } = args;
|
|
65
|
+
const { action, path: targetPath, content, recursive, bypassCache } = args;
|
|
52
66
|
|
|
53
67
|
switch (action) {
|
|
54
68
|
case 'read': {
|
|
@@ -61,10 +75,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
61
75
|
return res;
|
|
62
76
|
}
|
|
63
77
|
case 'stat': {
|
|
64
|
-
return await remoteFs.stat(profile, targetPath);
|
|
78
|
+
return await remoteFs.stat(profile, targetPath, Boolean(bypassCache));
|
|
65
79
|
}
|
|
66
80
|
case 'list': {
|
|
67
|
-
const entries = await remoteFs.listDir(profile, targetPath);
|
|
81
|
+
const entries = await remoteFs.listDir(profile, targetPath, Boolean(bypassCache));
|
|
68
82
|
return { entries };
|
|
69
83
|
}
|
|
70
84
|
case 'mkdir': {
|
|
@@ -82,7 +96,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
82
96
|
// 3. remote_sync
|
|
83
97
|
ctx.tools.register({
|
|
84
98
|
name: 'remote_sync',
|
|
85
|
-
description: 'Perform 3-way
|
|
99
|
+
description: 'Perform fast 3-way delta synchronization between local mirror and remote server',
|
|
86
100
|
parameters: {
|
|
87
101
|
direction: { type: 'string', required: true, description: 'pull (remote -> local) or push (local -> remote)' },
|
|
88
102
|
force: { type: 'boolean', description: 'Overwrite conflicts if true (use with caution)' },
|
|
@@ -107,7 +121,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
107
121
|
// 4. remote_tunnel
|
|
108
122
|
ctx.tools.register({
|
|
109
123
|
name: 'remote_tunnel',
|
|
110
|
-
description: 'Manage SSH port forwarding tunnels (start, stop, list)',
|
|
124
|
+
description: 'Manage SSH port forwarding tunnels (start, stop, list) with telemetry metrics',
|
|
111
125
|
parameters: {
|
|
112
126
|
action: { type: 'string', required: true, description: 'start, stop, or list' },
|
|
113
127
|
localPort: { type: 'number', description: 'Local listening port' },
|
package/lib/tunnel-service.js
CHANGED
|
@@ -6,66 +6,112 @@ export class TunnelService {
|
|
|
6
6
|
*/
|
|
7
7
|
constructor(sshService) {
|
|
8
8
|
this.sshService = sshService;
|
|
9
|
-
/** @type {Map<string, {
|
|
9
|
+
/** @type {Map<string, {
|
|
10
|
+
* server: net.Server,
|
|
11
|
+
* profileId: string,
|
|
12
|
+
* localPort: number,
|
|
13
|
+
* targetHost: string,
|
|
14
|
+
* remotePort: number,
|
|
15
|
+
* createdAt: number,
|
|
16
|
+
* bytesRx: number,
|
|
17
|
+
* bytesTx: number,
|
|
18
|
+
* totalConnections: number,
|
|
19
|
+
* activeConnections: number
|
|
20
|
+
* }>} */
|
|
10
21
|
this.activeTunnels = new Map();
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
/**
|
|
14
|
-
* Start
|
|
25
|
+
* Start local port forwarding tunnel: LocalPort -> RemoteHost:RemotePort with live telemetry
|
|
15
26
|
*/
|
|
16
|
-
async startLocalTunnel(profile, localPort, targetHost
|
|
17
|
-
const tunnelId = `${profile.id}:${localPort}->${
|
|
27
|
+
async startLocalTunnel(profile, localPort, targetHost, remotePort) {
|
|
28
|
+
const tunnelId = `${profile.id}:${localPort}->${targetHost}:${remotePort}`;
|
|
18
29
|
if (this.activeTunnels.has(tunnelId)) {
|
|
19
|
-
return { success: true,
|
|
30
|
+
return { success: true, tunnelId, alreadyRunning: true };
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
const conn = await this.sshService.getConnection(profile);
|
|
23
34
|
|
|
24
35
|
return new Promise((resolve, reject) => {
|
|
25
|
-
const
|
|
36
|
+
const tunnelMeta = {
|
|
37
|
+
server: null,
|
|
38
|
+
profileId: profile.id,
|
|
39
|
+
localPort,
|
|
40
|
+
targetHost,
|
|
41
|
+
remotePort,
|
|
42
|
+
createdAt: Date.now(),
|
|
43
|
+
bytesRx: 0,
|
|
44
|
+
bytesTx: 0,
|
|
45
|
+
totalConnections: 0,
|
|
46
|
+
activeConnections: 0
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const server = net.createServer((clientSocket) => {
|
|
50
|
+
tunnelMeta.totalConnections++;
|
|
51
|
+
tunnelMeta.activeConnections++;
|
|
52
|
+
|
|
26
53
|
conn.forwardOut(
|
|
27
54
|
'127.0.0.1',
|
|
28
|
-
|
|
55
|
+
clientSocket.remotePort || 0,
|
|
29
56
|
targetHost,
|
|
30
|
-
|
|
57
|
+
remotePort,
|
|
31
58
|
(err, stream) => {
|
|
32
59
|
if (err) {
|
|
33
|
-
|
|
60
|
+
tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
|
|
61
|
+
clientSocket.destroy();
|
|
34
62
|
return;
|
|
35
63
|
}
|
|
36
|
-
|
|
64
|
+
|
|
65
|
+
// Telemetry: measure bytes flowing through the tunnel
|
|
66
|
+
clientSocket.on('data', (d) => {
|
|
67
|
+
tunnelMeta.bytesRx += d.length;
|
|
68
|
+
});
|
|
69
|
+
stream.on('data', (d) => {
|
|
70
|
+
tunnelMeta.bytesTx += d.length;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
clientSocket.pipe(stream);
|
|
74
|
+
stream.pipe(clientSocket);
|
|
75
|
+
|
|
76
|
+
const cleanup = () => {
|
|
77
|
+
tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
|
|
78
|
+
};
|
|
79
|
+
clientSocket.on('close', cleanup);
|
|
80
|
+
clientSocket.on('error', cleanup);
|
|
81
|
+
stream.on('close', cleanup);
|
|
82
|
+
stream.on('error', cleanup);
|
|
37
83
|
}
|
|
38
84
|
);
|
|
39
85
|
});
|
|
40
86
|
|
|
41
|
-
server
|
|
42
|
-
this.activeTunnels.set(tunnelId, {
|
|
43
|
-
server,
|
|
44
|
-
profileId: profile.id,
|
|
45
|
-
localPort,
|
|
46
|
-
remotePort: targetPort
|
|
47
|
-
});
|
|
48
|
-
resolve({ success: true, tunnelId, localPort, targetPort });
|
|
49
|
-
});
|
|
87
|
+
tunnelMeta.server = server;
|
|
50
88
|
|
|
51
89
|
server.on('error', (err) => {
|
|
52
90
|
this.activeTunnels.delete(tunnelId);
|
|
53
91
|
reject(err);
|
|
54
92
|
});
|
|
93
|
+
|
|
94
|
+
server.listen(localPort, '127.0.0.1', () => {
|
|
95
|
+
this.activeTunnels.set(tunnelId, tunnelMeta);
|
|
96
|
+
resolve({
|
|
97
|
+
success: true,
|
|
98
|
+
tunnelId,
|
|
99
|
+
localPort,
|
|
100
|
+
targetHost,
|
|
101
|
+
remotePort
|
|
102
|
+
});
|
|
103
|
+
});
|
|
55
104
|
});
|
|
56
105
|
}
|
|
57
106
|
|
|
58
|
-
/**
|
|
59
|
-
* Stop active tunnel
|
|
60
|
-
*/
|
|
61
107
|
stopTunnel(tunnelId) {
|
|
62
108
|
const tunnel = this.activeTunnels.get(tunnelId);
|
|
63
|
-
if (tunnel
|
|
109
|
+
if (!tunnel) return false;
|
|
110
|
+
try {
|
|
64
111
|
tunnel.server.close();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return false;
|
|
112
|
+
} catch (_) {}
|
|
113
|
+
this.activeTunnels.delete(tunnelId);
|
|
114
|
+
return true;
|
|
69
115
|
}
|
|
70
116
|
|
|
71
117
|
listActiveTunnels() {
|
|
@@ -73,15 +119,22 @@ export class TunnelService {
|
|
|
73
119
|
id,
|
|
74
120
|
profileId: t.profileId,
|
|
75
121
|
localPort: t.localPort,
|
|
76
|
-
|
|
122
|
+
targetHost: t.targetHost,
|
|
123
|
+
remotePort: t.remotePort,
|
|
124
|
+
createdAt: t.createdAt,
|
|
125
|
+
uptimeSeconds: Math.floor((Date.now() - t.createdAt) / 1000),
|
|
126
|
+
bytesRx: t.bytesRx,
|
|
127
|
+
bytesTx: t.bytesTx,
|
|
128
|
+
totalConnections: t.totalConnections,
|
|
129
|
+
activeConnections: t.activeConnections
|
|
77
130
|
}));
|
|
78
131
|
}
|
|
79
132
|
|
|
80
133
|
stopAll() {
|
|
81
|
-
for (const [id,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
134
|
+
for (const [id, tunnel] of this.activeTunnels.entries()) {
|
|
135
|
+
try {
|
|
136
|
+
tunnel.server.close();
|
|
137
|
+
} catch (_) {}
|
|
85
138
|
}
|
|
86
139
|
this.activeTunnels.clear();
|
|
87
140
|
}
|