@goodandready/dsh-remote-workspace 0.3.8 → 0.3.10
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 +24 -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/docker.js +9 -9
- package/lib/routes/env.js +7 -7
- package/lib/routes/files.js +84 -9
- package/lib/routes/helpers.js +99 -11
- package/lib/routes/profiles.js +94 -20
- package/lib/routes/sync.js +5 -5
- package/lib/routes/terminal.js +28 -9
- package/lib/routes/tunnels.js +7 -7
- package/lib/routes.js +1 -1
- 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
package/lib/routes/files.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } 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
|
|
@@ -7,7 +27,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
7
27
|
path: '/dsh-remote-workspace/browse',
|
|
8
28
|
handler: async (req, res) => {
|
|
9
29
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
10
|
-
if (!
|
|
30
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
11
31
|
try {
|
|
12
32
|
const body = await readBody(req);
|
|
13
33
|
let profile = null;
|
|
@@ -28,7 +48,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
28
48
|
const entries = await remoteFs.listDir(profile, targetPath);
|
|
29
49
|
writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
|
|
30
50
|
} catch (err) {
|
|
31
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
51
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
32
52
|
}
|
|
33
53
|
}
|
|
34
54
|
}), 'dsh-remote-workspace: /browse');
|
|
@@ -39,7 +59,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
39
59
|
path: '/dsh-remote-workspace/file/view',
|
|
40
60
|
handler: async (req, res) => {
|
|
41
61
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
42
|
-
if (!
|
|
62
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
43
63
|
try {
|
|
44
64
|
const { profileId, filePath } = await readBody(req);
|
|
45
65
|
const profile = store.getProfile(profileId);
|
|
@@ -51,7 +71,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
51
71
|
const buf = await remoteFs.readFile(profile, filePath);
|
|
52
72
|
writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
|
|
53
73
|
} catch (err) {
|
|
54
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
74
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
55
75
|
}
|
|
56
76
|
}
|
|
57
77
|
}), 'dsh-remote-workspace: /file/view');
|
|
@@ -62,7 +82,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
62
82
|
path: '/dsh-remote-workspace/file/save',
|
|
63
83
|
handler: async (req, res) => {
|
|
64
84
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
65
|
-
if (!
|
|
85
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
66
86
|
try {
|
|
67
87
|
const { profileId, filePath, content } = await readBody(req);
|
|
68
88
|
const profile = store.getProfile(profileId);
|
|
@@ -70,18 +90,73 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
70
90
|
await remoteFs.writeFile(profile, filePath, Buffer.from(content, 'utf8'));
|
|
71
91
|
writeJson(res, 200, { ok: true });
|
|
72
92
|
} catch (err) {
|
|
73
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
93
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
74
94
|
}
|
|
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 (!authorizeRequest(sctx, req, res)) return;
|
|
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 (!authorizeRequest(sctx, req, res)) return;
|
|
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',
|
|
81
156
|
path: '/dsh-remote-workspace/transfer',
|
|
82
157
|
handler: async (req, res) => {
|
|
83
158
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
84
|
-
if (!
|
|
159
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
85
160
|
try {
|
|
86
161
|
const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
|
|
87
162
|
const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
|
|
@@ -94,7 +169,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
94
169
|
const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
|
|
95
170
|
writeJson(res, 200, { ok: result.success, ...result });
|
|
96
171
|
} catch (err) {
|
|
97
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
172
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
98
173
|
}
|
|
99
174
|
}
|
|
100
175
|
}), 'dsh-remote-workspace: /transfer');
|
package/lib/routes/helpers.js
CHANGED
|
@@ -10,18 +10,54 @@ export function writeJson(res, statusCode, body) {
|
|
|
10
10
|
res.end(JSON.stringify(body));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export
|
|
13
|
+
export const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB
|
|
14
|
+
|
|
15
|
+
export function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
14
16
|
return new Promise((resolve, reject) => {
|
|
17
|
+
const rawCl = req?.headers?.['content-length'];
|
|
18
|
+
const contentLength = rawCl !== undefined ? parseInt(Array.isArray(rawCl) ? rawCl[0] : rawCl, 10) : NaN;
|
|
19
|
+
if (!isNaN(contentLength) && contentLength > maxBytes) {
|
|
20
|
+
if (typeof req.destroy === 'function') req.destroy();
|
|
21
|
+
const error = new Error('Payload too large');
|
|
22
|
+
error.statusCode = 413;
|
|
23
|
+
return reject(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
let body = '';
|
|
16
|
-
|
|
27
|
+
let bytesReceived = 0;
|
|
28
|
+
let exceeded = false;
|
|
29
|
+
|
|
30
|
+
function onData(chunk) {
|
|
31
|
+
if (exceeded) return;
|
|
32
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
33
|
+
bytesReceived += buffer.length;
|
|
34
|
+
if (bytesReceived > maxBytes) {
|
|
35
|
+
exceeded = true;
|
|
36
|
+
if (typeof req.removeListener === 'function') {
|
|
37
|
+
req.removeListener('data', onData);
|
|
38
|
+
} else if (typeof req.off === 'function') {
|
|
39
|
+
req.off('data', onData);
|
|
40
|
+
}
|
|
41
|
+
if (typeof req.destroy === 'function') req.destroy();
|
|
42
|
+
const error = new Error('Payload too large');
|
|
43
|
+
error.statusCode = 413;
|
|
44
|
+
return reject(error);
|
|
45
|
+
}
|
|
46
|
+
body += buffer.toString('utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
req.on('data', onData);
|
|
17
50
|
req.on('end', () => {
|
|
51
|
+
if (exceeded) return;
|
|
18
52
|
try {
|
|
19
53
|
resolve(body ? JSON.parse(body) : {});
|
|
20
54
|
} catch (err) {
|
|
21
55
|
reject(err);
|
|
22
56
|
}
|
|
23
57
|
});
|
|
24
|
-
req.on('error',
|
|
58
|
+
req.on('error', (err) => {
|
|
59
|
+
if (!exceeded) reject(err);
|
|
60
|
+
});
|
|
25
61
|
});
|
|
26
62
|
}
|
|
27
63
|
|
|
@@ -39,16 +75,24 @@ export function parseCookies(cookieHeader) {
|
|
|
39
75
|
return cookies;
|
|
40
76
|
}
|
|
41
77
|
|
|
78
|
+
export function isLoopbackAddress(address) {
|
|
79
|
+
if (!address || typeof address !== 'string') return false;
|
|
80
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.');
|
|
81
|
+
}
|
|
82
|
+
|
|
42
83
|
export function isTrustedSettingsRequest(req) {
|
|
43
|
-
const
|
|
84
|
+
const ip = req?.socket?.remoteAddress || '';
|
|
85
|
+
const isLoopback = isLoopbackAddress(ip);
|
|
86
|
+
|
|
87
|
+
const secFetch = req?.headers?.['sec-fetch-site'];
|
|
44
88
|
// Fail-closed: unconditionally reject cross-site and cross-origin requests
|
|
45
89
|
if (secFetch && secFetch !== 'same-origin') {
|
|
46
90
|
return false;
|
|
47
91
|
}
|
|
48
92
|
|
|
49
93
|
// If origin is present, ensure it matches the request host
|
|
50
|
-
const origin = req
|
|
51
|
-
const host = req
|
|
94
|
+
const origin = req?.headers?.['origin'];
|
|
95
|
+
const host = req?.headers?.['host'];
|
|
52
96
|
if (origin && host) {
|
|
53
97
|
try {
|
|
54
98
|
const url = new URL(origin);
|
|
@@ -60,8 +104,8 @@ export function isTrustedSettingsRequest(req) {
|
|
|
60
104
|
}
|
|
61
105
|
}
|
|
62
106
|
|
|
63
|
-
const rawAuth = req
|
|
64
|
-
const cookieHeader = req
|
|
107
|
+
const rawAuth = req?.headers?.['authorization'];
|
|
108
|
+
const cookieHeader = req?.headers?.['cookie'];
|
|
65
109
|
const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
|
|
66
110
|
|
|
67
111
|
if (expectedToken) {
|
|
@@ -74,9 +118,53 @@ export function isTrustedSettingsRequest(req) {
|
|
|
74
118
|
const token = cookies['token'] || cookies['dsh_token'];
|
|
75
119
|
if (token && token === expectedToken) return true;
|
|
76
120
|
}
|
|
121
|
+
return false;
|
|
77
122
|
}
|
|
123
|
+
|
|
124
|
+
// Non-loopback requests without valid token must not pass simply on Sec-Fetch-Site: same-origin
|
|
125
|
+
if (!isLoopback) return false;
|
|
78
126
|
if (secFetch === 'same-origin') return true;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getConnection(ctx) {
|
|
131
|
+
try {
|
|
132
|
+
return ctx?.reflect?.get ? ctx.reflect.get('connection') : (ctx?.get ? ctx.get('connection') : Reflect.get(ctx || {}, 'connection'));
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function authorizeRequest(sctx, req, res) {
|
|
139
|
+
let connection;
|
|
140
|
+
try {
|
|
141
|
+
connection = getConnection(sctx);
|
|
142
|
+
} catch {
|
|
143
|
+
writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!connection || typeof connection.requestRejection !== 'function') {
|
|
148
|
+
writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let rejection;
|
|
153
|
+
try {
|
|
154
|
+
rejection = connection.requestRejection(req);
|
|
155
|
+
} catch {
|
|
156
|
+
writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (rejection !== undefined) {
|
|
161
|
+
const status = rejection === 401 ? 401 : 403;
|
|
162
|
+
writeJson(res, status, {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: status === 401 ? 'DSH browser authentication is required' : 'Forbidden'
|
|
165
|
+
});
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return true;
|
|
82
170
|
}
|
package/lib/routes/profiles.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } 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;
|
|
@@ -17,17 +19,19 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
17
19
|
path: '/dsh-remote-workspace/state',
|
|
18
20
|
handler: async (req, res) => {
|
|
19
21
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
20
|
-
if (!
|
|
22
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
21
23
|
try {
|
|
22
24
|
const profiles = store.getProfiles();
|
|
23
25
|
const activeId = store.getActiveId() || (profiles[0]?.id || '');
|
|
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
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
34
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
}), 'dsh-remote-workspace: /state');
|
|
@@ -38,7 +42,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
38
42
|
path: '/dsh-remote-workspace/profiles/save',
|
|
39
43
|
handler: async (req, res) => {
|
|
40
44
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
41
|
-
if (!
|
|
45
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
42
46
|
try {
|
|
43
47
|
const profile = await readBody(req);
|
|
44
48
|
if (!profile.id || !profile.host) {
|
|
@@ -49,7 +53,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
49
53
|
const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
|
|
50
54
|
writeJson(res, 200, { ok: true, profile: sanitized });
|
|
51
55
|
} catch (err) {
|
|
52
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
56
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
}), 'dsh-remote-workspace: /profiles/save');
|
|
@@ -60,7 +64,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
60
64
|
path: '/dsh-remote-workspace/profiles/delete',
|
|
61
65
|
handler: async (req, res) => {
|
|
62
66
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
63
|
-
if (!
|
|
67
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
64
68
|
try {
|
|
65
69
|
const { id } = await readBody(req);
|
|
66
70
|
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
@@ -68,7 +72,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
68
72
|
await store.deleteProfile(id);
|
|
69
73
|
writeJson(res, 200, { ok: true });
|
|
70
74
|
} catch (err) {
|
|
71
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
75
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
72
76
|
}
|
|
73
77
|
}
|
|
74
78
|
}), 'dsh-remote-workspace: /profiles/delete');
|
|
@@ -79,14 +83,14 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
79
83
|
path: '/dsh-remote-workspace/profiles/active',
|
|
80
84
|
handler: async (req, res) => {
|
|
81
85
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
82
|
-
if (!
|
|
86
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
83
87
|
try {
|
|
84
88
|
const { id } = await readBody(req);
|
|
85
89
|
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
86
90
|
await store.setActiveId(id);
|
|
87
91
|
writeJson(res, 200, { ok: true, activeId: id });
|
|
88
92
|
} catch (err) {
|
|
89
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
93
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
}), 'dsh-remote-workspace: /profiles/active');
|
|
@@ -97,30 +101,100 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
97
101
|
path: '/dsh-remote-workspace/profiles/import-ssh-config',
|
|
98
102
|
handler: async (req, res) => {
|
|
99
103
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
100
|
-
if (!
|
|
104
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
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
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
122
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 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 (!authorizeRequest(sctx, req, res)) return;
|
|
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, err.statusCode || (err.name === 'SyntaxError' ? 400 : 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 (!authorizeRequest(sctx, req, res)) return;
|
|
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, err.statusCode || (err.name === 'SyntaxError' ? 400 : 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 (!authorizeRequest(sctx, req, res)) return;
|
|
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, err.statusCode || (err.name === 'SyntaxError' ? 400 : 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',
|
|
120
194
|
path: '/dsh-remote-workspace/test',
|
|
121
195
|
handler: async (req, res) => {
|
|
122
196
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
123
|
-
if (!
|
|
197
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
124
198
|
try {
|
|
125
199
|
const profile = await readBody(req);
|
|
126
200
|
if (!profile.host) return writeJson(res, 400, { ok: false, error: 'Host is required' });
|
package/lib/routes/sync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
|
|
2
2
|
|
|
3
3
|
export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService, watcherService, store }) {
|
|
4
4
|
// 17. POST /dsh-remote-workspace/sync
|
|
@@ -7,7 +7,7 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
|
|
|
7
7
|
path: '/dsh-remote-workspace/sync',
|
|
8
8
|
handler: async (req, res) => {
|
|
9
9
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
10
|
-
if (!
|
|
10
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
11
11
|
try {
|
|
12
12
|
const { profileId, direction, force, mode } = await readBody(req);
|
|
13
13
|
const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
|
|
@@ -28,7 +28,7 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
|
|
|
28
28
|
: await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
|
|
29
29
|
writeJson(res, 200, { ok: resSync.success, ...resSync });
|
|
30
30
|
} catch (err) {
|
|
31
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
31
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}), 'dsh-remote-workspace: /sync');
|
|
@@ -39,13 +39,13 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
|
|
|
39
39
|
path: '/dsh-remote-workspace/watcher/toggle',
|
|
40
40
|
handler: async (req, res) => {
|
|
41
41
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
42
|
-
if (!
|
|
42
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
43
43
|
try {
|
|
44
44
|
const { enabled } = await readBody(req);
|
|
45
45
|
await store.setAutoSyncEnabled(Boolean(enabled));
|
|
46
46
|
writeJson(res, 200, { ok: true, autoSync: store.isAutoSyncEnabled() });
|
|
47
47
|
} catch (err) {
|
|
48
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
48
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
}), 'dsh-remote-workspace: /watcher/toggle');
|
package/lib/routes/terminal.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
|
|
2
|
+
import { normalizeTerminalFont } from '../terminal-font.js';
|
|
2
3
|
|
|
3
4
|
export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
5
|
+
|
|
6
|
+
sctx.effect(() => webServer.register({
|
|
7
|
+
kind: 'exact',
|
|
8
|
+
path: '/dsh-remote-workspace/terminal/font',
|
|
9
|
+
handler: async (req, res) => {
|
|
10
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
11
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
12
|
+
try {
|
|
13
|
+
const body = await readBody(req);
|
|
14
|
+
const fontFamily = await store.setTerminalFont(body.fontFamily);
|
|
15
|
+
writeJson(res, 200, { ok: true, terminalFontFamily: fontFamily });
|
|
16
|
+
} catch (err) {
|
|
17
|
+
const status = err.code === 'INVALID_FONT' ? 400 : 500;
|
|
18
|
+
writeJson(res, status, { ok: false, error: err.message });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}), 'dsh-remote-workspace: /terminal/font');
|
|
22
|
+
|
|
4
23
|
// 13. POST /dsh-remote-workspace/terminal/create
|
|
5
24
|
sctx.effect(() => webServer.register({
|
|
6
25
|
kind: 'exact',
|
|
7
26
|
path: '/dsh-remote-workspace/terminal/create',
|
|
8
27
|
handler: async (req, res) => {
|
|
9
28
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
10
|
-
if (!
|
|
29
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
11
30
|
try {
|
|
12
31
|
const { profileId, cols, rows } = await readBody(req);
|
|
13
32
|
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
@@ -15,7 +34,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
15
34
|
const session = await sshService.createTerminalSession(profile, { cols: cols || 80, rows: rows || 24 });
|
|
16
35
|
writeJson(res, 200, { ok: true, sessionId: session.id });
|
|
17
36
|
} catch (err) {
|
|
18
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
37
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
19
38
|
}
|
|
20
39
|
}
|
|
21
40
|
}), 'dsh-remote-workspace: /terminal/create');
|
|
@@ -26,7 +45,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
26
45
|
path: '/dsh-remote-workspace/terminal/input',
|
|
27
46
|
handler: async (req, res) => {
|
|
28
47
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
29
|
-
if (!
|
|
48
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
30
49
|
try {
|
|
31
50
|
const { sessionId, data, resize } = await readBody(req);
|
|
32
51
|
const session = sshService.getTerminalSession(sessionId);
|
|
@@ -39,7 +58,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
39
58
|
}
|
|
40
59
|
writeJson(res, 200, { ok: true });
|
|
41
60
|
} catch (err) {
|
|
42
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
61
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
43
62
|
}
|
|
44
63
|
}
|
|
45
64
|
}), 'dsh-remote-workspace: /terminal/input');
|
|
@@ -50,7 +69,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
50
69
|
path: '/dsh-remote-workspace/terminal/stream',
|
|
51
70
|
handler: async (req, res) => {
|
|
52
71
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
53
|
-
if (!
|
|
72
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
54
73
|
try {
|
|
55
74
|
const url = new URL(req.url, 'http://localhost');
|
|
56
75
|
const sessionId = url.searchParams.get('sessionId');
|
|
@@ -80,7 +99,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
80
99
|
session.subscribers.delete(sub);
|
|
81
100
|
});
|
|
82
101
|
} catch (err) {
|
|
83
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
102
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
84
103
|
}
|
|
85
104
|
}
|
|
86
105
|
}), 'dsh-remote-workspace: /terminal/stream');
|
|
@@ -91,13 +110,13 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
91
110
|
path: '/dsh-remote-workspace/terminal/close',
|
|
92
111
|
handler: async (req, res) => {
|
|
93
112
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
94
|
-
if (!
|
|
113
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
95
114
|
try {
|
|
96
115
|
const { sessionId } = await readBody(req);
|
|
97
116
|
sshService.closeTerminalSession(sessionId);
|
|
98
117
|
writeJson(res, 200, { ok: true });
|
|
99
118
|
} catch (err) {
|
|
100
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
119
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
101
120
|
}
|
|
102
121
|
}
|
|
103
122
|
}), 'dsh-remote-workspace: /terminal/close');
|