@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.
@@ -6,75 +6,112 @@ 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, targetHost: string, remotePort: number }>} */
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 Local Port Forwarding: 127.0.0.1:localPort -> Remote:targetHost:targetPort
25
+ * Start local port forwarding tunnel: LocalPort -> RemoteHost:RemotePort with live telemetry
15
26
  */
16
- async startLocalTunnel(profile, localPort, targetHost = '127.0.0.1', targetPort) {
17
- const tunnelId = `${profile.id}:${localPort}->${targetPort}`;
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, message: 'Tunnel already active', tunnelId, localPort, remotePort: targetPort };
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 server = net.createServer((socket) => {
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
- socket.remotePort || 0,
55
+ clientSocket.remotePort || 0,
29
56
  targetHost,
30
- targetPort,
57
+ remotePort,
31
58
  (err, stream) => {
32
59
  if (err) {
33
- try { socket.destroy(); } catch (_) {}
60
+ tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
61
+ clientSocket.destroy();
34
62
  return;
35
63
  }
36
- stream.on('error', () => {
37
- try { socket.destroy(); } catch (_) {}
64
+
65
+ // Telemetry: measure bytes flowing through the tunnel
66
+ clientSocket.on('data', (d) => {
67
+ tunnelMeta.bytesRx += d.length;
38
68
  });
39
- socket.on('error', () => {
40
- try { stream.destroy(); } catch (_) {}
69
+ stream.on('data', (d) => {
70
+ tunnelMeta.bytesTx += d.length;
41
71
  });
42
- socket.pipe(stream).pipe(socket);
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
- server,
55
- profileId: profile.id,
95
+ this.activeTunnels.set(tunnelId, tunnelMeta);
96
+ resolve({
97
+ success: true,
98
+ tunnelId,
56
99
  localPort,
57
100
  targetHost,
58
- remotePort: targetPort
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 && tunnel.server) {
71
- try {
72
- tunnel.server.close();
73
- } catch (_) {}
74
- this.activeTunnels.delete(tunnelId);
75
- return true;
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 || '127.0.0.1',
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, t] of this.activeTunnels.entries()) {
92
- if (t.server) {
93
- try { t.server.close(); } catch (_) {}
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.1.1",
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",