@goodandready/dsh-remote-workspace 0.3.1 → 0.3.3
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/docs/design/DESIGN.md +15 -1
- package/lib/client.js +2322 -2179
- package/lib/docker-service.js +1 -1
- package/lib/index.js +42 -12
- 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 +53 -627
- package/lib/ssh-service.js +24 -9
- package/lib/tunnel-service.js +2 -2
- package/lib/updater-service.js +339 -0
- package/lib/vault-service.js +29 -5
- package/lib/watcher-service.js +3 -3
- package/package.json +9 -4
- package/docs/plans/findings.md +0 -19
- package/docs/plans/progress.md +0 -15
- package/docs/plans/task_plan.md +0 -21
package/lib/docker-service.js
CHANGED
package/lib/index.js
CHANGED
|
@@ -82,6 +82,19 @@ export function apply(ctx, config = {}) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
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
|
+
|
|
85
98
|
const store = {
|
|
86
99
|
vault: vaultService,
|
|
87
100
|
getRawProfiles() { return currentConfig.profiles || []; },
|
|
@@ -105,9 +118,7 @@ export function apply(ctx, config = {}) {
|
|
|
105
118
|
if (enabled) watcherService.start(active, mirrorSync);
|
|
106
119
|
else watcherService.stop(active.id);
|
|
107
120
|
}
|
|
108
|
-
|
|
109
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
110
|
-
}
|
|
121
|
+
await persistConfig('update');
|
|
111
122
|
},
|
|
112
123
|
async saveProfile(rawP) {
|
|
113
124
|
const list = [...(currentConfig.profiles || [])];
|
|
@@ -140,9 +151,7 @@ export function apply(ctx, config = {}) {
|
|
|
140
151
|
currentConfig.activeProfileId = p.id;
|
|
141
152
|
}
|
|
142
153
|
sshService.invalidate(p.id);
|
|
143
|
-
|
|
144
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
145
|
-
}
|
|
154
|
+
await persistConfig('update');
|
|
146
155
|
},
|
|
147
156
|
async deleteProfile(id) {
|
|
148
157
|
watcherService.stop(id);
|
|
@@ -152,9 +161,7 @@ export function apply(ctx, config = {}) {
|
|
|
152
161
|
}
|
|
153
162
|
vaultService.deleteProfileSecrets(id);
|
|
154
163
|
sshService.disconnect(id);
|
|
155
|
-
|
|
156
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
157
|
-
}
|
|
164
|
+
await persistConfig('update');
|
|
158
165
|
},
|
|
159
166
|
async setActiveId(id) {
|
|
160
167
|
currentConfig.activeProfileId = id;
|
|
@@ -163,13 +170,34 @@ export function apply(ctx, config = {}) {
|
|
|
163
170
|
const active = store.getActiveProfile();
|
|
164
171
|
if (active) watcherService.start(active, mirrorSync);
|
|
165
172
|
}
|
|
166
|
-
|
|
167
|
-
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
168
|
-
}
|
|
173
|
+
await persistConfig('update');
|
|
169
174
|
}
|
|
170
175
|
};
|
|
171
176
|
|
|
172
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
|
+
};
|
|
173
201
|
|
|
174
202
|
// If autoSync was enabled on restart, resume watcher for active profile
|
|
175
203
|
if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
|
|
@@ -208,6 +236,8 @@ export function apply(ctx, config = {}) {
|
|
|
208
236
|
ctx.provide('remoteDiagnose');
|
|
209
237
|
ctx.provide('remoteEnv');
|
|
210
238
|
ctx.provide('remoteAlert');
|
|
239
|
+
ctx.provide('remoteProfiles');
|
|
240
|
+
ctx.remoteProfiles = safeProfilesService;
|
|
211
241
|
|
|
212
242
|
ctx.remoteSsh = sshService;
|
|
213
243
|
ctx.remoteFs = remoteFs;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
2
|
+
import { SshConfigParser } from '../ssh-config-parser.js';
|
|
3
|
+
|
|
4
|
+
export function registerProfileRoutes(sctx, webServer, { store, sshService, alertService, tunnelService }) {
|
|
5
|
+
// 1. GET /dsh-remote-workspace/state
|
|
6
|
+
sctx.effect(() => webServer.register({
|
|
7
|
+
kind: 'exact',
|
|
8
|
+
path: '/dsh-remote-workspace/state',
|
|
9
|
+
handler: async (req, res) => {
|
|
10
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
11
|
+
try {
|
|
12
|
+
const profiles = store.getProfiles();
|
|
13
|
+
const activeId = store.getActiveId() || (profiles[0]?.id || '');
|
|
14
|
+
const tunnels = tunnelService.listActiveTunnels();
|
|
15
|
+
const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
|
|
16
|
+
const alerts = alertService ? alertService.getAlerts() : [];
|
|
17
|
+
const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : p));
|
|
18
|
+
writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}), 'dsh-remote-workspace: /state');
|
|
24
|
+
|
|
25
|
+
// 2. POST /dsh-remote-workspace/profiles/save
|
|
26
|
+
sctx.effect(() => webServer.register({
|
|
27
|
+
kind: 'exact',
|
|
28
|
+
path: '/dsh-remote-workspace/profiles/save',
|
|
29
|
+
handler: async (req, res) => {
|
|
30
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
31
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
32
|
+
try {
|
|
33
|
+
const profile = await readBody(req);
|
|
34
|
+
if (!profile.id || !profile.host) {
|
|
35
|
+
return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
|
|
36
|
+
}
|
|
37
|
+
sshService.invalidate(profile.id);
|
|
38
|
+
await store.saveProfile(profile);
|
|
39
|
+
const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : profile;
|
|
40
|
+
writeJson(res, 200, { ok: true, profile: sanitized });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}), 'dsh-remote-workspace: /profiles/save');
|
|
46
|
+
|
|
47
|
+
// 3. POST /dsh-remote-workspace/profiles/delete
|
|
48
|
+
sctx.effect(() => webServer.register({
|
|
49
|
+
kind: 'exact',
|
|
50
|
+
path: '/dsh-remote-workspace/profiles/delete',
|
|
51
|
+
handler: async (req, res) => {
|
|
52
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
53
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
54
|
+
try {
|
|
55
|
+
const { id } = await readBody(req);
|
|
56
|
+
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
57
|
+
sshService.disconnect(id);
|
|
58
|
+
await store.deleteProfile(id);
|
|
59
|
+
writeJson(res, 200, { ok: true });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}), 'dsh-remote-workspace: /profiles/delete');
|
|
65
|
+
|
|
66
|
+
// 4. POST /dsh-remote-workspace/profiles/active
|
|
67
|
+
sctx.effect(() => webServer.register({
|
|
68
|
+
kind: 'exact',
|
|
69
|
+
path: '/dsh-remote-workspace/profiles/active',
|
|
70
|
+
handler: async (req, res) => {
|
|
71
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
72
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
73
|
+
try {
|
|
74
|
+
const { id } = await readBody(req);
|
|
75
|
+
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
76
|
+
await store.setActiveId(id);
|
|
77
|
+
writeJson(res, 200, { ok: true, activeId: id });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}), 'dsh-remote-workspace: /profiles/active');
|
|
83
|
+
|
|
84
|
+
// 5. POST /dsh-remote-workspace/profiles/import-ssh-config
|
|
85
|
+
sctx.effect(() => webServer.register({
|
|
86
|
+
kind: 'exact',
|
|
87
|
+
path: '/dsh-remote-workspace/profiles/import-ssh-config',
|
|
88
|
+
handler: async (req, res) => {
|
|
89
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
90
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
91
|
+
try {
|
|
92
|
+
const { content } = await readBody(req);
|
|
93
|
+
if (!content || typeof content !== 'string') {
|
|
94
|
+
return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
|
|
95
|
+
}
|
|
96
|
+
const importedProfiles = SshConfigParser.parse(content);
|
|
97
|
+
for (const p of importedProfiles) {
|
|
98
|
+
await store.saveProfile(p);
|
|
99
|
+
}
|
|
100
|
+
writeJson(res, 200, { ok: true, count: importedProfiles.length, profiles: importedProfiles });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}), 'dsh-remote-workspace: /profiles/import-ssh-config');
|
|
106
|
+
|
|
107
|
+
// 6. POST /dsh-remote-workspace/test
|
|
108
|
+
sctx.effect(() => webServer.register({
|
|
109
|
+
kind: 'exact',
|
|
110
|
+
path: '/dsh-remote-workspace/test',
|
|
111
|
+
handler: async (req, res) => {
|
|
112
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
113
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
114
|
+
try {
|
|
115
|
+
const profile = await readBody(req);
|
|
116
|
+
if (!profile.host) return writeJson(res, 400, { ok: false, error: 'Host is required' });
|
|
117
|
+
|
|
118
|
+
const existing = store.getProfile(profile.id);
|
|
119
|
+
const effectiveProfile = {
|
|
120
|
+
...profile,
|
|
121
|
+
password: profile.password && profile.password !== '••••••••'
|
|
122
|
+
? profile.password
|
|
123
|
+
: (existing?.password || ''),
|
|
124
|
+
privateKey: profile.privateKey && profile.privateKey !== '••••••••'
|
|
125
|
+
? profile.privateKey
|
|
126
|
+
: (existing?.privateKey || ''),
|
|
127
|
+
passphrase: profile.passphrase && profile.passphrase !== '••••••••'
|
|
128
|
+
? profile.passphrase
|
|
129
|
+
: (existing?.passphrase || '')
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const result = await sshService.testConnection(effectiveProfile);
|
|
133
|
+
const latencyMs = result.latency !== undefined ? result.latency : (result.latencyMs ?? 0);
|
|
134
|
+
const remoteOs = result.os !== undefined ? result.os : (result.remoteOs ?? 'Linux/Unix');
|
|
135
|
+
writeJson(res, 200, { ok: true, latency: latencyMs, latencyMs, os: remoteOs, remoteOs });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
writeJson(res, 200, { ok: false, error: err.message });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}), 'dsh-remote-workspace: /test');
|
|
141
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
|
|
2
|
+
|
|
3
|
+
export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService, watcherService, store }) {
|
|
4
|
+
// 17. POST /dsh-remote-workspace/sync
|
|
5
|
+
sctx.effect(() => webServer.register({
|
|
6
|
+
kind: 'exact',
|
|
7
|
+
path: '/dsh-remote-workspace/sync',
|
|
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, direction, force, mode } = await readBody(req);
|
|
13
|
+
const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
|
|
14
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
15
|
+
if (!profile.remoteWorkspace || !profile.localMirrorPath) {
|
|
16
|
+
return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be configured' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (mode === 'tarball' && tarSyncService) {
|
|
20
|
+
const resTar = direction === 'push'
|
|
21
|
+
? await tarSyncService.pushTar(profile, profile.localMirrorPath, profile.remoteWorkspace)
|
|
22
|
+
: await tarSyncService.pullTar(profile, profile.remoteWorkspace, profile.localMirrorPath);
|
|
23
|
+
return writeJson(res, 200, { ok: true, ...resTar });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const resSync = direction === 'push'
|
|
27
|
+
? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace, Boolean(force))
|
|
28
|
+
: await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
|
|
29
|
+
writeJson(res, 200, { ok: resSync.success, ...resSync });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}), 'dsh-remote-workspace: /sync');
|
|
35
|
+
|
|
36
|
+
// 25. POST /dsh-remote-workspace/watcher/toggle
|
|
37
|
+
sctx.effect(() => webServer.register({
|
|
38
|
+
kind: 'exact',
|
|
39
|
+
path: '/dsh-remote-workspace/watcher/toggle',
|
|
40
|
+
handler: async (req, res) => {
|
|
41
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
42
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
43
|
+
try {
|
|
44
|
+
const { enabled } = await readBody(req);
|
|
45
|
+
await store.setAutoSyncEnabled(Boolean(enabled));
|
|
46
|
+
writeJson(res, 200, { ok: true, autoSync: store.isAutoSyncEnabled() });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}), 'dsh-remote-workspace: /watcher/toggle');
|
|
52
|
+
}
|