@goodandready/dsh-remote-workspace 0.1.1 → 0.2.0
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/lib/client.js +1766 -1278
- package/lib/index.js +61 -5
- package/lib/mirror-sync-service.js +54 -13
- package/lib/remote-fs-service.js +58 -7
- package/lib/routes.js +215 -46
- package/lib/ssh-service.js +158 -10
- package/lib/tools.js +25 -11
- package/lib/tunnel-service.js +79 -36
- package/lib/vault-service.js +184 -0
- package/package.json +1 -1
package/lib/tunnel-service.js
CHANGED
|
@@ -6,75 +6,112 @@ export class TunnelService {
|
|
|
6
6
|
*/
|
|
7
7
|
constructor(sshService) {
|
|
8
8
|
this.sshService = sshService;
|
|
9
|
-
/** @type {Map<string, {
|
|
9
|
+
/** @type {Map<string, {
|
|
10
|
+
* server: net.Server,
|
|
11
|
+
* profileId: string,
|
|
12
|
+
* localPort: number,
|
|
13
|
+
* targetHost: string,
|
|
14
|
+
* remotePort: number,
|
|
15
|
+
* createdAt: number,
|
|
16
|
+
* bytesRx: number,
|
|
17
|
+
* bytesTx: number,
|
|
18
|
+
* totalConnections: number,
|
|
19
|
+
* activeConnections: number
|
|
20
|
+
* }>} */
|
|
10
21
|
this.activeTunnels = new Map();
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
/**
|
|
14
|
-
* Start
|
|
25
|
+
* Start local port forwarding tunnel: LocalPort -> RemoteHost:RemotePort with live telemetry
|
|
15
26
|
*/
|
|
16
|
-
async startLocalTunnel(profile, localPort, targetHost
|
|
17
|
-
const tunnelId = `${profile.id}:${localPort}->${
|
|
27
|
+
async startLocalTunnel(profile, localPort, targetHost, remotePort) {
|
|
28
|
+
const tunnelId = `${profile.id}:${localPort}->${targetHost}:${remotePort}`;
|
|
18
29
|
if (this.activeTunnels.has(tunnelId)) {
|
|
19
|
-
return { success: true,
|
|
30
|
+
return { success: true, tunnelId, alreadyRunning: true };
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
const conn = await this.sshService.getConnection(profile);
|
|
23
34
|
|
|
24
35
|
return new Promise((resolve, reject) => {
|
|
25
|
-
const
|
|
36
|
+
const tunnelMeta = {
|
|
37
|
+
server: null,
|
|
38
|
+
profileId: profile.id,
|
|
39
|
+
localPort,
|
|
40
|
+
targetHost,
|
|
41
|
+
remotePort,
|
|
42
|
+
createdAt: Date.now(),
|
|
43
|
+
bytesRx: 0,
|
|
44
|
+
bytesTx: 0,
|
|
45
|
+
totalConnections: 0,
|
|
46
|
+
activeConnections: 0
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const server = net.createServer((clientSocket) => {
|
|
50
|
+
tunnelMeta.totalConnections++;
|
|
51
|
+
tunnelMeta.activeConnections++;
|
|
52
|
+
|
|
26
53
|
conn.forwardOut(
|
|
27
54
|
'127.0.0.1',
|
|
28
|
-
|
|
55
|
+
clientSocket.remotePort || 0,
|
|
29
56
|
targetHost,
|
|
30
|
-
|
|
57
|
+
remotePort,
|
|
31
58
|
(err, stream) => {
|
|
32
59
|
if (err) {
|
|
33
|
-
|
|
60
|
+
tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
|
|
61
|
+
clientSocket.destroy();
|
|
34
62
|
return;
|
|
35
63
|
}
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
|
|
65
|
+
// Telemetry: measure bytes flowing through the tunnel
|
|
66
|
+
clientSocket.on('data', (d) => {
|
|
67
|
+
tunnelMeta.bytesRx += d.length;
|
|
38
68
|
});
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
stream.on('data', (d) => {
|
|
70
|
+
tunnelMeta.bytesTx += d.length;
|
|
41
71
|
});
|
|
42
|
-
|
|
72
|
+
|
|
73
|
+
clientSocket.pipe(stream);
|
|
74
|
+
stream.pipe(clientSocket);
|
|
75
|
+
|
|
76
|
+
const cleanup = () => {
|
|
77
|
+
tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
|
|
78
|
+
};
|
|
79
|
+
clientSocket.on('close', cleanup);
|
|
80
|
+
clientSocket.on('error', cleanup);
|
|
81
|
+
stream.on('close', cleanup);
|
|
82
|
+
stream.on('error', cleanup);
|
|
43
83
|
}
|
|
44
84
|
);
|
|
45
85
|
});
|
|
46
86
|
|
|
87
|
+
tunnelMeta.server = server;
|
|
88
|
+
|
|
47
89
|
server.on('error', (err) => {
|
|
48
90
|
this.activeTunnels.delete(tunnelId);
|
|
49
91
|
reject(err);
|
|
50
92
|
});
|
|
51
93
|
|
|
52
94
|
server.listen(localPort, '127.0.0.1', () => {
|
|
53
|
-
this.activeTunnels.set(tunnelId,
|
|
54
|
-
|
|
55
|
-
|
|
95
|
+
this.activeTunnels.set(tunnelId, tunnelMeta);
|
|
96
|
+
resolve({
|
|
97
|
+
success: true,
|
|
98
|
+
tunnelId,
|
|
56
99
|
localPort,
|
|
57
100
|
targetHost,
|
|
58
|
-
remotePort
|
|
101
|
+
remotePort
|
|
59
102
|
});
|
|
60
|
-
resolve({ success: true, tunnelId, localPort, targetHost, remotePort: targetPort });
|
|
61
103
|
});
|
|
62
104
|
});
|
|
63
105
|
}
|
|
64
106
|
|
|
65
|
-
/**
|
|
66
|
-
* Stop active tunnel
|
|
67
|
-
*/
|
|
68
107
|
stopTunnel(tunnelId) {
|
|
69
108
|
const tunnel = this.activeTunnels.get(tunnelId);
|
|
70
|
-
if (tunnel
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
return false;
|
|
109
|
+
if (!tunnel) return false;
|
|
110
|
+
try {
|
|
111
|
+
tunnel.server.close();
|
|
112
|
+
} catch (_) {}
|
|
113
|
+
this.activeTunnels.delete(tunnelId);
|
|
114
|
+
return true;
|
|
78
115
|
}
|
|
79
116
|
|
|
80
117
|
listActiveTunnels() {
|
|
@@ -82,16 +119,22 @@ export class TunnelService {
|
|
|
82
119
|
id,
|
|
83
120
|
profileId: t.profileId,
|
|
84
121
|
localPort: t.localPort,
|
|
85
|
-
targetHost: t.targetHost
|
|
86
|
-
remotePort: t.remotePort
|
|
122
|
+
targetHost: t.targetHost,
|
|
123
|
+
remotePort: t.remotePort,
|
|
124
|
+
createdAt: t.createdAt,
|
|
125
|
+
uptimeSeconds: Math.floor((Date.now() - t.createdAt) / 1000),
|
|
126
|
+
bytesRx: t.bytesRx,
|
|
127
|
+
bytesTx: t.bytesTx,
|
|
128
|
+
totalConnections: t.totalConnections,
|
|
129
|
+
activeConnections: t.activeConnections
|
|
87
130
|
}));
|
|
88
131
|
}
|
|
89
132
|
|
|
90
133
|
stopAll() {
|
|
91
|
-
for (const [id,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
134
|
+
for (const [id, tunnel] of this.activeTunnels.entries()) {
|
|
135
|
+
try {
|
|
136
|
+
tunnel.server.close();
|
|
137
|
+
} catch (_) {}
|
|
95
138
|
}
|
|
96
139
|
this.activeTunnels.clear();
|
|
97
140
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* VaultService manages credentials (password, privateKey, passphrase)
|
|
7
|
+
* stored in an isolated, secure .env file instead of DSH profiles JSON.
|
|
8
|
+
*/
|
|
9
|
+
export class VaultService {
|
|
10
|
+
constructor(customPath = null) {
|
|
11
|
+
if (customPath) {
|
|
12
|
+
this.envPath = customPath;
|
|
13
|
+
} else {
|
|
14
|
+
const homeDir = os.homedir();
|
|
15
|
+
const secretsDir = path.join(homeDir, '.dsh', 'secrets');
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.existsSync(secretsDir)) {
|
|
18
|
+
fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
|
19
|
+
}
|
|
20
|
+
} catch (_) {}
|
|
21
|
+
this.envPath = path.join(secretsDir, 'dsh-remote-workspace.env');
|
|
22
|
+
}
|
|
23
|
+
this.ensureEnvFile();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ensureEnvFile() {
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(this.envPath)) {
|
|
29
|
+
fs.writeFileSync(this.envPath, '# DSH Remote Workspace Encrypted Secrets Vault\n', { mode: 0o600, encoding: 'utf8' });
|
|
30
|
+
} else {
|
|
31
|
+
// Ensure file permissions are restricted to owner only
|
|
32
|
+
try { fs.chmodSync(this.envPath, 0o600); } catch (_) {}
|
|
33
|
+
}
|
|
34
|
+
} catch (_) {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse .env file into key-value map
|
|
39
|
+
*/
|
|
40
|
+
readAll() {
|
|
41
|
+
this.ensureEnvFile();
|
|
42
|
+
try {
|
|
43
|
+
const content = fs.readFileSync(this.envPath, 'utf8');
|
|
44
|
+
const lines = content.split('\n');
|
|
45
|
+
const store = {};
|
|
46
|
+
let currentKey = null;
|
|
47
|
+
let currentValue = '';
|
|
48
|
+
let isMultiline = false;
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (!isMultiline) {
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
54
|
+
const eqIdx = line.indexOf('=');
|
|
55
|
+
if (eqIdx === -1) continue;
|
|
56
|
+
const key = line.slice(0, eqIdx).trim();
|
|
57
|
+
let val = line.slice(eqIdx + 1);
|
|
58
|
+
|
|
59
|
+
if (val.startsWith('"') && !val.endsWith('"')) {
|
|
60
|
+
isMultiline = true;
|
|
61
|
+
currentKey = key;
|
|
62
|
+
currentValue = val.slice(1) + '\n';
|
|
63
|
+
} else if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
|
64
|
+
store[key] = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
|
65
|
+
} else {
|
|
66
|
+
store[key] = val;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
if (line.endsWith('"')) {
|
|
70
|
+
currentValue += line.slice(0, -1);
|
|
71
|
+
store[currentKey] = currentValue.replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
|
72
|
+
isMultiline = false;
|
|
73
|
+
currentKey = null;
|
|
74
|
+
currentValue = '';
|
|
75
|
+
} else {
|
|
76
|
+
currentValue += line + '\n';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return store;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
writeAll(store) {
|
|
87
|
+
this.ensureEnvFile();
|
|
88
|
+
const lines = ['# DSH Remote Workspace Secrets Vault - Auto Generated', ''];
|
|
89
|
+
for (const [key, val] of Object.entries(store)) {
|
|
90
|
+
if (val === undefined || val === null || val === '') continue;
|
|
91
|
+
const strVal = String(val);
|
|
92
|
+
if (strVal.includes('\n') || strVal.includes('"') || strVal.includes('=')) {
|
|
93
|
+
const escaped = strVal.replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
|
|
94
|
+
lines.push(`${key}="${escaped}"`);
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(`${key}=${strVal}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lines.push('');
|
|
100
|
+
fs.writeFileSync(this.envPath, lines.join('\n'), { mode: 0o600, encoding: 'utf8' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
keyPrefix(profileId) {
|
|
104
|
+
const cleanId = profileId.replace(/[^a-zA-Z0-9_]/g, '_').toUpperCase();
|
|
105
|
+
return `DRW_${cleanId}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getProfileSecrets(profileId) {
|
|
109
|
+
if (!profileId) return {};
|
|
110
|
+
const store = this.readAll();
|
|
111
|
+
const prefix = this.keyPrefix(profileId);
|
|
112
|
+
return {
|
|
113
|
+
password: store[`${prefix}_PASSWORD`] || '',
|
|
114
|
+
privateKey: store[`${prefix}_PRIVATE_KEY`] || '',
|
|
115
|
+
passphrase: store[`${prefix}_PASSPHRASE`] || ''
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setProfileSecrets(profileId, secrets = {}) {
|
|
120
|
+
if (!profileId) return;
|
|
121
|
+
const store = this.readAll();
|
|
122
|
+
const prefix = this.keyPrefix(profileId);
|
|
123
|
+
|
|
124
|
+
if (secrets.password !== undefined) {
|
|
125
|
+
if (secrets.password) store[`${prefix}_PASSWORD`] = secrets.password;
|
|
126
|
+
else delete store[`${prefix}_PASSWORD`];
|
|
127
|
+
}
|
|
128
|
+
if (secrets.privateKey !== undefined) {
|
|
129
|
+
if (secrets.privateKey) store[`${prefix}_PRIVATE_KEY`] = secrets.privateKey;
|
|
130
|
+
else delete store[`${prefix}_PRIVATE_KEY`];
|
|
131
|
+
}
|
|
132
|
+
if (secrets.passphrase !== undefined) {
|
|
133
|
+
if (secrets.passphrase) store[`${prefix}_PASSPHRASE`] = secrets.passphrase;
|
|
134
|
+
else delete store[`${prefix}_PASSPHRASE`];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.writeAll(store);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
deleteProfileSecrets(profileId) {
|
|
141
|
+
if (!profileId) return;
|
|
142
|
+
const store = this.readAll();
|
|
143
|
+
const prefix = this.keyPrefix(profileId);
|
|
144
|
+
delete store[`${prefix}_PASSWORD`];
|
|
145
|
+
delete store[`${prefix}_PRIVATE_KEY`];
|
|
146
|
+
delete store[`${prefix}_PASSPHRASE`];
|
|
147
|
+
this.writeAll(store);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Enrich profile with secrets from .env vault before connecting
|
|
152
|
+
*/
|
|
153
|
+
hydrateProfile(profile) {
|
|
154
|
+
if (!profile || !profile.id) return profile;
|
|
155
|
+
const secrets = this.getProfileSecrets(profile.id);
|
|
156
|
+
return {
|
|
157
|
+
...profile,
|
|
158
|
+
password: secrets.password || profile.password || '',
|
|
159
|
+
privateKey: secrets.privateKey || profile.privateKey || '',
|
|
160
|
+
passphrase: secrets.passphrase || profile.passphrase || ''
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Strip secrets for public config/UI serialization
|
|
166
|
+
*/
|
|
167
|
+
sanitizeProfile(profile) {
|
|
168
|
+
if (!profile) return profile;
|
|
169
|
+
const secrets = this.getProfileSecrets(profile.id);
|
|
170
|
+
const hasPassword = Boolean(secrets.password || profile.password);
|
|
171
|
+
const hasPrivateKey = Boolean(secrets.privateKey || profile.privateKey);
|
|
172
|
+
const hasPassphrase = Boolean(secrets.passphrase || profile.passphrase);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
...profile,
|
|
176
|
+
password: hasPassword ? '••••••••' : '',
|
|
177
|
+
privateKey: hasPrivateKey ? '••••••••' : '',
|
|
178
|
+
passphrase: hasPassphrase ? '••••••••' : '',
|
|
179
|
+
hasStoredPassword: hasPassword,
|
|
180
|
+
hasStoredPrivateKey: hasPrivateKey,
|
|
181
|
+
hasStoredPassphrase: hasPassphrase
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-remote-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|