@goodandready/dsh-remote-workspace 0.3.0 → 0.3.2
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 +17 -0
- package/README.ru.md +17 -0
- package/README.zh.md +17 -0
- package/docs/design/DESIGN.md +1 -1
- package/lib/alert-service.js +109 -0
- package/lib/client.js +2316 -1991
- package/lib/diagnose-service.js +57 -0
- package/lib/docker-service.js +1 -1
- package/lib/env-service.js +122 -0
- package/lib/index.js +83 -16
- package/lib/mirror-sync-service.js +2 -2
- package/lib/routes/docker.js +85 -0
- package/lib/routes/env.js +60 -0
- package/lib/routes/files.js +88 -0
- package/lib/routes/helpers.js +40 -0
- package/lib/routes/profiles.js +141 -0
- package/lib/routes/sync.js +52 -0
- package/lib/routes/terminal.js +103 -0
- package/lib/routes/tunnels.js +59 -0
- package/lib/routes.js +47 -516
- package/lib/ssh-config-parser.js +89 -0
- package/lib/ssh-service.js +24 -9
- package/lib/tar-sync-service.js +85 -0
- package/lib/tools.js +78 -3
- package/lib/tunnel-service.js +2 -2
- package/lib/updater-service.js +338 -0
- package/lib/vault-service.js +29 -5
- package/lib/watcher-service.js +3 -3
- package/package.json +6 -3
- package/docs/plans/findings.md +0 -19
- package/docs/plans/progress.md +0 -15
- package/docs/plans/task_plan.md +0 -23
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export class DiagnoseService {
|
|
2
|
+
/**
|
|
3
|
+
* @param {import('./ssh-service.js').SshService} sshService
|
|
4
|
+
*/
|
|
5
|
+
constructor(sshService) {
|
|
6
|
+
this.sshService = sshService;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Diagnose system aspects on remote host
|
|
11
|
+
* @param {any} profile
|
|
12
|
+
* @param {'ports'|'disk'|'memory'|'oom_killer'|'service_logs'} category
|
|
13
|
+
* @param {string} [target] optional port, service name, or path
|
|
14
|
+
*/
|
|
15
|
+
async diagnose(profile, category, target = '') {
|
|
16
|
+
if (!profile || !profile.host) throw new Error('Invalid profile');
|
|
17
|
+
|
|
18
|
+
let cmd = '';
|
|
19
|
+
switch (category) {
|
|
20
|
+
case 'ports': {
|
|
21
|
+
const filter = target ? `grep ':${target} '` : 'cat';
|
|
22
|
+
cmd = `(ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null || lsof -i -P -n 2>/dev/null) | ${filter} | head -n 40`;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
case 'disk': {
|
|
26
|
+
const path = target || '/';
|
|
27
|
+
cmd = `df -h ${path} 2>/dev/null; echo "---TOP 10 DIRS---"; du -ahx ${path} 2>/dev/null | sort -rh | head -n 10`;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
case 'memory': {
|
|
31
|
+
cmd = `free -h 2>/dev/null || free -m; echo "---TOP 10 PROCESSES BY RAM---"; ps aux --sort=-%mem 2>/dev/null | head -n 11`;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case 'oom_killer': {
|
|
35
|
+
cmd = `(dmesg -T 2>/dev/null || dmesg 2>/dev/null) | grep -i -E 'oom|out of memory|killed process' | tail -n 25 || echo "No OOM events detected"`;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'service_logs': {
|
|
39
|
+
if (!target) throw new Error('Target service name required for service_logs');
|
|
40
|
+
const safeName = target.replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
41
|
+
cmd = `(journalctl -u ${safeName} -n 40 --no-pager 2>/dev/null || pm2 logs ${safeName} --lines 40 --nostream 2>/dev/null || tail -n 40 /var/log/${safeName}.log 2>/dev/null || echo "No logs found for ${safeName}")`;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Unsupported diagnosis category: ${category}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const res = await this.sshService.exec(profile, cmd);
|
|
49
|
+
const code = res.code !== undefined ? res.code : (res.exitCode !== undefined ? res.exitCode : 0);
|
|
50
|
+
return {
|
|
51
|
+
ok: code === 0,
|
|
52
|
+
category,
|
|
53
|
+
target: target || null,
|
|
54
|
+
output: (res.stdout || res.stderr || '').trim()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
package/lib/docker-service.js
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export class EnvService {
|
|
2
|
+
/**
|
|
3
|
+
* @param {import('./remote-fs-service.js').RemoteFsService} remoteFsService
|
|
4
|
+
*/
|
|
5
|
+
constructor(remoteFsService) {
|
|
6
|
+
this.remoteFsService = remoteFsService;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse .env text into structured entries (preserving comments and order)
|
|
11
|
+
* @param {string} text
|
|
12
|
+
*/
|
|
13
|
+
parseEnv(text) {
|
|
14
|
+
if (!text) return [];
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
const result = [];
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const line = lines[i];
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed) {
|
|
22
|
+
result.push({ type: 'empty', raw: line });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (trimmed.startsWith('#')) {
|
|
26
|
+
result.push({ type: 'comment', raw: line });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const eqIdx = line.indexOf('=');
|
|
31
|
+
if (eqIdx === -1) {
|
|
32
|
+
result.push({ type: 'raw', raw: line });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const key = line.slice(0, eqIdx).trim();
|
|
37
|
+
let val = line.slice(eqIdx + 1);
|
|
38
|
+
// Check if value is wrapped in quotes
|
|
39
|
+
let isQuoted = false;
|
|
40
|
+
const valTrimmed = val.trim();
|
|
41
|
+
if ((valTrimmed.startsWith('"') && valTrimmed.endsWith('"')) ||
|
|
42
|
+
(valTrimmed.startsWith("'") && valTrimmed.endsWith("'"))) {
|
|
43
|
+
isQuoted = true;
|
|
44
|
+
val = valTrimmed.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
result.push({
|
|
48
|
+
type: 'var',
|
|
49
|
+
key,
|
|
50
|
+
value: val,
|
|
51
|
+
isQuoted,
|
|
52
|
+
raw: line
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read and parse remote .env file
|
|
61
|
+
*/
|
|
62
|
+
async readEnv(profile, filePath) {
|
|
63
|
+
const content = await this.remoteFsService.readFile(profile, filePath);
|
|
64
|
+
const parsed = this.parseEnv(content);
|
|
65
|
+
return {
|
|
66
|
+
filePath,
|
|
67
|
+
entries: parsed,
|
|
68
|
+
raw: content
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Set or update variable in remote .env file atomically
|
|
74
|
+
*/
|
|
75
|
+
async setEnvVar(profile, filePath, key, value) {
|
|
76
|
+
if (!key || typeof key !== 'string') throw new Error('Key must be non-empty string');
|
|
77
|
+
|
|
78
|
+
let currentContent = '';
|
|
79
|
+
try {
|
|
80
|
+
currentContent = await this.remoteFsService.readFile(profile, filePath);
|
|
81
|
+
} catch {
|
|
82
|
+
currentContent = '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const lines = currentContent ? currentContent.split('\n') : [];
|
|
86
|
+
let updated = false;
|
|
87
|
+
const newLines = [];
|
|
88
|
+
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (!trimmed.startsWith('#') && trimmed.includes('=')) {
|
|
92
|
+
const k = trimmed.slice(0, trimmed.indexOf('=')).trim();
|
|
93
|
+
if (k === key) {
|
|
94
|
+
// Quote if contains spaces or special symbols
|
|
95
|
+
const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
|
|
96
|
+
newLines.push(`${key}=${formattedVal}`);
|
|
97
|
+
updated = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
newLines.push(line);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!updated) {
|
|
105
|
+
const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
|
|
106
|
+
if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
|
|
107
|
+
newLines.push('');
|
|
108
|
+
}
|
|
109
|
+
newLines.push(`${key}=${formattedVal}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const finalContent = newLines.join('\n');
|
|
113
|
+
await this.remoteFsService.writeFile(profile, filePath, finalContent);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
filePath,
|
|
118
|
+
key,
|
|
119
|
+
updated
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -7,6 +7,10 @@ import { TunnelService } from './tunnel-service.js';
|
|
|
7
7
|
import { DockerService } from './docker-service.js';
|
|
8
8
|
import { HealthService } from './health-service.js';
|
|
9
9
|
import { WatcherService } from './watcher-service.js';
|
|
10
|
+
import { TarSyncService } from './tar-sync-service.js';
|
|
11
|
+
import { DiagnoseService } from './diagnose-service.js';
|
|
12
|
+
import { EnvService } from './env-service.js';
|
|
13
|
+
import { AlertService } from './alert-service.js';
|
|
10
14
|
import { registerRemoteTools } from './tools.js';
|
|
11
15
|
import { registerApiRoutes } from './routes.js';
|
|
12
16
|
|
|
@@ -33,7 +37,8 @@ export const Config = z.object({
|
|
|
33
37
|
jumpHostId: z.string().default('')
|
|
34
38
|
})).default([]).description('List of configured remote machines and workspaces.'),
|
|
35
39
|
activeProfileId: z.string().default('').description('ID of active remote profile.'),
|
|
36
|
-
autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.')
|
|
40
|
+
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.')
|
|
37
42
|
});
|
|
38
43
|
|
|
39
44
|
export function apply(ctx, config = {}) {
|
|
@@ -42,7 +47,8 @@ export function apply(ctx, config = {}) {
|
|
|
42
47
|
let currentConfig = {
|
|
43
48
|
profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
|
|
44
49
|
activeProfileId: cfg.activeProfileId || null,
|
|
45
|
-
autoSyncActive: Boolean(cfg.autoSyncActive)
|
|
50
|
+
autoSyncActive: Boolean(cfg.autoSyncActive),
|
|
51
|
+
alertMonitoringActive: Boolean(cfg.alertMonitoringActive)
|
|
46
52
|
};
|
|
47
53
|
|
|
48
54
|
const vaultService = new VaultService();
|
|
@@ -53,6 +59,10 @@ export function apply(ctx, config = {}) {
|
|
|
53
59
|
const dockerService = new DockerService(sshService);
|
|
54
60
|
const healthService = new HealthService(sshService);
|
|
55
61
|
const watcherService = new WatcherService();
|
|
62
|
+
const tarSyncService = new TarSyncService(sshService);
|
|
63
|
+
const diagnoseService = new DiagnoseService(sshService);
|
|
64
|
+
const envService = new EnvService(remoteFs);
|
|
65
|
+
const alertService = new AlertService(ctx, healthService, dockerService);
|
|
56
66
|
|
|
57
67
|
// Migrate any existing plain passwords into vault upon start
|
|
58
68
|
for (const p of currentConfig.profiles) {
|
|
@@ -72,6 +82,19 @@ export function apply(ctx, config = {}) {
|
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
const persistConfig = async (actionLabel) => {
|
|
86
|
+
if (settingsApi?.replace) {
|
|
87
|
+
try {
|
|
88
|
+
await settingsApi.replace(Config(currentConfig));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (ctx.logger?.warn) {
|
|
91
|
+
ctx.logger.warn(`[dsh-remote-workspace] Failed to persist config after ${actionLabel}: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Failed to save configuration: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
75
98
|
const store = {
|
|
76
99
|
vault: vaultService,
|
|
77
100
|
getRawProfiles() { return currentConfig.profiles || []; },
|
|
@@ -95,9 +118,7 @@ export function apply(ctx, config = {}) {
|
|
|
95
118
|
if (enabled) watcherService.start(active, mirrorSync);
|
|
96
119
|
else watcherService.stop(active.id);
|
|
97
120
|
}
|
|
98
|
-
|
|
99
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
100
|
-
}
|
|
121
|
+
await persistConfig('update');
|
|
101
122
|
},
|
|
102
123
|
async saveProfile(rawP) {
|
|
103
124
|
const list = [...(currentConfig.profiles || [])];
|
|
@@ -130,9 +151,7 @@ export function apply(ctx, config = {}) {
|
|
|
130
151
|
currentConfig.activeProfileId = p.id;
|
|
131
152
|
}
|
|
132
153
|
sshService.invalidate(p.id);
|
|
133
|
-
|
|
134
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
135
|
-
}
|
|
154
|
+
await persistConfig('update');
|
|
136
155
|
},
|
|
137
156
|
async deleteProfile(id) {
|
|
138
157
|
watcherService.stop(id);
|
|
@@ -142,9 +161,7 @@ export function apply(ctx, config = {}) {
|
|
|
142
161
|
}
|
|
143
162
|
vaultService.deleteProfileSecrets(id);
|
|
144
163
|
sshService.disconnect(id);
|
|
145
|
-
|
|
146
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
147
|
-
}
|
|
164
|
+
await persistConfig('update');
|
|
148
165
|
},
|
|
149
166
|
async setActiveId(id) {
|
|
150
167
|
currentConfig.activeProfileId = id;
|
|
@@ -153,13 +170,34 @@ export function apply(ctx, config = {}) {
|
|
|
153
170
|
const active = store.getActiveProfile();
|
|
154
171
|
if (active) watcherService.start(active, mirrorSync);
|
|
155
172
|
}
|
|
156
|
-
|
|
157
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
158
|
-
}
|
|
173
|
+
await persistConfig('update');
|
|
159
174
|
}
|
|
160
175
|
};
|
|
161
176
|
|
|
162
177
|
sshService.setJumpProfileResolver((jumpId) => store.getProfile(jumpId));
|
|
178
|
+
sshService.setProfileResolver((id) => store.getProfile(id));
|
|
179
|
+
|
|
180
|
+
const safeProfilesService = {
|
|
181
|
+
list() {
|
|
182
|
+
return (store.getProfiles() || []).map((p) => ({
|
|
183
|
+
id: p.id,
|
|
184
|
+
name: p.name || p.host,
|
|
185
|
+
host: p.host,
|
|
186
|
+
port: p.port || 22,
|
|
187
|
+
username: p.username || 'root',
|
|
188
|
+
authType: p.authType || 'key'
|
|
189
|
+
}));
|
|
190
|
+
},
|
|
191
|
+
get(id) {
|
|
192
|
+
return store.getProfile(id);
|
|
193
|
+
},
|
|
194
|
+
getActiveId() {
|
|
195
|
+
return store.getActiveId();
|
|
196
|
+
},
|
|
197
|
+
getActive() {
|
|
198
|
+
return store.getActiveProfile();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
163
201
|
|
|
164
202
|
// If autoSync was enabled on restart, resume watcher for active profile
|
|
165
203
|
if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
|
|
@@ -179,6 +217,7 @@ export function apply(ctx, config = {}) {
|
|
|
179
217
|
if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
|
|
180
218
|
if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
|
|
181
219
|
if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
|
|
220
|
+
if (snap.alertMonitoringActive !== undefined) currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
|
|
182
221
|
}
|
|
183
222
|
sctx.effect(() => () => {
|
|
184
223
|
settingsApi = null;
|
|
@@ -193,6 +232,12 @@ export function apply(ctx, config = {}) {
|
|
|
193
232
|
ctx.provide('remoteDocker');
|
|
194
233
|
ctx.provide('remoteHealth');
|
|
195
234
|
ctx.provide('remoteWatcher');
|
|
235
|
+
ctx.provide('remoteTarSync');
|
|
236
|
+
ctx.provide('remoteDiagnose');
|
|
237
|
+
ctx.provide('remoteEnv');
|
|
238
|
+
ctx.provide('remoteAlert');
|
|
239
|
+
ctx.provide('remoteProfiles');
|
|
240
|
+
ctx.remoteProfiles = safeProfilesService;
|
|
196
241
|
|
|
197
242
|
ctx.remoteSsh = sshService;
|
|
198
243
|
ctx.remoteFs = remoteFs;
|
|
@@ -202,6 +247,10 @@ export function apply(ctx, config = {}) {
|
|
|
202
247
|
ctx.remoteDocker = dockerService;
|
|
203
248
|
ctx.remoteHealth = healthService;
|
|
204
249
|
ctx.remoteWatcher = watcherService;
|
|
250
|
+
ctx.remoteTarSync = tarSyncService;
|
|
251
|
+
ctx.remoteDiagnose = diagnoseService;
|
|
252
|
+
ctx.remoteEnv = envService;
|
|
253
|
+
ctx.remoteAlert = alertService;
|
|
205
254
|
|
|
206
255
|
// Tools registration
|
|
207
256
|
registerRemoteTools(
|
|
@@ -212,14 +261,32 @@ export function apply(ctx, config = {}) {
|
|
|
212
261
|
tunnelService,
|
|
213
262
|
() => store.getActiveProfile(),
|
|
214
263
|
(id) => store.getProfile(id),
|
|
215
|
-
dockerService
|
|
264
|
+
dockerService,
|
|
265
|
+
diagnoseService,
|
|
266
|
+
envService,
|
|
267
|
+
tarSyncService
|
|
216
268
|
);
|
|
217
269
|
|
|
218
270
|
// API Routes
|
|
219
|
-
registerApiRoutes(
|
|
271
|
+
registerApiRoutes(
|
|
272
|
+
ctx,
|
|
273
|
+
sshService,
|
|
274
|
+
remoteFs,
|
|
275
|
+
mirrorSync,
|
|
276
|
+
tunnelService,
|
|
277
|
+
store,
|
|
278
|
+
dockerService,
|
|
279
|
+
healthService,
|
|
280
|
+
watcherService,
|
|
281
|
+
diagnoseService,
|
|
282
|
+
envService,
|
|
283
|
+
tarSyncService,
|
|
284
|
+
alertService
|
|
285
|
+
);
|
|
220
286
|
|
|
221
287
|
// Cleanup on unload
|
|
222
288
|
ctx.on('dispose', () => {
|
|
289
|
+
alertService.stop();
|
|
223
290
|
watcherService.stopAll();
|
|
224
291
|
tunnelService.stopAll();
|
|
225
292
|
sshService.disconnectAll();
|
|
@@ -51,7 +51,7 @@ export class MirrorSyncService {
|
|
|
51
51
|
if (fs.existsSync(snapshotFile)) {
|
|
52
52
|
try {
|
|
53
53
|
lastSnapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
|
54
|
-
} catch (
|
|
54
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const conflicts = [];
|
|
@@ -172,7 +172,7 @@ export class MirrorSyncService {
|
|
|
172
172
|
if (fs.existsSync(snapshotFile)) {
|
|
173
173
|
try {
|
|
174
174
|
snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
|
175
|
-
} catch (
|
|
175
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
const pushed = [];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
2
|
+
|
|
3
|
+
export function registerDockerRoutes(sctx, webServer, { dockerService, healthService, store }) {
|
|
4
|
+
// 21. GET /dsh-remote-workspace/health
|
|
5
|
+
sctx.effect(() => webServer.register({
|
|
6
|
+
kind: 'exact',
|
|
7
|
+
path: '/dsh-remote-workspace/health',
|
|
8
|
+
handler: async (req, res) => {
|
|
9
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(req.url, 'http://localhost');
|
|
12
|
+
const profileId = url.searchParams.get('profileId');
|
|
13
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
14
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
15
|
+
|
|
16
|
+
const health = await healthService.getHealth(profile);
|
|
17
|
+
writeJson(res, 200, { ok: true, health });
|
|
18
|
+
} catch (err) {
|
|
19
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}), 'dsh-remote-workspace: /health');
|
|
23
|
+
|
|
24
|
+
// 22. GET /dsh-remote-workspace/docker/list
|
|
25
|
+
sctx.effect(() => webServer.register({
|
|
26
|
+
kind: 'exact',
|
|
27
|
+
path: '/dsh-remote-workspace/docker/list',
|
|
28
|
+
handler: async (req, res) => {
|
|
29
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(req.url, 'http://localhost');
|
|
32
|
+
const profileId = url.searchParams.get('profileId');
|
|
33
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
34
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
35
|
+
|
|
36
|
+
const result = await dockerService.listContainers(profile, true);
|
|
37
|
+
writeJson(res, 200, { ok: true, ...result });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}), 'dsh-remote-workspace: /docker/list');
|
|
43
|
+
|
|
44
|
+
// 23. POST /dsh-remote-workspace/docker/action
|
|
45
|
+
sctx.effect(() => webServer.register({
|
|
46
|
+
kind: 'exact',
|
|
47
|
+
path: '/dsh-remote-workspace/docker/action',
|
|
48
|
+
handler: async (req, res) => {
|
|
49
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
50
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
51
|
+
try {
|
|
52
|
+
const { profileId, containerId, action } = await readBody(req);
|
|
53
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
54
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
55
|
+
if (!containerId || !action) return writeJson(res, 400, { ok: false, error: 'containerId and action required' });
|
|
56
|
+
|
|
57
|
+
const result = await dockerService.containerAction(profile, containerId, action);
|
|
58
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}), 'dsh-remote-workspace: /docker/action');
|
|
64
|
+
|
|
65
|
+
// 24. POST /dsh-remote-workspace/docker/logs
|
|
66
|
+
sctx.effect(() => webServer.register({
|
|
67
|
+
kind: 'exact',
|
|
68
|
+
path: '/dsh-remote-workspace/docker/logs',
|
|
69
|
+
handler: async (req, res) => {
|
|
70
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
71
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
72
|
+
try {
|
|
73
|
+
const { profileId, containerId, tail } = await readBody(req);
|
|
74
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
75
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
76
|
+
if (!containerId) return writeJson(res, 400, { ok: false, error: 'containerId required' });
|
|
77
|
+
|
|
78
|
+
const result = await dockerService.containerLogs(profile, containerId, tail || 100);
|
|
79
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}), 'dsh-remote-workspace: /docker/logs');
|
|
85
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
2
|
+
|
|
3
|
+
export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store }) {
|
|
4
|
+
// 10. POST /dsh-remote-workspace/env/view
|
|
5
|
+
sctx.effect(() => webServer.register({
|
|
6
|
+
kind: 'exact',
|
|
7
|
+
path: '/dsh-remote-workspace/env/view',
|
|
8
|
+
handler: async (req, res) => {
|
|
9
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
10
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
11
|
+
try {
|
|
12
|
+
const { profileId, remotePath } = await readBody(req);
|
|
13
|
+
const profile = store.getProfile(profileId);
|
|
14
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
15
|
+
const result = await envService.readRemoteEnv(sshService, profile, remotePath);
|
|
16
|
+
writeJson(res, 200, { ok: true, ...result });
|
|
17
|
+
} catch (err) {
|
|
18
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}), 'dsh-remote-workspace: /env/view');
|
|
22
|
+
|
|
23
|
+
// 11. POST /dsh-remote-workspace/env/save
|
|
24
|
+
sctx.effect(() => webServer.register({
|
|
25
|
+
kind: 'exact',
|
|
26
|
+
path: '/dsh-remote-workspace/env/save',
|
|
27
|
+
handler: async (req, res) => {
|
|
28
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
29
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
30
|
+
try {
|
|
31
|
+
const { profileId, remotePath, content } = await readBody(req);
|
|
32
|
+
const profile = store.getProfile(profileId);
|
|
33
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
34
|
+
const result = await envService.saveRemoteEnv(sshService, profile, remotePath, content);
|
|
35
|
+
writeJson(res, 200, { ok: true, ...result });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}), 'dsh-remote-workspace: /env/save');
|
|
41
|
+
|
|
42
|
+
// 12. POST /dsh-remote-workspace/diagnose
|
|
43
|
+
sctx.effect(() => webServer.register({
|
|
44
|
+
kind: 'exact',
|
|
45
|
+
path: '/dsh-remote-workspace/diagnose',
|
|
46
|
+
handler: async (req, res) => {
|
|
47
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
48
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
49
|
+
try {
|
|
50
|
+
const { profileId, checks } = await readBody(req);
|
|
51
|
+
const profile = store.getProfile(profileId);
|
|
52
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
53
|
+
const results = await diagnoseService.diagnose(sshService, profile, checks);
|
|
54
|
+
writeJson(res, 200, { ok: true, results });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}), 'dsh-remote-workspace: /diagnose');
|
|
60
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
2
|
+
|
|
3
|
+
export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshService }) {
|
|
4
|
+
// 7. POST /dsh-remote-workspace/browse
|
|
5
|
+
sctx.effect(() => webServer.register({
|
|
6
|
+
kind: 'exact',
|
|
7
|
+
path: '/dsh-remote-workspace/browse',
|
|
8
|
+
handler: async (req, res) => {
|
|
9
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
10
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
11
|
+
try {
|
|
12
|
+
const { profileId, remotePath } = await readBody(req);
|
|
13
|
+
const profile = store.getProfile(profileId);
|
|
14
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
15
|
+
const items = await remoteFs.list(profile, remotePath || profile.remotePath || '.');
|
|
16
|
+
writeJson(res, 200, { ok: true, items });
|
|
17
|
+
} catch (err) {
|
|
18
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}), 'dsh-remote-workspace: /browse');
|
|
22
|
+
|
|
23
|
+
// 8. POST /dsh-remote-workspace/file/view
|
|
24
|
+
sctx.effect(() => webServer.register({
|
|
25
|
+
kind: 'exact',
|
|
26
|
+
path: '/dsh-remote-workspace/file/view',
|
|
27
|
+
handler: async (req, res) => {
|
|
28
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
29
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
30
|
+
try {
|
|
31
|
+
const { profileId, filePath } = await readBody(req);
|
|
32
|
+
const profile = store.getProfile(profileId);
|
|
33
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
34
|
+
const stat = await remoteFs.stat(profile, filePath);
|
|
35
|
+
if (stat.size > 2 * 1024 * 1024) {
|
|
36
|
+
return writeJson(res, 400, { ok: false, error: 'File exceeds 2MB preview limit' });
|
|
37
|
+
}
|
|
38
|
+
const buf = await remoteFs.readFile(profile, filePath);
|
|
39
|
+
writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}), 'dsh-remote-workspace: /file/view');
|
|
45
|
+
|
|
46
|
+
// 9. POST /dsh-remote-workspace/file/save
|
|
47
|
+
sctx.effect(() => webServer.register({
|
|
48
|
+
kind: 'exact',
|
|
49
|
+
path: '/dsh-remote-workspace/file/save',
|
|
50
|
+
handler: async (req, res) => {
|
|
51
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
52
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
53
|
+
try {
|
|
54
|
+
const { profileId, filePath, content } = await readBody(req);
|
|
55
|
+
const profile = store.getProfile(profileId);
|
|
56
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
57
|
+
await remoteFs.writeFile(profile, filePath, Buffer.from(content, 'utf8'));
|
|
58
|
+
writeJson(res, 200, { ok: true });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}), 'dsh-remote-workspace: /file/save');
|
|
64
|
+
|
|
65
|
+
// 26. POST /dsh-remote-workspace/transfer
|
|
66
|
+
sctx.effect(() => webServer.register({
|
|
67
|
+
kind: 'exact',
|
|
68
|
+
path: '/dsh-remote-workspace/transfer',
|
|
69
|
+
handler: async (req, res) => {
|
|
70
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
71
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
72
|
+
try {
|
|
73
|
+
const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
|
|
74
|
+
const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
|
|
75
|
+
const destProfile = store.getProfile(destProfileId);
|
|
76
|
+
|
|
77
|
+
if (!srcProfile) return writeJson(res, 404, { ok: false, error: 'Source profile not found' });
|
|
78
|
+
if (!destProfile) return writeJson(res, 404, { ok: false, error: 'Destination profile not found' });
|
|
79
|
+
if (!sourcePath || !destPath) return writeJson(res, 400, { ok: false, error: 'sourcePath and destPath required' });
|
|
80
|
+
|
|
81
|
+
const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
|
|
82
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}), 'dsh-remote-workspace: /transfer');
|
|
88
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function writeJson(res, statusCode, body) {
|
|
2
|
+
if (typeof res.setHeader === 'function') {
|
|
3
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
4
|
+
res.statusCode = statusCode;
|
|
5
|
+
} else if (typeof res.writeHead === 'function') {
|
|
6
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
7
|
+
} else {
|
|
8
|
+
res.statusCode = statusCode;
|
|
9
|
+
}
|
|
10
|
+
res.end(JSON.stringify(body));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readBody(req) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let body = '';
|
|
16
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
17
|
+
req.on('end', () => {
|
|
18
|
+
try {
|
|
19
|
+
resolve(body ? JSON.parse(body) : {});
|
|
20
|
+
} catch (err) {
|
|
21
|
+
reject(err);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
req.on('error', reject);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isTrustedSettingsRequest(req) {
|
|
29
|
+
const rawAuth = req.headers['authorization'];
|
|
30
|
+
if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) return true;
|
|
31
|
+
const cookieHeader = req.headers['cookie'];
|
|
32
|
+
if (typeof cookieHeader === 'string' && (cookieHeader.includes('token=') || cookieHeader.includes('dsh_token='))) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const secFetch = req.headers['sec-fetch-site'];
|
|
36
|
+
if (secFetch === 'same-origin') return true;
|
|
37
|
+
const ip = req.socket?.remoteAddress || '';
|
|
38
|
+
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|