@linxin666/dsh-ssh 0.1.18 → 0.1.20
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/README.i18n.yaml +2 -2
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +14 -11
- package/lib/client.js.map +1 -1
- package/lib/index.js +211 -111
- package/lib/types/client/locales.d.ts +127 -127
- package/lib/types/engine/connection-pool.d.ts +3 -1
- package/lib/types/engine/tunnel.d.ts +4 -2
- package/lib/types/engine.d.ts +7 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/protocol.d.ts +1 -1
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/store.d.ts +6 -0
- package/package.json +1 -1
- package/src/client/locales.ts +2 -2
- package/src/client/panel/SshPanel.tsx +9 -1
- package/src/client/panel/panel.module.css +6 -0
- package/src/engine/connection-pool.ts +28 -8
- package/src/engine/sftp.ts +29 -38
- package/src/engine/tunnel.ts +19 -5
- package/src/engine.ts +11 -0
- package/src/index.ts +4 -1
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/routes.ts +24 -25
- package/src/store.ts +21 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
-
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { Client } from "ssh2";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
@@ -1272,16 +1272,35 @@ var HostStore = class {
|
|
|
1272
1272
|
};
|
|
1273
1273
|
}
|
|
1274
1274
|
skippedNames = /* @__PURE__ */ new Set();
|
|
1275
|
+
/**
|
|
1276
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
1277
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
1278
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
1279
|
+
*/
|
|
1280
|
+
cache;
|
|
1275
1281
|
load() {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
}
|
|
1282
|
+
let stats;
|
|
1283
|
+
try {
|
|
1284
|
+
stats = statSync(this.path);
|
|
1285
|
+
} catch {
|
|
1286
|
+
this.cache = void 0;
|
|
1287
|
+
return {
|
|
1288
|
+
version: FORMAT_VERSION,
|
|
1289
|
+
hosts: []
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
if (this.cache !== void 0 && this.cache.mtimeMs === stats.mtimeMs && this.cache.size === stats.size) return this.cache.file;
|
|
1280
1293
|
try {
|
|
1281
1294
|
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
1282
1295
|
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.hosts)) throw new Error("store file shape invalid");
|
|
1296
|
+
this.cache = {
|
|
1297
|
+
mtimeMs: stats.mtimeMs,
|
|
1298
|
+
size: stats.size,
|
|
1299
|
+
file: parsed
|
|
1300
|
+
};
|
|
1283
1301
|
return parsed;
|
|
1284
1302
|
} catch {
|
|
1303
|
+
this.cache = void 0;
|
|
1285
1304
|
try {
|
|
1286
1305
|
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`);
|
|
1287
1306
|
} catch {}
|
|
@@ -1303,6 +1322,7 @@ var HostStore = class {
|
|
|
1303
1322
|
mode: 384
|
|
1304
1323
|
});
|
|
1305
1324
|
renameSync(tmp, this.path);
|
|
1325
|
+
this.cache = void 0;
|
|
1306
1326
|
}
|
|
1307
1327
|
};
|
|
1308
1328
|
/** Expand a leading `~` in a filesystem path. */
|
|
@@ -1328,13 +1348,13 @@ const DEFAULTS = {
|
|
|
1328
1348
|
sftpConcurrency: 8
|
|
1329
1349
|
};
|
|
1330
1350
|
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
1331
|
-
function buildConnectConfig(entry, sock) {
|
|
1351
|
+
function buildConnectConfig(entry, sock, opts) {
|
|
1332
1352
|
const config = {
|
|
1333
1353
|
host: entry.host,
|
|
1334
1354
|
port: entry.port,
|
|
1335
1355
|
username: entry.user,
|
|
1336
|
-
readyTimeout:
|
|
1337
|
-
keepaliveInterval:
|
|
1356
|
+
readyTimeout: opts.connectTimeoutMs,
|
|
1357
|
+
keepaliveInterval: opts.keepaliveIntervalMs,
|
|
1338
1358
|
keepaliveCountMax: 3
|
|
1339
1359
|
};
|
|
1340
1360
|
if (sock !== void 0) config.sock = sock;
|
|
@@ -1401,7 +1421,7 @@ async function connectChain(engine, entry) {
|
|
|
1401
1421
|
for (const client of hops) client.end();
|
|
1402
1422
|
throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
|
|
1403
1423
|
}
|
|
1404
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock));
|
|
1424
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
|
|
1405
1425
|
hops.push(hopClient);
|
|
1406
1426
|
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
|
|
1407
1427
|
const nextHost = next !== void 0 ? next.host : entry.host;
|
|
@@ -1417,7 +1437,7 @@ async function connectChain(engine, entry) {
|
|
|
1417
1437
|
}
|
|
1418
1438
|
let target;
|
|
1419
1439
|
try {
|
|
1420
|
-
target = await connectClient(buildConnectConfig(entry, sock));
|
|
1440
|
+
target = await connectClient(buildConnectConfig(entry, sock, engine.opts));
|
|
1421
1441
|
return {
|
|
1422
1442
|
client: target,
|
|
1423
1443
|
hops
|
|
@@ -1473,10 +1493,14 @@ function disposeRecord(engine, alias, record) {
|
|
|
1473
1493
|
if (record !== void 0 && current !== record) return;
|
|
1474
1494
|
if (current === void 0) return;
|
|
1475
1495
|
engine.pool.delete(alias);
|
|
1496
|
+
endRecordChain(current);
|
|
1497
|
+
}
|
|
1498
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
1499
|
+
function endRecordChain(record) {
|
|
1476
1500
|
try {
|
|
1477
|
-
|
|
1501
|
+
record.client.end();
|
|
1478
1502
|
} catch {}
|
|
1479
|
-
for (const hop of
|
|
1503
|
+
for (const hop of record.hops) try {
|
|
1480
1504
|
hop.end();
|
|
1481
1505
|
} catch {}
|
|
1482
1506
|
}
|
|
@@ -1503,6 +1527,10 @@ async function withClient(engine, alias, fn, attempts = 3) {
|
|
|
1503
1527
|
const result = await fn(record.client);
|
|
1504
1528
|
record.idleAt = Date.now();
|
|
1505
1529
|
return result;
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
lastError = error;
|
|
1532
|
+
if (!record.broken) throw error;
|
|
1533
|
+
disposeRecord(engine, alias, record);
|
|
1506
1534
|
} finally {
|
|
1507
1535
|
record.inFlight -= 1;
|
|
1508
1536
|
}
|
|
@@ -1560,8 +1588,12 @@ async function execCommand(engine, alias, command, timeoutMs) {
|
|
|
1560
1588
|
if (settled) return;
|
|
1561
1589
|
settled = true;
|
|
1562
1590
|
clearTimeout(timer);
|
|
1591
|
+
if (typeof code !== "number" && !timedOut) {
|
|
1592
|
+
reject(/* @__PURE__ */ new Error("ssh: connection lost mid-flight (channel closed without an exit status)"));
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1563
1595
|
resolve({
|
|
1564
|
-
success: code === 0
|
|
1596
|
+
success: code === 0,
|
|
1565
1597
|
exitCode: code,
|
|
1566
1598
|
timedOut,
|
|
1567
1599
|
stdout: stdout.text,
|
|
@@ -1673,7 +1705,8 @@ function walkLocalDir(root) {
|
|
|
1673
1705
|
const visit = (dir) => {
|
|
1674
1706
|
for (const name of readdirSync(dir)) {
|
|
1675
1707
|
const full = join(dir, name);
|
|
1676
|
-
const stat =
|
|
1708
|
+
const stat = lstatSync(full);
|
|
1709
|
+
if (stat.isSymbolicLink()) continue;
|
|
1677
1710
|
if (stat.isDirectory()) visit(full);
|
|
1678
1711
|
else if (stat.isFile()) files.push(relative(root, full));
|
|
1679
1712
|
}
|
|
@@ -1797,40 +1830,41 @@ function ensureRemoteDir(sftp, remote) {
|
|
|
1797
1830
|
walk(0);
|
|
1798
1831
|
});
|
|
1799
1832
|
}
|
|
1800
|
-
|
|
1833
|
+
/** One fastPut/fastGet transfer with throttled progress (the two directions share everything but the verb). */
|
|
1834
|
+
function fastTransfer(sftp, kind, src, dst, concurrency, onProgress) {
|
|
1801
1835
|
return new Promise((resolve, reject) => {
|
|
1836
|
+
const file = kind === "put" ? dst : src;
|
|
1837
|
+
const finalSize = () => statSync(kind === "put" ? src : dst).size;
|
|
1802
1838
|
let last = 0;
|
|
1803
1839
|
let lastEmit = 0;
|
|
1804
1840
|
const started = Date.now();
|
|
1805
|
-
onProgress?.({
|
|
1841
|
+
if (kind === "put") onProgress?.({
|
|
1806
1842
|
phase: "transferring",
|
|
1807
|
-
file
|
|
1843
|
+
file,
|
|
1808
1844
|
transferred: 0,
|
|
1809
1845
|
total: statSync(src).size,
|
|
1810
1846
|
percent: 0
|
|
1811
1847
|
});
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
}
|
|
1829
|
-
}, (error) => {
|
|
1848
|
+
const step = (transferred, _chunk, total) => {
|
|
1849
|
+
const now = Date.now();
|
|
1850
|
+
if (now - lastEmit < 100 && transferred < total) return;
|
|
1851
|
+
lastEmit = now;
|
|
1852
|
+
const elapsed = (now - started) / 1e3;
|
|
1853
|
+
onProgress?.({
|
|
1854
|
+
phase: "transferring",
|
|
1855
|
+
file,
|
|
1856
|
+
transferred,
|
|
1857
|
+
total,
|
|
1858
|
+
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1859
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1860
|
+
});
|
|
1861
|
+
last = transferred;
|
|
1862
|
+
};
|
|
1863
|
+
const done = (error) => {
|
|
1830
1864
|
if (error !== void 0) {
|
|
1831
1865
|
onProgress?.({
|
|
1832
1866
|
phase: "error",
|
|
1833
|
-
file
|
|
1867
|
+
file,
|
|
1834
1868
|
transferred: 0,
|
|
1835
1869
|
total: 0,
|
|
1836
1870
|
percent: 0,
|
|
@@ -1840,61 +1874,29 @@ function fastPut(sftp, src, dst, concurrency, onProgress) {
|
|
|
1840
1874
|
} else {
|
|
1841
1875
|
onProgress?.({
|
|
1842
1876
|
phase: "done",
|
|
1843
|
-
file
|
|
1844
|
-
transferred:
|
|
1845
|
-
total:
|
|
1877
|
+
file,
|
|
1878
|
+
transferred: finalSize(),
|
|
1879
|
+
total: finalSize(),
|
|
1846
1880
|
percent: 100
|
|
1847
1881
|
});
|
|
1848
1882
|
resolve();
|
|
1849
1883
|
}
|
|
1850
|
-
}
|
|
1884
|
+
};
|
|
1885
|
+
if (kind === "put") sftp.fastPut(src, dst, {
|
|
1886
|
+
concurrency,
|
|
1887
|
+
step
|
|
1888
|
+
}, done);
|
|
1889
|
+
else sftp.fastGet(src, dst, {
|
|
1890
|
+
concurrency,
|
|
1891
|
+
step
|
|
1892
|
+
}, done);
|
|
1851
1893
|
});
|
|
1852
1894
|
}
|
|
1895
|
+
function fastPut(sftp, src, dst, concurrency, onProgress) {
|
|
1896
|
+
return fastTransfer(sftp, "put", src, dst, concurrency, onProgress);
|
|
1897
|
+
}
|
|
1853
1898
|
function fastGet(sftp, src, dst, concurrency, onProgress) {
|
|
1854
|
-
return
|
|
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
|
-
});
|
|
1899
|
+
return fastTransfer(sftp, "get", src, dst, concurrency, onProgress);
|
|
1898
1900
|
}
|
|
1899
1901
|
//#endregion
|
|
1900
1902
|
//#region src/engine/tunnel.ts
|
|
@@ -1920,7 +1922,9 @@ async function startTunnel(engine, alias, options) {
|
|
|
1920
1922
|
state: "connecting",
|
|
1921
1923
|
startedAt: Date.now()
|
|
1922
1924
|
};
|
|
1923
|
-
const
|
|
1925
|
+
const existing = engine.pool.get(alias);
|
|
1926
|
+
if (existing !== void 0 && existing.broken) disposeRecord(engine, alias, existing);
|
|
1927
|
+
const record = engine.pool.get(alias) ?? await acquire(engine, alias);
|
|
1924
1928
|
const client = record.client;
|
|
1925
1929
|
const sockets = /* @__PURE__ */ new Set();
|
|
1926
1930
|
const server = createServer((socket) => {
|
|
@@ -1968,6 +1972,7 @@ async function startTunnel(engine, alias, options) {
|
|
|
1968
1972
|
info,
|
|
1969
1973
|
server,
|
|
1970
1974
|
alias,
|
|
1975
|
+
record,
|
|
1971
1976
|
sockets
|
|
1972
1977
|
});
|
|
1973
1978
|
return info;
|
|
@@ -1988,7 +1993,8 @@ function stopTunnel(engine, id) {
|
|
|
1988
1993
|
socket.destroy();
|
|
1989
1994
|
} catch {}
|
|
1990
1995
|
tunnel.sockets.clear();
|
|
1991
|
-
disposeRecord(engine, tunnel.alias);
|
|
1996
|
+
if (![...engine.tunnels.values()].some((candidate) => candidate.record === tunnel.record)) if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record);
|
|
1997
|
+
else endRecordChain(tunnel.record);
|
|
1992
1998
|
return true;
|
|
1993
1999
|
}
|
|
1994
2000
|
/** Stop all tunnels (optionally for one alias). */
|
|
@@ -2116,6 +2122,16 @@ var SshEngine = class {
|
|
|
2116
2122
|
stopAllTunnels(alias) {
|
|
2117
2123
|
return stopAllTunnels(this, alias);
|
|
2118
2124
|
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
2127
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
2128
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
2129
|
+
* connection — the next operation re-connects from the current config.
|
|
2130
|
+
*/
|
|
2131
|
+
dropAlias(alias) {
|
|
2132
|
+
stopAllTunnels(this, alias);
|
|
2133
|
+
disposeRecord(this, alias);
|
|
2134
|
+
}
|
|
2119
2135
|
/** Probe connectivity: connect, run `true`, close. */
|
|
2120
2136
|
async test(alias) {
|
|
2121
2137
|
const started = Date.now();
|
|
@@ -2145,6 +2161,51 @@ var SshEngine = class {
|
|
|
2145
2161
|
}
|
|
2146
2162
|
};
|
|
2147
2163
|
//#endregion
|
|
2164
|
+
//#region src/loopback.ts
|
|
2165
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
2166
|
+
function isIPv4Loopback(v4) {
|
|
2167
|
+
const parts = v4.split(".");
|
|
2168
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
2169
|
+
}
|
|
2170
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
2171
|
+
function isLoopbackAddress(address) {
|
|
2172
|
+
if (address === void 0) return false;
|
|
2173
|
+
const normalized = address.toLowerCase();
|
|
2174
|
+
if (normalized === "::1") return true;
|
|
2175
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
2176
|
+
return isIPv4Loopback(normalized);
|
|
2177
|
+
}
|
|
2178
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
2179
|
+
function isLoopbackHostname(hostname) {
|
|
2180
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
2181
|
+
return isIPv4Loopback(hostname);
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
2185
|
+
* header, plus browser same-origin markers. The socket address is
|
|
2186
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
2187
|
+
*/
|
|
2188
|
+
function isLoopbackRequest(request) {
|
|
2189
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
2190
|
+
const host = request.headers.host;
|
|
2191
|
+
if (typeof host !== "string") return false;
|
|
2192
|
+
let hostUrl;
|
|
2193
|
+
try {
|
|
2194
|
+
hostUrl = new URL("http://" + host);
|
|
2195
|
+
} catch {
|
|
2196
|
+
return false;
|
|
2197
|
+
}
|
|
2198
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
2199
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2200
|
+
const origin = request.headers.origin;
|
|
2201
|
+
if (origin === void 0) return true;
|
|
2202
|
+
try {
|
|
2203
|
+
return new URL(origin).host === hostUrl.host;
|
|
2204
|
+
} catch {
|
|
2205
|
+
return false;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
//#endregion
|
|
2148
2209
|
//#region src/protocol.ts
|
|
2149
2210
|
const SSH_API = {
|
|
2150
2211
|
hosts: "/api/dsh-ssh/hosts",
|
|
@@ -2182,28 +2243,6 @@ const terminalWss = new WebSocketServer({ noServer: true });
|
|
|
2182
2243
|
const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
|
|
2183
2244
|
/** …and resume once it drains below this. */
|
|
2184
2245
|
const BACKPRESSURE_LOW_WATER = 512 * 1024;
|
|
2185
|
-
/** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
|
|
2186
|
-
function isLoopbackRequest(request) {
|
|
2187
|
-
const address = request.socket.remoteAddress;
|
|
2188
|
-
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
2189
|
-
const host = request.headers.host;
|
|
2190
|
-
if (typeof host !== "string") return false;
|
|
2191
|
-
let hostUrl;
|
|
2192
|
-
try {
|
|
2193
|
-
hostUrl = new URL(`http://${host}`);
|
|
2194
|
-
} catch {
|
|
2195
|
-
return false;
|
|
2196
|
-
}
|
|
2197
|
-
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
2198
|
-
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2199
|
-
const origin = request.headers.origin;
|
|
2200
|
-
if (origin === void 0) return true;
|
|
2201
|
-
try {
|
|
2202
|
-
return new URL(origin).host === hostUrl.host;
|
|
2203
|
-
} catch {
|
|
2204
|
-
return false;
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
2246
|
/** One JSON response. */
|
|
2208
2247
|
function writeJson(res, status, body) {
|
|
2209
2248
|
const payload = JSON.stringify(body);
|
|
@@ -2243,6 +2282,7 @@ function queryParam(url, name) {
|
|
|
2243
2282
|
function makeRoutes(deps) {
|
|
2244
2283
|
const { store, engine } = deps;
|
|
2245
2284
|
const staging = deps.stagingDir ?? join(tmpdir(), "dsh-ssh-uploads");
|
|
2285
|
+
const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES;
|
|
2246
2286
|
mkdirSync(staging, { recursive: true });
|
|
2247
2287
|
/** Guard helper: fence + method check. */
|
|
2248
2288
|
const guard = (req, res, method) => {
|
|
@@ -2303,6 +2343,14 @@ function makeRoutes(deps) {
|
|
|
2303
2343
|
}
|
|
2304
2344
|
try {
|
|
2305
2345
|
const entry = store.update(alias, body);
|
|
2346
|
+
const patch = body;
|
|
2347
|
+
if ([
|
|
2348
|
+
"host",
|
|
2349
|
+
"port",
|
|
2350
|
+
"user",
|
|
2351
|
+
"auth",
|
|
2352
|
+
"proxyJump"
|
|
2353
|
+
].some((key) => patch[key] !== void 0)) engine.dropAlias(alias);
|
|
2306
2354
|
writeJson(res, 200, { host: store.summarize(entry) });
|
|
2307
2355
|
} catch (error) {
|
|
2308
2356
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -2311,7 +2359,7 @@ function makeRoutes(deps) {
|
|
|
2311
2359
|
}
|
|
2312
2360
|
if (method === "DELETE") {
|
|
2313
2361
|
try {
|
|
2314
|
-
engine.
|
|
2362
|
+
engine.dropAlias(alias);
|
|
2315
2363
|
store.delete(alias);
|
|
2316
2364
|
writeJson(res, 200, { ok: true });
|
|
2317
2365
|
} catch (error) {
|
|
@@ -2480,7 +2528,7 @@ function makeRoutes(deps) {
|
|
|
2480
2528
|
return;
|
|
2481
2529
|
}
|
|
2482
2530
|
const declared = Number(req.headers["content-length"]);
|
|
2483
|
-
if (Number.isFinite(declared) && declared >
|
|
2531
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
2484
2532
|
writeJson(res, 413, { error: "upload body too large" });
|
|
2485
2533
|
return;
|
|
2486
2534
|
}
|
|
@@ -2527,6 +2575,16 @@ function makeRoutes(deps) {
|
|
|
2527
2575
|
res.on("close", () => {
|
|
2528
2576
|
if (!res.writableEnded) fail("connection closed");
|
|
2529
2577
|
});
|
|
2578
|
+
let received = 0;
|
|
2579
|
+
req.on("data", (chunk) => {
|
|
2580
|
+
received += chunk.byteLength;
|
|
2581
|
+
if (received > maxUploadBytes) {
|
|
2582
|
+
fail("upload body too large");
|
|
2583
|
+
try {
|
|
2584
|
+
req.destroy();
|
|
2585
|
+
} catch {}
|
|
2586
|
+
}
|
|
2587
|
+
});
|
|
2530
2588
|
req.pipe(sink);
|
|
2531
2589
|
sink.on("finish", async () => {
|
|
2532
2590
|
if (settled) return;
|
|
@@ -3278,6 +3336,47 @@ function sshClusterTool(engine) {
|
|
|
3278
3336
|
});
|
|
3279
3337
|
}
|
|
3280
3338
|
//#endregion
|
|
3339
|
+
//#region src/mount-once.ts
|
|
3340
|
+
/**
|
|
3341
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
3342
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
3343
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
3344
|
+
* without this guard the second instance would still re-register the same
|
|
3345
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
3346
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
3347
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
3348
|
+
* package name in the client module host).
|
|
3349
|
+
*
|
|
3350
|
+
* The registry rides a global symbol so two module instances of the same
|
|
3351
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
3352
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
3353
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
3354
|
+
*/
|
|
3355
|
+
const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
|
|
3356
|
+
function mountedSet() {
|
|
3357
|
+
const registry = globalThis;
|
|
3358
|
+
return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
|
|
3359
|
+
}
|
|
3360
|
+
/**
|
|
3361
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
3362
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
3363
|
+
* any later mount of the same package name is a no-op.
|
|
3364
|
+
* @param packageName - npm package identity shared by every install source.
|
|
3365
|
+
* @param fn - the original plugin apply.
|
|
3366
|
+
* @returns an apply of the same shape.
|
|
3367
|
+
*/
|
|
3368
|
+
function mountOnce(packageName, fn) {
|
|
3369
|
+
return ((...args) => {
|
|
3370
|
+
const mounted = mountedSet();
|
|
3371
|
+
if (mounted.has(packageName)) return;
|
|
3372
|
+
mounted.add(packageName);
|
|
3373
|
+
args[0]?.effect?.(() => () => {
|
|
3374
|
+
mounted.delete(packageName);
|
|
3375
|
+
});
|
|
3376
|
+
return fn(...args);
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
//#endregion
|
|
3281
3380
|
//#region src/index.ts
|
|
3282
3381
|
/** Stable cordis plugin name. */
|
|
3283
3382
|
const name = "ssh";
|
|
@@ -3308,7 +3407,8 @@ const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维)
|
|
|
3308
3407
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
3309
3408
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
3310
3409
|
*/
|
|
3311
|
-
|
|
3410
|
+
const apply = mountOnce("@linxin666/dsh-ssh", applyImpl);
|
|
3411
|
+
function applyImpl(ctx, config) {
|
|
3312
3412
|
let current = () => config ?? {};
|
|
3313
3413
|
const resolve = () => {
|
|
3314
3414
|
const value = current();
|