@linxin666/dsh-ssh 0.1.14 → 0.1.16
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 +26 -4
- package/lib/client.js.map +1 -1
- package/lib/index.js +675 -607
- package/lib/types/client/panel/TunnelsTab.d.ts +10 -0
- package/lib/types/engine/cluster.d.ts +17 -0
- package/lib/types/engine/connection-pool.d.ts +83 -0
- package/lib/types/engine/pty.d.ts +32 -0
- package/lib/types/engine/sftp.d.ts +20 -0
- package/lib/types/engine/tunnel.d.ts +32 -0
- package/lib/types/engine.d.ts +18 -79
- package/package.json +1 -1
- package/src/client/panel/TerminalTab.tsx +10 -3
- package/src/client/panel/TunnelsTab.tsx +31 -4
- package/src/engine/cluster.ts +51 -0
- package/src/engine/connection-pool.ts +318 -0
- package/src/engine/pty.ts +74 -0
- package/src/engine/sftp.ts +235 -0
- package/src/engine/tunnel.ts +125 -0
- package/src/engine.ts +45 -687
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
-
import { createServer } from "node:net";
|
|
3
2
|
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
3
|
import { Client } from "ssh2";
|
|
6
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { createServer } from "node:net";
|
|
7
7
|
import { unlink } from "node:fs/promises";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -1312,13 +1312,12 @@ function expandHome(path) {
|
|
|
1312
1312
|
return path;
|
|
1313
1313
|
}
|
|
1314
1314
|
//#endregion
|
|
1315
|
-
//#region src/engine.ts
|
|
1315
|
+
//#region src/engine/connection-pool.ts
|
|
1316
1316
|
/**
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
* local port-forward tunnels and cluster execution — the DSH counterpart of
|
|
1320
|
-
* ssh-skill's daemon + scripts, living entirely in the host process.
|
|
1317
|
+
* Connection pool: per-alias persistent ssh2 connections with multi-hop jump
|
|
1318
|
+
* support, the acquire / dispose / sweep lifecycle, and the pooled exec path.
|
|
1321
1319
|
*/
|
|
1320
|
+
/** Default engine knobs (applied when an option is omitted). */
|
|
1322
1321
|
const DEFAULTS = {
|
|
1323
1322
|
idleTimeoutMs: 30 * 6e4,
|
|
1324
1323
|
connectTimeoutMs: 15e3,
|
|
@@ -1342,7 +1341,7 @@ function buildConnectConfig(entry, sock) {
|
|
|
1342
1341
|
if (entry.auth.kind === "password") config.password = entry.auth.password;
|
|
1343
1342
|
else {
|
|
1344
1343
|
const keyPath = entry.auth.keyPath === void 0 ? void 0 : expandHome(entry.auth.keyPath);
|
|
1345
|
-
if (keyPath === void 0 || !existsSync(keyPath)) throw new Error(
|
|
1344
|
+
if (keyPath === void 0 || !existsSync(keyPath)) throw new Error("private key not found: " + (entry.auth.keyPath ?? "(unset)"));
|
|
1346
1345
|
config.privateKey = readFileSync(keyPath, "utf8");
|
|
1347
1346
|
if (entry.auth.passphrase !== void 0 && entry.auth.passphrase !== "") config.passphrase = entry.auth.passphrase;
|
|
1348
1347
|
}
|
|
@@ -1353,23 +1352,24 @@ function connectClient(config) {
|
|
|
1353
1352
|
return new Promise((resolve, reject) => {
|
|
1354
1353
|
const client = new Client();
|
|
1355
1354
|
let settled = false;
|
|
1356
|
-
|
|
1355
|
+
const fail = (error) => {
|
|
1357
1356
|
if (settled) return;
|
|
1358
1357
|
settled = true;
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1358
|
+
try {
|
|
1359
|
+
client.destroy();
|
|
1360
|
+
} catch {}
|
|
1361
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1362
|
+
};
|
|
1363
|
+
client.once("ready", () => {
|
|
1362
1364
|
if (settled) return;
|
|
1363
1365
|
settled = true;
|
|
1364
|
-
|
|
1366
|
+
resolve(client);
|
|
1365
1367
|
});
|
|
1368
|
+
client.on("error", fail);
|
|
1366
1369
|
try {
|
|
1367
1370
|
client.connect(config);
|
|
1368
1371
|
} catch (error) {
|
|
1369
|
-
|
|
1370
|
-
settled = true;
|
|
1371
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1372
|
-
}
|
|
1372
|
+
fail(error);
|
|
1373
1373
|
}
|
|
1374
1374
|
});
|
|
1375
1375
|
}
|
|
@@ -1385,6 +1385,288 @@ function appendOutput(target, chunk, maxBytes) {
|
|
|
1385
1385
|
}
|
|
1386
1386
|
target.text += chunk.toString("utf8");
|
|
1387
1387
|
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Build one full jump chain for an entry: hop clients connected through in
|
|
1390
|
+
* order, each forwarding a stream to the next destination, ending with the
|
|
1391
|
+
* target client. Shared by the pool and standalone shell sessions.
|
|
1392
|
+
*/
|
|
1393
|
+
async function connectChain(engine, entry) {
|
|
1394
|
+
const hops = [];
|
|
1395
|
+
let sock;
|
|
1396
|
+
const chain = entry.proxyJump;
|
|
1397
|
+
for (let index = 0; index < chain.length; index += 1) {
|
|
1398
|
+
const hopAlias = chain[index];
|
|
1399
|
+
const hop = engine.store.find(hopAlias);
|
|
1400
|
+
if (hop === void 0) {
|
|
1401
|
+
for (const client of hops) client.end();
|
|
1402
|
+
throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
|
|
1403
|
+
}
|
|
1404
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock));
|
|
1405
|
+
hops.push(hopClient);
|
|
1406
|
+
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
|
|
1407
|
+
const nextHost = next !== void 0 ? next.host : entry.host;
|
|
1408
|
+
const nextPort = next !== void 0 ? next.port : entry.port;
|
|
1409
|
+
sock = await new Promise((resolve, reject) => {
|
|
1410
|
+
hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
|
|
1411
|
+
if (error !== void 0) {
|
|
1412
|
+
for (const client of hops) client.end();
|
|
1413
|
+
reject(error);
|
|
1414
|
+
} else resolve(stream);
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
let target;
|
|
1419
|
+
try {
|
|
1420
|
+
target = await connectClient(buildConnectConfig(entry, sock));
|
|
1421
|
+
return {
|
|
1422
|
+
client: target,
|
|
1423
|
+
hops
|
|
1424
|
+
};
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
for (const client of hops) client.end();
|
|
1427
|
+
if (target !== void 0) try {
|
|
1428
|
+
target.destroy();
|
|
1429
|
+
} catch {}
|
|
1430
|
+
throw error;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
/** Connect (or reuse) the pooled chain for one alias; pins nothing. */
|
|
1434
|
+
async function acquire(engine, alias) {
|
|
1435
|
+
const pending = engine.acquireQueue.get(alias);
|
|
1436
|
+
if (pending !== void 0) return pending;
|
|
1437
|
+
const task = doAcquire(engine, alias);
|
|
1438
|
+
engine.acquireQueue.set(alias, task);
|
|
1439
|
+
try {
|
|
1440
|
+
return await task;
|
|
1441
|
+
} finally {
|
|
1442
|
+
if (engine.acquireQueue.get(alias) === task) engine.acquireQueue.delete(alias);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
async function doAcquire(engine, alias) {
|
|
1446
|
+
const entry = engine.store.find(alias);
|
|
1447
|
+
if (entry === void 0) throw new Error("alias '" + alias + "' not found — add it first");
|
|
1448
|
+
const { client, hops } = await connectChain(engine, entry);
|
|
1449
|
+
const record = {
|
|
1450
|
+
client,
|
|
1451
|
+
hops,
|
|
1452
|
+
idleAt: Date.now(),
|
|
1453
|
+
pinned: false,
|
|
1454
|
+
broken: false,
|
|
1455
|
+
inFlight: 0
|
|
1456
|
+
};
|
|
1457
|
+
client.on("error", () => {
|
|
1458
|
+
record.broken = true;
|
|
1459
|
+
});
|
|
1460
|
+
client.on("close", () => {
|
|
1461
|
+
record.broken = true;
|
|
1462
|
+
});
|
|
1463
|
+
engine.pool.set(alias, record);
|
|
1464
|
+
return record;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Tear down one alias's record. When `record` is given and no longer the
|
|
1468
|
+
* pooled record for the alias (a concurrent acquire replaced it), nothing
|
|
1469
|
+
* is torn down — the connection belongs to someone else now.
|
|
1470
|
+
*/
|
|
1471
|
+
function disposeRecord(engine, alias, record) {
|
|
1472
|
+
const current = engine.pool.get(alias);
|
|
1473
|
+
if (record !== void 0 && current !== record) return;
|
|
1474
|
+
if (current === void 0) return;
|
|
1475
|
+
engine.pool.delete(alias);
|
|
1476
|
+
try {
|
|
1477
|
+
current.client.end();
|
|
1478
|
+
} catch {}
|
|
1479
|
+
for (const hop of current.hops) try {
|
|
1480
|
+
hop.end();
|
|
1481
|
+
} catch {}
|
|
1482
|
+
}
|
|
1483
|
+
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
1484
|
+
function sweepPool(engine) {
|
|
1485
|
+
const cutoff = Date.now() - engine.opts.idleTimeoutMs;
|
|
1486
|
+
for (const [alias, record] of engine.pool) if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) disposeRecord(engine, alias, record);
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Run `fn` with a live client for `alias`, reconnecting (up to the
|
|
1490
|
+
* attempt budget) when the connection broke mid-flight.
|
|
1491
|
+
*/
|
|
1492
|
+
async function withClient(engine, alias, fn, attempts = 3) {
|
|
1493
|
+
let lastError;
|
|
1494
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1495
|
+
let record = engine.pool.get(alias);
|
|
1496
|
+
if (record === void 0 || record.broken) {
|
|
1497
|
+
if (record !== void 0) disposeRecord(engine, alias, record);
|
|
1498
|
+
record = await acquire(engine, alias);
|
|
1499
|
+
}
|
|
1500
|
+
record.idleAt = Date.now();
|
|
1501
|
+
record.inFlight += 1;
|
|
1502
|
+
try {
|
|
1503
|
+
const result = await fn(record.client);
|
|
1504
|
+
record.idleAt = Date.now();
|
|
1505
|
+
return result;
|
|
1506
|
+
} finally {
|
|
1507
|
+
record.inFlight -= 1;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1511
|
+
}
|
|
1512
|
+
/** Run one command on `alias` (reusing the pooled connection). */
|
|
1513
|
+
async function execCommand(engine, alias, command, timeoutMs) {
|
|
1514
|
+
const started = Date.now();
|
|
1515
|
+
const budget = timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs;
|
|
1516
|
+
return withClient(engine, alias, async (client) => {
|
|
1517
|
+
return await new Promise((resolve, reject) => {
|
|
1518
|
+
client.exec(command, (error, stream) => {
|
|
1519
|
+
if (error !== void 0) {
|
|
1520
|
+
reject(error);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
const stdout = {
|
|
1524
|
+
text: "",
|
|
1525
|
+
truncated: false
|
|
1526
|
+
};
|
|
1527
|
+
const stderr = {
|
|
1528
|
+
text: "",
|
|
1529
|
+
truncated: false
|
|
1530
|
+
};
|
|
1531
|
+
let timedOut = false;
|
|
1532
|
+
let settled = false;
|
|
1533
|
+
const finish = () => {
|
|
1534
|
+
if (settled) return;
|
|
1535
|
+
settled = true;
|
|
1536
|
+
clearTimeout(timer);
|
|
1537
|
+
resolve({
|
|
1538
|
+
success: false,
|
|
1539
|
+
exitCode: null,
|
|
1540
|
+
timedOut,
|
|
1541
|
+
stdout: stdout.text,
|
|
1542
|
+
stderr: stderr.text,
|
|
1543
|
+
durationMs: Date.now() - started,
|
|
1544
|
+
error: timedOut ? "command timed out after " + budget + " ms" : void 0
|
|
1545
|
+
});
|
|
1546
|
+
};
|
|
1547
|
+
const timer = setTimeout(() => {
|
|
1548
|
+
timedOut = true;
|
|
1549
|
+
try {
|
|
1550
|
+
stream.signal("KILL");
|
|
1551
|
+
} catch {}
|
|
1552
|
+
try {
|
|
1553
|
+
stream.close();
|
|
1554
|
+
} catch {}
|
|
1555
|
+
finish();
|
|
1556
|
+
}, budget);
|
|
1557
|
+
stream.on("data", (chunk) => appendOutput(stdout, chunk, engine.opts.maxOutputBytes));
|
|
1558
|
+
stream.stderr.on("data", (chunk) => appendOutput(stderr, chunk, engine.opts.maxOutputBytes));
|
|
1559
|
+
stream.on("close", (code) => {
|
|
1560
|
+
if (settled) return;
|
|
1561
|
+
settled = true;
|
|
1562
|
+
clearTimeout(timer);
|
|
1563
|
+
resolve({
|
|
1564
|
+
success: code === 0 && !timedOut,
|
|
1565
|
+
exitCode: code,
|
|
1566
|
+
timedOut,
|
|
1567
|
+
stdout: stdout.text,
|
|
1568
|
+
stderr: stderr.text,
|
|
1569
|
+
durationMs: Date.now() - started
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
stream.on("error", (streamError) => {
|
|
1573
|
+
if (settled) return;
|
|
1574
|
+
settled = true;
|
|
1575
|
+
clearTimeout(timer);
|
|
1576
|
+
reject(streamError);
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
});
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
//#endregion
|
|
1583
|
+
//#region src/engine/pty.ts
|
|
1584
|
+
/**
|
|
1585
|
+
* Open a PTY shell session for the web terminal (standalone connection).
|
|
1586
|
+
* The shell is a long-lived exclusive stream: it uses its own connection so
|
|
1587
|
+
* closing it can never tear down a pooled exec/tunnel sharing the alias.
|
|
1588
|
+
*/
|
|
1589
|
+
async function openShell(engine, alias, size) {
|
|
1590
|
+
const entry = engine.store.find(alias);
|
|
1591
|
+
if (entry === void 0) throw new Error("alias '" + alias + "' not found — add it first");
|
|
1592
|
+
const { client, hops } = await connectChain(engine, entry);
|
|
1593
|
+
return await new Promise((resolve, reject) => {
|
|
1594
|
+
client.shell({
|
|
1595
|
+
term: "xterm-256color",
|
|
1596
|
+
cols: size.cols,
|
|
1597
|
+
rows: size.rows
|
|
1598
|
+
}, (error, stream) => {
|
|
1599
|
+
if (error !== void 0) {
|
|
1600
|
+
try {
|
|
1601
|
+
client.end();
|
|
1602
|
+
} catch {}
|
|
1603
|
+
for (const hop of hops) try {
|
|
1604
|
+
hop.end();
|
|
1605
|
+
} catch {}
|
|
1606
|
+
reject(error);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
let tornDown = false;
|
|
1610
|
+
const teardown = () => {
|
|
1611
|
+
if (tornDown) return;
|
|
1612
|
+
tornDown = true;
|
|
1613
|
+
try {
|
|
1614
|
+
client.end();
|
|
1615
|
+
} catch {}
|
|
1616
|
+
for (const hop of hops) try {
|
|
1617
|
+
hop.end();
|
|
1618
|
+
} catch {}
|
|
1619
|
+
};
|
|
1620
|
+
const session = {
|
|
1621
|
+
send: (data) => {
|
|
1622
|
+
try {
|
|
1623
|
+
stream.write(data);
|
|
1624
|
+
} catch {}
|
|
1625
|
+
},
|
|
1626
|
+
resize: (cols, rows) => {
|
|
1627
|
+
try {
|
|
1628
|
+
stream.setWindow(rows, cols, rows, cols);
|
|
1629
|
+
} catch {}
|
|
1630
|
+
},
|
|
1631
|
+
close: () => {
|
|
1632
|
+
try {
|
|
1633
|
+
stream.close();
|
|
1634
|
+
} catch {}
|
|
1635
|
+
teardown();
|
|
1636
|
+
},
|
|
1637
|
+
pause: () => {
|
|
1638
|
+
try {
|
|
1639
|
+
stream.pause();
|
|
1640
|
+
} catch {}
|
|
1641
|
+
},
|
|
1642
|
+
resume: () => {
|
|
1643
|
+
try {
|
|
1644
|
+
stream.resume();
|
|
1645
|
+
} catch {}
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
stream.on("data", (chunk) => {
|
|
1649
|
+
session.onData?.(chunk);
|
|
1650
|
+
});
|
|
1651
|
+
stream.on("close", (code) => {
|
|
1652
|
+
teardown();
|
|
1653
|
+
session.onExit?.(code);
|
|
1654
|
+
});
|
|
1655
|
+
stream.on("error", (streamError) => {
|
|
1656
|
+
teardown();
|
|
1657
|
+
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError));
|
|
1658
|
+
});
|
|
1659
|
+
resolve(session);
|
|
1660
|
+
});
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
//#endregion
|
|
1664
|
+
//#region src/engine/sftp.ts
|
|
1665
|
+
/**
|
|
1666
|
+
* SFTP transfers: upload (file or recursive tree), single-file download, and
|
|
1667
|
+
* remote directory listing. Every channel is opened once per operation and
|
|
1668
|
+
* released exactly once so sshd's MaxSessions cap is never exhausted.
|
|
1669
|
+
*/
|
|
1388
1670
|
/** Walk a local directory, collecting relative paths of every file. */
|
|
1389
1671
|
function walkLocalDir(root) {
|
|
1390
1672
|
const files = [];
|
|
@@ -1399,6 +1681,366 @@ function walkLocalDir(root) {
|
|
|
1399
1681
|
visit(root);
|
|
1400
1682
|
return files;
|
|
1401
1683
|
}
|
|
1684
|
+
/** Upload one local file (or directory tree) to a remote path. */
|
|
1685
|
+
async function upload(engine, alias, localPath, remotePath, recursive, onProgress) {
|
|
1686
|
+
if (!remotePath.startsWith("/")) throw new Error("remotePath must be an absolute path (got '" + remotePath + "')");
|
|
1687
|
+
const local = resolve(localPath);
|
|
1688
|
+
if (!existsSync(local)) throw new Error("local path not found: '" + localPath + "'");
|
|
1689
|
+
return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
|
|
1690
|
+
const stat = statSync(local);
|
|
1691
|
+
let files;
|
|
1692
|
+
if (stat.isDirectory()) {
|
|
1693
|
+
if (!recursive) throw new Error("'" + localPath + "' is a directory — enable recursive upload");
|
|
1694
|
+
files = walkLocalDir(local);
|
|
1695
|
+
await ensureRemoteDir(sftp, remotePath);
|
|
1696
|
+
} else {
|
|
1697
|
+
files = [""];
|
|
1698
|
+
await ensureRemoteDir(sftp, dirname(remotePath));
|
|
1699
|
+
}
|
|
1700
|
+
let bytes = 0;
|
|
1701
|
+
for (const rel of files) {
|
|
1702
|
+
const src = rel === "" ? local : join(local, rel);
|
|
1703
|
+
const remoteRel = rel.split(/[\\/]/).join("/");
|
|
1704
|
+
await fastPut(sftp, src, rel === "" ? remotePath : remotePath.replace(/\/$/, "") + "/" + remoteRel, engine.opts.sftpConcurrency, onProgress);
|
|
1705
|
+
bytes += statSync(src).size;
|
|
1706
|
+
}
|
|
1707
|
+
return {
|
|
1708
|
+
bytes,
|
|
1709
|
+
files: files.length
|
|
1710
|
+
};
|
|
1711
|
+
}));
|
|
1712
|
+
}
|
|
1713
|
+
/** Download one remote file to a local path. */
|
|
1714
|
+
async function download(engine, alias, remotePath, localPath, onProgress) {
|
|
1715
|
+
return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
|
|
1716
|
+
if ((await new Promise((resolve, reject) => {
|
|
1717
|
+
sftp.stat(remotePath, (error, stats) => error !== void 0 ? reject(error) : resolve(stats));
|
|
1718
|
+
})).isDirectory()) throw new Error("'" + remotePath + "' is a directory — directory download is not supported yet (download individual files)");
|
|
1719
|
+
const local = resolve(localPath);
|
|
1720
|
+
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true });
|
|
1721
|
+
await fastGet(sftp, remotePath, local, engine.opts.sftpConcurrency, onProgress);
|
|
1722
|
+
return { bytes: statSync(local).size };
|
|
1723
|
+
}));
|
|
1724
|
+
}
|
|
1725
|
+
/** List a remote directory (file browser). */
|
|
1726
|
+
async function ls(engine, alias, path) {
|
|
1727
|
+
return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
|
|
1728
|
+
return await new Promise((resolve, reject) => {
|
|
1729
|
+
sftp.readdir(path, (error, list) => {
|
|
1730
|
+
if (error !== void 0) {
|
|
1731
|
+
reject(error);
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
resolve(list.map((item) => ({
|
|
1735
|
+
name: item.filename,
|
|
1736
|
+
type: item.attrs.isDirectory() ? "dir" : item.attrs.isFile() ? "file" : "other",
|
|
1737
|
+
size: item.attrs.size,
|
|
1738
|
+
mtimeMs: item.attrs.mtime * 1e3,
|
|
1739
|
+
mode: item.attrs.mode
|
|
1740
|
+
})));
|
|
1741
|
+
});
|
|
1742
|
+
});
|
|
1743
|
+
}));
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Open one SFTP channel, run the operation, and release the channel exactly
|
|
1747
|
+
* once when the operation settles (success or error). ssh2 keeps each
|
|
1748
|
+
* subsystem channel open until end(); without this, every transfer leaks a
|
|
1749
|
+
* channel until sshd's MaxSessions cap makes all later opens fail.
|
|
1750
|
+
*/
|
|
1751
|
+
async function withSftp(client, run) {
|
|
1752
|
+
const sftp = await sftpChannel(client);
|
|
1753
|
+
let ended = false;
|
|
1754
|
+
const endOnce = () => {
|
|
1755
|
+
if (ended) return;
|
|
1756
|
+
ended = true;
|
|
1757
|
+
try {
|
|
1758
|
+
sftp.end();
|
|
1759
|
+
} catch {}
|
|
1760
|
+
};
|
|
1761
|
+
sftp.once("close", endOnce);
|
|
1762
|
+
try {
|
|
1763
|
+
return await run(sftp);
|
|
1764
|
+
} finally {
|
|
1765
|
+
endOnce();
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
function sftpChannel(client) {
|
|
1769
|
+
return new Promise((resolve, reject) => {
|
|
1770
|
+
client.sftp((error, sftp) => error !== void 0 ? reject(error) : resolve(sftp));
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
1774
|
+
function ensureRemoteDir(sftp, remote) {
|
|
1775
|
+
return new Promise((resolve, reject) => {
|
|
1776
|
+
const segments = remote.replace(/^\/+/, "").split("/").filter((segment) => segment !== "");
|
|
1777
|
+
const walk = (index) => {
|
|
1778
|
+
if (index >= segments.length) {
|
|
1779
|
+
resolve();
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const current = "/" + segments.slice(0, index + 1).join("/");
|
|
1783
|
+
sftp.stat(current, (statError) => {
|
|
1784
|
+
if (statError === void 0) {
|
|
1785
|
+
walk(index + 1);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
sftp.mkdir(current, (mkdirError) => {
|
|
1789
|
+
if (mkdirError !== void 0) {
|
|
1790
|
+
reject(mkdirError);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
walk(index + 1);
|
|
1794
|
+
});
|
|
1795
|
+
});
|
|
1796
|
+
};
|
|
1797
|
+
walk(0);
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
function fastPut(sftp, src, dst, concurrency, onProgress) {
|
|
1801
|
+
return new Promise((resolve, reject) => {
|
|
1802
|
+
let last = 0;
|
|
1803
|
+
let lastEmit = 0;
|
|
1804
|
+
const started = Date.now();
|
|
1805
|
+
onProgress?.({
|
|
1806
|
+
phase: "transferring",
|
|
1807
|
+
file: dst,
|
|
1808
|
+
transferred: 0,
|
|
1809
|
+
total: statSync(src).size,
|
|
1810
|
+
percent: 0
|
|
1811
|
+
});
|
|
1812
|
+
sftp.fastPut(src, dst, {
|
|
1813
|
+
concurrency,
|
|
1814
|
+
step: (transferred, _chunk, total) => {
|
|
1815
|
+
const now = Date.now();
|
|
1816
|
+
if (now - lastEmit < 100 && transferred < total) return;
|
|
1817
|
+
lastEmit = now;
|
|
1818
|
+
const elapsed = (now - started) / 1e3;
|
|
1819
|
+
onProgress?.({
|
|
1820
|
+
phase: "transferring",
|
|
1821
|
+
file: dst,
|
|
1822
|
+
transferred,
|
|
1823
|
+
total,
|
|
1824
|
+
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1825
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1826
|
+
});
|
|
1827
|
+
last = transferred;
|
|
1828
|
+
}
|
|
1829
|
+
}, (error) => {
|
|
1830
|
+
if (error !== void 0) {
|
|
1831
|
+
onProgress?.({
|
|
1832
|
+
phase: "error",
|
|
1833
|
+
file: dst,
|
|
1834
|
+
transferred: 0,
|
|
1835
|
+
total: 0,
|
|
1836
|
+
percent: 0,
|
|
1837
|
+
error: String(error)
|
|
1838
|
+
});
|
|
1839
|
+
reject(error);
|
|
1840
|
+
} else {
|
|
1841
|
+
onProgress?.({
|
|
1842
|
+
phase: "done",
|
|
1843
|
+
file: dst,
|
|
1844
|
+
transferred: statSync(src).size,
|
|
1845
|
+
total: statSync(src).size,
|
|
1846
|
+
percent: 100
|
|
1847
|
+
});
|
|
1848
|
+
resolve();
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
function fastGet(sftp, src, dst, concurrency, onProgress) {
|
|
1854
|
+
return new Promise((resolve, reject) => {
|
|
1855
|
+
let last = 0;
|
|
1856
|
+
let lastEmit = 0;
|
|
1857
|
+
const started = Date.now();
|
|
1858
|
+
sftp.fastGet(src, dst, {
|
|
1859
|
+
concurrency,
|
|
1860
|
+
step: (transferred, _chunk, total) => {
|
|
1861
|
+
const now = Date.now();
|
|
1862
|
+
if (now - lastEmit < 100 && transferred < total) return;
|
|
1863
|
+
lastEmit = now;
|
|
1864
|
+
const elapsed = (now - started) / 1e3;
|
|
1865
|
+
onProgress?.({
|
|
1866
|
+
phase: "transferring",
|
|
1867
|
+
file: src,
|
|
1868
|
+
transferred,
|
|
1869
|
+
total,
|
|
1870
|
+
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1871
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1872
|
+
});
|
|
1873
|
+
last = transferred;
|
|
1874
|
+
}
|
|
1875
|
+
}, (error) => {
|
|
1876
|
+
if (error !== void 0) {
|
|
1877
|
+
onProgress?.({
|
|
1878
|
+
phase: "error",
|
|
1879
|
+
file: src,
|
|
1880
|
+
transferred: 0,
|
|
1881
|
+
total: 0,
|
|
1882
|
+
percent: 0,
|
|
1883
|
+
error: String(error)
|
|
1884
|
+
});
|
|
1885
|
+
reject(error);
|
|
1886
|
+
} else {
|
|
1887
|
+
onProgress?.({
|
|
1888
|
+
phase: "done",
|
|
1889
|
+
file: src,
|
|
1890
|
+
transferred: statSync(dst).size,
|
|
1891
|
+
total: statSync(dst).size,
|
|
1892
|
+
percent: 100
|
|
1893
|
+
});
|
|
1894
|
+
resolve();
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
//#endregion
|
|
1900
|
+
//#region src/engine/tunnel.ts
|
|
1901
|
+
/**
|
|
1902
|
+
* Local port-forward tunnels: one loopback-only listener per tunnel, pinned
|
|
1903
|
+
* to a pooled connection so an idle sweep never closes it, with per-tunnel
|
|
1904
|
+
* socket tracking and stop (single / alias-scoped / all).
|
|
1905
|
+
*/
|
|
1906
|
+
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
1907
|
+
async function startTunnel(engine, alias, options) {
|
|
1908
|
+
if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) throw new Error("remotePort must be an integer in 1..65535");
|
|
1909
|
+
if (options.localPort !== void 0 && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) throw new Error("localPort must be an integer in 1..65535");
|
|
1910
|
+
if (engine.store.find(alias) === void 0) throw new Error("alias '" + alias + "' not found — add it first");
|
|
1911
|
+
const remoteHost = options.remoteHost ?? "127.0.0.1";
|
|
1912
|
+
const id = "tun-" + engine.nextTunnelId;
|
|
1913
|
+
engine.nextTunnelId += 1;
|
|
1914
|
+
const info = {
|
|
1915
|
+
id,
|
|
1916
|
+
alias,
|
|
1917
|
+
localPort: 0,
|
|
1918
|
+
remoteHost,
|
|
1919
|
+
remotePort: options.remotePort,
|
|
1920
|
+
state: "connecting",
|
|
1921
|
+
startedAt: Date.now()
|
|
1922
|
+
};
|
|
1923
|
+
const record = await acquire(engine, alias);
|
|
1924
|
+
const client = record.client;
|
|
1925
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1926
|
+
const server = createServer((socket) => {
|
|
1927
|
+
sockets.add(socket);
|
|
1928
|
+
socket.on("close", () => {
|
|
1929
|
+
sockets.delete(socket);
|
|
1930
|
+
});
|
|
1931
|
+
client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
|
|
1932
|
+
if (error !== void 0) {
|
|
1933
|
+
socket.destroy();
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
const destroy = () => {
|
|
1937
|
+
try {
|
|
1938
|
+
socket.destroy();
|
|
1939
|
+
} catch {}
|
|
1940
|
+
try {
|
|
1941
|
+
stream.close();
|
|
1942
|
+
} catch {}
|
|
1943
|
+
};
|
|
1944
|
+
stream.on("error", destroy);
|
|
1945
|
+
socket.on("error", destroy);
|
|
1946
|
+
stream.on("close", destroy);
|
|
1947
|
+
socket.on("close", destroy);
|
|
1948
|
+
stream.pipe(socket).pipe(stream);
|
|
1949
|
+
});
|
|
1950
|
+
});
|
|
1951
|
+
try {
|
|
1952
|
+
await new Promise((resolve, reject) => {
|
|
1953
|
+
server.once("error", reject);
|
|
1954
|
+
server.listen(options.localPort ?? 0, "127.0.0.1", () => {
|
|
1955
|
+
server.removeListener("error", reject);
|
|
1956
|
+
resolve();
|
|
1957
|
+
});
|
|
1958
|
+
});
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
if (!record.pinned && record.inFlight === 0) disposeRecord(engine, alias, record);
|
|
1961
|
+
throw error;
|
|
1962
|
+
}
|
|
1963
|
+
record.pinned = true;
|
|
1964
|
+
const address = server.address();
|
|
1965
|
+
info.localPort = typeof address === "object" && address !== null ? address.port : 0;
|
|
1966
|
+
info.state = "forwarding";
|
|
1967
|
+
engine.tunnels.set(id, {
|
|
1968
|
+
info,
|
|
1969
|
+
server,
|
|
1970
|
+
alias,
|
|
1971
|
+
sockets
|
|
1972
|
+
});
|
|
1973
|
+
return info;
|
|
1974
|
+
}
|
|
1975
|
+
/** All active tunnels. */
|
|
1976
|
+
function listTunnels(engine) {
|
|
1977
|
+
return [...engine.tunnels.values()].map((tunnel) => ({ ...tunnel.info }));
|
|
1978
|
+
}
|
|
1979
|
+
/** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
|
|
1980
|
+
function stopTunnel(engine, id) {
|
|
1981
|
+
const tunnel = engine.tunnels.get(id);
|
|
1982
|
+
if (tunnel === void 0) return false;
|
|
1983
|
+
engine.tunnels.delete(id);
|
|
1984
|
+
try {
|
|
1985
|
+
tunnel.server.close();
|
|
1986
|
+
} catch {}
|
|
1987
|
+
for (const socket of tunnel.sockets) try {
|
|
1988
|
+
socket.destroy();
|
|
1989
|
+
} catch {}
|
|
1990
|
+
tunnel.sockets.clear();
|
|
1991
|
+
disposeRecord(engine, tunnel.alias);
|
|
1992
|
+
return true;
|
|
1993
|
+
}
|
|
1994
|
+
/** Stop all tunnels (optionally for one alias). */
|
|
1995
|
+
function stopAllTunnels(engine, alias) {
|
|
1996
|
+
let count = 0;
|
|
1997
|
+
for (const [id, tunnel] of [...engine.tunnels]) if (alias === void 0 || tunnel.alias === alias) {
|
|
1998
|
+
stopTunnel(engine, id);
|
|
1999
|
+
count += 1;
|
|
2000
|
+
}
|
|
2001
|
+
return count;
|
|
2002
|
+
}
|
|
2003
|
+
//#endregion
|
|
2004
|
+
//#region src/engine/cluster.ts
|
|
2005
|
+
/** Run one command against many hosts concurrently. */
|
|
2006
|
+
async function cluster(engine, options) {
|
|
2007
|
+
let targets = engine.store.list();
|
|
2008
|
+
if (options.aliases !== void 0 && options.aliases.length > 0) targets = targets.filter((entry) => options.aliases.includes(entry.alias));
|
|
2009
|
+
if (options.environment !== void 0 && options.environment !== "") targets = targets.filter((entry) => entry.environment === options.environment);
|
|
2010
|
+
if (options.tags !== void 0 && options.tags.length > 0) targets = targets.filter((entry) => options.tags.every((tag) => entry.tags.includes(tag)));
|
|
2011
|
+
if (targets.length === 0) return [];
|
|
2012
|
+
if (options.maxWorkers !== void 0 && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) throw new Error("maxWorkers must be a positive integer");
|
|
2013
|
+
const workers = Math.min(engine.opts.defaultMaxWorkers, options.maxWorkers ?? engine.opts.defaultMaxWorkers, targets.length);
|
|
2014
|
+
const results = [];
|
|
2015
|
+
const queue = [...targets];
|
|
2016
|
+
const run = async () => {
|
|
2017
|
+
while (queue.length > 0) {
|
|
2018
|
+
const entry = queue.shift();
|
|
2019
|
+
try {
|
|
2020
|
+
const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs);
|
|
2021
|
+
results.push({
|
|
2022
|
+
alias: entry.alias,
|
|
2023
|
+
ok: result.success,
|
|
2024
|
+
exitCode: result.exitCode,
|
|
2025
|
+
timedOut: result.timedOut,
|
|
2026
|
+
stdout: result.stdout,
|
|
2027
|
+
stderr: result.stderr,
|
|
2028
|
+
durationMs: result.durationMs
|
|
2029
|
+
});
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
results.push({
|
|
2032
|
+
alias: entry.alias,
|
|
2033
|
+
ok: false,
|
|
2034
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
await Promise.all(Array.from({ length: workers }, () => run()));
|
|
2040
|
+
return results;
|
|
2041
|
+
}
|
|
2042
|
+
//#endregion
|
|
2043
|
+
//#region src/engine.ts
|
|
1402
2044
|
/**
|
|
1403
2045
|
* The engine. Owns the pool, tunnels, and all operations. One instance per
|
|
1404
2046
|
* plugin apply; dispose() closes every connection.
|
|
@@ -1407,9 +2049,10 @@ var SshEngine = class {
|
|
|
1407
2049
|
store;
|
|
1408
2050
|
opts;
|
|
1409
2051
|
pool = /* @__PURE__ */ new Map();
|
|
2052
|
+
acquireQueue = /* @__PURE__ */ new Map();
|
|
1410
2053
|
tunnels = /* @__PURE__ */ new Map();
|
|
1411
|
-
sweepTimer;
|
|
1412
2054
|
nextTunnelId = 1;
|
|
2055
|
+
sweepTimer;
|
|
1413
2056
|
/**
|
|
1414
2057
|
* @param store - the host config store.
|
|
1415
2058
|
* @param options - engine knobs (defaults applied).
|
|
@@ -1420,7 +2063,7 @@ var SshEngine = class {
|
|
|
1420
2063
|
...DEFAULTS,
|
|
1421
2064
|
...options
|
|
1422
2065
|
};
|
|
1423
|
-
this.sweepTimer = setInterval(() => this
|
|
2066
|
+
this.sweepTimer = setInterval(() => sweepPool(this), Math.max(1e4, this.opts.idleTimeoutMs / 4));
|
|
1424
2067
|
this.sweepTimer.unref?.();
|
|
1425
2068
|
}
|
|
1426
2069
|
/** Secret-free host list (filtered by the optional query). */
|
|
@@ -1433,620 +2076,45 @@ var SshEngine = class {
|
|
|
1433
2076
|
const entry = this.store.find(alias);
|
|
1434
2077
|
return entry === void 0 ? void 0 : this.store.summarize(entry);
|
|
1435
2078
|
}
|
|
1436
|
-
/**
|
|
1437
|
-
* Run `fn` with a live client for `alias`, reconnecting (up to the
|
|
1438
|
-
* attempt budget) when the connection broke mid-flight.
|
|
1439
|
-
*/
|
|
1440
|
-
async withClient(alias, fn, attempts = 3) {
|
|
1441
|
-
let lastError;
|
|
1442
|
-
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1443
|
-
let record = this.pool.get(alias);
|
|
1444
|
-
if (record === void 0 || record.broken) {
|
|
1445
|
-
if (record !== void 0) this.disposeRecord(alias, record);
|
|
1446
|
-
record = await this.acquire(alias);
|
|
1447
|
-
}
|
|
1448
|
-
record.idleAt = Date.now();
|
|
1449
|
-
record.inFlight += 1;
|
|
1450
|
-
try {
|
|
1451
|
-
const result = await fn(record.client);
|
|
1452
|
-
record.idleAt = Date.now();
|
|
1453
|
-
return result;
|
|
1454
|
-
} finally {
|
|
1455
|
-
record.inFlight -= 1;
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1459
|
-
}
|
|
1460
|
-
/**
|
|
1461
|
-
* Build one full jump chain for an entry: hop clients connected through in
|
|
1462
|
-
* order, each forwarding a stream to the next destination, ending with the
|
|
1463
|
-
* target client. Shared by the pool and standalone shell sessions.
|
|
1464
|
-
*/
|
|
1465
|
-
async connectChain(entry) {
|
|
1466
|
-
const hops = [];
|
|
1467
|
-
let sock;
|
|
1468
|
-
const chain = entry.proxyJump;
|
|
1469
|
-
for (let index = 0; index < chain.length; index += 1) {
|
|
1470
|
-
const hopAlias = chain[index];
|
|
1471
|
-
const hop = this.store.find(hopAlias);
|
|
1472
|
-
if (hop === void 0) {
|
|
1473
|
-
for (const client of hops) client.end();
|
|
1474
|
-
throw new Error(`proxyJump alias '${hopAlias}' not found — create it first`);
|
|
1475
|
-
}
|
|
1476
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock));
|
|
1477
|
-
hops.push(hopClient);
|
|
1478
|
-
const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : void 0;
|
|
1479
|
-
const nextHost = next !== void 0 ? next.host : entry.host;
|
|
1480
|
-
const nextPort = next !== void 0 ? next.port : entry.port;
|
|
1481
|
-
sock = await new Promise((resolve, reject) => {
|
|
1482
|
-
hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
|
|
1483
|
-
if (error !== void 0) {
|
|
1484
|
-
for (const client of hops) client.end();
|
|
1485
|
-
reject(error);
|
|
1486
|
-
} else resolve(stream);
|
|
1487
|
-
});
|
|
1488
|
-
});
|
|
1489
|
-
}
|
|
1490
|
-
try {
|
|
1491
|
-
return {
|
|
1492
|
-
client: await connectClient(buildConnectConfig(entry, sock)),
|
|
1493
|
-
hops
|
|
1494
|
-
};
|
|
1495
|
-
} catch (error) {
|
|
1496
|
-
for (const client of hops) client.end();
|
|
1497
|
-
throw error;
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
/** In-flight acquire promises, deduped per alias (concurrent first use). */
|
|
1501
|
-
acquireQueue = /* @__PURE__ */ new Map();
|
|
1502
|
-
/** Connect (or reuse) the pooled chain for one alias; pins nothing. */
|
|
1503
|
-
async acquire(alias) {
|
|
1504
|
-
const pending = this.acquireQueue.get(alias);
|
|
1505
|
-
if (pending !== void 0) return pending;
|
|
1506
|
-
const task = this.doAcquire(alias);
|
|
1507
|
-
this.acquireQueue.set(alias, task);
|
|
1508
|
-
try {
|
|
1509
|
-
return await task;
|
|
1510
|
-
} finally {
|
|
1511
|
-
if (this.acquireQueue.get(alias) === task) this.acquireQueue.delete(alias);
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
async doAcquire(alias) {
|
|
1515
|
-
const entry = this.store.find(alias);
|
|
1516
|
-
if (entry === void 0) throw new Error(`alias '${alias}' not found — add it first`);
|
|
1517
|
-
const { client, hops } = await this.connectChain(entry);
|
|
1518
|
-
const record = {
|
|
1519
|
-
client,
|
|
1520
|
-
hops,
|
|
1521
|
-
idleAt: Date.now(),
|
|
1522
|
-
pinned: false,
|
|
1523
|
-
broken: false,
|
|
1524
|
-
inFlight: 0
|
|
1525
|
-
};
|
|
1526
|
-
client.on("error", () => {
|
|
1527
|
-
record.broken = true;
|
|
1528
|
-
});
|
|
1529
|
-
client.on("close", () => {
|
|
1530
|
-
record.broken = true;
|
|
1531
|
-
});
|
|
1532
|
-
this.pool.set(alias, record);
|
|
1533
|
-
return record;
|
|
1534
|
-
}
|
|
1535
|
-
/**
|
|
1536
|
-
* Tear down one alias's record. When `record` is given and no longer the
|
|
1537
|
-
* pooled record for the alias (a concurrent acquire replaced it), nothing
|
|
1538
|
-
* is torn down — the connection belongs to someone else now.
|
|
1539
|
-
*/
|
|
1540
|
-
disposeRecord(alias, record) {
|
|
1541
|
-
const current = this.pool.get(alias);
|
|
1542
|
-
if (record !== void 0 && current !== record) return;
|
|
1543
|
-
if (current === void 0) return;
|
|
1544
|
-
this.pool.delete(alias);
|
|
1545
|
-
try {
|
|
1546
|
-
current.client.end();
|
|
1547
|
-
} catch {}
|
|
1548
|
-
for (const hop of current.hops) try {
|
|
1549
|
-
hop.end();
|
|
1550
|
-
} catch {}
|
|
1551
|
-
}
|
|
1552
|
-
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
1553
|
-
sweep() {
|
|
1554
|
-
const cutoff = Date.now() - this.opts.idleTimeoutMs;
|
|
1555
|
-
for (const [alias, record] of this.pool) if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) this.disposeRecord(alias, record);
|
|
1556
|
-
}
|
|
1557
2079
|
/** Run one command on `alias` (reusing the pooled connection). */
|
|
1558
2080
|
async exec(alias, command, timeoutMs) {
|
|
1559
|
-
|
|
1560
|
-
const budget = timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : this.opts.defaultExecTimeoutMs;
|
|
1561
|
-
return this.withClient(alias, async (client) => {
|
|
1562
|
-
return await new Promise((resolve, reject) => {
|
|
1563
|
-
client.exec(command, (error, stream) => {
|
|
1564
|
-
if (error !== void 0) {
|
|
1565
|
-
reject(error);
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
const stdout = {
|
|
1569
|
-
text: "",
|
|
1570
|
-
truncated: false
|
|
1571
|
-
};
|
|
1572
|
-
const stderr = {
|
|
1573
|
-
text: "",
|
|
1574
|
-
truncated: false
|
|
1575
|
-
};
|
|
1576
|
-
let timedOut = false;
|
|
1577
|
-
let settled = false;
|
|
1578
|
-
const finish = () => {
|
|
1579
|
-
if (settled) return;
|
|
1580
|
-
settled = true;
|
|
1581
|
-
clearTimeout(timer);
|
|
1582
|
-
resolve({
|
|
1583
|
-
success: false,
|
|
1584
|
-
exitCode: null,
|
|
1585
|
-
timedOut,
|
|
1586
|
-
stdout: stdout.text,
|
|
1587
|
-
stderr: stderr.text,
|
|
1588
|
-
durationMs: Date.now() - started,
|
|
1589
|
-
error: timedOut ? `command timed out after ${budget} ms` : void 0
|
|
1590
|
-
});
|
|
1591
|
-
};
|
|
1592
|
-
const timer = setTimeout(() => {
|
|
1593
|
-
timedOut = true;
|
|
1594
|
-
try {
|
|
1595
|
-
stream.signal("KILL");
|
|
1596
|
-
} catch {}
|
|
1597
|
-
try {
|
|
1598
|
-
stream.close();
|
|
1599
|
-
} catch {}
|
|
1600
|
-
finish();
|
|
1601
|
-
}, budget);
|
|
1602
|
-
stream.on("data", (chunk) => appendOutput(stdout, chunk, this.opts.maxOutputBytes));
|
|
1603
|
-
stream.stderr.on("data", (chunk) => appendOutput(stderr, chunk, this.opts.maxOutputBytes));
|
|
1604
|
-
stream.on("close", (code) => {
|
|
1605
|
-
if (settled) return;
|
|
1606
|
-
settled = true;
|
|
1607
|
-
clearTimeout(timer);
|
|
1608
|
-
resolve({
|
|
1609
|
-
success: code === 0 && !timedOut,
|
|
1610
|
-
exitCode: code,
|
|
1611
|
-
timedOut,
|
|
1612
|
-
stdout: stdout.text,
|
|
1613
|
-
stderr: stderr.text,
|
|
1614
|
-
durationMs: Date.now() - started
|
|
1615
|
-
});
|
|
1616
|
-
});
|
|
1617
|
-
stream.on("error", (streamError) => {
|
|
1618
|
-
if (settled) return;
|
|
1619
|
-
settled = true;
|
|
1620
|
-
clearTimeout(timer);
|
|
1621
|
-
reject(streamError);
|
|
1622
|
-
});
|
|
1623
|
-
});
|
|
1624
|
-
});
|
|
1625
|
-
});
|
|
2081
|
+
return execCommand(this, alias, command, timeoutMs);
|
|
1626
2082
|
}
|
|
1627
2083
|
/** Run one command against many hosts concurrently. */
|
|
1628
2084
|
async cluster(options) {
|
|
1629
|
-
|
|
1630
|
-
if (options.aliases !== void 0 && options.aliases.length > 0) targets = targets.filter((entry) => options.aliases.includes(entry.alias));
|
|
1631
|
-
if (options.environment !== void 0 && options.environment !== "") targets = targets.filter((entry) => entry.environment === options.environment);
|
|
1632
|
-
if (options.tags !== void 0 && options.tags.length > 0) targets = targets.filter((entry) => options.tags.every((tag) => entry.tags.includes(tag)));
|
|
1633
|
-
if (targets.length === 0) return [];
|
|
1634
|
-
if (options.maxWorkers !== void 0 && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) throw new Error("maxWorkers must be a positive integer");
|
|
1635
|
-
const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length);
|
|
1636
|
-
const results = [];
|
|
1637
|
-
const queue = [...targets];
|
|
1638
|
-
const run = async () => {
|
|
1639
|
-
while (queue.length > 0) {
|
|
1640
|
-
const entry = queue.shift();
|
|
1641
|
-
try {
|
|
1642
|
-
const result = await this.exec(entry.alias, options.command, options.timeoutMs);
|
|
1643
|
-
results.push({
|
|
1644
|
-
alias: entry.alias,
|
|
1645
|
-
ok: result.success,
|
|
1646
|
-
exitCode: result.exitCode,
|
|
1647
|
-
timedOut: result.timedOut,
|
|
1648
|
-
stdout: result.stdout,
|
|
1649
|
-
stderr: result.stderr,
|
|
1650
|
-
durationMs: result.durationMs
|
|
1651
|
-
});
|
|
1652
|
-
} catch (error) {
|
|
1653
|
-
results.push({
|
|
1654
|
-
alias: entry.alias,
|
|
1655
|
-
ok: false,
|
|
1656
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1657
|
-
});
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
};
|
|
1661
|
-
await Promise.all(Array.from({ length: workers }, () => run()));
|
|
1662
|
-
return results;
|
|
2085
|
+
return cluster(this, options);
|
|
1663
2086
|
}
|
|
1664
2087
|
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
1665
2088
|
async openShell(alias, size) {
|
|
1666
|
-
|
|
1667
|
-
if (entry === void 0) throw new Error(`alias '${alias}' not found — add it first`);
|
|
1668
|
-
const { client, hops } = await this.connectChain(entry);
|
|
1669
|
-
return await new Promise((resolve, reject) => {
|
|
1670
|
-
client.shell({
|
|
1671
|
-
term: "xterm-256color",
|
|
1672
|
-
cols: size.cols,
|
|
1673
|
-
rows: size.rows
|
|
1674
|
-
}, (error, stream) => {
|
|
1675
|
-
if (error !== void 0) {
|
|
1676
|
-
try {
|
|
1677
|
-
client.end();
|
|
1678
|
-
} catch {}
|
|
1679
|
-
for (const hop of hops) try {
|
|
1680
|
-
hop.end();
|
|
1681
|
-
} catch {}
|
|
1682
|
-
reject(error);
|
|
1683
|
-
return;
|
|
1684
|
-
}
|
|
1685
|
-
let tornDown = false;
|
|
1686
|
-
const teardown = () => {
|
|
1687
|
-
if (tornDown) return;
|
|
1688
|
-
tornDown = true;
|
|
1689
|
-
try {
|
|
1690
|
-
client.end();
|
|
1691
|
-
} catch {}
|
|
1692
|
-
for (const hop of hops) try {
|
|
1693
|
-
hop.end();
|
|
1694
|
-
} catch {}
|
|
1695
|
-
};
|
|
1696
|
-
const session = {
|
|
1697
|
-
send: (data) => {
|
|
1698
|
-
try {
|
|
1699
|
-
stream.write(data);
|
|
1700
|
-
} catch {}
|
|
1701
|
-
},
|
|
1702
|
-
resize: (cols, rows) => {
|
|
1703
|
-
try {
|
|
1704
|
-
stream.setWindow(rows, cols, rows, cols);
|
|
1705
|
-
} catch {}
|
|
1706
|
-
},
|
|
1707
|
-
close: () => {
|
|
1708
|
-
try {
|
|
1709
|
-
stream.close();
|
|
1710
|
-
} catch {}
|
|
1711
|
-
teardown();
|
|
1712
|
-
},
|
|
1713
|
-
pause: () => {
|
|
1714
|
-
try {
|
|
1715
|
-
stream.pause();
|
|
1716
|
-
} catch {}
|
|
1717
|
-
},
|
|
1718
|
-
resume: () => {
|
|
1719
|
-
try {
|
|
1720
|
-
stream.resume();
|
|
1721
|
-
} catch {}
|
|
1722
|
-
}
|
|
1723
|
-
};
|
|
1724
|
-
stream.on("data", (chunk) => {
|
|
1725
|
-
session.onData?.(chunk);
|
|
1726
|
-
});
|
|
1727
|
-
stream.on("close", (code) => {
|
|
1728
|
-
teardown();
|
|
1729
|
-
session.onExit?.(code);
|
|
1730
|
-
});
|
|
1731
|
-
stream.on("error", (streamError) => {
|
|
1732
|
-
teardown();
|
|
1733
|
-
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError));
|
|
1734
|
-
});
|
|
1735
|
-
resolve(session);
|
|
1736
|
-
});
|
|
1737
|
-
});
|
|
2089
|
+
return openShell(this, alias, size);
|
|
1738
2090
|
}
|
|
1739
2091
|
/** Upload one local file (or directory tree) to a remote path. */
|
|
1740
2092
|
async upload(alias, localPath, remotePath, recursive, onProgress) {
|
|
1741
|
-
|
|
1742
|
-
const local = resolve(localPath);
|
|
1743
|
-
if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`);
|
|
1744
|
-
return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
|
|
1745
|
-
const stat = statSync(local);
|
|
1746
|
-
let files;
|
|
1747
|
-
if (stat.isDirectory()) {
|
|
1748
|
-
if (!recursive) throw new Error(`'${localPath}' is a directory — enable recursive upload`);
|
|
1749
|
-
files = walkLocalDir(local);
|
|
1750
|
-
await this.ensureRemoteDir(sftp, remotePath);
|
|
1751
|
-
} else {
|
|
1752
|
-
files = [""];
|
|
1753
|
-
await this.ensureRemoteDir(sftp, dirname(remotePath));
|
|
1754
|
-
}
|
|
1755
|
-
let bytes = 0;
|
|
1756
|
-
for (const rel of files) {
|
|
1757
|
-
const src = rel === "" ? local : join(local, rel);
|
|
1758
|
-
const remoteRel = rel.split(/[\\/]/).join("/");
|
|
1759
|
-
const dst = rel === "" ? remotePath : remotePath.replace(/\/$/, "") + "/" + remoteRel;
|
|
1760
|
-
await this.fastPut(sftp, src, dst, onProgress);
|
|
1761
|
-
bytes += statSync(src).size;
|
|
1762
|
-
}
|
|
1763
|
-
return {
|
|
1764
|
-
bytes,
|
|
1765
|
-
files: files.length
|
|
1766
|
-
};
|
|
1767
|
-
}));
|
|
2093
|
+
return upload(this, alias, localPath, remotePath, recursive, onProgress);
|
|
1768
2094
|
}
|
|
1769
2095
|
/** Download one remote file to a local path. */
|
|
1770
2096
|
async download(alias, remotePath, localPath, onProgress) {
|
|
1771
|
-
return this
|
|
1772
|
-
if ((await new Promise((resolve, reject) => {
|
|
1773
|
-
sftp.stat(remotePath, (error, stats) => error !== void 0 ? reject(error) : resolve(stats));
|
|
1774
|
-
})).isDirectory()) throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`);
|
|
1775
|
-
const local = resolve(localPath);
|
|
1776
|
-
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true });
|
|
1777
|
-
await this.fastGet(sftp, remotePath, local, onProgress);
|
|
1778
|
-
return { bytes: statSync(local).size };
|
|
1779
|
-
}));
|
|
2097
|
+
return download(this, alias, remotePath, localPath, onProgress);
|
|
1780
2098
|
}
|
|
1781
2099
|
/** List a remote directory (file browser). */
|
|
1782
2100
|
async ls(alias, path) {
|
|
1783
|
-
return this
|
|
1784
|
-
return await new Promise((resolve, reject) => {
|
|
1785
|
-
sftp.readdir(path, (error, list) => {
|
|
1786
|
-
if (error !== void 0) {
|
|
1787
|
-
reject(error);
|
|
1788
|
-
return;
|
|
1789
|
-
}
|
|
1790
|
-
resolve(list.map((item) => ({
|
|
1791
|
-
name: item.filename,
|
|
1792
|
-
type: item.attrs.isDirectory() ? "dir" : item.attrs.isFile() ? "file" : "other",
|
|
1793
|
-
size: item.attrs.size,
|
|
1794
|
-
mtimeMs: item.attrs.mtime * 1e3,
|
|
1795
|
-
mode: item.attrs.mode
|
|
1796
|
-
})));
|
|
1797
|
-
});
|
|
1798
|
-
});
|
|
1799
|
-
}));
|
|
1800
|
-
}
|
|
1801
|
-
/**
|
|
1802
|
-
* Open one SFTP channel, run the operation, and release the channel exactly
|
|
1803
|
-
* once when the operation settles (success or error). ssh2 keeps each
|
|
1804
|
-
* subsystem channel open until end(); without this, every transfer leaks a
|
|
1805
|
-
* channel until sshd's MaxSessions cap makes all later opens fail.
|
|
1806
|
-
*/
|
|
1807
|
-
async withSftp(client, run) {
|
|
1808
|
-
const sftp = await this.sftp(client);
|
|
1809
|
-
let ended = false;
|
|
1810
|
-
const endOnce = () => {
|
|
1811
|
-
if (ended) return;
|
|
1812
|
-
ended = true;
|
|
1813
|
-
try {
|
|
1814
|
-
sftp.end();
|
|
1815
|
-
} catch {}
|
|
1816
|
-
};
|
|
1817
|
-
sftp.once("close", endOnce);
|
|
1818
|
-
try {
|
|
1819
|
-
return await run(sftp);
|
|
1820
|
-
} finally {
|
|
1821
|
-
endOnce();
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
sftp(client) {
|
|
1825
|
-
return new Promise((resolve, reject) => {
|
|
1826
|
-
client.sftp((error, sftp) => error !== void 0 ? reject(error) : resolve(sftp));
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1829
|
-
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
1830
|
-
ensureRemoteDir(sftp, remote) {
|
|
1831
|
-
return new Promise((resolve, reject) => {
|
|
1832
|
-
const segments = remote.replace(/^\/+/, "").split("/").filter((segment) => segment !== "");
|
|
1833
|
-
const walk = (index) => {
|
|
1834
|
-
if (index >= segments.length) {
|
|
1835
|
-
resolve();
|
|
1836
|
-
return;
|
|
1837
|
-
}
|
|
1838
|
-
const current = "/" + segments.slice(0, index + 1).join("/");
|
|
1839
|
-
sftp.stat(current, (statError) => {
|
|
1840
|
-
if (statError === void 0) {
|
|
1841
|
-
walk(index + 1);
|
|
1842
|
-
return;
|
|
1843
|
-
}
|
|
1844
|
-
sftp.mkdir(current, (mkdirError) => {
|
|
1845
|
-
if (mkdirError !== void 0) {
|
|
1846
|
-
reject(mkdirError);
|
|
1847
|
-
return;
|
|
1848
|
-
}
|
|
1849
|
-
walk(index + 1);
|
|
1850
|
-
});
|
|
1851
|
-
});
|
|
1852
|
-
};
|
|
1853
|
-
walk(0);
|
|
1854
|
-
});
|
|
1855
|
-
}
|
|
1856
|
-
fastPut(sftp, src, dst, onProgress) {
|
|
1857
|
-
return new Promise((resolve, reject) => {
|
|
1858
|
-
let last = 0;
|
|
1859
|
-
let lastEmit = 0;
|
|
1860
|
-
const started = Date.now();
|
|
1861
|
-
onProgress?.({
|
|
1862
|
-
phase: "transferring",
|
|
1863
|
-
file: dst,
|
|
1864
|
-
transferred: 0,
|
|
1865
|
-
total: statSync(src).size,
|
|
1866
|
-
percent: 0
|
|
1867
|
-
});
|
|
1868
|
-
sftp.fastPut(src, dst, {
|
|
1869
|
-
concurrency: this.opts.sftpConcurrency,
|
|
1870
|
-
step: (transferred, _chunk, total) => {
|
|
1871
|
-
const now = Date.now();
|
|
1872
|
-
if (now - lastEmit < 100 && transferred < total) return;
|
|
1873
|
-
lastEmit = now;
|
|
1874
|
-
const elapsed = (now - started) / 1e3;
|
|
1875
|
-
onProgress?.({
|
|
1876
|
-
phase: "transferring",
|
|
1877
|
-
file: dst,
|
|
1878
|
-
transferred,
|
|
1879
|
-
total,
|
|
1880
|
-
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1881
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1882
|
-
});
|
|
1883
|
-
last = transferred;
|
|
1884
|
-
}
|
|
1885
|
-
}, (error) => {
|
|
1886
|
-
if (error !== void 0) {
|
|
1887
|
-
onProgress?.({
|
|
1888
|
-
phase: "error",
|
|
1889
|
-
file: dst,
|
|
1890
|
-
transferred: 0,
|
|
1891
|
-
total: 0,
|
|
1892
|
-
percent: 0,
|
|
1893
|
-
error: String(error)
|
|
1894
|
-
});
|
|
1895
|
-
reject(error);
|
|
1896
|
-
} else {
|
|
1897
|
-
onProgress?.({
|
|
1898
|
-
phase: "done",
|
|
1899
|
-
file: dst,
|
|
1900
|
-
transferred: statSync(src).size,
|
|
1901
|
-
total: statSync(src).size,
|
|
1902
|
-
percent: 100
|
|
1903
|
-
});
|
|
1904
|
-
resolve();
|
|
1905
|
-
}
|
|
1906
|
-
});
|
|
1907
|
-
});
|
|
1908
|
-
}
|
|
1909
|
-
fastGet(sftp, src, dst, onProgress) {
|
|
1910
|
-
return new Promise((resolve, reject) => {
|
|
1911
|
-
let last = 0;
|
|
1912
|
-
let lastEmit = 0;
|
|
1913
|
-
const started = Date.now();
|
|
1914
|
-
sftp.fastGet(src, dst, {
|
|
1915
|
-
concurrency: this.opts.sftpConcurrency,
|
|
1916
|
-
step: (transferred, _chunk, total) => {
|
|
1917
|
-
const now = Date.now();
|
|
1918
|
-
if (now - lastEmit < 100 && transferred < total) return;
|
|
1919
|
-
lastEmit = now;
|
|
1920
|
-
const elapsed = (now - started) / 1e3;
|
|
1921
|
-
onProgress?.({
|
|
1922
|
-
phase: "transferring",
|
|
1923
|
-
file: src,
|
|
1924
|
-
transferred,
|
|
1925
|
-
total,
|
|
1926
|
-
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1927
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1928
|
-
});
|
|
1929
|
-
last = transferred;
|
|
1930
|
-
}
|
|
1931
|
-
}, (error) => {
|
|
1932
|
-
if (error !== void 0) {
|
|
1933
|
-
onProgress?.({
|
|
1934
|
-
phase: "error",
|
|
1935
|
-
file: src,
|
|
1936
|
-
transferred: 0,
|
|
1937
|
-
total: 0,
|
|
1938
|
-
percent: 0,
|
|
1939
|
-
error: String(error)
|
|
1940
|
-
});
|
|
1941
|
-
reject(error);
|
|
1942
|
-
} else {
|
|
1943
|
-
onProgress?.({
|
|
1944
|
-
phase: "done",
|
|
1945
|
-
file: src,
|
|
1946
|
-
transferred: statSync(dst).size,
|
|
1947
|
-
total: statSync(dst).size,
|
|
1948
|
-
percent: 100
|
|
1949
|
-
});
|
|
1950
|
-
resolve();
|
|
1951
|
-
}
|
|
1952
|
-
});
|
|
1953
|
-
});
|
|
2101
|
+
return ls(this, alias, path);
|
|
1954
2102
|
}
|
|
1955
2103
|
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
1956
2104
|
async startTunnel(alias, options) {
|
|
1957
|
-
|
|
1958
|
-
if (options.localPort !== void 0 && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) throw new Error("localPort must be an integer in 1..65535");
|
|
1959
|
-
if (this.store.find(alias) === void 0) throw new Error(`alias '${alias}' not found — add it first`);
|
|
1960
|
-
const remoteHost = options.remoteHost ?? "127.0.0.1";
|
|
1961
|
-
const id = `tun-${this.nextTunnelId++}`;
|
|
1962
|
-
const info = {
|
|
1963
|
-
id,
|
|
1964
|
-
alias,
|
|
1965
|
-
localPort: 0,
|
|
1966
|
-
remoteHost,
|
|
1967
|
-
remotePort: options.remotePort,
|
|
1968
|
-
state: "connecting",
|
|
1969
|
-
startedAt: Date.now()
|
|
1970
|
-
};
|
|
1971
|
-
const record = await this.acquire(alias);
|
|
1972
|
-
const client = record.client;
|
|
1973
|
-
const sockets = /* @__PURE__ */ new Set();
|
|
1974
|
-
const server = createServer((socket) => {
|
|
1975
|
-
sockets.add(socket);
|
|
1976
|
-
socket.on("close", () => {
|
|
1977
|
-
sockets.delete(socket);
|
|
1978
|
-
});
|
|
1979
|
-
client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
|
|
1980
|
-
if (error !== void 0) {
|
|
1981
|
-
socket.destroy();
|
|
1982
|
-
return;
|
|
1983
|
-
}
|
|
1984
|
-
const destroy = () => {
|
|
1985
|
-
try {
|
|
1986
|
-
socket.destroy();
|
|
1987
|
-
} catch {}
|
|
1988
|
-
try {
|
|
1989
|
-
stream.close();
|
|
1990
|
-
} catch {}
|
|
1991
|
-
};
|
|
1992
|
-
stream.on("error", destroy);
|
|
1993
|
-
socket.on("error", destroy);
|
|
1994
|
-
stream.on("close", destroy);
|
|
1995
|
-
socket.on("close", destroy);
|
|
1996
|
-
stream.pipe(socket).pipe(stream);
|
|
1997
|
-
});
|
|
1998
|
-
});
|
|
1999
|
-
try {
|
|
2000
|
-
await new Promise((resolve, reject) => {
|
|
2001
|
-
server.once("error", reject);
|
|
2002
|
-
server.listen(options.localPort ?? 0, "127.0.0.1", () => {
|
|
2003
|
-
server.removeListener("error", reject);
|
|
2004
|
-
resolve();
|
|
2005
|
-
});
|
|
2006
|
-
});
|
|
2007
|
-
} catch (error) {
|
|
2008
|
-
if (!record.pinned && record.inFlight === 0) this.disposeRecord(alias, record);
|
|
2009
|
-
throw error;
|
|
2010
|
-
}
|
|
2011
|
-
record.pinned = true;
|
|
2012
|
-
const address = server.address();
|
|
2013
|
-
info.localPort = typeof address === "object" && address !== null ? address.port : 0;
|
|
2014
|
-
info.state = "forwarding";
|
|
2015
|
-
this.tunnels.set(id, {
|
|
2016
|
-
info,
|
|
2017
|
-
server,
|
|
2018
|
-
alias,
|
|
2019
|
-
sockets
|
|
2020
|
-
});
|
|
2021
|
-
return info;
|
|
2105
|
+
return startTunnel(this, alias, options);
|
|
2022
2106
|
}
|
|
2023
2107
|
/** All active tunnels. */
|
|
2024
2108
|
listTunnels() {
|
|
2025
|
-
return
|
|
2109
|
+
return listTunnels(this);
|
|
2026
2110
|
}
|
|
2027
2111
|
/** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
|
|
2028
2112
|
stopTunnel(id) {
|
|
2029
|
-
|
|
2030
|
-
if (tunnel === void 0) return false;
|
|
2031
|
-
this.tunnels.delete(id);
|
|
2032
|
-
try {
|
|
2033
|
-
tunnel.server.close();
|
|
2034
|
-
} catch {}
|
|
2035
|
-
for (const socket of tunnel.sockets) try {
|
|
2036
|
-
socket.destroy();
|
|
2037
|
-
} catch {}
|
|
2038
|
-
tunnel.sockets.clear();
|
|
2039
|
-
this.disposeRecord(tunnel.alias);
|
|
2040
|
-
return true;
|
|
2113
|
+
return stopTunnel(this, id);
|
|
2041
2114
|
}
|
|
2042
2115
|
/** Stop all tunnels (optionally for one alias). */
|
|
2043
2116
|
stopAllTunnels(alias) {
|
|
2044
|
-
|
|
2045
|
-
for (const [id, tunnel] of [...this.tunnels]) if (alias === void 0 || tunnel.alias === alias) {
|
|
2046
|
-
this.stopTunnel(id);
|
|
2047
|
-
count += 1;
|
|
2048
|
-
}
|
|
2049
|
-
return count;
|
|
2117
|
+
return stopAllTunnels(this, alias);
|
|
2050
2118
|
}
|
|
2051
2119
|
/** Probe connectivity: connect, run `true`, close. */
|
|
2052
2120
|
async test(alias) {
|
|
@@ -2059,7 +2127,7 @@ var SshEngine = class {
|
|
|
2059
2127
|
} : {
|
|
2060
2128
|
ok: false,
|
|
2061
2129
|
latencyMs: result.durationMs,
|
|
2062
|
-
error:
|
|
2130
|
+
error: "remote exit code " + result.exitCode
|
|
2063
2131
|
};
|
|
2064
2132
|
} catch (error) {
|
|
2065
2133
|
return {
|
|
@@ -2072,8 +2140,8 @@ var SshEngine = class {
|
|
|
2072
2140
|
/** Close every pooled connection and tunnel. */
|
|
2073
2141
|
dispose() {
|
|
2074
2142
|
if (this.sweepTimer !== void 0) clearInterval(this.sweepTimer);
|
|
2075
|
-
for (const id of [...this.tunnels.keys()])
|
|
2076
|
-
for (const alias of [...this.pool.keys()])
|
|
2143
|
+
for (const id of [...this.tunnels.keys()]) stopTunnel(this, id);
|
|
2144
|
+
for (const alias of [...this.pool.keys()]) disposeRecord(this, alias);
|
|
2077
2145
|
}
|
|
2078
2146
|
};
|
|
2079
2147
|
//#endregion
|