@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.
- package/lib/client.js +1991 -1278
- package/lib/docker-service.js +93 -0
- package/lib/health-service.js +117 -0
- package/lib/index.js +116 -10
- package/lib/mirror-sync-service.js +54 -13
- package/lib/remote-fs-service.js +58 -7
- package/lib/routes.js +339 -46
- package/lib/ssh-service.js +207 -20
- package/lib/tools.js +124 -12
- package/lib/tunnel-service.js +79 -36
- package/lib/vault-service.js +184 -0
- package/lib/watcher-service.js +111 -0
- package/package.json +1 -1
package/lib/ssh-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
58
|
+
* Connect to remote machine with pooling, keep-alive, auto-reconnect and ProxyJump
|
|
38
59
|
*/
|
|
39
|
-
async getConnection(
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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' },
|
|
@@ -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
|
}
|
package/lib/tunnel-service.js
CHANGED
|
@@ -6,75 +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
|
-
|
|
37
|
-
|
|
64
|
+
|
|
65
|
+
// Telemetry: measure bytes flowing through the tunnel
|
|
66
|
+
clientSocket.on('data', (d) => {
|
|
67
|
+
tunnelMeta.bytesRx += d.length;
|
|
38
68
|
});
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
stream.on('data', (d) => {
|
|
70
|
+
tunnelMeta.bytesTx += d.length;
|
|
41
71
|
});
|
|
42
|
-
|
|
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
|
-
|
|
55
|
-
|
|
95
|
+
this.activeTunnels.set(tunnelId, tunnelMeta);
|
|
96
|
+
resolve({
|
|
97
|
+
success: true,
|
|
98
|
+
tunnelId,
|
|
56
99
|
localPort,
|
|
57
100
|
targetHost,
|
|
58
|
-
remotePort
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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,
|
|
92
|
-
|
|
93
|
-
|
|
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
|
}
|