@goodandready/dsh-remote-workspace 0.1.0 → 0.1.1
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 +60 -51
- package/docs/plans/findings.md +14 -2
- package/docs/plans/progress.md +11 -10
- package/docs/plans/task_plan.md +16 -19
- package/lib/client.js +439 -127
- package/lib/index.js +19 -14
- package/lib/mirror-sync-service.js +22 -6
- package/lib/routes.js +85 -20
- package/lib/ssh-service.js +31 -8
- package/lib/tunnel-service.js +20 -10
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -14,11 +14,11 @@ export const inject = ['tools'];
|
|
|
14
14
|
|
|
15
15
|
export const Config = z.object({
|
|
16
16
|
profiles: z.array(z.object({
|
|
17
|
-
id: z.string(),
|
|
18
|
-
name: z.string(),
|
|
19
|
-
host: z.string(),
|
|
17
|
+
id: z.string().default(''),
|
|
18
|
+
name: z.string().default(''),
|
|
19
|
+
host: z.string().default(''),
|
|
20
20
|
port: z.number().default(22),
|
|
21
|
-
username: z.string(),
|
|
21
|
+
username: z.string().default('root'),
|
|
22
22
|
authType: z.string().default('key'),
|
|
23
23
|
privateKeyPath: z.string().default(''),
|
|
24
24
|
privateKey: z.string().default(''),
|
|
@@ -38,6 +38,11 @@ export function apply(ctx, config = {}) {
|
|
|
38
38
|
activeProfileId: cfg.activeProfileId || null
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
const sshService = new SshService(ctx);
|
|
42
|
+
const remoteFs = new RemoteFsService(sshService);
|
|
43
|
+
const mirrorSync = new MirrorSyncService(remoteFs, sshService);
|
|
44
|
+
const tunnelService = new TunnelService(sshService);
|
|
45
|
+
|
|
41
46
|
const store = {
|
|
42
47
|
getProfiles() { return currentConfig.profiles || []; },
|
|
43
48
|
getProfile(id) { return (currentConfig.profiles || []).find((p) => p.id === id); },
|
|
@@ -52,8 +57,9 @@ export function apply(ctx, config = {}) {
|
|
|
52
57
|
if (!currentConfig.activeProfileId && list.length === 1) {
|
|
53
58
|
currentConfig.activeProfileId = p.id;
|
|
54
59
|
}
|
|
60
|
+
sshService.invalidate(p.id);
|
|
55
61
|
if (settingsApi?.replace) {
|
|
56
|
-
await settingsApi.replace(Config(currentConfig));
|
|
62
|
+
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
57
63
|
}
|
|
58
64
|
},
|
|
59
65
|
async deleteProfile(id) {
|
|
@@ -61,23 +67,27 @@ export function apply(ctx, config = {}) {
|
|
|
61
67
|
if (currentConfig.activeProfileId === id) {
|
|
62
68
|
currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
|
|
63
69
|
}
|
|
70
|
+
sshService.disconnect(id);
|
|
64
71
|
if (settingsApi?.replace) {
|
|
65
|
-
await settingsApi.replace(Config(currentConfig));
|
|
72
|
+
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
66
73
|
}
|
|
67
74
|
},
|
|
68
75
|
async setActiveId(id) {
|
|
69
76
|
currentConfig.activeProfileId = id;
|
|
70
77
|
if (settingsApi?.replace) {
|
|
71
|
-
await settingsApi.replace(Config(currentConfig));
|
|
78
|
+
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
};
|
|
75
82
|
|
|
76
83
|
// Register settings namespace if settings service is available
|
|
77
84
|
ctx.inject(['settings'], (sctx) => {
|
|
78
|
-
const
|
|
85
|
+
const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
|
|
86
|
+
if (!settingsService || typeof settingsService.register !== 'function') return;
|
|
87
|
+
|
|
88
|
+
const scope = settingsService.register(NS, Config, { base: cfg });
|
|
79
89
|
settingsApi = scope;
|
|
80
|
-
const snap = scope.get();
|
|
90
|
+
const snap = scope.get ? scope.get() : null;
|
|
81
91
|
if (snap) {
|
|
82
92
|
if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
|
|
83
93
|
if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
|
|
@@ -87,11 +97,6 @@ export function apply(ctx, config = {}) {
|
|
|
87
97
|
});
|
|
88
98
|
});
|
|
89
99
|
|
|
90
|
-
const sshService = new SshService(ctx);
|
|
91
|
-
const remoteFs = new RemoteFsService(sshService);
|
|
92
|
-
const mirrorSync = new MirrorSyncService(remoteFs, sshService);
|
|
93
|
-
const tunnelService = new TunnelService(sshService);
|
|
94
|
-
|
|
95
100
|
ctx.provide('remoteSsh');
|
|
96
101
|
ctx.provide('remoteFs');
|
|
97
102
|
ctx.provide('remoteSync');
|
|
@@ -57,22 +57,28 @@ export class MirrorSyncService {
|
|
|
57
57
|
const updated = [];
|
|
58
58
|
const newSnapshot = { ...lastSnapshot };
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
|
|
60
|
+
// Try GNU find with printf first; fallback to standard find if unsupported
|
|
61
|
+
let findCmd = `find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*' -printf '%P\\t%s\\t%T@\\n' 2>/dev/null || find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*'`;
|
|
62
62
|
const execRes = await this.sshService.exec(profile, findCmd, remoteDir);
|
|
63
|
-
if (execRes.code !== 0) {
|
|
64
|
-
throw new Error(`Failed to scan remote dir: ${execRes.stderr}`);
|
|
63
|
+
if (execRes.code !== 0 && !execRes.stdout.trim()) {
|
|
64
|
+
throw new Error(`Failed to scan remote dir: ${execRes.stderr || 'No response from remote host'}`);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
const lines = execRes.stdout.split('\n').filter(Boolean);
|
|
68
68
|
for (const line of lines) {
|
|
69
|
-
|
|
69
|
+
let relPath = line.split('\t')[0].trim();
|
|
70
|
+
if (relPath.startsWith('./')) relPath = relPath.slice(2);
|
|
70
71
|
if (!relPath || this.isIgnored(relPath)) continue;
|
|
71
72
|
|
|
72
73
|
const remoteFullPath = path.posix.join(remoteDir, relPath);
|
|
73
74
|
const localFullPath = path.join(localDir, relPath);
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
let remoteContent = '';
|
|
77
|
+
try {
|
|
78
|
+
remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
|
|
79
|
+
} catch (err) {
|
|
80
|
+
continue; // skip unreadable files
|
|
81
|
+
}
|
|
76
82
|
const remoteHash = this.computeHash(remoteContent);
|
|
77
83
|
const baseHash = lastSnapshot[relPath];
|
|
78
84
|
|
|
@@ -117,6 +123,16 @@ export class MirrorSyncService {
|
|
|
117
123
|
* 3-way conflict aware push: Local Mirror -> Remote
|
|
118
124
|
*/
|
|
119
125
|
async push(profile, localDir, remoteDir, force = false, dryRun = false) {
|
|
126
|
+
if (!fs.existsSync(localDir)) {
|
|
127
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
128
|
+
return {
|
|
129
|
+
success: true,
|
|
130
|
+
pushed: [],
|
|
131
|
+
conflicts: [],
|
|
132
|
+
dryRun
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
120
136
|
const snapshotFile = path.join(localDir, '.dsh-sync-snapshot.json');
|
|
121
137
|
let snapshot = {};
|
|
122
138
|
if (fs.existsSync(snapshotFile)) {
|
package/lib/routes.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
function writeJson(res, code, data) {
|
|
1
|
+
export function writeJson(res, code, data) {
|
|
2
2
|
try {
|
|
3
|
-
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
3
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
4
4
|
res.end(JSON.stringify(data));
|
|
5
5
|
} catch {}
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
function readBody(req, maxBytes = 256 * 1024) {
|
|
8
|
+
export function readBody(req, maxBytes = 256 * 1024) {
|
|
9
9
|
return new Promise((resolve, reject) => {
|
|
10
10
|
const chunks = [];
|
|
11
11
|
let size = 0;
|
|
@@ -30,14 +30,22 @@ function readBody(req, maxBytes = 256 * 1024) {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Reject cross-site writes. LAN / reverse-proxy UIs are allowed. */
|
|
34
|
+
export function isTrustedSettingsRequest(request) {
|
|
35
|
+
return request.headers['sec-fetch-site'] !== 'cross-site';
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
|
|
34
39
|
ctx.inject(['webServer'], (sctx) => {
|
|
40
|
+
const webServer = sctx.get ? (sctx.get('webServer') ?? sctx.webServer) : sctx.webServer;
|
|
41
|
+
if (!webServer || typeof webServer.register !== 'function') return;
|
|
42
|
+
|
|
35
43
|
// 1. GET /dsh-remote-workspace/state
|
|
36
|
-
sctx.effect(() =>
|
|
44
|
+
sctx.effect(() => webServer.register({
|
|
37
45
|
kind: 'exact',
|
|
38
46
|
path: '/dsh-remote-workspace/state',
|
|
39
47
|
handler: async (req, res) => {
|
|
40
|
-
if (req.method !== 'GET') return writeJson(res, 405, { error: 'GET only' });
|
|
48
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
41
49
|
const profiles = store.getProfiles();
|
|
42
50
|
const activeId = store.getActiveId();
|
|
43
51
|
const tunnels = tunnelService.listActiveTunnels();
|
|
@@ -46,16 +54,18 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
46
54
|
}), 'dsh-remote-workspace: /state');
|
|
47
55
|
|
|
48
56
|
// 2. POST /dsh-remote-workspace/profiles/save
|
|
49
|
-
sctx.effect(() =>
|
|
57
|
+
sctx.effect(() => webServer.register({
|
|
50
58
|
kind: 'exact',
|
|
51
59
|
path: '/dsh-remote-workspace/profiles/save',
|
|
52
60
|
handler: async (req, res) => {
|
|
53
|
-
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
61
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
62
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
54
63
|
try {
|
|
55
64
|
const profile = await readBody(req);
|
|
56
65
|
if (!profile.id || !profile.host) {
|
|
57
66
|
return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
|
|
58
67
|
}
|
|
68
|
+
sshService.invalidate(profile.id);
|
|
59
69
|
await store.saveProfile(profile);
|
|
60
70
|
writeJson(res, 200, { ok: true, profile });
|
|
61
71
|
} catch (err) {
|
|
@@ -65,14 +75,16 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
65
75
|
}), 'dsh-remote-workspace: /profiles/save');
|
|
66
76
|
|
|
67
77
|
// 3. POST /dsh-remote-workspace/profiles/delete
|
|
68
|
-
sctx.effect(() =>
|
|
78
|
+
sctx.effect(() => webServer.register({
|
|
69
79
|
kind: 'exact',
|
|
70
80
|
path: '/dsh-remote-workspace/profiles/delete',
|
|
71
81
|
handler: async (req, res) => {
|
|
72
|
-
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
82
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
83
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
73
84
|
try {
|
|
74
85
|
const { id } = await readBody(req);
|
|
75
86
|
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
87
|
+
sshService.disconnect(id);
|
|
76
88
|
await store.deleteProfile(id);
|
|
77
89
|
writeJson(res, 200, { ok: true });
|
|
78
90
|
} catch (err) {
|
|
@@ -82,11 +94,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
82
94
|
}), 'dsh-remote-workspace: /profiles/delete');
|
|
83
95
|
|
|
84
96
|
// 4. POST /dsh-remote-workspace/profiles/active
|
|
85
|
-
sctx.effect(() =>
|
|
97
|
+
sctx.effect(() => webServer.register({
|
|
86
98
|
kind: 'exact',
|
|
87
99
|
path: '/dsh-remote-workspace/profiles/active',
|
|
88
100
|
handler: async (req, res) => {
|
|
89
|
-
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
101
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
102
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
90
103
|
try {
|
|
91
104
|
const { id } = await readBody(req);
|
|
92
105
|
await store.setActiveId(id || null);
|
|
@@ -98,11 +111,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
98
111
|
}), 'dsh-remote-workspace: /profiles/active');
|
|
99
112
|
|
|
100
113
|
// 5. POST /dsh-remote-workspace/test
|
|
101
|
-
sctx.effect(() =>
|
|
114
|
+
sctx.effect(() => webServer.register({
|
|
102
115
|
kind: 'exact',
|
|
103
116
|
path: '/dsh-remote-workspace/test',
|
|
104
117
|
handler: async (req, res) => {
|
|
105
|
-
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
118
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
119
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
106
120
|
try {
|
|
107
121
|
const body = await readBody(req);
|
|
108
122
|
const profile = body.profile || (body.id ? store.getProfile(body.id) : body);
|
|
@@ -110,7 +124,14 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
110
124
|
return writeJson(res, 400, { ok: false, error: 'Invalid profile configuration' });
|
|
111
125
|
}
|
|
112
126
|
const result = await sshService.testConnection(profile);
|
|
113
|
-
writeJson(res, 200, {
|
|
127
|
+
writeJson(res, 200, {
|
|
128
|
+
ok: result.success,
|
|
129
|
+
success: result.success,
|
|
130
|
+
latency: result.latency,
|
|
131
|
+
latencyMs: result.latency,
|
|
132
|
+
os: result.os,
|
|
133
|
+
remoteOs: result.os
|
|
134
|
+
});
|
|
114
135
|
} catch (err) {
|
|
115
136
|
writeJson(res, 200, { ok: false, error: err.message });
|
|
116
137
|
}
|
|
@@ -118,11 +139,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
118
139
|
}), 'dsh-remote-workspace: /test');
|
|
119
140
|
|
|
120
141
|
// 6. POST /dsh-remote-workspace/browse
|
|
121
|
-
sctx.effect(() =>
|
|
142
|
+
sctx.effect(() => webServer.register({
|
|
122
143
|
kind: 'exact',
|
|
123
144
|
path: '/dsh-remote-workspace/browse',
|
|
124
145
|
handler: async (req, res) => {
|
|
125
|
-
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
146
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
147
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
126
148
|
try {
|
|
127
149
|
const body = await readBody(req);
|
|
128
150
|
const profile = body.profile || (body.profileId ? store.getProfile(body.profileId) : store.getActiveProfile());
|
|
@@ -139,17 +161,18 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
139
161
|
}), 'dsh-remote-workspace: /browse');
|
|
140
162
|
|
|
141
163
|
// 7. POST /dsh-remote-workspace/sync
|
|
142
|
-
sctx.effect(() =>
|
|
164
|
+
sctx.effect(() => webServer.register({
|
|
143
165
|
kind: 'exact',
|
|
144
166
|
path: '/dsh-remote-workspace/sync',
|
|
145
167
|
handler: async (req, res) => {
|
|
146
|
-
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
168
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
169
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
147
170
|
try {
|
|
148
171
|
const { profileId, direction } = await readBody(req);
|
|
149
|
-
const profile = store.getProfile(profileId) || store.getActiveProfile();
|
|
172
|
+
const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
|
|
150
173
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
151
174
|
if (!profile.remoteWorkspace || !profile.localMirrorPath) {
|
|
152
|
-
return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be
|
|
175
|
+
return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be configured' });
|
|
153
176
|
}
|
|
154
177
|
const resSync = direction === 'push'
|
|
155
178
|
? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace)
|
|
@@ -160,5 +183,47 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
160
183
|
}
|
|
161
184
|
}
|
|
162
185
|
}), 'dsh-remote-workspace: /sync');
|
|
186
|
+
|
|
187
|
+
// 8. POST /dsh-remote-workspace/tunnels/start
|
|
188
|
+
sctx.effect(() => webServer.register({
|
|
189
|
+
kind: 'exact',
|
|
190
|
+
path: '/dsh-remote-workspace/tunnels/start',
|
|
191
|
+
handler: async (req, res) => {
|
|
192
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
193
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
194
|
+
try {
|
|
195
|
+
const { profileId, localPort, targetHost, remotePort } = await readBody(req);
|
|
196
|
+
const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
|
|
197
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
198
|
+
const lPort = parseInt(localPort, 10);
|
|
199
|
+
const rPort = parseInt(remotePort, 10);
|
|
200
|
+
if (!lPort || !rPort) {
|
|
201
|
+
return writeJson(res, 400, { ok: false, error: 'Valid localPort and remotePort required' });
|
|
202
|
+
}
|
|
203
|
+
const result = await tunnelService.startLocalTunnel(profile, lPort, targetHost || '127.0.0.1', rPort);
|
|
204
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}), 'dsh-remote-workspace: /tunnels/start');
|
|
210
|
+
|
|
211
|
+
// 9. POST /dsh-remote-workspace/tunnels/stop
|
|
212
|
+
sctx.effect(() => webServer.register({
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: '/dsh-remote-workspace/tunnels/stop',
|
|
215
|
+
handler: async (req, res) => {
|
|
216
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
217
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
218
|
+
try {
|
|
219
|
+
const { tunnelId } = await readBody(req);
|
|
220
|
+
if (!tunnelId) return writeJson(res, 400, { ok: false, error: 'tunnelId required' });
|
|
221
|
+
const stopped = tunnelService.stopTunnel(tunnelId);
|
|
222
|
+
writeJson(res, 200, { ok: stopped });
|
|
223
|
+
} catch (err) {
|
|
224
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}), 'dsh-remote-workspace: /tunnels/stop');
|
|
163
228
|
});
|
|
164
229
|
}
|
package/lib/ssh-service.js
CHANGED
|
@@ -20,13 +20,15 @@ export class SshService {
|
|
|
20
20
|
return profile.privateKey;
|
|
21
21
|
}
|
|
22
22
|
if (profile.privateKeyPath) {
|
|
23
|
-
let resolvedPath = profile.privateKeyPath;
|
|
23
|
+
let resolvedPath = profile.privateKeyPath.trim();
|
|
24
24
|
if (resolvedPath.startsWith('~')) {
|
|
25
25
|
resolvedPath = path.join(os.homedir(), resolvedPath.slice(1));
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
try {
|
|
28
|
+
if (fs.existsSync(resolvedPath)) {
|
|
29
|
+
return fs.readFileSync(resolvedPath, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
} catch (_) {}
|
|
30
32
|
}
|
|
31
33
|
return undefined;
|
|
32
34
|
}
|
|
@@ -53,7 +55,7 @@ export class SshService {
|
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
if (profile.authType === 'password') {
|
|
56
|
-
connConfig.password = profile.password;
|
|
58
|
+
connConfig.password = profile.password || '';
|
|
57
59
|
} else if (profile.authType === 'agent') {
|
|
58
60
|
connConfig.agent = process.env.SSH_AUTH_SOCK;
|
|
59
61
|
} else {
|
|
@@ -92,6 +94,10 @@ export class SshService {
|
|
|
92
94
|
* Test connection and measure ping / detect OS
|
|
93
95
|
*/
|
|
94
96
|
async testConnection(profile) {
|
|
97
|
+
// Invalidate any existing connection for this profile id to ensure fresh credentials are used
|
|
98
|
+
if (profile.id) {
|
|
99
|
+
this.disconnect(profile.id);
|
|
100
|
+
}
|
|
95
101
|
const start = Date.now();
|
|
96
102
|
const conn = await this.getConnection(profile);
|
|
97
103
|
const latency = Date.now() - start;
|
|
@@ -99,12 +105,25 @@ export class SshService {
|
|
|
99
105
|
return new Promise((resolve) => {
|
|
100
106
|
conn.exec('uname -s -r -m || ver', (err, stream) => {
|
|
101
107
|
if (err) {
|
|
102
|
-
return resolve({
|
|
108
|
+
return resolve({
|
|
109
|
+
success: true,
|
|
110
|
+
latency,
|
|
111
|
+
latencyMs: latency,
|
|
112
|
+
os: 'Unknown',
|
|
113
|
+
remoteOs: 'Unknown'
|
|
114
|
+
});
|
|
103
115
|
}
|
|
104
116
|
let output = '';
|
|
105
117
|
stream.on('data', (d) => { output += d.toString(); });
|
|
106
118
|
stream.on('close', () => {
|
|
107
|
-
|
|
119
|
+
const osStr = output.trim() || 'Unknown';
|
|
120
|
+
resolve({
|
|
121
|
+
success: true,
|
|
122
|
+
latency,
|
|
123
|
+
latencyMs: latency,
|
|
124
|
+
os: osStr,
|
|
125
|
+
remoteOs: osStr
|
|
126
|
+
});
|
|
108
127
|
});
|
|
109
128
|
});
|
|
110
129
|
});
|
|
@@ -157,10 +176,14 @@ export class SshService {
|
|
|
157
176
|
});
|
|
158
177
|
}
|
|
159
178
|
|
|
179
|
+
invalidate(profileId) {
|
|
180
|
+
this.disconnect(profileId);
|
|
181
|
+
}
|
|
182
|
+
|
|
160
183
|
disconnect(profileId) {
|
|
161
184
|
const conn = this.connections.get(profileId);
|
|
162
185
|
if (conn) {
|
|
163
|
-
conn.end();
|
|
186
|
+
try { conn.end(); } catch (_) {}
|
|
164
187
|
this.connections.delete(profileId);
|
|
165
188
|
this.sftpSessions.delete(profileId);
|
|
166
189
|
}
|
package/lib/tunnel-service.js
CHANGED
|
@@ -6,7 +6,7 @@ export class TunnelService {
|
|
|
6
6
|
*/
|
|
7
7
|
constructor(sshService) {
|
|
8
8
|
this.sshService = sshService;
|
|
9
|
-
/** @type {Map<string, { server?: net.Server, profileId: string, localPort: number, remotePort: number }>} */
|
|
9
|
+
/** @type {Map<string, { server?: net.Server, profileId: string, localPort: number, targetHost: string, remotePort: number }>} */
|
|
10
10
|
this.activeTunnels = new Map();
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -16,7 +16,7 @@ export class TunnelService {
|
|
|
16
16
|
async startLocalTunnel(profile, localPort, targetHost = '127.0.0.1', targetPort) {
|
|
17
17
|
const tunnelId = `${profile.id}:${localPort}->${targetPort}`;
|
|
18
18
|
if (this.activeTunnels.has(tunnelId)) {
|
|
19
|
-
return { success: true, message: 'Tunnel already active', tunnelId };
|
|
19
|
+
return { success: true, message: 'Tunnel already active', tunnelId, localPort, remotePort: targetPort };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const conn = await this.sshService.getConnection(profile);
|
|
@@ -30,27 +30,34 @@ export class TunnelService {
|
|
|
30
30
|
targetPort,
|
|
31
31
|
(err, stream) => {
|
|
32
32
|
if (err) {
|
|
33
|
-
socket.destroy();
|
|
33
|
+
try { socket.destroy(); } catch (_) {}
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
+
stream.on('error', () => {
|
|
37
|
+
try { socket.destroy(); } catch (_) {}
|
|
38
|
+
});
|
|
39
|
+
socket.on('error', () => {
|
|
40
|
+
try { stream.destroy(); } catch (_) {}
|
|
41
|
+
});
|
|
36
42
|
socket.pipe(stream).pipe(socket);
|
|
37
43
|
}
|
|
38
44
|
);
|
|
39
45
|
});
|
|
40
46
|
|
|
47
|
+
server.on('error', (err) => {
|
|
48
|
+
this.activeTunnels.delete(tunnelId);
|
|
49
|
+
reject(err);
|
|
50
|
+
});
|
|
51
|
+
|
|
41
52
|
server.listen(localPort, '127.0.0.1', () => {
|
|
42
53
|
this.activeTunnels.set(tunnelId, {
|
|
43
54
|
server,
|
|
44
55
|
profileId: profile.id,
|
|
45
56
|
localPort,
|
|
57
|
+
targetHost,
|
|
46
58
|
remotePort: targetPort
|
|
47
59
|
});
|
|
48
|
-
resolve({ success: true, tunnelId, localPort, targetPort });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
server.on('error', (err) => {
|
|
52
|
-
this.activeTunnels.delete(tunnelId);
|
|
53
|
-
reject(err);
|
|
60
|
+
resolve({ success: true, tunnelId, localPort, targetHost, remotePort: targetPort });
|
|
54
61
|
});
|
|
55
62
|
});
|
|
56
63
|
}
|
|
@@ -61,7 +68,9 @@ export class TunnelService {
|
|
|
61
68
|
stopTunnel(tunnelId) {
|
|
62
69
|
const tunnel = this.activeTunnels.get(tunnelId);
|
|
63
70
|
if (tunnel && tunnel.server) {
|
|
64
|
-
|
|
71
|
+
try {
|
|
72
|
+
tunnel.server.close();
|
|
73
|
+
} catch (_) {}
|
|
65
74
|
this.activeTunnels.delete(tunnelId);
|
|
66
75
|
return true;
|
|
67
76
|
}
|
|
@@ -73,6 +82,7 @@ export class TunnelService {
|
|
|
73
82
|
id,
|
|
74
83
|
profileId: t.profileId,
|
|
75
84
|
localPort: t.localPort,
|
|
85
|
+
targetHost: t.targetHost || '127.0.0.1',
|
|
76
86
|
remotePort: t.remotePort
|
|
77
87
|
}));
|
|
78
88
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-remote-workspace",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Enterprise Remote Workspace Plugin for DeepSeek Harness: SSH2 connection pool, SFTP operations, 3-way conflict-aware mirror sync, port forwarding, and native DSH UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|