@goodandready/dsh-remote-workspace 0.1.1 → 0.3.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.
@@ -3,18 +3,39 @@ 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
+ return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
12
+ }
13
+
6
14
  export class SshService {
7
- constructor(ctx) {
15
+ /**
16
+ * @param {any} ctx
17
+ * @param {import('./vault-service.js').VaultService} [vaultService]
18
+ */
19
+ constructor(ctx, vaultService = null) {
8
20
  this.ctx = ctx;
21
+ this.vaultService = vaultService;
9
22
  /** @type {Map<string, Client>} */
10
23
  this.connections = new Map();
11
24
  /** @type {Map<string, any>} */
12
25
  this.sftpSessions = new Map();
26
+ /** @type {Map<string, any>} */
27
+ this.terminalSessions = new Map();
28
+ this.getJumpProfile = null;
29
+ }
30
+
31
+ setVaultService(vault) {
32
+ this.vaultService = vault;
33
+ }
34
+
35
+ setJumpProfileResolver(resolver) {
36
+ this.getJumpProfile = resolver;
13
37
  }
14
38
 
15
- /**
16
- * Resolve private key content either from direct string or file path
17
- */
18
39
  resolvePrivateKey(profile) {
19
40
  if (profile.privateKey && profile.privateKey.trim()) {
20
41
  return profile.privateKey;
@@ -34,15 +55,35 @@ export class SshService {
34
55
  }
35
56
 
36
57
  /**
37
- * Connect to remote machine with pooling and keep-alive
58
+ * Connect to remote machine with pooling, keep-alive, auto-reconnect and ProxyJump
38
59
  */
39
- async getConnection(profile) {
60
+ async getConnection(rawProfile) {
61
+ const profile = this.vaultService ? this.vaultService.hydrateProfile(rawProfile) : rawProfile;
40
62
  const connId = profile.id;
63
+
41
64
  const existing = this.connections.get(connId);
42
65
  if (existing && existing._sock && !existing._sock.destroyed) {
43
66
  return existing;
44
67
  }
45
68
 
69
+ this.connections.delete(connId);
70
+ this.sftpSessions.delete(connId);
71
+
72
+ // If jumpHost is configured, obtain socket through the jump host first
73
+ let sock = undefined;
74
+ if (profile.jumpHostId && this.getJumpProfile) {
75
+ const jumpProfile = this.getJumpProfile(profile.jumpHostId);
76
+ if (jumpProfile) {
77
+ const jumpConn = await this.getConnection(jumpProfile);
78
+ sock = await new Promise((resolve, reject) => {
79
+ jumpConn.forwardOut('127.0.0.1', 0, profile.host, profile.port || 22, (err, stream) => {
80
+ if (err) return reject(new Error(`ProxyJump via ${jumpProfile.host} failed: ${err.message}`));
81
+ resolve(stream);
82
+ });
83
+ });
84
+ }
85
+ }
86
+
46
87
  return new Promise((resolve, reject) => {
47
88
  const conn = new Client();
48
89
  const connConfig = {
@@ -54,6 +95,10 @@ export class SshService {
54
95
  readyTimeout: 20000
55
96
  };
56
97
 
98
+ if (sock) {
99
+ connConfig.sock = sock;
100
+ }
101
+
57
102
  if (profile.authType === 'password') {
58
103
  connConfig.password = profile.password || '';
59
104
  } else if (profile.authType === 'agent') {
@@ -90,11 +135,7 @@ export class SshService {
90
135
  });
91
136
  }
92
137
 
93
- /**
94
- * Test connection and measure ping / detect OS
95
- */
96
138
  async testConnection(profile) {
97
- // Invalidate any existing connection for this profile id to ensure fresh credentials are used
98
139
  if (profile.id) {
99
140
  this.disconnect(profile.id);
100
141
  }
@@ -129,9 +170,6 @@ export class SshService {
129
170
  });
130
171
  }
131
172
 
132
- /**
133
- * Obtain active SFTP wrapper
134
- */
135
173
  async getSftp(profile) {
136
174
  const connId = profile.id;
137
175
  if (this.sftpSessions.has(connId)) {
@@ -147,35 +185,180 @@ export class SshService {
147
185
  });
148
186
  }
149
187
 
150
- /**
151
- * Execute command on remote host
152
- */
153
- async exec(profile, command, cwd, onData) {
188
+ async exec(profile, command, optionsOrCwd = null, legacyOnData = null) {
189
+ let cwd = null;
190
+ let onData = legacyOnData;
191
+ let pty = false;
192
+ let timeoutMs = 0;
193
+ let cleanAnsi = false;
194
+
195
+ if (typeof optionsOrCwd === 'string') {
196
+ cwd = optionsOrCwd;
197
+ } else if (optionsOrCwd && typeof optionsOrCwd === 'object') {
198
+ cwd = optionsOrCwd.cwd || null;
199
+ onData = optionsOrCwd.onData || legacyOnData;
200
+ pty = Boolean(optionsOrCwd.pty);
201
+ timeoutMs = optionsOrCwd.timeout ? optionsOrCwd.timeout * 1000 : 0;
202
+ cleanAnsi = Boolean(optionsOrCwd.cleanAnsi);
203
+ }
204
+
154
205
  const conn = await this.getConnection(profile);
155
206
  const shellCmd = cwd ? `cd ${JSON.stringify(cwd)} && ${command}` : command;
156
207
 
208
+ const execOptions = {};
209
+ if (pty) {
210
+ execOptions.pty = {
211
+ rows: typeof pty === 'object' ? (pty.rows || 24) : 24,
212
+ cols: typeof pty === 'object' ? (pty.cols || 80) : 80,
213
+ term: typeof pty === 'object' ? (pty.term || 'xterm-256color') : 'xterm-256color'
214
+ };
215
+ }
216
+
157
217
  return new Promise((resolve, reject) => {
158
- conn.exec(shellCmd, (err, stream) => {
218
+ let timer = null;
219
+
220
+ conn.exec(shellCmd, execOptions, (err, stream) => {
159
221
  if (err) return reject(err);
160
222
  let stdout = '';
161
223
  let stderr = '';
224
+
225
+ if (timeoutMs > 0) {
226
+ timer = setTimeout(() => {
227
+ try {
228
+ stream.signal('SIGTERM');
229
+ stream.close();
230
+ } catch (_) {}
231
+ const errTimeout = new Error(`Command timed out after ${timeoutMs / 1000}s: ${command}`);
232
+ errTimeout.code = 'ETIMEDOUT';
233
+ errTimeout.stdout = cleanAnsi ? stripAnsi(stdout) : stdout;
234
+ errTimeout.stderr = cleanAnsi ? stripAnsi(stderr) : stderr;
235
+ reject(errTimeout);
236
+ }, timeoutMs);
237
+ }
238
+
162
239
  stream.on('data', (data) => {
163
240
  const str = data.toString();
164
241
  stdout += str;
165
242
  if (onData) onData({ type: 'stdout', data: str });
166
243
  });
167
- stream.stderr.on('data', (data) => {
244
+
245
+ stream.stderr?.on('data', (data) => {
168
246
  const str = data.toString();
169
247
  stderr += str;
170
248
  if (onData) onData({ type: 'stderr', data: str });
171
249
  });
250
+
172
251
  stream.on('close', (code, signal) => {
173
- resolve({ code: code ?? 0, signal, stdout, stderr });
252
+ if (timer) clearTimeout(timer);
253
+ resolve({
254
+ code: code ?? 0,
255
+ signal,
256
+ stdout: cleanAnsi ? stripAnsi(stdout) : stdout,
257
+ stderr: cleanAnsi ? stripAnsi(stderr) : stderr
258
+ });
259
+ });
260
+ });
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Direct Server-to-Server file transfer streaming between two SSH hosts.
266
+ * Reads from srcProfile SFTP stream and pipes directly into destProfile SFTP stream.
267
+ * No intermediate files touch the local DSH disk!
268
+ */
269
+ async transferFileBetweenHosts(srcProfile, srcPath, destProfile, destPath) {
270
+ const srcSftp = await this.getSftp(srcProfile);
271
+ const destSftp = await this.getSftp(destProfile);
272
+
273
+ return new Promise((resolve, reject) => {
274
+ const readStream = srcSftp.createReadStream(srcPath);
275
+ const writeStream = destSftp.createWriteStream(destPath);
276
+ let bytesTransferred = 0;
277
+
278
+ readStream.on('data', (chunk) => {
279
+ bytesTransferred += chunk.length;
280
+ });
281
+
282
+ writeStream.on('finish', () => {
283
+ resolve({
284
+ success: true,
285
+ bytesTransferred,
286
+ srcHost: srcProfile.host,
287
+ destHost: destProfile.host,
288
+ srcPath,
289
+ destPath
290
+ });
291
+ });
292
+
293
+ readStream.on('error', (err) => reject(new Error(`Read from ${srcProfile.host} failed: ${err.message}`)));
294
+ writeStream.on('error', (err) => reject(new Error(`Write to ${destProfile.host} failed: ${err.message}`)));
295
+
296
+ readStream.pipe(writeStream);
297
+ });
298
+ }
299
+
300
+ async createTerminalSession(profile, options = {}) {
301
+ const conn = await this.getConnection(profile);
302
+ const cols = options.cols || 80;
303
+ const rows = options.rows || 24;
304
+ const term = options.term || 'xterm-256color';
305
+ const sessionId = `term_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
306
+
307
+ return new Promise((resolve, reject) => {
308
+ conn.shell({ term, cols, rows }, (err, stream) => {
309
+ if (err) return reject(err);
310
+
311
+ const session = {
312
+ id: sessionId,
313
+ profileId: profile.id,
314
+ stream,
315
+ createdAt: Date.now(),
316
+ buffer: [],
317
+ subscribers: new Set(),
318
+ write(data) {
319
+ try { stream.write(data); } catch (_) {}
320
+ },
321
+ resize(c, r) {
322
+ try { stream.setWindow(r, c, 0, 0); } catch (_) {}
323
+ },
324
+ close() {
325
+ try { stream.end(); } catch (_) {}
326
+ }
327
+ };
328
+
329
+ stream.on('data', (d) => {
330
+ const chunk = d.toString('utf8');
331
+ session.buffer.push(chunk);
332
+ if (session.buffer.length > 500) session.buffer.shift();
333
+ for (const sub of session.subscribers) {
334
+ try { sub(chunk); } catch (_) {}
335
+ }
174
336
  });
337
+
338
+ stream.on('close', () => {
339
+ this.terminalSessions.delete(sessionId);
340
+ });
341
+
342
+ this.terminalSessions.set(sessionId, session);
343
+ resolve(session);
175
344
  });
176
345
  });
177
346
  }
178
347
 
348
+ getTerminalSession(sessionId) {
349
+ return this.terminalSessions.get(sessionId);
350
+ }
351
+
352
+ closeTerminalSession(sessionId) {
353
+ const session = this.terminalSessions.get(sessionId);
354
+ if (session) {
355
+ session.close();
356
+ this.terminalSessions.delete(sessionId);
357
+ return true;
358
+ }
359
+ return false;
360
+ }
361
+
179
362
  invalidate(profileId) {
180
363
  this.disconnect(profileId);
181
364
  }
@@ -195,5 +378,9 @@ export class SshService {
195
378
  }
196
379
  this.connections.clear();
197
380
  this.sftpSessions.clear();
381
+ for (const [id, term] of this.terminalSessions.entries()) {
382
+ try { term.close(); } catch (_) {}
383
+ }
384
+ this.terminalSessions.clear();
198
385
  }
199
386
  }
package/lib/tools.js CHANGED
@@ -1,4 +1,4 @@
1
- export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile) {
1
+ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService) {
2
2
  if (!ctx.tools || typeof ctx.tools.register !== 'function') {
3
3
  return;
4
4
  }
@@ -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 res = await sshService.exec(profile, args.command, dir);
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 conflict-aware synchronization between local mirror and remote server',
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' },
@@ -135,4 +149,102 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
135
149
  }
136
150
  }
137
151
  });
152
+
153
+ // 5. remote_docker
154
+ if (dockerService) {
155
+ ctx.tools.register({
156
+ name: 'remote_docker',
157
+ description: 'Manage Docker/Podman containers and Docker Compose on remote host',
158
+ parameters: {
159
+ action: { type: 'string', required: true, description: 'list, logs, start, stop, restart, compose' },
160
+ containerId: { type: 'string', description: 'Container ID or name' },
161
+ tail: { type: 'number', description: 'Number of log lines to retrieve (default 50)' },
162
+ composeAction: { type: 'string', description: 'Compose action: up -d, down, ps, restart' },
163
+ composeCwd: { type: 'string', description: 'Directory containing docker-compose.yml' }
164
+ },
165
+ output: genericOutput,
166
+ async execute(args) {
167
+ const profile = getActiveProfile();
168
+ if (!profile) throw new Error('No active remote workspace profile selected');
169
+
170
+ switch (args.action) {
171
+ case 'list':
172
+ return await dockerService.listContainers(profile, true);
173
+ case 'logs':
174
+ if (!args.containerId) throw new Error('containerId required for logs');
175
+ return await dockerService.containerLogs(profile, args.containerId, args.tail || 50);
176
+ case 'start':
177
+ case 'stop':
178
+ case 'restart':
179
+ if (!args.containerId) throw new Error(`containerId required for ${args.action}`);
180
+ return await dockerService.containerAction(profile, args.containerId, args.action);
181
+ case 'compose':
182
+ const dir = args.composeCwd || profile.remoteWorkspace;
183
+ return await dockerService.composeAction(profile, dir, args.composeAction || 'ps');
184
+ default:
185
+ throw new Error(`Unknown docker action: ${args.action}`);
186
+ }
187
+ }
188
+ });
189
+ }
190
+
191
+ // 6. remote_service
192
+ ctx.tools.register({
193
+ name: 'remote_service',
194
+ description: 'Inspect and manage Linux systemd or PM2 services on the remote machine',
195
+ parameters: {
196
+ action: { type: 'string', required: true, description: 'status, start, stop, restart, logs' },
197
+ service: { type: 'string', required: true, description: 'Service name (e.g. nginx, redis, my-app)' },
198
+ manager: { type: 'string', description: 'systemd (default) or pm2' },
199
+ lines: { type: 'number', description: 'Number of log lines for logs action (default 40)' }
200
+ },
201
+ output: genericOutput,
202
+ async execute(args) {
203
+ const profile = getActiveProfile();
204
+ if (!profile) throw new Error('No active remote workspace profile selected');
205
+
206
+ const mgr = args.manager || 'systemd';
207
+ const svc = args.service;
208
+ const lines = args.lines || 40;
209
+
210
+ let cmd = '';
211
+ if (mgr === 'pm2') {
212
+ if (args.action === 'logs') cmd = `pm2 logs ${JSON.stringify(svc)} --lines ${lines} --nostream`;
213
+ else cmd = `pm2 ${args.action} ${JSON.stringify(svc)}`;
214
+ } else {
215
+ // systemd
216
+ if (args.action === 'logs') cmd = `journalctl -u ${JSON.stringify(svc)} -n ${lines} --no-pager`;
217
+ else if (args.action === 'status') cmd = `systemctl status ${JSON.stringify(svc)} --no-pager`;
218
+ else cmd = `sudo systemctl ${args.action} ${JSON.stringify(svc)} || systemctl ${args.action} ${JSON.stringify(svc)}`;
219
+ }
220
+
221
+ const res = await sshService.exec(profile, cmd, { cleanAnsi: true });
222
+ return {
223
+ exitCode: res.code,
224
+ output: res.stdout || res.stderr
225
+ };
226
+ }
227
+ });
228
+
229
+ // 7. remote_transfer
230
+ ctx.tools.register({
231
+ name: 'remote_transfer',
232
+ description: 'Transfer a file directly between two configured remote servers via in-memory SFTP streams',
233
+ parameters: {
234
+ sourcePath: { type: 'string', required: true, description: 'File path on the source remote server' },
235
+ destPath: { type: 'string', required: true, description: 'File path on the destination remote server' },
236
+ destProfileId: { type: 'string', required: true, description: 'Target remote profile ID' },
237
+ sourceProfileId: { type: 'string', description: 'Source remote profile ID (defaults to active profile)' }
238
+ },
239
+ output: genericOutput,
240
+ async execute(args) {
241
+ const srcProfile = args.sourceProfileId && getProfileById ? getProfileById(args.sourceProfileId) : getActiveProfile();
242
+ if (!srcProfile) throw new Error('Source profile not found or no active profile');
243
+
244
+ const destProfile = getProfileById ? getProfileById(args.destProfileId) : null;
245
+ if (!destProfile) throw new Error(`Destination profile "${args.destProfileId}" not found`);
246
+
247
+ return await sshService.transferFileBetweenHosts(srcProfile, args.sourcePath, destProfile, args.destPath);
248
+ }
249
+ });
138
250
  }
@@ -6,75 +6,112 @@ export class TunnelService {
6
6
  */
7
7
  constructor(sshService) {
8
8
  this.sshService = sshService;
9
- /** @type {Map<string, { server?: net.Server, profileId: string, localPort: number, targetHost: string, remotePort: number }>} */
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 Local Port Forwarding: 127.0.0.1:localPort -> Remote:targetHost:targetPort
25
+ * Start local port forwarding tunnel: LocalPort -> RemoteHost:RemotePort with live telemetry
15
26
  */
16
- async startLocalTunnel(profile, localPort, targetHost = '127.0.0.1', targetPort) {
17
- const tunnelId = `${profile.id}:${localPort}->${targetPort}`;
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, message: 'Tunnel already active', tunnelId, localPort, remotePort: targetPort };
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 server = net.createServer((socket) => {
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
- socket.remotePort || 0,
55
+ clientSocket.remotePort || 0,
29
56
  targetHost,
30
- targetPort,
57
+ remotePort,
31
58
  (err, stream) => {
32
59
  if (err) {
33
- try { socket.destroy(); } catch (_) {}
60
+ tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
61
+ clientSocket.destroy();
34
62
  return;
35
63
  }
36
- stream.on('error', () => {
37
- try { socket.destroy(); } catch (_) {}
64
+
65
+ // Telemetry: measure bytes flowing through the tunnel
66
+ clientSocket.on('data', (d) => {
67
+ tunnelMeta.bytesRx += d.length;
38
68
  });
39
- socket.on('error', () => {
40
- try { stream.destroy(); } catch (_) {}
69
+ stream.on('data', (d) => {
70
+ tunnelMeta.bytesTx += d.length;
41
71
  });
42
- socket.pipe(stream).pipe(socket);
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);
43
83
  }
44
84
  );
45
85
  });
46
86
 
87
+ tunnelMeta.server = server;
88
+
47
89
  server.on('error', (err) => {
48
90
  this.activeTunnels.delete(tunnelId);
49
91
  reject(err);
50
92
  });
51
93
 
52
94
  server.listen(localPort, '127.0.0.1', () => {
53
- this.activeTunnels.set(tunnelId, {
54
- server,
55
- profileId: profile.id,
95
+ this.activeTunnels.set(tunnelId, tunnelMeta);
96
+ resolve({
97
+ success: true,
98
+ tunnelId,
56
99
  localPort,
57
100
  targetHost,
58
- remotePort: targetPort
101
+ remotePort
59
102
  });
60
- resolve({ success: true, tunnelId, localPort, targetHost, remotePort: targetPort });
61
103
  });
62
104
  });
63
105
  }
64
106
 
65
- /**
66
- * Stop active tunnel
67
- */
68
107
  stopTunnel(tunnelId) {
69
108
  const tunnel = this.activeTunnels.get(tunnelId);
70
- if (tunnel && tunnel.server) {
71
- try {
72
- tunnel.server.close();
73
- } catch (_) {}
74
- this.activeTunnels.delete(tunnelId);
75
- return true;
76
- }
77
- return false;
109
+ if (!tunnel) return false;
110
+ try {
111
+ tunnel.server.close();
112
+ } catch (_) {}
113
+ this.activeTunnels.delete(tunnelId);
114
+ return true;
78
115
  }
79
116
 
80
117
  listActiveTunnels() {
@@ -82,16 +119,22 @@ export class TunnelService {
82
119
  id,
83
120
  profileId: t.profileId,
84
121
  localPort: t.localPort,
85
- targetHost: t.targetHost || '127.0.0.1',
86
- remotePort: t.remotePort
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
87
130
  }));
88
131
  }
89
132
 
90
133
  stopAll() {
91
- for (const [id, t] of this.activeTunnels.entries()) {
92
- if (t.server) {
93
- try { t.server.close(); } catch (_) {}
94
- }
134
+ for (const [id, tunnel] of this.activeTunnels.entries()) {
135
+ try {
136
+ tunnel.server.close();
137
+ } catch (_) {}
95
138
  }
96
139
  this.activeTunnels.clear();
97
140
  }