@goodandready/dsh-remote-workspace 0.3.8 → 0.3.9
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/README.md +45 -0
- package/README.ru.md +45 -0
- package/README.zh.md +45 -0
- package/lib/agent-endpoint.js +16 -0
- package/lib/client.js +796 -14
- package/lib/cluster-service.js +57 -0
- package/lib/exec-retry.js +24 -0
- package/lib/file-transfer.js +56 -0
- package/lib/host-projection.js +42 -0
- package/lib/index.js +23 -4
- package/lib/jump-chain.js +56 -0
- package/lib/poll-gate.js +5 -0
- package/lib/profile-groups.js +60 -0
- package/lib/proxy-command.js +53 -0
- package/lib/remote-fs-service.js +25 -0
- package/lib/routes/files.js +75 -0
- package/lib/routes/profiles.js +82 -8
- package/lib/routes/terminal.js +19 -0
- package/lib/ssh-config-parser.js +111 -49
- package/lib/ssh-service.js +180 -21
- package/lib/terminal-font.js +12 -0
- package/lib/tools.js +52 -1
- package/lib/tunnel-service.js +3 -0
- package/lib/types.js +7 -0
- package/lib/workspace-mount.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function filterClusterProfiles(profiles, filter = {}) {
|
|
2
|
+
const aliases = new Set((filter.aliases || []).map((item) => String(item)));
|
|
3
|
+
const tags = (filter.tags || []).map((item) => String(item).toLowerCase()).filter(Boolean);
|
|
4
|
+
const environment = String(filter.environment || '').trim().toLowerCase();
|
|
5
|
+
return (profiles || []).filter((profile) => {
|
|
6
|
+
if (aliases.size && !aliases.has(String(profile.id)) && !aliases.has(String(profile.name || ''))) return false;
|
|
7
|
+
if (environment && String(profile.environment || '').toLowerCase() !== environment) return false;
|
|
8
|
+
if (tags.length) {
|
|
9
|
+
const have = (Array.isArray(profile.tags) ? profile.tags : []).map((item) => String(item).toLowerCase());
|
|
10
|
+
if (!tags.every((tag) => have.includes(tag))) return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runCluster(profiles, command, options = {}) {
|
|
17
|
+
const list = profiles || [];
|
|
18
|
+
if (!list.length) return [];
|
|
19
|
+
const limit = Math.max(1, Math.min(Number(options.maxWorkers) || 8, list.length));
|
|
20
|
+
const exec = options.exec;
|
|
21
|
+
const results = new Array(list.length);
|
|
22
|
+
let cursor = 0;
|
|
23
|
+
const worker = async () => {
|
|
24
|
+
while (cursor < list.length) {
|
|
25
|
+
const index = cursor;
|
|
26
|
+
cursor += 1;
|
|
27
|
+
const profile = list[index];
|
|
28
|
+
const started = Date.now();
|
|
29
|
+
try {
|
|
30
|
+
const res = await exec(profile, command);
|
|
31
|
+
results[index] = {
|
|
32
|
+
alias: profile.name || profile.id || '',
|
|
33
|
+
host: profile.host || '',
|
|
34
|
+
success: (res.code ?? 0) === 0,
|
|
35
|
+
exitCode: res.code ?? 0,
|
|
36
|
+
durationMs: Date.now() - started,
|
|
37
|
+
stdout: res.stdout || '',
|
|
38
|
+
stderr: res.stderr || '',
|
|
39
|
+
error: ''
|
|
40
|
+
};
|
|
41
|
+
} catch (err) {
|
|
42
|
+
results[index] = {
|
|
43
|
+
alias: profile.name || profile.id || '',
|
|
44
|
+
host: profile.host || '',
|
|
45
|
+
success: false,
|
|
46
|
+
exitCode: null,
|
|
47
|
+
durationMs: Date.now() - started,
|
|
48
|
+
stdout: '',
|
|
49
|
+
stderr: '',
|
|
50
|
+
error: err && err.message ? err.message : 'failed'
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function isRetryableConnectionError(err) {
|
|
2
|
+
if (!err || err.commandTimeout) return false;
|
|
3
|
+
if (err.stdout) return false;
|
|
4
|
+
const code = err.code || '';
|
|
5
|
+
if (['ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENOTFOUND', 'EPIPE'].includes(code)) return true;
|
|
6
|
+
return /connection reset|not connected|No response from server|socket hang up/i.test(String(err.message || ''));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function runWithReconnect(run, options = {}) {
|
|
10
|
+
const attempts = options.idempotent === false ? 1 : 3;
|
|
11
|
+
const wait = options.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
12
|
+
let lastError = null;
|
|
13
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
14
|
+
try {
|
|
15
|
+
return await run();
|
|
16
|
+
} catch (err) {
|
|
17
|
+
lastError = err;
|
|
18
|
+
if (attempt >= attempts || !isRetryableConnectionError(err)) throw err;
|
|
19
|
+
if (options.onRetry) options.onRetry(err, attempt);
|
|
20
|
+
await wait(50 * attempt);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw lastError;
|
|
24
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const MAX_BYTES = 512 * 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export function transferPercent(loaded, total) {
|
|
4
|
+
const done = Number(loaded) || 0;
|
|
5
|
+
const size = Number(total) || 0;
|
|
6
|
+
if (size <= 0) return 0;
|
|
7
|
+
return Math.max(0, Math.min(100, Math.round((done / size) * 100)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function attachmentName(filePath) {
|
|
11
|
+
const base = String(filePath || '').split('/').filter(Boolean).pop() || 'download';
|
|
12
|
+
const clean = base.replace(/[\r\n"\\]/g, '');
|
|
13
|
+
if (!clean || clean === '.' || clean === '..') return 'download';
|
|
14
|
+
return clean.slice(0, 180);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function contentDisposition(filePath) {
|
|
18
|
+
const name = attachmentName(filePath);
|
|
19
|
+
const ascii = name.replace(/[^\x20-\x7E]/g, '_') || 'download';
|
|
20
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pipeWithProgress(source, dest) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let loaded = 0;
|
|
26
|
+
let settled = false;
|
|
27
|
+
const finish = (err) => {
|
|
28
|
+
if (settled) return;
|
|
29
|
+
settled = true;
|
|
30
|
+
if (err) reject(err);
|
|
31
|
+
else resolve(loaded);
|
|
32
|
+
};
|
|
33
|
+
source.on('data', (chunk) => {
|
|
34
|
+
loaded += chunk.length;
|
|
35
|
+
if (loaded > MAX_BYTES) {
|
|
36
|
+
const err = new Error('File exceeds the 512MB transfer limit');
|
|
37
|
+
err.code = 'LIMIT';
|
|
38
|
+
source.destroy();
|
|
39
|
+
dest.destroy();
|
|
40
|
+
finish(err);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
source.on('error', finish);
|
|
44
|
+
dest.on('error', finish);
|
|
45
|
+
dest.on('finish', () => finish());
|
|
46
|
+
source.on('aborted', () => {
|
|
47
|
+
const err = new Error('Transfer aborted');
|
|
48
|
+
err.code = 'ABORTED';
|
|
49
|
+
dest.destroy();
|
|
50
|
+
finish(err);
|
|
51
|
+
});
|
|
52
|
+
source.pipe(dest);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { MAX_BYTES };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const SECRET = ['password', 'privateKey', 'privateKeyPath', 'passphrase', 'agentPath', 'proxyCommand'];
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One host row safe to place in a model context.
|
|
5
|
+
* @param {any} profile
|
|
6
|
+
*/
|
|
7
|
+
export function toAgentHostRow(profile) {
|
|
8
|
+
const jump = Boolean(
|
|
9
|
+
(typeof profile?.jumpHostId === 'string' && profile.jumpHostId.trim())
|
|
10
|
+
|| (Array.isArray(profile?.jumpHosts) && profile.jumpHosts.length)
|
|
11
|
+
);
|
|
12
|
+
const proxy = Boolean(typeof profile?.proxyCommand === 'string' && profile.proxyCommand.trim());
|
|
13
|
+
const access = [jump ? 'jump' : '', proxy ? 'proxy' : ''].filter(Boolean).join(',') || 'direct';
|
|
14
|
+
return {
|
|
15
|
+
alias: String(profile?.name || profile?.id || ''),
|
|
16
|
+
host: String(profile?.host || ''),
|
|
17
|
+
port: profile?.port || 22,
|
|
18
|
+
user: String(profile?.username || ''),
|
|
19
|
+
auth: String(profile?.authType || 'key'),
|
|
20
|
+
access,
|
|
21
|
+
env: String(profile?.environment || ''),
|
|
22
|
+
tags: Array.isArray(profile?.tags) ? profile.tags.map((item) => String(item)).join(',') : '',
|
|
23
|
+
description: String(profile?.description || '')
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {any[]} profiles
|
|
29
|
+
*/
|
|
30
|
+
export function renderHostTable(profiles) {
|
|
31
|
+
const header = 'alias | host | port | user | auth | jump/proxy | env | tags | description';
|
|
32
|
+
const lines = (profiles || []).map((profile) => {
|
|
33
|
+
const row = toAgentHostRow(profile);
|
|
34
|
+
return [row.alias, row.host, row.port, row.user, row.auth, row.access, row.env, row.tags, row.description].join(' | ');
|
|
35
|
+
});
|
|
36
|
+
return [header, ...lines].join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function hostProjectionLeaks(value) {
|
|
40
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
41
|
+
return SECRET.some((key) => text.includes(key));
|
|
42
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { normalizeTerminalFont } from './terminal-font.js';
|
|
2
3
|
import { VaultService } from './vault-service.js';
|
|
3
4
|
import { SshService } from './ssh-service.js';
|
|
4
5
|
import { RemoteFsService } from './remote-fs-service.js';
|
|
@@ -28,17 +29,25 @@ export const Config = z.object({
|
|
|
28
29
|
port: z.number().default(22),
|
|
29
30
|
username: z.string().default('root'),
|
|
30
31
|
authType: z.string().default('key'),
|
|
32
|
+
agentPath: z.string().default(''),
|
|
31
33
|
privateKeyPath: z.string().default(''),
|
|
32
34
|
privateKey: z.string().default(''),
|
|
33
35
|
passphrase: z.string().default(''),
|
|
34
36
|
password: z.string().default(''),
|
|
35
37
|
remoteWorkspace: z.string().default(''),
|
|
36
38
|
localMirrorPath: z.string().default(''),
|
|
37
|
-
jumpHostId: z.string().default('')
|
|
39
|
+
jumpHostId: z.string().default(''),
|
|
40
|
+
jumpHosts: z.array(z.string()).default([]),
|
|
41
|
+
proxyCommand: z.string().default(''),
|
|
42
|
+
environment: z.string().default(''),
|
|
43
|
+
tags: z.array(z.string()).default([]),
|
|
44
|
+
location: z.string().default(''),
|
|
45
|
+
description: z.string().default('')
|
|
38
46
|
})).default([]).description('List of configured remote machines and workspaces.'),
|
|
39
47
|
activeProfileId: z.string().default('').description('ID of active remote profile.'),
|
|
40
48
|
autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.'),
|
|
41
|
-
alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.')
|
|
49
|
+
alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.'),
|
|
50
|
+
terminalFontFamily: z.string().default('').description('CSS font family used by the terminal. Empty keeps the default monospace stack.')
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
export function apply(ctx, config = {}) {
|
|
@@ -48,11 +57,14 @@ export function apply(ctx, config = {}) {
|
|
|
48
57
|
profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
|
|
49
58
|
activeProfileId: cfg.activeProfileId || null,
|
|
50
59
|
autoSyncActive: Boolean(cfg.autoSyncActive),
|
|
51
|
-
alertMonitoringActive: Boolean(cfg.alertMonitoringActive)
|
|
60
|
+
alertMonitoringActive: Boolean(cfg.alertMonitoringActive),
|
|
61
|
+
terminalFontFamily: typeof cfg.terminalFontFamily === 'string' ? cfg.terminalFontFamily : ''
|
|
52
62
|
};
|
|
53
63
|
|
|
54
64
|
const vaultService = new VaultService();
|
|
55
65
|
const sshService = new SshService(ctx, vaultService);
|
|
66
|
+
const idleTimer = setInterval(() => sshService.sweepIdle(), 60 * 1000);
|
|
67
|
+
idleTimer.unref?.();
|
|
56
68
|
const remoteFs = new RemoteFsService(sshService);
|
|
57
69
|
const mirrorSync = new MirrorSyncService(remoteFs, sshService);
|
|
58
70
|
const tunnelService = new TunnelService(sshService);
|
|
@@ -111,6 +123,12 @@ export function apply(ctx, config = {}) {
|
|
|
111
123
|
return p ? vaultService.hydrateProfile(p) : undefined;
|
|
112
124
|
},
|
|
113
125
|
isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
|
|
126
|
+
getTerminalFont() { return currentConfig.terminalFontFamily || ''; },
|
|
127
|
+
async setTerminalFont(fontFamily) {
|
|
128
|
+
currentConfig.terminalFontFamily = normalizeTerminalFont(fontFamily);
|
|
129
|
+
await persistConfig('terminal-font');
|
|
130
|
+
return currentConfig.terminalFontFamily;
|
|
131
|
+
},
|
|
114
132
|
async setAutoSyncEnabled(enabled) {
|
|
115
133
|
currentConfig.autoSyncActive = Boolean(enabled);
|
|
116
134
|
const active = store.getActiveProfile();
|
|
@@ -264,7 +282,8 @@ export function apply(ctx, config = {}) {
|
|
|
264
282
|
dockerService,
|
|
265
283
|
diagnoseService,
|
|
266
284
|
envService,
|
|
267
|
-
tarSyncService
|
|
285
|
+
tarSyncService,
|
|
286
|
+
() => store.getProfiles()
|
|
268
287
|
);
|
|
269
288
|
|
|
270
289
|
// API Routes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jump ids from an explicit list, or from a comma-separated jumpHostId.
|
|
3
|
+
* @param {any} profile
|
|
4
|
+
* @returns {string[]}
|
|
5
|
+
*/
|
|
6
|
+
export function resolveJumpHops(profile) {
|
|
7
|
+
if (Array.isArray(profile?.jumpHosts) && profile.jumpHosts.length) {
|
|
8
|
+
return profile.jumpHosts.map((item) => String(item).trim()).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
if (typeof profile?.jumpHostId === 'string' && profile.jumpHostId.trim()) {
|
|
11
|
+
return profile.jumpHostId.split(',').map((item) => item.trim()).filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {any} client
|
|
18
|
+
* @param {string} host
|
|
19
|
+
* @param {number} port
|
|
20
|
+
*/
|
|
21
|
+
export function forwardOut(client, host, port) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
client.forwardOut('127.0.0.1', 0, host, port || 22, (err, stream) => {
|
|
24
|
+
if (err) reject(err);
|
|
25
|
+
else resolve(stream);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Connect through bastions in order. Only clients created after the first hop
|
|
32
|
+
* are released with the target; the first hop stays in the shared pool.
|
|
33
|
+
*/
|
|
34
|
+
export async function openJumpChain(target, hopProfiles, deps) {
|
|
35
|
+
if (!hopProfiles.length) throw new Error('Jump chain is empty');
|
|
36
|
+
const intermediates = [];
|
|
37
|
+
const release = () => {
|
|
38
|
+
for (const extra of intermediates) {
|
|
39
|
+
try { extra.end(); } catch { /* already closed */ }
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
let client = await deps.connectPooled(hopProfiles[0]);
|
|
44
|
+
for (let i = 1; i < hopProfiles.length; i += 1) {
|
|
45
|
+
const hop = hopProfiles[i];
|
|
46
|
+
const stream = await forwardOut(client, hop.host, hop.port || 22);
|
|
47
|
+
client = await deps.connectDedicated(hop, stream);
|
|
48
|
+
intermediates.push(client);
|
|
49
|
+
}
|
|
50
|
+
const sock = await forwardOut(client, target.host, target.port || 22);
|
|
51
|
+
return { sock, release };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
release();
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/lib/poll-gate.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function normalizeTags(value) {
|
|
2
|
+
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
|
3
|
+
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function bucket(map, key, profile) {
|
|
7
|
+
const name = key || 'ungrouped';
|
|
8
|
+
if (!map.has(name)) map.set(name, []);
|
|
9
|
+
map.get(name).push(profile);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function groupProfiles(profiles, mode) {
|
|
13
|
+
const list = Array.isArray(profiles) ? profiles : [];
|
|
14
|
+
if (mode === 'environment') {
|
|
15
|
+
const map = new Map();
|
|
16
|
+
for (const profile of list) bucket(map, String(profile.environment || '').trim(), profile);
|
|
17
|
+
return [...map.entries()].map(([key, items]) => ({ key, items }));
|
|
18
|
+
}
|
|
19
|
+
if (mode === 'tag') {
|
|
20
|
+
const map = new Map();
|
|
21
|
+
for (const profile of list) {
|
|
22
|
+
const tags = normalizeTags(profile.tags);
|
|
23
|
+
if (!tags.length) bucket(map, '', profile);
|
|
24
|
+
else for (const tag of tags) bucket(map, tag, profile);
|
|
25
|
+
}
|
|
26
|
+
return [...map.entries()].map(([key, items]) => ({ key, items }));
|
|
27
|
+
}
|
|
28
|
+
return [{ key: 'all', items: list }];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function testProfileGroup(profiles, testOne) {
|
|
32
|
+
const results = [];
|
|
33
|
+
const queue = [...(profiles || [])];
|
|
34
|
+
const workers = Array.from({ length: Math.min(4, Math.max(queue.length, 1)) }, async () => {
|
|
35
|
+
while (queue.length) {
|
|
36
|
+
const profile = queue.shift();
|
|
37
|
+
if (!profile) return;
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
try {
|
|
40
|
+
const result = await testOne(profile);
|
|
41
|
+
results.push({
|
|
42
|
+
id: profile.id,
|
|
43
|
+
name: profile.name || profile.host,
|
|
44
|
+
ok: true,
|
|
45
|
+
latencyMs: result.latencyMs ?? result.latency ?? (Date.now() - started)
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
results.push({
|
|
49
|
+
id: profile.id,
|
|
50
|
+
name: profile.name || profile.host,
|
|
51
|
+
ok: false,
|
|
52
|
+
error: err && err.message ? err.message : 'Connection failed'
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
if (!queue.length) return results;
|
|
58
|
+
await Promise.all(workers);
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { Duplex } from 'node:stream';
|
|
3
|
+
|
|
4
|
+
const TOKEN = /%[%hprn]/g;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Expand OpenSSH ProxyCommand tokens. %% stays a literal percent.
|
|
8
|
+
* @param {string} template
|
|
9
|
+
* @param {{ host?: string, port?: number|string, user?: string, alias?: string }} tokens
|
|
10
|
+
*/
|
|
11
|
+
export function expandProxyCommand(template, tokens = {}) {
|
|
12
|
+
const map = {
|
|
13
|
+
'%h': String(tokens.host ?? ''),
|
|
14
|
+
'%p': String(tokens.port ?? ''),
|
|
15
|
+
'%r': String(tokens.user ?? ''),
|
|
16
|
+
'%n': String(tokens.alias ?? ''),
|
|
17
|
+
'%%': '%'
|
|
18
|
+
};
|
|
19
|
+
return String(template ?? '').replace(TOKEN, (token) => (token in map ? map[token] : token));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run a ProxyCommand and expose its stdio as a duplex socket for ssh2.
|
|
24
|
+
* POSIX uses `$SHELL -c`; Windows uses `cmd.exe /d /s /c`.
|
|
25
|
+
* @param {string} command
|
|
26
|
+
*/
|
|
27
|
+
export function openProxyCommand(command) {
|
|
28
|
+
const windows = process.platform === 'win32';
|
|
29
|
+
const shell = windows ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
|
|
30
|
+
const args = windows ? ['/d', '/s', '/c', command] : ['-c', command];
|
|
31
|
+
const child = spawn(shell, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
32
|
+
const sock = Duplex.from({ writable: child.stdin, readable: child.stdout });
|
|
33
|
+
let stopped = false;
|
|
34
|
+
|
|
35
|
+
const stop = () => {
|
|
36
|
+
if (stopped) return;
|
|
37
|
+
stopped = true;
|
|
38
|
+
if (child.exitCode !== null || child.signalCode) return;
|
|
39
|
+
child.kill('SIGTERM');
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
|
|
42
|
+
}, 2000);
|
|
43
|
+
timer.unref?.();
|
|
44
|
+
child.once('exit', () => clearTimeout(timer));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
sock.on('close', stop);
|
|
48
|
+
child.on('error', (err) => sock.destroy(err));
|
|
49
|
+
child.on('exit', () => {
|
|
50
|
+
if (!sock.destroyed) sock.destroy();
|
|
51
|
+
});
|
|
52
|
+
return { sock, child, stop };
|
|
53
|
+
}
|
package/lib/remote-fs-service.js
CHANGED
|
@@ -117,6 +117,31 @@ export class RemoteFsService {
|
|
|
117
117
|
return sftp.createReadStream(remotePath);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
async beginUpload(profile, remotePath) {
|
|
121
|
+
const sftp = await this.sshService.getSftp(profile);
|
|
122
|
+
const tempPath = `${remotePath}.dsh-tmp.${Date.now()}`;
|
|
123
|
+
const stream = sftp.createWriteStream(tempPath);
|
|
124
|
+
return {
|
|
125
|
+
stream,
|
|
126
|
+
commit() {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
sftp.rename(tempPath, remotePath, (err) => {
|
|
129
|
+
if (err) {
|
|
130
|
+
sftp.unlink(tempPath, () => {});
|
|
131
|
+
return reject(err);
|
|
132
|
+
}
|
|
133
|
+
resolve();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
abort() {
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
sftp.unlink(tempPath, () => resolve());
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
120
145
|
/**
|
|
121
146
|
* Atomic file write using temp file and rename
|
|
122
147
|
*/
|
package/lib/routes/files.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
2
|
+
import { contentDisposition, pipeWithProgress, MAX_BYTES } from '../file-transfer.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
function transferTarget(req, store) {
|
|
6
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
7
|
+
const profileId = url.searchParams.get('profileId') || '';
|
|
8
|
+
const filePath = url.searchParams.get('filePath') || '';
|
|
9
|
+
if (!filePath || filePath.length > 1024 || filePath.includes('\0')) {
|
|
10
|
+
const err = new Error('A file path is required');
|
|
11
|
+
err.status = 400;
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
15
|
+
if (!profile) {
|
|
16
|
+
const err = new Error('Profile not found');
|
|
17
|
+
err.status = 404;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
return { profile, filePath };
|
|
21
|
+
}
|
|
2
22
|
|
|
3
23
|
export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshService }) {
|
|
4
24
|
// 7. POST /dsh-remote-workspace/browse
|
|
@@ -75,6 +95,61 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
75
95
|
}
|
|
76
96
|
}), 'dsh-remote-workspace: /file/save');
|
|
77
97
|
|
|
98
|
+
sctx.effect(() => webServer.register({
|
|
99
|
+
kind: 'exact',
|
|
100
|
+
path: '/dsh-remote-workspace/file/download',
|
|
101
|
+
handler: async (req, res) => {
|
|
102
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
103
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
104
|
+
try {
|
|
105
|
+
const { profile, filePath } = transferTarget(req, store);
|
|
106
|
+
const stat = await remoteFs.stat(profile, filePath);
|
|
107
|
+
if (!stat.isFile) return writeJson(res, 400, { ok: false, error: 'Only a file can be downloaded' });
|
|
108
|
+
if (stat.size > MAX_BYTES) return writeJson(res, 413, { ok: false, error: 'File exceeds the 512MB transfer limit' });
|
|
109
|
+
const stream = await remoteFs.createReadStream(profile, filePath);
|
|
110
|
+
const stop = () => { try { stream.destroy(); } catch (err) { /* best-effort cleanup */ } };
|
|
111
|
+
req.on('close', stop);
|
|
112
|
+
stream.on('error', () => stop());
|
|
113
|
+
res.writeHead(200, {
|
|
114
|
+
'Content-Type': 'application/octet-stream',
|
|
115
|
+
'Content-Length': stat.size,
|
|
116
|
+
'Content-Disposition': contentDisposition(filePath),
|
|
117
|
+
'Cache-Control': 'no-store'
|
|
118
|
+
});
|
|
119
|
+
stream.pipe(res);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (!res.headersSent) writeJson(res, err.status || 500, { ok: false, error: err.message });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}), 'dsh-remote-workspace: /file/download');
|
|
125
|
+
|
|
126
|
+
sctx.effect(() => webServer.register({
|
|
127
|
+
kind: 'exact',
|
|
128
|
+
path: '/dsh-remote-workspace/file/upload',
|
|
129
|
+
handler: async (req, res) => {
|
|
130
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
131
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
132
|
+
let upload = null;
|
|
133
|
+
try {
|
|
134
|
+
const { profile, filePath } = transferTarget(req, store);
|
|
135
|
+
upload = await remoteFs.beginUpload(profile, filePath);
|
|
136
|
+
const bytes = await pipeWithProgress(req, upload.stream);
|
|
137
|
+
await upload.commit();
|
|
138
|
+
if (remoteFs.invalidateCache) remoteFs.invalidateCache(profile.id);
|
|
139
|
+
writeJson(res, 200, { ok: true, bytes });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (upload) {
|
|
142
|
+
try { await upload.abort(); } catch (abortErr) { /* best-effort cleanup */ }
|
|
143
|
+
}
|
|
144
|
+
if (!res.headersSent) {
|
|
145
|
+
const status = err.code === 'ABORTED' ? 499 : (err.code === 'LIMIT' ? 413 : (err.status || 500));
|
|
146
|
+
writeJson(res, status, { ok: false, error: err.message });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}), 'dsh-remote-workspace: /file/upload');
|
|
151
|
+
|
|
152
|
+
|
|
78
153
|
// 26. POST /dsh-remote-workspace/transfer
|
|
79
154
|
sctx.effect(() => webServer.register({
|
|
80
155
|
kind: 'exact',
|
package/lib/routes/profiles.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
2
2
|
import { SshConfigParser } from '../ssh-config-parser.js';
|
|
3
|
+
import { testProfileGroup } from '../profile-groups.js';
|
|
4
|
+
import { filterClusterProfiles, runCluster } from '../cluster-service.js';
|
|
3
5
|
|
|
4
6
|
function maskProfile(p) {
|
|
5
7
|
if (!p || typeof p !== 'object') return p;
|
|
@@ -24,8 +26,10 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
24
26
|
const tunnels = tunnelService.listActiveTunnels();
|
|
25
27
|
const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
|
|
26
28
|
const alerts = alertService ? alertService.getAlerts() : [];
|
|
29
|
+
const authPrompts = sshService.listKeyboardPrompts ? sshService.listKeyboardPrompts() : [];
|
|
27
30
|
const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : maskProfile(p)));
|
|
28
|
-
|
|
31
|
+
const terminalFontFamily = store.getTerminalFont ? store.getTerminalFont() : '';
|
|
32
|
+
writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts, authPrompts, terminalFontFamily });
|
|
29
33
|
} catch (err) {
|
|
30
34
|
writeJson(res, 500, { ok: false, error: err.message });
|
|
31
35
|
}
|
|
@@ -99,21 +103,91 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
99
103
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
100
104
|
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
101
105
|
try {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
const body = await readBody(req);
|
|
107
|
+
const content = typeof body.content === 'string' && body.content.trim()
|
|
108
|
+
? body.content
|
|
109
|
+
: SshConfigParser.readDefaultConfig();
|
|
110
|
+
if (!content) return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
|
|
111
|
+
const report = SshConfigParser.parseReport(content, { baseDir: body.baseDir });
|
|
112
|
+
for (const p of report.profiles) {
|
|
108
113
|
await store.saveProfile(p);
|
|
109
114
|
}
|
|
110
|
-
writeJson(res, 200, {
|
|
115
|
+
writeJson(res, 200, {
|
|
116
|
+
ok: true,
|
|
117
|
+
count: report.profiles.length,
|
|
118
|
+
profiles: report.profiles,
|
|
119
|
+
skipped: report.skipped
|
|
120
|
+
});
|
|
111
121
|
} catch (err) {
|
|
112
122
|
writeJson(res, 500, { ok: false, error: err.message });
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
}), 'dsh-remote-workspace: /profiles/import-ssh-config');
|
|
116
126
|
|
|
127
|
+
|
|
128
|
+
sctx.effect(() => webServer.register({
|
|
129
|
+
kind: 'exact',
|
|
130
|
+
path: '/dsh-remote-workspace/auth/keyboard',
|
|
131
|
+
handler: async (req, res) => {
|
|
132
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
133
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
134
|
+
try {
|
|
135
|
+
const body = await readBody(req);
|
|
136
|
+
const answers = Array.isArray(body.answers) ? body.answers.slice(0, 20).map((item) => String(item ?? '').slice(0, 256)) : [];
|
|
137
|
+
const accepted = sshService.submitKeyboardPrompt(body.id || '', answers);
|
|
138
|
+
if (!accepted) return writeJson(res, 404, { ok: false, error: 'No authentication prompt is waiting' });
|
|
139
|
+
writeJson(res, 200, { ok: true });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}), 'dsh-remote-workspace: /auth/keyboard');
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
sctx.effect(() => webServer.register({
|
|
148
|
+
kind: 'exact',
|
|
149
|
+
path: '/dsh-remote-workspace/profiles/test-group',
|
|
150
|
+
handler: async (req, res) => {
|
|
151
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
152
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
153
|
+
try {
|
|
154
|
+
const body = await readBody(req);
|
|
155
|
+
const ids = Array.isArray(body.ids) ? body.ids.map((item) => String(item)) : [];
|
|
156
|
+
const selected = ids.map((id) => store.getProfile(id)).filter(Boolean);
|
|
157
|
+
const results = await testProfileGroup(selected, (profile) => sshService.testConnection(profile));
|
|
158
|
+
writeJson(res, 200, { ok: true, results });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}), 'dsh-remote-workspace: /profiles/test-group');
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
sctx.effect(() => webServer.register({
|
|
167
|
+
kind: 'exact',
|
|
168
|
+
path: '/dsh-remote-workspace/cluster',
|
|
169
|
+
handler: async (req, res) => {
|
|
170
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
171
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
172
|
+
try {
|
|
173
|
+
const body = await readBody(req);
|
|
174
|
+
if (!body.command || typeof body.command !== 'string') return writeJson(res, 400, { ok: false, error: 'Command is required' });
|
|
175
|
+
const selected = filterClusterProfiles(store.getProfiles(), {
|
|
176
|
+
environment: body.environment,
|
|
177
|
+
tags: Array.isArray(body.tags) ? body.tags : [],
|
|
178
|
+
aliases: Array.isArray(body.aliases) ? body.aliases : []
|
|
179
|
+
});
|
|
180
|
+
const results = await runCluster(selected, body.command, {
|
|
181
|
+
maxWorkers: body.maxWorkers,
|
|
182
|
+
exec: (profile, command) => sshService.exec(profile, command, { timeout: 60, cleanAnsi: true })
|
|
183
|
+
});
|
|
184
|
+
writeJson(res, 200, { ok: true, results });
|
|
185
|
+
} catch (err) {
|
|
186
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}), 'dsh-remote-workspace: /cluster');
|
|
190
|
+
|
|
117
191
|
// 6. POST /dsh-remote-workspace/test
|
|
118
192
|
sctx.effect(() => webServer.register({
|
|
119
193
|
kind: 'exact',
|