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