@goodandready/dsh-remote-workspace 0.3.9 → 0.3.11
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 +12 -0
- package/README.ru.md +12 -0
- package/README.zh.md +12 -0
- package/lib/client.js +47 -37
- package/lib/diagnose-service.js +4 -3
- package/lib/docker-service.js +5 -2
- package/lib/index.js +41 -14
- package/lib/mirror-sync-service.js +1 -1
- package/lib/remote-fs-service.js +7 -2
- package/lib/routes/docker.js +9 -9
- package/lib/routes/env.js +27 -14
- package/lib/routes/files.js +11 -11
- package/lib/routes/helpers.js +99 -11
- package/lib/routes/profiles.js +18 -18
- package/lib/routes/sync.js +5 -5
- package/lib/routes/terminal.js +10 -10
- package/lib/routes/tunnels.js +7 -7
- package/lib/routes.js +2 -2
- package/lib/ssh-service.js +21 -1
- package/lib/tar-sync-service.js +25 -4
- package/lib/tunnel-service.js +5 -0
- package/lib/watcher-service.js +19 -2
- package/package.json +1 -1
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,4 +1,4 @@
|
|
|
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
3
|
import { testProfileGroup } from '../profile-groups.js';
|
|
4
4
|
import { filterClusterProfiles, runCluster } from '../cluster-service.js';
|
|
@@ -19,7 +19,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
19
19
|
path: '/dsh-remote-workspace/state',
|
|
20
20
|
handler: async (req, res) => {
|
|
21
21
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
22
|
-
if (!
|
|
22
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
23
23
|
try {
|
|
24
24
|
const profiles = store.getProfiles();
|
|
25
25
|
const activeId = store.getActiveId() || (profiles[0]?.id || '');
|
|
@@ -31,7 +31,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
31
31
|
const terminalFontFamily = store.getTerminalFont ? store.getTerminalFont() : '';
|
|
32
32
|
writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts, authPrompts, terminalFontFamily });
|
|
33
33
|
} catch (err) {
|
|
34
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
34
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
}), 'dsh-remote-workspace: /state');
|
|
@@ -42,7 +42,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
42
42
|
path: '/dsh-remote-workspace/profiles/save',
|
|
43
43
|
handler: async (req, res) => {
|
|
44
44
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
45
|
-
if (!
|
|
45
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
46
46
|
try {
|
|
47
47
|
const profile = await readBody(req);
|
|
48
48
|
if (!profile.id || !profile.host) {
|
|
@@ -53,7 +53,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
53
53
|
const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
|
|
54
54
|
writeJson(res, 200, { ok: true, profile: sanitized });
|
|
55
55
|
} catch (err) {
|
|
56
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
56
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}), 'dsh-remote-workspace: /profiles/save');
|
|
@@ -64,7 +64,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
64
64
|
path: '/dsh-remote-workspace/profiles/delete',
|
|
65
65
|
handler: async (req, res) => {
|
|
66
66
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
67
|
-
if (!
|
|
67
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
68
68
|
try {
|
|
69
69
|
const { id } = await readBody(req);
|
|
70
70
|
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
@@ -72,7 +72,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
72
72
|
await store.deleteProfile(id);
|
|
73
73
|
writeJson(res, 200, { ok: true });
|
|
74
74
|
} catch (err) {
|
|
75
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
75
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
}), 'dsh-remote-workspace: /profiles/delete');
|
|
@@ -83,14 +83,14 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
83
83
|
path: '/dsh-remote-workspace/profiles/active',
|
|
84
84
|
handler: async (req, res) => {
|
|
85
85
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
86
|
-
if (!
|
|
86
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
87
87
|
try {
|
|
88
88
|
const { id } = await readBody(req);
|
|
89
89
|
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
90
90
|
await store.setActiveId(id);
|
|
91
91
|
writeJson(res, 200, { ok: true, activeId: id });
|
|
92
92
|
} catch (err) {
|
|
93
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
93
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
}), 'dsh-remote-workspace: /profiles/active');
|
|
@@ -101,7 +101,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
101
101
|
path: '/dsh-remote-workspace/profiles/import-ssh-config',
|
|
102
102
|
handler: async (req, res) => {
|
|
103
103
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
104
|
-
if (!
|
|
104
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
105
105
|
try {
|
|
106
106
|
const body = await readBody(req);
|
|
107
107
|
const content = typeof body.content === 'string' && body.content.trim()
|
|
@@ -119,7 +119,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
119
119
|
skipped: report.skipped
|
|
120
120
|
});
|
|
121
121
|
} catch (err) {
|
|
122
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
122
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
}), 'dsh-remote-workspace: /profiles/import-ssh-config');
|
|
@@ -130,7 +130,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
130
130
|
path: '/dsh-remote-workspace/auth/keyboard',
|
|
131
131
|
handler: async (req, res) => {
|
|
132
132
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
133
|
-
if (!
|
|
133
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
134
134
|
try {
|
|
135
135
|
const body = await readBody(req);
|
|
136
136
|
const answers = Array.isArray(body.answers) ? body.answers.slice(0, 20).map((item) => String(item ?? '').slice(0, 256)) : [];
|
|
@@ -138,7 +138,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
138
138
|
if (!accepted) return writeJson(res, 404, { ok: false, error: 'No authentication prompt is waiting' });
|
|
139
139
|
writeJson(res, 200, { ok: true });
|
|
140
140
|
} catch (err) {
|
|
141
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
141
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
}), 'dsh-remote-workspace: /auth/keyboard');
|
|
@@ -149,7 +149,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
149
149
|
path: '/dsh-remote-workspace/profiles/test-group',
|
|
150
150
|
handler: async (req, res) => {
|
|
151
151
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
152
|
-
if (!
|
|
152
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
153
153
|
try {
|
|
154
154
|
const body = await readBody(req);
|
|
155
155
|
const ids = Array.isArray(body.ids) ? body.ids.map((item) => String(item)) : [];
|
|
@@ -157,7 +157,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
157
157
|
const results = await testProfileGroup(selected, (profile) => sshService.testConnection(profile));
|
|
158
158
|
writeJson(res, 200, { ok: true, results });
|
|
159
159
|
} catch (err) {
|
|
160
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
160
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
}), 'dsh-remote-workspace: /profiles/test-group');
|
|
@@ -168,7 +168,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
168
168
|
path: '/dsh-remote-workspace/cluster',
|
|
169
169
|
handler: async (req, res) => {
|
|
170
170
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
171
|
-
if (!
|
|
171
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
172
172
|
try {
|
|
173
173
|
const body = await readBody(req);
|
|
174
174
|
if (!body.command || typeof body.command !== 'string') return writeJson(res, 400, { ok: false, error: 'Command is required' });
|
|
@@ -183,7 +183,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
183
183
|
});
|
|
184
184
|
writeJson(res, 200, { ok: true, results });
|
|
185
185
|
} catch (err) {
|
|
186
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
186
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
}), 'dsh-remote-workspace: /cluster');
|
|
@@ -194,7 +194,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
194
194
|
path: '/dsh-remote-workspace/test',
|
|
195
195
|
handler: async (req, res) => {
|
|
196
196
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
197
|
-
if (!
|
|
197
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
198
198
|
try {
|
|
199
199
|
const profile = await readBody(req);
|
|
200
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,4 +1,4 @@
|
|
|
1
|
-
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
|
|
2
2
|
import { normalizeTerminalFont } from '../terminal-font.js';
|
|
3
3
|
|
|
4
4
|
export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
@@ -8,7 +8,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
8
8
|
path: '/dsh-remote-workspace/terminal/font',
|
|
9
9
|
handler: async (req, res) => {
|
|
10
10
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
11
|
-
if (!
|
|
11
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
12
12
|
try {
|
|
13
13
|
const body = await readBody(req);
|
|
14
14
|
const fontFamily = await store.setTerminalFont(body.fontFamily);
|
|
@@ -26,7 +26,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
26
26
|
path: '/dsh-remote-workspace/terminal/create',
|
|
27
27
|
handler: async (req, res) => {
|
|
28
28
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
29
|
-
if (!
|
|
29
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
30
30
|
try {
|
|
31
31
|
const { profileId, cols, rows } = await readBody(req);
|
|
32
32
|
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
@@ -34,7 +34,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
34
34
|
const session = await sshService.createTerminalSession(profile, { cols: cols || 80, rows: rows || 24 });
|
|
35
35
|
writeJson(res, 200, { ok: true, sessionId: session.id });
|
|
36
36
|
} catch (err) {
|
|
37
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
37
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}), 'dsh-remote-workspace: /terminal/create');
|
|
@@ -45,7 +45,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
45
45
|
path: '/dsh-remote-workspace/terminal/input',
|
|
46
46
|
handler: async (req, res) => {
|
|
47
47
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
48
|
-
if (!
|
|
48
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
49
49
|
try {
|
|
50
50
|
const { sessionId, data, resize } = await readBody(req);
|
|
51
51
|
const session = sshService.getTerminalSession(sessionId);
|
|
@@ -58,7 +58,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
58
58
|
}
|
|
59
59
|
writeJson(res, 200, { ok: true });
|
|
60
60
|
} catch (err) {
|
|
61
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
61
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
}), 'dsh-remote-workspace: /terminal/input');
|
|
@@ -69,7 +69,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
69
69
|
path: '/dsh-remote-workspace/terminal/stream',
|
|
70
70
|
handler: async (req, res) => {
|
|
71
71
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
72
|
-
if (!
|
|
72
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
73
73
|
try {
|
|
74
74
|
const url = new URL(req.url, 'http://localhost');
|
|
75
75
|
const sessionId = url.searchParams.get('sessionId');
|
|
@@ -99,7 +99,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
99
99
|
session.subscribers.delete(sub);
|
|
100
100
|
});
|
|
101
101
|
} catch (err) {
|
|
102
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
102
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
}), 'dsh-remote-workspace: /terminal/stream');
|
|
@@ -110,13 +110,13 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
|
|
110
110
|
path: '/dsh-remote-workspace/terminal/close',
|
|
111
111
|
handler: async (req, res) => {
|
|
112
112
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
113
|
-
if (!
|
|
113
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
114
114
|
try {
|
|
115
115
|
const { sessionId } = await readBody(req);
|
|
116
116
|
sshService.closeTerminalSession(sessionId);
|
|
117
117
|
writeJson(res, 200, { ok: true });
|
|
118
118
|
} catch (err) {
|
|
119
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
119
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
}), 'dsh-remote-workspace: /terminal/close');
|
package/lib/routes/tunnels.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 registerTunnelRoutes(sctx, webServer, { tunnelService, store }) {
|
|
4
4
|
// 18. POST /dsh-remote-workspace/tunnels/start
|
|
@@ -7,7 +7,7 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
|
|
|
7
7
|
path: '/dsh-remote-workspace/tunnels/start',
|
|
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, localPort, targetHost, remotePort } = await readBody(req);
|
|
13
13
|
const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
|
|
@@ -20,7 +20,7 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
|
|
|
20
20
|
const result = await tunnelService.startLocalTunnel(profile, lPort, targetHost || '127.0.0.1', rPort);
|
|
21
21
|
writeJson(res, 200, { ok: result.success, ...result });
|
|
22
22
|
} catch (err) {
|
|
23
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
23
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}), 'dsh-remote-workspace: /tunnels/start');
|
|
@@ -31,14 +31,14 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
|
|
|
31
31
|
path: '/dsh-remote-workspace/tunnels/stop',
|
|
32
32
|
handler: async (req, res) => {
|
|
33
33
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
34
|
-
if (!
|
|
34
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
35
35
|
try {
|
|
36
36
|
const { tunnelId } = await readBody(req);
|
|
37
37
|
if (!tunnelId) return writeJson(res, 400, { ok: false, error: 'tunnelId required' });
|
|
38
38
|
const stopped = tunnelService.stopTunnel(tunnelId);
|
|
39
39
|
writeJson(res, 200, { ok: stopped });
|
|
40
40
|
} catch (err) {
|
|
41
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
41
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
}), 'dsh-remote-workspace: /tunnels/stop');
|
|
@@ -49,11 +49,11 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
|
|
|
49
49
|
path: '/dsh-remote-workspace/tunnels/telemetry',
|
|
50
50
|
handler: async (req, res) => {
|
|
51
51
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
52
|
-
if (!
|
|
52
|
+
if (!authorizeRequest(sctx, req, res)) return;
|
|
53
53
|
try {
|
|
54
54
|
writeJson(res, 200, { ok: true, tunnels: tunnelService.listActiveTunnels() });
|
|
55
55
|
} catch (err) {
|
|
56
|
-
writeJson(res, 500, { ok: false, error: err.message });
|
|
56
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}), 'dsh-remote-workspace: /tunnels/telemetry');
|
package/lib/routes.js
CHANGED
|
@@ -8,7 +8,7 @@ import { registerTunnelRoutes } from './routes/tunnels.js';
|
|
|
8
8
|
import { registerDockerRoutes } from './routes/docker.js';
|
|
9
9
|
import { registerSyncRoutes } from './routes/sync.js';
|
|
10
10
|
|
|
11
|
-
export { isTrustedSettingsRequest } from './routes/helpers.js';
|
|
11
|
+
export { isTrustedSettingsRequest, authorizeRequest, readBody, MAX_BODY_BYTES } from './routes/helpers.js';
|
|
12
12
|
|
|
13
13
|
export function registerApiRoutes(
|
|
14
14
|
ctx,
|
|
@@ -44,7 +44,7 @@ export function registerApiRoutes(
|
|
|
44
44
|
|
|
45
45
|
registerProfileRoutes(sctx, webServer, { store, sshService, alertService, tunnelService });
|
|
46
46
|
registerFileRoutes(sctx, webServer, { remoteFs, store, sshService });
|
|
47
|
-
registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store });
|
|
47
|
+
registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store, remoteFs });
|
|
48
48
|
registerTerminalRoutes(sctx, webServer, { sshService, store });
|
|
49
49
|
registerTunnelRoutes(sctx, webServer, { tunnelService, store });
|
|
50
50
|
registerDockerRoutes(sctx, webServer, { dockerService, healthService, store });
|
package/lib/ssh-service.js
CHANGED
|
@@ -93,6 +93,21 @@ export class SshService {
|
|
|
93
93
|
this.connMeta.delete(id);
|
|
94
94
|
closed.push(id);
|
|
95
95
|
}
|
|
96
|
+
this.sweepAbandonedTerminalSessions(now);
|
|
97
|
+
return closed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
sweepAbandonedTerminalSessions(now = Date.now(), idleTimeoutMs = 10 * 60 * 1000) {
|
|
101
|
+
const closed = [];
|
|
102
|
+
for (const [id, session] of this.terminalSessions.entries()) {
|
|
103
|
+
const hasSubscribers = session.subscribers && session.subscribers.size > 0;
|
|
104
|
+
const lastActive = session.lastActive || session.createdAt || now;
|
|
105
|
+
if (!hasSubscribers && (now - lastActive >= idleTimeoutMs)) {
|
|
106
|
+
try { session.close(); } catch (err) { /* best-effort cleanup */ }
|
|
107
|
+
this.terminalSessions.delete(id);
|
|
108
|
+
closed.push(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
96
111
|
return closed;
|
|
97
112
|
}
|
|
98
113
|
|
|
@@ -368,7 +383,8 @@ export class SshService {
|
|
|
368
383
|
this.bumpInFlight(profile.id, -1);
|
|
369
384
|
throw err;
|
|
370
385
|
}
|
|
371
|
-
const
|
|
386
|
+
const safeCwd = cwd ? "'" + String(cwd).replace(/'/g, "'\\''") + "'" : '';
|
|
387
|
+
const shellCmd = cwd ? `cd ${safeCwd} && ${command}` : command;
|
|
372
388
|
|
|
373
389
|
const execOptions = {};
|
|
374
390
|
if (pty) {
|
|
@@ -486,12 +502,15 @@ export class SshService {
|
|
|
486
502
|
profileId: profile.id,
|
|
487
503
|
stream,
|
|
488
504
|
createdAt: Date.now(),
|
|
505
|
+
lastActive: Date.now(),
|
|
489
506
|
buffer: [],
|
|
490
507
|
subscribers: new Set(),
|
|
491
508
|
write(data) {
|
|
509
|
+
session.lastActive = Date.now();
|
|
492
510
|
try { stream.write(data); } catch (err) { session.status = 'disconnected'; const lost = '\r\n\x1b[31m[Connection lost]\x1b[0m\r\n'; session.buffer.push(lost); for (const sub of session.subscribers) { try { sub(lost); } catch (e) { /* best-effort notify */ } } }
|
|
493
511
|
},
|
|
494
512
|
resize(c, r) {
|
|
513
|
+
session.lastActive = Date.now();
|
|
495
514
|
try { stream.setWindow(r, c, 0, 0); } catch (err) { /* best-effort cleanup */ }
|
|
496
515
|
},
|
|
497
516
|
close() {
|
|
@@ -503,6 +522,7 @@ export class SshService {
|
|
|
503
522
|
};
|
|
504
523
|
|
|
505
524
|
stream.on('data', (d) => {
|
|
525
|
+
session.lastActive = Date.now();
|
|
506
526
|
const chunk = d.toString('utf8');
|
|
507
527
|
session.buffer.push(chunk);
|
|
508
528
|
if (session.buffer.length > 500) session.buffer.shift();
|
package/lib/tar-sync-service.js
CHANGED
|
@@ -40,14 +40,27 @@ export class TarSyncService {
|
|
|
40
40
|
|
|
41
41
|
localTar.on('close', (code) => {
|
|
42
42
|
if (code !== 0) {
|
|
43
|
+
try { stream.destroy(); } catch (_) {}
|
|
43
44
|
reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
|
|
44
45
|
} else {
|
|
45
46
|
resolve({ ok: true, direction: 'pull', remoteDir, localDir });
|
|
46
47
|
}
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
localTar.on('error', (err) => {
|
|
51
|
+
try { stream.destroy(); } catch (_) {}
|
|
52
|
+
reject(err);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
stream.on('error', (err) => {
|
|
56
|
+
try { localTar.kill('SIGTERM'); } catch (_) {}
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
localTar.stdin.on('error', (err) => {
|
|
61
|
+
try { stream.destroy(); } catch (_) {}
|
|
62
|
+
reject(err);
|
|
63
|
+
});
|
|
51
64
|
});
|
|
52
65
|
});
|
|
53
66
|
}
|
|
@@ -80,14 +93,22 @@ export class TarSyncService {
|
|
|
80
93
|
|
|
81
94
|
stream.on('close', (code) => {
|
|
82
95
|
if (code !== 0) {
|
|
96
|
+
try { localTar.kill('SIGTERM'); } catch (_) {}
|
|
83
97
|
reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
|
|
84
98
|
} else {
|
|
85
99
|
resolve({ ok: true, direction: 'push', localDir, remoteDir });
|
|
86
100
|
}
|
|
87
101
|
});
|
|
88
102
|
|
|
89
|
-
stream.on('error',
|
|
90
|
-
|
|
103
|
+
stream.on('error', (err) => {
|
|
104
|
+
try { localTar.kill('SIGTERM'); } catch (_) {}
|
|
105
|
+
reject(err);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
localTar.on('error', (err) => {
|
|
109
|
+
try { stream.destroy(); } catch (_) {}
|
|
110
|
+
reject(err);
|
|
111
|
+
});
|
|
91
112
|
});
|
|
92
113
|
});
|
|
93
114
|
}
|
package/lib/tunnel-service.js
CHANGED
|
@@ -73,8 +73,13 @@ export class TunnelService {
|
|
|
73
73
|
clientSocket.pipe(stream);
|
|
74
74
|
stream.pipe(clientSocket);
|
|
75
75
|
|
|
76
|
+
let cleaned = false;
|
|
76
77
|
const cleanup = () => {
|
|
78
|
+
if (cleaned) return;
|
|
79
|
+
cleaned = true;
|
|
77
80
|
tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
|
|
81
|
+
try { clientSocket.destroy(); } catch (err) { /* best-effort */ }
|
|
82
|
+
try { stream.destroy(); } catch (err) { /* best-effort */ }
|
|
78
83
|
};
|
|
79
84
|
clientSocket.on('close', cleanup);
|
|
80
85
|
clientSocket.on('error', cleanup);
|