@statechange/ssh-tunnel-manager 0.1.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/dist/app.mjs ADDED
@@ -0,0 +1,563 @@
1
+ import { createRequire } from "module"; const require = createRequire(import.meta.url);
2
+
3
+ // src/app/main.ts
4
+ import { app, Tray, Menu, BrowserWindow, ipcMain, nativeImage } from "electron";
5
+ import { join as join3, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // src/core/config.ts
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ var BASE_DIR = join(homedir(), ".ssh-tunnels");
13
+ var CONFIG_PATH = join(BASE_DIR, "config.json");
14
+ var PIDS_DIR = join(BASE_DIR, "pids");
15
+ var HISTORY_DIR = join(BASE_DIR, "history");
16
+ function getConfigPath() {
17
+ return CONFIG_PATH;
18
+ }
19
+ function getPidsDir() {
20
+ return PIDS_DIR;
21
+ }
22
+ function getHistoryDir() {
23
+ return HISTORY_DIR;
24
+ }
25
+ async function ensureDirs() {
26
+ await mkdir(BASE_DIR, { recursive: true });
27
+ await mkdir(PIDS_DIR, { recursive: true });
28
+ await mkdir(HISTORY_DIR, { recursive: true });
29
+ }
30
+ async function readConfig() {
31
+ await ensureDirs();
32
+ try {
33
+ const raw = await readFile(CONFIG_PATH, "utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ return validateConfig(parsed);
36
+ } catch (err) {
37
+ if (err.code === "ENOENT") {
38
+ const defaultConfig = { tunnels: [] };
39
+ await writeConfig(defaultConfig);
40
+ return defaultConfig;
41
+ }
42
+ throw err;
43
+ }
44
+ }
45
+ async function writeConfig(config) {
46
+ await ensureDirs();
47
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
48
+ }
49
+ function validateConfig(data) {
50
+ if (!data || typeof data !== "object") {
51
+ throw new Error("Invalid config: expected an object");
52
+ }
53
+ const obj = data;
54
+ if (!Array.isArray(obj.tunnels)) {
55
+ throw new Error("Invalid config: expected tunnels array");
56
+ }
57
+ for (const t of obj.tunnels) {
58
+ if (!t.id || typeof t.id !== "string") throw new Error("Invalid tunnel: missing id");
59
+ if (!t.host || typeof t.host !== "string") throw new Error(`Invalid tunnel ${t.id}: missing host`);
60
+ if (typeof t.localPort !== "number") throw new Error(`Invalid tunnel ${t.id}: missing localPort`);
61
+ if (!t.remoteHost || typeof t.remoteHost !== "string") throw new Error(`Invalid tunnel ${t.id}: missing remoteHost`);
62
+ if (typeof t.remotePort !== "number") throw new Error(`Invalid tunnel ${t.id}: missing remotePort`);
63
+ }
64
+ return data;
65
+ }
66
+ function slugify(name) {
67
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
68
+ }
69
+
70
+ // src/core/tunnel.ts
71
+ import { spawn } from "node:child_process";
72
+ import { readFile as readFile2, writeFile as writeFile2, unlink } from "node:fs/promises";
73
+ import { join as join2 } from "node:path";
74
+ import { createConnection } from "node:net";
75
+ function pidFilePath(id) {
76
+ return join2(getPidsDir(), `${id}.pid`);
77
+ }
78
+ function logFilePath(id) {
79
+ return join2(getHistoryDir(), `${id}.log`);
80
+ }
81
+ async function readPid(id) {
82
+ try {
83
+ const raw = await readFile2(pidFilePath(id), "utf-8");
84
+ const pid = parseInt(raw.trim(), 10);
85
+ return isNaN(pid) ? null : pid;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+ function isProcessAlive(pid) {
91
+ try {
92
+ process.kill(pid, 0);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+ function probePort(port, host = "127.0.0.1", timeoutMs = 1e3) {
99
+ return new Promise((resolve) => {
100
+ const socket = createConnection({ port, host, timeout: timeoutMs });
101
+ socket.on("connect", () => {
102
+ socket.destroy();
103
+ resolve(true);
104
+ });
105
+ socket.on("error", () => {
106
+ socket.destroy();
107
+ resolve(false);
108
+ });
109
+ socket.on("timeout", () => {
110
+ socket.destroy();
111
+ resolve(false);
112
+ });
113
+ });
114
+ }
115
+ async function getTunnelStatus(tunnel) {
116
+ const pid = await readPid(tunnel.id);
117
+ const alive = pid !== null && isProcessAlive(pid);
118
+ const portOpen = alive ? await probePort(tunnel.localPort) : false;
119
+ return {
120
+ id: tunnel.id,
121
+ name: tunnel.name,
122
+ enabled: tunnel.enabled,
123
+ pid,
124
+ alive,
125
+ portOpen,
126
+ localPort: tunnel.localPort,
127
+ remoteHost: tunnel.remoteHost,
128
+ remotePort: tunnel.remotePort,
129
+ host: tunnel.host
130
+ };
131
+ }
132
+ async function startTunnel(tunnel) {
133
+ const args = [
134
+ "-N",
135
+ // No remote command
136
+ "-L",
137
+ `${tunnel.localPort}:${tunnel.remoteHost}:${tunnel.remotePort}`,
138
+ "-o",
139
+ "ServerAliveInterval=30",
140
+ "-o",
141
+ "ServerAliveCountMax=3",
142
+ "-o",
143
+ "ExitOnForwardFailure=yes",
144
+ "-o",
145
+ "StrictHostKeyChecking=accept-new"
146
+ ];
147
+ if (tunnel.user) {
148
+ args.push("-l", tunnel.user);
149
+ }
150
+ if (tunnel.sshPort && tunnel.sshPort !== 22) {
151
+ args.push("-p", String(tunnel.sshPort));
152
+ }
153
+ if (tunnel.identityFile) {
154
+ const expandedPath = tunnel.identityFile.replace(/^~/, process.env.HOME || "");
155
+ args.push("-i", expandedPath);
156
+ }
157
+ if (tunnel.extraArgs) {
158
+ args.push(...tunnel.extraArgs);
159
+ }
160
+ args.push(tunnel.host);
161
+ const { openSync } = await import("node:fs");
162
+ const logFd = openSync(logFilePath(tunnel.id), "a");
163
+ const child = spawn("ssh", args, {
164
+ detached: true,
165
+ stdio: ["ignore", logFd, logFd]
166
+ });
167
+ const { closeSync } = await import("node:fs");
168
+ closeSync(logFd);
169
+ child.unref();
170
+ if (child.pid === void 0) {
171
+ throw new Error(`Failed to start tunnel ${tunnel.id}`);
172
+ }
173
+ await writeFile2(pidFilePath(tunnel.id), String(child.pid), "utf-8");
174
+ return child.pid;
175
+ }
176
+ async function stopTunnel(id) {
177
+ const pid = await readPid(id);
178
+ if (pid === null) return;
179
+ if (isProcessAlive(pid)) {
180
+ const treeKill = (await import("tree-kill")).default;
181
+ await new Promise((resolve, reject) => {
182
+ treeKill(pid, "SIGTERM", (err) => {
183
+ if (err) {
184
+ treeKill(pid, "SIGKILL", () => resolve());
185
+ } else {
186
+ resolve();
187
+ }
188
+ });
189
+ });
190
+ }
191
+ try {
192
+ await unlink(pidFilePath(id));
193
+ } catch {
194
+ }
195
+ }
196
+ async function cleanStalePid(id) {
197
+ const pid = await readPid(id);
198
+ if (pid !== null && !isProcessAlive(pid)) {
199
+ try {
200
+ await unlink(pidFilePath(id));
201
+ } catch {
202
+ }
203
+ }
204
+ }
205
+
206
+ // src/core/sync.ts
207
+ async function sync(config) {
208
+ const cfg = config ?? await readConfig();
209
+ const result = {
210
+ started: [],
211
+ stopped: [],
212
+ alreadyRunning: [],
213
+ alreadyStopped: [],
214
+ errors: []
215
+ };
216
+ for (const tunnel of cfg.tunnels) {
217
+ try {
218
+ await cleanStalePid(tunnel.id);
219
+ const status = await getTunnelStatus(tunnel);
220
+ if (tunnel.enabled) {
221
+ if (status.alive && status.portOpen) {
222
+ result.alreadyRunning.push(tunnel.id);
223
+ } else {
224
+ if (status.alive && !status.portOpen) {
225
+ await stopTunnel(tunnel.id);
226
+ }
227
+ await startTunnel(tunnel);
228
+ result.started.push(tunnel.id);
229
+ }
230
+ } else {
231
+ if (status.alive) {
232
+ await stopTunnel(tunnel.id);
233
+ result.stopped.push(tunnel.id);
234
+ } else {
235
+ result.alreadyStopped.push(tunnel.id);
236
+ }
237
+ }
238
+ } catch (err) {
239
+ result.errors.push({
240
+ id: tunnel.id,
241
+ error: err instanceof Error ? err.message : String(err)
242
+ });
243
+ }
244
+ }
245
+ return result;
246
+ }
247
+ async function getStatus(config) {
248
+ const cfg = config ?? await readConfig();
249
+ const statuses = [];
250
+ for (const tunnel of cfg.tunnels) {
251
+ await cleanStalePid(tunnel.id);
252
+ statuses.push(await getTunnelStatus(tunnel));
253
+ }
254
+ return statuses;
255
+ }
256
+
257
+ // src/core/watch.ts
258
+ import { watch as chokidarWatch } from "chokidar";
259
+ function watchConfig(options) {
260
+ const configPath = getConfigPath();
261
+ const debounceMs = options?.debounceMs ?? 500;
262
+ let debounceTimer = null;
263
+ let syncing = false;
264
+ const doSync = async () => {
265
+ if (syncing) return;
266
+ syncing = true;
267
+ try {
268
+ const result = await sync();
269
+ options?.onSync?.(result);
270
+ } catch (err) {
271
+ options?.onError?.(err instanceof Error ? err : new Error(String(err)));
272
+ } finally {
273
+ syncing = false;
274
+ }
275
+ };
276
+ const watcher = chokidarWatch(configPath, {
277
+ persistent: true,
278
+ ignoreInitial: true
279
+ });
280
+ watcher.on("change", () => {
281
+ if (debounceTimer) clearTimeout(debounceTimer);
282
+ debounceTimer = setTimeout(doSync, debounceMs);
283
+ });
284
+ return {
285
+ close: async () => {
286
+ if (debounceTimer) clearTimeout(debounceTimer);
287
+ await watcher.close();
288
+ }
289
+ };
290
+ }
291
+
292
+ // src/app/main.ts
293
+ var __dirname = dirname(fileURLToPath(import.meta.url));
294
+ var tray = null;
295
+ var addWindow = null;
296
+ var configWatcher = null;
297
+ var syncTimer = null;
298
+ var currentStatuses = [];
299
+ function buildPng(width, height, rgba) {
300
+ function crc32(buf) {
301
+ let c = 4294967295;
302
+ for (let i = 0; i < buf.length; i++) {
303
+ c ^= buf[i];
304
+ for (let j = 0; j < 8; j++) c = c >>> 1 ^ (c & 1 ? 3988292384 : 0);
305
+ }
306
+ return (c ^ 4294967295) >>> 0;
307
+ }
308
+ function adler32(buf) {
309
+ let a = 1, b = 0;
310
+ for (let i = 0; i < buf.length; i++) {
311
+ a = (a + buf[i]) % 65521;
312
+ b = (b + a) % 65521;
313
+ }
314
+ return (b << 16 | a) >>> 0;
315
+ }
316
+ function chunk(type, data) {
317
+ const len = Buffer.alloc(4);
318
+ len.writeUInt32BE(data.length);
319
+ const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data]);
320
+ const crc = Buffer.alloc(4);
321
+ crc.writeUInt32BE(crc32(typeAndData));
322
+ return Buffer.concat([len, typeAndData, crc]);
323
+ }
324
+ const ihdr = Buffer.alloc(13);
325
+ ihdr.writeUInt32BE(width, 0);
326
+ ihdr.writeUInt32BE(height, 4);
327
+ ihdr[8] = 8;
328
+ ihdr[9] = 6;
329
+ ihdr[10] = 0;
330
+ ihdr[11] = 0;
331
+ ihdr[12] = 0;
332
+ const rowSize = width * 4 + 1;
333
+ const rawData = Buffer.alloc(rowSize * height);
334
+ for (let y = 0; y < height; y++) {
335
+ rawData[y * rowSize] = 0;
336
+ rgba.copy(rawData, y * rowSize + 1, y * width * 4, (y + 1) * width * 4);
337
+ }
338
+ const blocks = [];
339
+ const maxBlock = 65535;
340
+ for (let offset = 0; offset < rawData.length; offset += maxBlock) {
341
+ const end = Math.min(offset + maxBlock, rawData.length);
342
+ const isLast = end === rawData.length;
343
+ const blockData = rawData.subarray(offset, end);
344
+ const header = Buffer.alloc(5);
345
+ header[0] = isLast ? 1 : 0;
346
+ header.writeUInt16LE(blockData.length, 1);
347
+ header.writeUInt16LE(~blockData.length & 65535, 3);
348
+ blocks.push(header, blockData);
349
+ }
350
+ const deflateData = Buffer.concat(blocks);
351
+ const zlibHeader = Buffer.from([120, 1]);
352
+ const adler = Buffer.alloc(4);
353
+ adler.writeUInt32BE(adler32(rawData));
354
+ const compressedData = Buffer.concat([zlibHeader, deflateData, adler]);
355
+ const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
356
+ const iend = chunk("IEND", Buffer.alloc(0));
357
+ return Buffer.concat([signature, chunk("IHDR", ihdr), chunk("IDAT", compressedData), iend]);
358
+ }
359
+ function createTrayIcon(state) {
360
+ const size = 32;
361
+ const colors = {
362
+ green: [52, 199, 89],
363
+ yellow: [255, 149, 0],
364
+ red: [255, 59, 48]
365
+ };
366
+ const [r, g, b] = colors[state];
367
+ const cx = size / 2;
368
+ const cy = size / 2;
369
+ const radius = 8;
370
+ const rgba = Buffer.alloc(size * size * 4, 0);
371
+ for (let y = 0; y < size; y++) {
372
+ for (let x = 0; x < size; x++) {
373
+ const dx = x - cx;
374
+ const dy = y - cy;
375
+ const dist = Math.sqrt(dx * dx + dy * dy);
376
+ if (dist <= radius) {
377
+ const alpha = dist > radius - 1 ? Math.max(0, Math.round((radius - dist) * 255)) : 255;
378
+ const offset = (y * size + x) * 4;
379
+ rgba[offset] = r;
380
+ rgba[offset + 1] = g;
381
+ rgba[offset + 2] = b;
382
+ rgba[offset + 3] = alpha;
383
+ }
384
+ }
385
+ }
386
+ const png = buildPng(size, size, rgba);
387
+ return nativeImage.createFromBuffer(png, { scaleFactor: 2 });
388
+ }
389
+ function getTrayState() {
390
+ const enabled = currentStatuses.filter((s) => s.enabled);
391
+ if (enabled.length === 0) return "green";
392
+ const allRunning = enabled.every((s) => s.alive && s.portOpen);
393
+ if (allRunning) return "green";
394
+ const anyRunning = enabled.some((s) => s.alive && s.portOpen);
395
+ return anyRunning ? "yellow" : "red";
396
+ }
397
+ async function refreshStatuses() {
398
+ try {
399
+ currentStatuses = await getStatus();
400
+ } catch {
401
+ currentStatuses = [];
402
+ }
403
+ }
404
+ async function buildTrayMenu() {
405
+ await refreshStatuses();
406
+ const tunnelItems = currentStatuses.map((s) => ({
407
+ label: `${s.alive && s.portOpen ? "\u25CF" : "\u25CB"} ${s.name} \u2014 :${s.localPort} \u2192 ${s.remoteHost}:${s.remotePort}`,
408
+ type: "checkbox",
409
+ checked: s.enabled,
410
+ click: async () => {
411
+ const config = await readConfig();
412
+ const tunnel = config.tunnels.find((t) => t.id === s.id);
413
+ if (tunnel) {
414
+ tunnel.enabled = !tunnel.enabled;
415
+ await writeConfig(config);
416
+ await sync(config);
417
+ if (tunnel.enabled) {
418
+ await new Promise((r) => setTimeout(r, 2e3));
419
+ }
420
+ await updateTray();
421
+ }
422
+ }
423
+ }));
424
+ if (tunnelItems.length === 0) {
425
+ tunnelItems.push({
426
+ label: "No tunnels configured",
427
+ enabled: false
428
+ });
429
+ }
430
+ return Menu.buildFromTemplate([
431
+ ...tunnelItems,
432
+ { type: "separator" },
433
+ {
434
+ label: "Add Tunnel...",
435
+ click: () => openAddWindow()
436
+ },
437
+ {
438
+ label: "Sync Now",
439
+ click: async () => {
440
+ await sync();
441
+ await updateTray();
442
+ }
443
+ },
444
+ { type: "separator" },
445
+ {
446
+ label: `Config: ${getConfigPath()}`,
447
+ enabled: false
448
+ },
449
+ { type: "separator" },
450
+ {
451
+ label: "Quit",
452
+ click: () => {
453
+ app.quit();
454
+ }
455
+ }
456
+ ]);
457
+ }
458
+ async function updateTray() {
459
+ if (!tray) return;
460
+ const menu = await buildTrayMenu();
461
+ tray.setContextMenu(menu);
462
+ tray.setImage(createTrayIcon(getTrayState()));
463
+ const enabled = currentStatuses.filter((s) => s.enabled);
464
+ const running = enabled.filter((s) => s.alive && s.portOpen);
465
+ tray.setToolTip(`SSH Tunnels: ${running.length}/${enabled.length} running`);
466
+ }
467
+ function openAddWindow() {
468
+ if (addWindow) {
469
+ addWindow.focus();
470
+ return;
471
+ }
472
+ addWindow = new BrowserWindow({
473
+ width: 480,
474
+ height: 520,
475
+ resizable: false,
476
+ minimizable: false,
477
+ maximizable: false,
478
+ title: "Add SSH Tunnel",
479
+ webPreferences: {
480
+ preload: join3(__dirname, "preload.js"),
481
+ contextIsolation: true,
482
+ nodeIntegration: false
483
+ }
484
+ });
485
+ addWindow.loadFile(join3(__dirname, "..", "renderer", "add-tunnel.html"));
486
+ addWindow.on("closed", () => {
487
+ addWindow = null;
488
+ });
489
+ }
490
+ ipcMain.handle("get-statuses", async () => {
491
+ await refreshStatuses();
492
+ return currentStatuses;
493
+ });
494
+ ipcMain.handle("add-tunnel", async (_event, tunnelData) => {
495
+ console.log("IPC add-tunnel received:", JSON.stringify(tunnelData));
496
+ const config = await readConfig();
497
+ const id = slugify(tunnelData.name);
498
+ if (config.tunnels.some((t) => t.id === id)) {
499
+ throw new Error(`Tunnel with id '${id}' already exists`);
500
+ }
501
+ const tunnel = {
502
+ ...tunnelData,
503
+ id,
504
+ extraArgs: []
505
+ };
506
+ config.tunnels.push(tunnel);
507
+ await writeConfig(config);
508
+ if (tunnel.enabled) {
509
+ await sync(config);
510
+ }
511
+ await updateTray();
512
+ return tunnel;
513
+ });
514
+ ipcMain.handle("toggle-tunnel", async (_event, id) => {
515
+ const config = await readConfig();
516
+ const tunnel = config.tunnels.find((t) => t.id === id);
517
+ if (!tunnel) throw new Error(`Tunnel '${id}' not found`);
518
+ tunnel.enabled = !tunnel.enabled;
519
+ await writeConfig(config);
520
+ await sync(config);
521
+ await updateTray();
522
+ return tunnel.enabled;
523
+ });
524
+ ipcMain.handle("remove-tunnel", async (_event, id) => {
525
+ const config = await readConfig();
526
+ const idx = config.tunnels.findIndex((t) => t.id === id);
527
+ if (idx === -1) throw new Error(`Tunnel '${id}' not found`);
528
+ config.tunnels.splice(idx, 1);
529
+ await writeConfig(config);
530
+ await stopTunnel(id);
531
+ await updateTray();
532
+ });
533
+ app.dock?.hide();
534
+ app.whenReady().then(async () => {
535
+ tray = new Tray(createTrayIcon("yellow"));
536
+ tray.setToolTip("SSH Tunnels \u2014 loading...");
537
+ tray.on("mouse-down", async () => {
538
+ await updateTray();
539
+ });
540
+ await updateTray();
541
+ configWatcher = watchConfig({
542
+ onSync: async () => {
543
+ await updateTray();
544
+ },
545
+ onError: (err) => {
546
+ console.error("Config watch error:", err);
547
+ }
548
+ });
549
+ syncTimer = setInterval(async () => {
550
+ try {
551
+ await sync();
552
+ await updateTray();
553
+ } catch (err) {
554
+ console.error("Periodic sync error:", err);
555
+ }
556
+ }, 3e4);
557
+ });
558
+ app.on("before-quit", async () => {
559
+ if (configWatcher) await configWatcher.close();
560
+ if (syncTimer) clearInterval(syncTimer);
561
+ });
562
+ app.on("window-all-closed", () => {
563
+ });