@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/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
- if (!existsSync(this.path)) return {
1277
- version: FORMAT_VERSION,
1278
- hosts: []
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: 15e3,
1337
- keepaliveInterval: 15e3,
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
- current.client.end();
1501
+ record.client.end();
1478
1502
  } catch {}
1479
- for (const hop of current.hops) try {
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 && !timedOut,
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 = statSync(full);
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
- function fastPut(sftp, src, dst, concurrency, onProgress) {
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: dst,
1843
+ file,
1808
1844
  transferred: 0,
1809
1845
  total: statSync(src).size,
1810
1846
  percent: 0
1811
1847
  });
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) => {
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: dst,
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: dst,
1844
- transferred: statSync(src).size,
1845
- total: statSync(src).size,
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 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
- });
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 record = await acquire(engine, alias);
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.stopAllTunnels(alias);
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 > MAX_UPLOAD_BYTES) {
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
- function apply(ctx, config) {
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();