zerogterm 0.7.0-alpha2 → 0.8.0-alpha
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/LICENSE +21 -21
- package/README.md +244 -21
- package/bin/zerogterm.cjs +23 -23
- package/dist/main/main/ai-protocol.js +225 -0
- package/dist/main/main/ai-service.js +148 -0
- package/dist/main/main/command-history-store.js +231 -0
- package/dist/main/main/main.js +228 -9
- package/dist/main/main/port-forward-protocol.js +161 -0
- package/dist/main/main/port-forward-service.js +230 -0
- package/dist/main/main/port-forward-store.js +133 -0
- package/dist/main/main/preload.cjs +30 -1
- package/dist/main/main/secret-store.js +156 -0
- package/dist/main/main/session-service.js +22 -4
- package/dist/main/main/shell-catalog.js +63 -2
- package/dist/main/main/ssh-inventory.js +11 -0
- package/dist/main/main/workspace-store.js +213 -0
- package/dist/main/main/wsl-home.js +62 -0
- package/dist/main/shared/endpoints.js +73 -0
- package/dist/main/shared/version.js +32 -0
- package/dist/renderer/assets/index-BPS6JAQV.js +142 -0
- package/dist/renderer/assets/index-p0gJDyuE.css +1 -0
- package/dist/renderer/index.html +7 -4
- package/package.json +32 -2
- package/dist/renderer/assets/index-BjXtNztF.css +0 -1
- package/dist/renderer/assets/index-Ogyjvi0e.js +0 -19
package/dist/main/main/main.js
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
|
-
import { app, BrowserWindow, clipboard, ipcMain, Menu, session, shell } from 'electron';
|
|
1
|
+
import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, session, shell } from 'electron';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { writeClipboardText } from './clipboard.js';
|
|
5
5
|
import { ScreenService, parseWslDistributions } from './session-service.js';
|
|
6
6
|
import { discoverShellBackends } from './shell-catalog.js';
|
|
7
7
|
import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
|
|
8
|
+
import { CommandHistoryStore, defaultCommandHistoryPath } from './command-history-store.js';
|
|
9
|
+
import { WorkspaceStore, defaultWorkspacePath } from './workspace-store.js';
|
|
10
|
+
import { PortForwardService } from './port-forward-service.js';
|
|
11
|
+
import { PortForwardStore, defaultPortForwardPath } from './port-forward-store.js';
|
|
8
12
|
import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
|
|
9
13
|
import { createLocalDirectory, listLocalDirectory, localHome, removeLocalEntry, renameLocalEntry } from './local-fs.js';
|
|
14
|
+
import { wslHomeDirectory } from './wsl-home.js';
|
|
10
15
|
import { decideExternalLink, isApplicationUrl } from './external-links.js';
|
|
11
16
|
import { SftpService } from './sftp-service.js';
|
|
17
|
+
import { AI_API_KEY, SPEECH_API_KEY, SecretStore, defaultSecretsPath } from './secret-store.js';
|
|
18
|
+
import { AiService } from './ai-service.js';
|
|
12
19
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
13
20
|
const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
|
|
21
|
+
// The only store holding what the user typed. Nothing reaches it that has not
|
|
22
|
+
// been through command-redaction in the renderer, and it holds nothing at all
|
|
23
|
+
// until the setting is turned on.
|
|
24
|
+
const commands = new CommandHistoryStore({ filePath: defaultCommandHistoryPath(app.getPath('userData')) });
|
|
25
|
+
const workspaceStore = new WorkspaceStore({ filePath: defaultWorkspacePath(app.getPath('userData')) });
|
|
26
|
+
const forwardStore = new PortForwardStore({ filePath: defaultPortForwardPath(app.getPath('userData')) });
|
|
27
|
+
// Tunnels outlive the Ports view being closed: authenticating again is a real
|
|
28
|
+
// cost to pay for having looked away.
|
|
29
|
+
const forwards = new PortForwardService({ onEvent: (event) => win?.webContents.send('forwards:event', event) });
|
|
14
30
|
const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
|
|
15
31
|
let win;
|
|
16
32
|
// Transfer connections outlive any single panel opening, so the panel can be
|
|
17
33
|
// closed and reopened without re-authenticating to the host.
|
|
18
34
|
const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:event', event) });
|
|
35
|
+
// API keys for speech servers. safeStorage is only usable after the app is
|
|
36
|
+
// ready, which every IPC call here already is.
|
|
37
|
+
const secrets = new SecretStore({ filePath: defaultSecretsPath(app.getPath('userData')), crypto: safeStorage });
|
|
19
38
|
/** A pane's measured size, as it arrives from the renderer. */
|
|
20
39
|
function parsePtySize(value) {
|
|
21
40
|
if (!value || typeof value !== 'object')
|
|
@@ -25,10 +44,53 @@ function parsePtySize(value) {
|
|
|
25
44
|
return undefined;
|
|
26
45
|
return { cols: cols, rows: rows };
|
|
27
46
|
}
|
|
28
|
-
|
|
29
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Software rendering, on the one platform that needs it.
|
|
49
|
+
*
|
|
50
|
+
* The GPU is unstable under Toolbox/Wayland, which is where this was found and
|
|
51
|
+
* why the switch exists. It was applied everywhere, though, and on Windows that
|
|
52
|
+
* is a straight loss: every pane is then rasterised on the CPU, and a window
|
|
53
|
+
* holding a dozen terminals can stall long enough for the compositor to grey it
|
|
54
|
+
* out — which reads to the user as a crash. Scoped to Linux, with an override in
|
|
55
|
+
* each direction so a machine that disagrees can say so.
|
|
56
|
+
*/
|
|
57
|
+
if (process.env.ZEROG_DISABLE_GPU === '1') {
|
|
58
|
+
app.disableHardwareAcceleration();
|
|
59
|
+
}
|
|
60
|
+
else if (process.platform === 'linux' && process.env.ZEROG_ENABLE_GPU !== '1') {
|
|
30
61
|
app.disableHardwareAcceleration();
|
|
31
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* How long the main process may block before it is worth saying so.
|
|
65
|
+
*
|
|
66
|
+
* Every pty write, every IPC reply and every window event goes through this one
|
|
67
|
+
* loop, so a stall here freezes the whole application however many panes are
|
|
68
|
+
* open. 250ms is well past a slow frame and well short of anything a person
|
|
69
|
+
* would call a hang, which makes it a warning rather than a report.
|
|
70
|
+
*/
|
|
71
|
+
const LOOP_STALL_MS = 250;
|
|
72
|
+
const LOOP_SAMPLE_MS = 500;
|
|
73
|
+
/**
|
|
74
|
+
* Watch the main process for stalls.
|
|
75
|
+
*
|
|
76
|
+
* A timer that knows when it should have fired is the whole instrument: the
|
|
77
|
+
* difference between that and when it did fire is time the loop spent unable to
|
|
78
|
+
* run anything. It cannot say what blocked — only that something did, and for
|
|
79
|
+
* how long, which is the fact that was missing when a frozen window had to be
|
|
80
|
+
* explained from a screenshot.
|
|
81
|
+
*/
|
|
82
|
+
function watchEventLoop() {
|
|
83
|
+
let due = Date.now() + LOOP_SAMPLE_MS;
|
|
84
|
+
const timer = setInterval(() => {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const lag = now - due;
|
|
87
|
+
due = now + LOOP_SAMPLE_MS;
|
|
88
|
+
if (lag >= LOOP_STALL_MS)
|
|
89
|
+
console.warn(`[zerog] main process blocked for ${Math.round(lag)}ms`);
|
|
90
|
+
}, LOOP_SAMPLE_MS);
|
|
91
|
+
// Diagnostics must never be the reason the process stays alive.
|
|
92
|
+
timer.unref();
|
|
93
|
+
}
|
|
32
94
|
function createWindow() {
|
|
33
95
|
win = new BrowserWindow({
|
|
34
96
|
width: 1440,
|
|
@@ -76,8 +138,32 @@ function createWindow() {
|
|
|
76
138
|
win.webContents.on('preload-error', (_event, path, error) => {
|
|
77
139
|
console.error('[zerog] preload-error', path, error);
|
|
78
140
|
});
|
|
79
|
-
|
|
80
|
-
|
|
141
|
+
// The renderer's console, copied into this one — which is the only way a
|
|
142
|
+
// warning from the workspace reaches a log at all.
|
|
143
|
+
//
|
|
144
|
+
// Read from the event object rather than from the positional arguments that
|
|
145
|
+
// used to carry it. Electron still passes those, and says so on every start:
|
|
146
|
+
// "'console-message' arguments are deprecated and will be removed". When they
|
|
147
|
+
// go, a handler reading them would keep being called and print nothing, so the
|
|
148
|
+
// renderer would fall silent without anything appearing to break — the exact
|
|
149
|
+
// failure this logging exists to rule out.
|
|
150
|
+
win.webContents.on('console-message', (details) => {
|
|
151
|
+
console.log('[renderer]', details.message);
|
|
152
|
+
});
|
|
153
|
+
// The two ends of the symptom this instrumentation exists for. `unresponsive`
|
|
154
|
+
// is the window the desktop has started greying out; `render-process-gone` is
|
|
155
|
+
// the renderer having actually died, which is a different failure that looks
|
|
156
|
+
// similar from the outside. Only recorded here: what to *do* about either —
|
|
157
|
+
// offer a reload, reattach the panes — is a decision about the interface, not
|
|
158
|
+
// about diagnostics.
|
|
159
|
+
win.on('unresponsive', () => {
|
|
160
|
+
console.warn('[zerog] window unresponsive');
|
|
161
|
+
});
|
|
162
|
+
win.on('responsive', () => {
|
|
163
|
+
console.warn('[zerog] window responsive again');
|
|
164
|
+
});
|
|
165
|
+
win.webContents.on('render-process-gone', (_event, details) => {
|
|
166
|
+
console.error('[zerog] render-process-gone', details);
|
|
81
167
|
});
|
|
82
168
|
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
|
83
169
|
if (devUrl) {
|
|
@@ -91,6 +177,9 @@ function createWindow() {
|
|
|
91
177
|
win = undefined;
|
|
92
178
|
service.detachAll();
|
|
93
179
|
sftp.closeAll();
|
|
180
|
+
// A tunnel is a listening socket on someone's machine. It does not outlive
|
|
181
|
+
// the window that opened it.
|
|
182
|
+
forwards.closeAll();
|
|
94
183
|
});
|
|
95
184
|
}
|
|
96
185
|
function isRecord(value) {
|
|
@@ -128,6 +217,18 @@ ipcMain.handle('links:openExternal', (_event, url) => {
|
|
|
128
217
|
ipcMain.handle('sessions:list', () => service.list());
|
|
129
218
|
ipcMain.handle('sessions:history', () => history.list());
|
|
130
219
|
ipcMain.handle('sessions:historyRemove', (_event, entryId) => history.remove(entryId));
|
|
220
|
+
ipcMain.handle('commands:list', () => commands.list());
|
|
221
|
+
ipcMain.handle('commands:record', (_event, record) => commands.record(requireCommandRecord(record)));
|
|
222
|
+
ipcMain.handle('commands:pick', (_event, id) => commands.pick(requireString(id, 'A command')));
|
|
223
|
+
ipcMain.handle('commands:clear', () => commands.clear());
|
|
224
|
+
ipcMain.handle('forwards:list', () => forwards.list());
|
|
225
|
+
ipcMain.handle('forwards:open', (_event, request) => forwards.open(requireForwardRequest(request)));
|
|
226
|
+
ipcMain.handle('forwards:close', (_event, id) => forwards.close(requireString(id, 'A shared port')));
|
|
227
|
+
ipcMain.handle('forwards:answerPrompt', (_event, id, answer) => forwards.answerPrompt(requireString(id, 'A shared port'), requireString(answer, 'An answer')));
|
|
228
|
+
ipcMain.handle('forwards:load', () => forwardStore.load());
|
|
229
|
+
ipcMain.handle('forwards:save', (_event, file) => forwardStore.save(file));
|
|
230
|
+
ipcMain.handle('workspaces:load', () => workspaceStore.load());
|
|
231
|
+
ipcMain.handle('workspaces:save', (_event, file) => workspaceStore.save(file));
|
|
131
232
|
ipcMain.handle('sessions:backends', () => discoverShellBackends());
|
|
132
233
|
ipcMain.handle('sessions:wslDistributions', async () => {
|
|
133
234
|
try {
|
|
@@ -211,12 +312,100 @@ function requireString(value, field) {
|
|
|
211
312
|
throw new Error(`${field} is required.`);
|
|
212
313
|
return value;
|
|
213
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* A captured command, shaped.
|
|
317
|
+
*
|
|
318
|
+
* Only the shape is checked here. Whether the text is safe to store was decided
|
|
319
|
+
* in the renderer by command-redaction, next to the capture that produced it —
|
|
320
|
+
* that is where the rules and their tests live.
|
|
321
|
+
*/
|
|
322
|
+
function requireCommandRecord(value) {
|
|
323
|
+
if (!isRecord(value))
|
|
324
|
+
throw new Error('A command is required.');
|
|
325
|
+
const text = (field) => (typeof field === 'string' && field ? field : undefined);
|
|
326
|
+
return {
|
|
327
|
+
command: requireString(value.command, 'A command'),
|
|
328
|
+
...(text(value.cwd) ? { cwd: String(value.cwd) } : {}),
|
|
329
|
+
...(text(value.host) ? { host: String(value.host) } : {}),
|
|
330
|
+
...(value.kind === 'ssh' || value.kind === 'local' ? { kind: value.kind } : {}),
|
|
331
|
+
...(typeof value.exitCode === 'number' ? { exitCode: value.exitCode } : {})
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function requireAiConfig(value) {
|
|
335
|
+
if (!isRecord(value))
|
|
336
|
+
throw new Error('An AI endpoint is required.');
|
|
337
|
+
return {
|
|
338
|
+
baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : '',
|
|
339
|
+
model: typeof value.model === 'string' ? value.model : ''
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* A suggestion request, shaped.
|
|
344
|
+
*
|
|
345
|
+
* The prompt and the context are checked for type here and for content by
|
|
346
|
+
* buildSuggestionRequest, which is where those rules are tested. The captured
|
|
347
|
+
* output is deliberately not inspected: it is untrusted by design, and
|
|
348
|
+
* ai-protocol is what makes it safe to include.
|
|
349
|
+
*/
|
|
350
|
+
function requireSuggestionRequest(value) {
|
|
351
|
+
if (!isRecord(value))
|
|
352
|
+
throw new Error('A request is required.');
|
|
353
|
+
const context = isRecord(value.context) ? value.context : {};
|
|
354
|
+
const text = (field) => (typeof field === 'string' && field ? field : undefined);
|
|
355
|
+
return {
|
|
356
|
+
prompt: typeof value.prompt === 'string' ? value.prompt : '',
|
|
357
|
+
...(text(value.sessionId) ? { sessionId: String(value.sessionId) } : {}),
|
|
358
|
+
context: {
|
|
359
|
+
...(text(context.shell) ? { shell: String(context.shell) } : {}),
|
|
360
|
+
...(text(context.cwd) ? { cwd: String(context.cwd) } : {}),
|
|
361
|
+
...(text(context.host) ? { host: String(context.host) } : {}),
|
|
362
|
+
...(context.kind === 'ssh' || context.kind === 'local' ? { kind: context.kind } : {}),
|
|
363
|
+
...(text(context.output) ? { output: String(context.output) } : {})
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* A forwarding request, shaped.
|
|
369
|
+
*
|
|
370
|
+
* Only the shape is checked here: the values are vetted by buildForwardArgs,
|
|
371
|
+
* which is where the rules live and where they are tested, and which throws a
|
|
372
|
+
* sentence the renderer can show as-is.
|
|
373
|
+
*/
|
|
374
|
+
function requireForwardRequest(value) {
|
|
375
|
+
if (!isRecord(value))
|
|
376
|
+
throw new Error('A shared port is required.');
|
|
377
|
+
return {
|
|
378
|
+
target: requireString(value.target, 'An SSH target'),
|
|
379
|
+
direction: value.direction,
|
|
380
|
+
bind: value.bind,
|
|
381
|
+
listenPort: value.listenPort,
|
|
382
|
+
destinationPort: value.destinationPort,
|
|
383
|
+
...(typeof value.destinationHost === 'string' && value.destinationHost ? { destinationHost: value.destinationHost } : {}),
|
|
384
|
+
...(typeof value.id === 'string' && value.id ? { id: value.id } : {})
|
|
385
|
+
};
|
|
386
|
+
}
|
|
214
387
|
function requireEntryKind(value) {
|
|
215
388
|
if (value === 'file' || value === 'directory' || value === 'symlink')
|
|
216
389
|
return value;
|
|
217
390
|
throw new Error('An entry kind of file, directory, or symlink is required.');
|
|
218
391
|
}
|
|
392
|
+
// Electron's own reading of package.json, so the title bar shows what is
|
|
393
|
+
// actually running rather than a string compiled in from the repo.
|
|
394
|
+
ipcMain.handle('app:version', () => app.getVersion());
|
|
219
395
|
ipcMain.handle('fs:localHome', () => localHome());
|
|
396
|
+
// Cached on success only, so a distribution that was not running when it was
|
|
397
|
+
// first asked can answer later.
|
|
398
|
+
const wslHomes = new Map();
|
|
399
|
+
ipcMain.handle('fs:wslHome', async (_event, distribution) => {
|
|
400
|
+
const name = typeof distribution === 'string' ? distribution : '';
|
|
401
|
+
const known = wslHomes.get(name);
|
|
402
|
+
if (known)
|
|
403
|
+
return known;
|
|
404
|
+
const home = await wslHomeDirectory(name);
|
|
405
|
+
if (home)
|
|
406
|
+
wslHomes.set(name, home);
|
|
407
|
+
return home;
|
|
408
|
+
});
|
|
220
409
|
ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
|
|
221
410
|
ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
|
|
222
411
|
ipcMain.handle('fs:renameLocal', (_event, from, to) => renameLocalEntry(requireString(from, 'The current path'), requireString(to, 'The new path')));
|
|
@@ -236,10 +425,39 @@ ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
|
|
|
236
425
|
sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
|
|
237
426
|
});
|
|
238
427
|
ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
428
|
+
// The key is read here, per request, and never handed to the renderer.
|
|
429
|
+
const ai = new AiService({ readApiKey: () => secrets.get(AI_API_KEY) });
|
|
430
|
+
ipcMain.handle('ai:suggest', (_event, config, request) => ai.suggest(requireAiConfig(config), requireSuggestionRequest(request)));
|
|
431
|
+
ipcMain.handle('ai:models', (_event, baseUrl) => ai.listModels(requireString(baseUrl, 'A base URL')));
|
|
432
|
+
ipcMain.handle('ai:test', (_event, config) => ai.test(requireAiConfig(config)));
|
|
433
|
+
ipcMain.handle('ai:cancel', () => ai.cancel());
|
|
434
|
+
async function aiKeyStatus() {
|
|
435
|
+
return { stored: await secrets.has(AI_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
|
|
436
|
+
}
|
|
437
|
+
ipcMain.handle('aiKey:status', () => aiKeyStatus());
|
|
438
|
+
ipcMain.handle('aiKey:save', async (_event, key) => {
|
|
439
|
+
await secrets.set(AI_API_KEY, typeof key === 'string' ? key.trim() : '');
|
|
440
|
+
return aiKeyStatus();
|
|
441
|
+
});
|
|
442
|
+
ipcMain.handle('aiKey:clear', async () => {
|
|
443
|
+
await secrets.clear(AI_API_KEY);
|
|
444
|
+
return aiKeyStatus();
|
|
445
|
+
});
|
|
446
|
+
async function speechKeyStatus() {
|
|
447
|
+
return { stored: await secrets.has(SPEECH_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
|
|
448
|
+
}
|
|
449
|
+
ipcMain.handle('speechKey:status', () => speechKeyStatus());
|
|
450
|
+
ipcMain.handle('speechKey:save', async (_event, key) => {
|
|
451
|
+
// An empty key means "clear", which SecretStore already does — but the
|
|
452
|
+
// string still has to be a string, and a pasted key often carries newlines.
|
|
453
|
+
await secrets.set(SPEECH_API_KEY, typeof key === 'string' ? key.trim() : '');
|
|
454
|
+
return speechKeyStatus();
|
|
455
|
+
});
|
|
456
|
+
ipcMain.handle('speechKey:clear', async () => {
|
|
457
|
+
await secrets.clear(SPEECH_API_KEY);
|
|
458
|
+
return speechKeyStatus();
|
|
459
|
+
});
|
|
460
|
+
ipcMain.handle('speechKey:read', () => secrets.get(SPEECH_API_KEY));
|
|
243
461
|
app.whenReady().then(() => {
|
|
244
462
|
// Voice input captures the local microphone only. Electron denies all
|
|
245
463
|
// permission requests unless a handler answers, so grant media explicitly.
|
|
@@ -249,6 +467,7 @@ app.whenReady().then(() => {
|
|
|
249
467
|
// Keep the normal window chrome, but let the ZeroG UI occupy the full
|
|
250
468
|
// client area instead of showing Electron's default File/Edit/etc. menu.
|
|
251
469
|
Menu.setApplicationMenu(null);
|
|
470
|
+
watchEventLoop();
|
|
252
471
|
createWindow();
|
|
253
472
|
app.on('activate', () => {
|
|
254
473
|
if (!BrowserWindow.getAllWindows().length)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Turning a forwarding request into an ssh command line, and reading back
|
|
2
|
+
// whether the tunnel came up.
|
|
3
|
+
//
|
|
4
|
+
// Kept apart from the service so the part that decides what `ssh` is asked to do
|
|
5
|
+
// can be tested exhaustively without spawning anything. That matters more here
|
|
6
|
+
// than usual: every field lands in an argv element, and the whole point of the
|
|
7
|
+
// feature is to open a listening socket.
|
|
8
|
+
import { parseSshTarget, sshExecutable } from './session-service.js';
|
|
9
|
+
import { isSshHostName } from './ssh-inventory.js';
|
|
10
|
+
/** Where traffic goes when the request does not say. */
|
|
11
|
+
const DEFAULT_DESTINATION_HOST = 'localhost';
|
|
12
|
+
/**
|
|
13
|
+
* How long to wait for a tunnel to declare itself before assuming it is up.
|
|
14
|
+
*
|
|
15
|
+
* Only a fallback: an ssh that has authenticated and gone quiet without saying
|
|
16
|
+
* it started listening is almost certainly listening. The explicit signals in
|
|
17
|
+
* readForwardOutcome are what normally settle this, and they arrive in
|
|
18
|
+
* milliseconds.
|
|
19
|
+
*/
|
|
20
|
+
export const FORWARD_SETTLE_MS = 1500;
|
|
21
|
+
function requirePort(value, what) {
|
|
22
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
|
|
23
|
+
throw new Error(`${what} must be a whole number between 1 and 65535.`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function requireDirection(value) {
|
|
28
|
+
if (value !== 'local' && value !== 'remote')
|
|
29
|
+
throw new Error('A forward must be local or remote.');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function requireBind(value) {
|
|
33
|
+
if (value !== 'loopback' && value !== 'all')
|
|
34
|
+
throw new Error('A forward must bind to loopback or to all interfaces.');
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The host on the receiving side of the tunnel.
|
|
39
|
+
*
|
|
40
|
+
* Vetted with the same rule as a HostName in ~/.ssh/config, because it is
|
|
41
|
+
* concatenated into the forward spec — one argv element — and a value beginning
|
|
42
|
+
* with '-' would be read by ssh's getopt as an option instead.
|
|
43
|
+
*
|
|
44
|
+
* Colons are then refused on top of that rule, which a HostName is allowed to
|
|
45
|
+
* carry for an IPv6 literal. ssh splits a forward spec on colons, so an
|
|
46
|
+
* unbracketed IPv6 address there does not mean what it looks like: `[::1]` is
|
|
47
|
+
* the syntax, and accepting a bare `::1` would silently build a spec describing
|
|
48
|
+
* a different tunnel. Bracketed literals are a separate job, so this says no
|
|
49
|
+
* rather than guessing.
|
|
50
|
+
*/
|
|
51
|
+
function requireDestinationHost(value) {
|
|
52
|
+
if (value === undefined || value === '')
|
|
53
|
+
return DEFAULT_DESTINATION_HOST;
|
|
54
|
+
if (typeof value !== 'string' || !isSshHostName(value)) {
|
|
55
|
+
throw new Error('The destination host must be a hostname or IP address.');
|
|
56
|
+
}
|
|
57
|
+
if (value.includes(':')) {
|
|
58
|
+
throw new Error('An IPv6 destination is not supported yet. Use a hostname or an IPv4 address.');
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The bind address to put in the spec, or nothing.
|
|
64
|
+
*
|
|
65
|
+
* The two directions are not symmetric here, and it is the server's rule that
|
|
66
|
+
* makes them differ. A `-L` forward binds on this machine, so the address is
|
|
67
|
+
* ours to state and is always stated. A `-R` forward binds on the remote, where
|
|
68
|
+
* sshd's GatewayPorts decides: the default (`no`) binds loopback and rejects a
|
|
69
|
+
* client-specified address outright, so loopback has to be expressed by saying
|
|
70
|
+
* nothing at all. `*` is used for the wide case because it is accepted under
|
|
71
|
+
* both `yes` and `clientspecified`.
|
|
72
|
+
*/
|
|
73
|
+
function bindPrefix(direction, bind) {
|
|
74
|
+
if (direction === 'local')
|
|
75
|
+
return bind === 'all' ? '0.0.0.0:' : '127.0.0.1:';
|
|
76
|
+
return bind === 'all' ? '*:' : '';
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the command line for a forward.
|
|
80
|
+
*
|
|
81
|
+
* Two options carry most of the weight:
|
|
82
|
+
*
|
|
83
|
+
* `ExitOnForwardFailure=yes` — without it, ssh stays connected when the forward
|
|
84
|
+
* cannot bind, and there is no way to tell a working tunnel from a useless one
|
|
85
|
+
* short of trying to use it. With it, a forward that fails is a process that
|
|
86
|
+
* exits, and the UI can say so.
|
|
87
|
+
*
|
|
88
|
+
* `-v` — so that the tunnel being up is something ssh said rather than something
|
|
89
|
+
* we assumed. `-N` produces no output of its own on success, so without this the
|
|
90
|
+
* only signal available is the absence of an error.
|
|
91
|
+
*/
|
|
92
|
+
export function buildForwardArgs(request, options = {}) {
|
|
93
|
+
const direction = requireDirection(request.direction);
|
|
94
|
+
const bind = requireBind(request.bind);
|
|
95
|
+
const listenPort = requirePort(request.listenPort, 'The port to open');
|
|
96
|
+
const destinationPort = requirePort(request.destinationPort, 'The port to forward to');
|
|
97
|
+
const destinationHost = requireDestinationHost(request.destinationHost);
|
|
98
|
+
const { destination, port } = parseSshTarget(request.target);
|
|
99
|
+
const spec = `${bindPrefix(direction, bind)}${listenPort}:${destinationHost}:${destinationPort}`;
|
|
100
|
+
const args = ['-v', '-N', '-o', 'ExitOnForwardFailure=yes', '-o', 'ConnectTimeout=20'];
|
|
101
|
+
if (port)
|
|
102
|
+
args.push('-p', port);
|
|
103
|
+
args.push(direction === 'local' ? '-L' : '-R', spec);
|
|
104
|
+
// '--' ends option parsing, so the destination can never be read as a flag.
|
|
105
|
+
args.push('--', destination);
|
|
106
|
+
return { file: sshExecutable(options), args, spec };
|
|
107
|
+
}
|
|
108
|
+
const FAILURES = [
|
|
109
|
+
{
|
|
110
|
+
pattern: /bind \[?([^\]\s]+)\]?:(\d+): (.+)/,
|
|
111
|
+
reason: (match) => `Could not bind ${match[1]}:${match[2]} — ${match[3].trim()}.`
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
pattern: /Warning: remote port forwarding failed for listen port (\d+)/,
|
|
115
|
+
reason: (match) => `The remote host refused to open port ${match[1]}. Its sshd may need GatewayPorts enabled to bind beyond loopback.`
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
pattern: /channel_setup_fwd_listener_tcpip: cannot listen to port: (\d+)/,
|
|
119
|
+
reason: (match) => `Could not listen on port ${match[1]}.`
|
|
120
|
+
},
|
|
121
|
+
{ pattern: /Permission denied \(([^)]*)\)/, reason: (match) => `Authentication failed (${match[1]}).` },
|
|
122
|
+
{ pattern: /(?:ssh: )?Could not resolve hostname ([^\s:]+)/, reason: (match) => `Could not resolve ${match[1]}.` },
|
|
123
|
+
{ pattern: /Connection (?:refused|timed out|closed) by ([^\s]+)/, reason: (match) => `The host ${match[1]} refused the connection.` },
|
|
124
|
+
{ pattern: /(ssh: connect to host [^\r\n]+)/, reason: (match) => `${match[1].trim()}.` },
|
|
125
|
+
{ pattern: /Host key verification failed/, reason: () => 'Host key verification failed.' }
|
|
126
|
+
];
|
|
127
|
+
/**
|
|
128
|
+
* What the client's output so far says about the tunnel.
|
|
129
|
+
*
|
|
130
|
+
* Read from the whole buffer rather than the tail: `-v` is talkative, and the one
|
|
131
|
+
* line that matters is followed by many that do not. Failure is checked before
|
|
132
|
+
* success because ssh reports a partial success — one forward of several — while
|
|
133
|
+
* still exiting.
|
|
134
|
+
*/
|
|
135
|
+
export function readForwardOutcome(buffer, direction) {
|
|
136
|
+
for (const { pattern, reason } of FAILURES) {
|
|
137
|
+
const match = buffer.match(pattern);
|
|
138
|
+
if (match)
|
|
139
|
+
return { kind: 'failed', reason: reason(match) };
|
|
140
|
+
}
|
|
141
|
+
const success = direction === 'local'
|
|
142
|
+
? /Local forwarding listening on \S+ port (\d+)/
|
|
143
|
+
: /remote forward success for: listen (\d+)/;
|
|
144
|
+
if (success.test(buffer))
|
|
145
|
+
return { kind: 'open' };
|
|
146
|
+
// Not proof of a tunnel, but it does mean the connection got that far — the
|
|
147
|
+
// service uses it to start the settle timer rather than waiting the full
|
|
148
|
+
// timeout on a host that never answered at all.
|
|
149
|
+
if (/Authentication succeeded|debug1: Entering interactive session/.test(buffer)) {
|
|
150
|
+
return { kind: 'authenticated' };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
/** How a forward reads in the UI and in a log line. */
|
|
155
|
+
export function describeForward(request) {
|
|
156
|
+
const destinationHost = request.destinationHost || DEFAULT_DESTINATION_HOST;
|
|
157
|
+
const listener = request.bind === 'all' ? '0.0.0.0' : 'localhost';
|
|
158
|
+
return request.direction === 'local'
|
|
159
|
+
? `${listener}:${request.listenPort} → ${destinationHost}:${request.destinationPort} on ${request.target}`
|
|
160
|
+
: `${request.target}:${request.listenPort} → ${destinationHost}:${request.destinationPort} here`;
|
|
161
|
+
}
|