@linxin666/dsh-ssh 0.1.19 → 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/index.js +161 -84
- package/lib/types/client/locales.d.ts +127 -127
- package/lib/types/engine/connection-pool.d.ts +2 -0
- 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/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/engine/connection-pool.ts +23 -3
- 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/mount-once.ts +48 -0
- package/src/routes.ts +23 -2
- package/src/store.ts +21 -2
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# node scripts/verify-docs.mjs --write <dir>
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: 0ec42622377937f15e508d4b28a95688f511f5e5
|
|
6
|
+
README.zh.md: 8a5de44f200457cef7d01c94162b8bc3a5af1c5e
|
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Built on the capability list of [badseal/ssh-skill](https://github.com/badseal/s
|
|
|
23
23
|
- All `/api/dsh-ssh/*` routes are loopback-only (with same-origin checks) — the interfaces that execute commands against remote servers are not exposed to the LAN.
|
|
24
24
|
- Passwords / key passphrases are stored in plain text in `~/.dsh/dsh-ssh.json`, file mode 0600, directory 0700 (the same trust model as ssh-skill writing passwords into ssh-config comments).
|
|
25
25
|
- Tunnels only listen on `127.0.0.1`.
|
|
26
|
+
- Deleting a host or changing its connection fields (host / port / user / auth / proxyJump) immediately closes that alias's pooled connection and tunnels; later operations reconnect with the new configuration and never reuse a connection authenticated with the old credentials.
|
|
26
27
|
- Before the Agent uses a tool, the host must first be configured in the GUI (or imported from ~/.ssh/config).
|
|
27
28
|
- `ssh_upload` / `ssh_download` read/write arbitrary local paths on this machine with host-process privileges (not through the bash sandbox) — same host-local-path semantics as ssh-skill, be aware of that permission surface.
|
|
28
29
|
- The remote output of exec / cluster is returned verbatim (not sanitized); a command like `env` may bring secrets from the remote environment back into the conversation log.
|
package/README.zh.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- 所有 `/api/dsh-ssh/*` 路由仅限 loopback 访问(含同源校验)——对远程服务器执行命令的接口不会暴露给局域网。
|
|
24
24
|
- 密码 / 密钥口令以明文保存在 `~/.dsh/dsh-ssh.json`,文件权限 0600、目录 0700(与 ssh-skill 把密码写进 ssh-config 注释同一信任模型)。
|
|
25
25
|
- 隧道只监听 `127.0.0.1`。
|
|
26
|
+
- 删除主机或修改其连接字段(host / port / user / auth / proxyJump)会立即断开该别名的池化连接与隧道,后续操作按新配置重新建连,不会复用旧凭据的已认证连接。
|
|
26
27
|
- Agent 使用工具前,主机需先在 GUI 中配置(或从 ~/.ssh/config 导入)。
|
|
27
28
|
- `ssh_upload` / `ssh_download` 以宿主进程权限直接读写本机任意路径(不经 bash 沙箱)——与 ssh-skill 的宿主本地路径语义一致,注意该权限面。
|
|
28
29
|
- exec / cluster 的远程输出原样返回(不脱敏),命令如 `env` 可能把远端环境中的密钥带回对话记录。
|
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. */
|
|
@@ -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();
|
|
@@ -2266,6 +2282,7 @@ function queryParam(url, name) {
|
|
|
2266
2282
|
function makeRoutes(deps) {
|
|
2267
2283
|
const { store, engine } = deps;
|
|
2268
2284
|
const staging = deps.stagingDir ?? join(tmpdir(), "dsh-ssh-uploads");
|
|
2285
|
+
const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES;
|
|
2269
2286
|
mkdirSync(staging, { recursive: true });
|
|
2270
2287
|
/** Guard helper: fence + method check. */
|
|
2271
2288
|
const guard = (req, res, method) => {
|
|
@@ -2326,6 +2343,14 @@ function makeRoutes(deps) {
|
|
|
2326
2343
|
}
|
|
2327
2344
|
try {
|
|
2328
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);
|
|
2329
2354
|
writeJson(res, 200, { host: store.summarize(entry) });
|
|
2330
2355
|
} catch (error) {
|
|
2331
2356
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -2334,7 +2359,7 @@ function makeRoutes(deps) {
|
|
|
2334
2359
|
}
|
|
2335
2360
|
if (method === "DELETE") {
|
|
2336
2361
|
try {
|
|
2337
|
-
engine.
|
|
2362
|
+
engine.dropAlias(alias);
|
|
2338
2363
|
store.delete(alias);
|
|
2339
2364
|
writeJson(res, 200, { ok: true });
|
|
2340
2365
|
} catch (error) {
|
|
@@ -2503,7 +2528,7 @@ function makeRoutes(deps) {
|
|
|
2503
2528
|
return;
|
|
2504
2529
|
}
|
|
2505
2530
|
const declared = Number(req.headers["content-length"]);
|
|
2506
|
-
if (Number.isFinite(declared) && declared >
|
|
2531
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
2507
2532
|
writeJson(res, 413, { error: "upload body too large" });
|
|
2508
2533
|
return;
|
|
2509
2534
|
}
|
|
@@ -2550,6 +2575,16 @@ function makeRoutes(deps) {
|
|
|
2550
2575
|
res.on("close", () => {
|
|
2551
2576
|
if (!res.writableEnded) fail("connection closed");
|
|
2552
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
|
+
});
|
|
2553
2588
|
req.pipe(sink);
|
|
2554
2589
|
sink.on("finish", async () => {
|
|
2555
2590
|
if (settled) return;
|
|
@@ -3301,6 +3336,47 @@ function sshClusterTool(engine) {
|
|
|
3301
3336
|
});
|
|
3302
3337
|
}
|
|
3303
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
|
|
3304
3380
|
//#region src/index.ts
|
|
3305
3381
|
/** Stable cordis plugin name. */
|
|
3306
3382
|
const name = "ssh";
|
|
@@ -3331,7 +3407,8 @@ const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维)
|
|
|
3331
3407
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
3332
3408
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
3333
3409
|
*/
|
|
3334
|
-
|
|
3410
|
+
const apply = mountOnce("@linxin666/dsh-ssh", applyImpl);
|
|
3411
|
+
function applyImpl(ctx, config) {
|
|
3335
3412
|
let current = () => config ?? {};
|
|
3336
3413
|
const resolve = () => {
|
|
3337
3414
|
const value = current();
|
|
@@ -2,133 +2,133 @@
|
|
|
2
2
|
* dsh-ssh surface copy: zh is the key source, en mirrors every key.
|
|
3
3
|
*/
|
|
4
4
|
export declare const zh: {
|
|
5
|
-
readonly 'entry.label':
|
|
6
|
-
readonly 'entry.tooltip':
|
|
7
|
-
readonly 'panel.title':
|
|
8
|
-
readonly 'panel.backToConversation':
|
|
9
|
-
readonly 'tab.hosts':
|
|
10
|
-
readonly 'tab.terminal':
|
|
11
|
-
readonly 'tab.transfer':
|
|
12
|
-
readonly 'tab.tunnels':
|
|
13
|
-
readonly 'tab.cluster':
|
|
14
|
-
readonly 'hosts.empty':
|
|
15
|
-
readonly 'hosts.add':
|
|
16
|
-
readonly 'hosts.import':
|
|
17
|
-
readonly 'hosts.search':
|
|
18
|
-
readonly 'hosts.col.alias':
|
|
19
|
-
readonly 'hosts.col.host':
|
|
20
|
-
readonly 'hosts.col.user':
|
|
21
|
-
readonly 'hosts.col.auth':
|
|
22
|
-
readonly 'hosts.col.environment':
|
|
23
|
-
readonly 'hosts.col.tags':
|
|
24
|
-
readonly 'hosts.col.description':
|
|
25
|
-
readonly 'hosts.col.actions':
|
|
26
|
-
readonly 'hosts.test':
|
|
27
|
-
readonly 'hosts.edit':
|
|
28
|
-
readonly 'hosts.delete':
|
|
29
|
-
readonly 'hosts.deleteConfirm':
|
|
30
|
-
readonly 'hosts.imported':
|
|
31
|
-
readonly 'hosts.testing':
|
|
32
|
-
readonly 'hosts.testOk':
|
|
33
|
-
readonly 'hosts.testFail':
|
|
34
|
-
readonly 'hosts.connected':
|
|
35
|
-
readonly 'hosts.notConnected':
|
|
36
|
-
readonly 'form.title.create':
|
|
37
|
-
readonly 'form.title.edit':
|
|
38
|
-
readonly 'form.alias':
|
|
39
|
-
readonly 'form.aliasHint':
|
|
40
|
-
readonly 'form.host':
|
|
41
|
-
readonly 'form.port':
|
|
42
|
-
readonly 'form.user':
|
|
43
|
-
readonly 'form.auth':
|
|
44
|
-
readonly 'form.auth.key':
|
|
45
|
-
readonly 'form.auth.password':
|
|
46
|
-
readonly 'form.authKeepHint':
|
|
47
|
-
readonly 'form.keyPath':
|
|
48
|
-
readonly 'form.keyPathHint':
|
|
49
|
-
readonly 'form.passphrase':
|
|
50
|
-
readonly 'form.password':
|
|
51
|
-
readonly 'form.passwordHint':
|
|
52
|
-
readonly 'form.proxyJump':
|
|
53
|
-
readonly 'form.proxyJumpHint':
|
|
54
|
-
readonly 'form.description':
|
|
55
|
-
readonly 'form.environment':
|
|
56
|
-
readonly 'form.environmentHint':
|
|
57
|
-
readonly 'form.tags':
|
|
58
|
-
readonly 'form.tagsHint':
|
|
59
|
-
readonly 'form.location':
|
|
60
|
-
readonly 'form.save':
|
|
61
|
-
readonly 'form.cancel':
|
|
62
|
-
readonly 'form.required':
|
|
63
|
-
readonly 'form.portInvalid':
|
|
64
|
-
readonly 'terminal.selectHost':
|
|
65
|
-
readonly 'terminal.connect':
|
|
66
|
-
readonly 'terminal.disconnect':
|
|
67
|
-
readonly 'terminal.placeholder':
|
|
68
|
-
readonly 'terminal.connecting':
|
|
69
|
-
readonly 'terminal.ready':
|
|
70
|
-
readonly 'terminal.exited':
|
|
71
|
-
readonly 'terminal.error':
|
|
72
|
-
readonly 'transfer.selectHost':
|
|
73
|
-
readonly 'transfer.remotePath':
|
|
74
|
-
readonly 'transfer.remotePathHint':
|
|
75
|
-
readonly 'transfer.upload':
|
|
76
|
-
readonly 'transfer.uploading':
|
|
77
|
-
readonly 'transfer.download':
|
|
78
|
-
readonly 'transfer.downloading':
|
|
79
|
-
readonly 'transfer.done':
|
|
80
|
-
readonly 'transfer.failed':
|
|
81
|
-
readonly 'transfer.browseRemote':
|
|
82
|
-
readonly 'transfer.refresh':
|
|
83
|
-
readonly 'transfer.name':
|
|
84
|
-
readonly 'transfer.upLevel':
|
|
85
|
-
readonly 'transfer.size':
|
|
86
|
-
readonly 'transfer.modified':
|
|
87
|
-
readonly 'transfer.bytes':
|
|
88
|
-
readonly 'transfer.kib':
|
|
89
|
-
readonly 'transfer.mib':
|
|
90
|
-
readonly 'transfer.gib':
|
|
91
|
-
readonly 'transfer.percent':
|
|
92
|
-
readonly 'transfer.speed':
|
|
93
|
-
readonly 'transfer.dir':
|
|
94
|
-
readonly 'transfer.file':
|
|
95
|
-
readonly 'tunnel.empty':
|
|
96
|
-
readonly 'tunnel.start':
|
|
97
|
-
readonly 'tunnel.alias':
|
|
98
|
-
readonly 'tunnel.remotePort':
|
|
99
|
-
readonly 'tunnel.remoteHost':
|
|
100
|
-
readonly 'tunnel.remoteHostHint':
|
|
101
|
-
readonly 'tunnel.localPort':
|
|
102
|
-
readonly 'tunnel.localPortHint':
|
|
103
|
-
readonly 'tunnel.stop':
|
|
104
|
-
readonly 'tunnel.stopAll':
|
|
105
|
-
readonly 'tunnel.stopAllConfirm':
|
|
106
|
-
readonly 'tunnel.row':
|
|
107
|
-
readonly 'tunnel.started':
|
|
108
|
-
readonly 'tunnel.failed':
|
|
109
|
-
readonly 'cluster.command':
|
|
110
|
-
readonly 'cluster.run':
|
|
111
|
-
readonly 'cluster.confirm':
|
|
112
|
-
readonly 'cluster.aliases':
|
|
113
|
-
readonly 'cluster.environment':
|
|
114
|
-
readonly 'cluster.tags':
|
|
115
|
-
readonly 'cluster.results':
|
|
116
|
-
readonly 'cluster.empty':
|
|
117
|
-
readonly 'cluster.noMatch':
|
|
118
|
-
readonly 'cluster.ok':
|
|
119
|
-
readonly 'cluster.fail':
|
|
120
|
-
readonly 'cluster.timeout':
|
|
121
|
-
readonly 'cluster.col.alias':
|
|
122
|
-
readonly 'cluster.col.status':
|
|
123
|
-
readonly 'cluster.col.exit':
|
|
124
|
-
readonly 'cluster.col.stdout':
|
|
125
|
-
readonly 'cluster.col.stderr':
|
|
126
|
-
readonly 'cluster.col.error':
|
|
127
|
-
readonly 'cluster.col.duration':
|
|
128
|
-
readonly 'common.loading':
|
|
129
|
-
readonly 'common.error':
|
|
130
|
-
readonly 'common.refresh':
|
|
131
|
-
readonly 'common.confirm':
|
|
5
|
+
readonly 'entry.label': 'SSH';
|
|
6
|
+
readonly 'entry.tooltip': '远程 SSH 运维面板';
|
|
7
|
+
readonly 'panel.title': '远程 SSH';
|
|
8
|
+
readonly 'panel.backToConversation': '返回会话';
|
|
9
|
+
readonly 'tab.hosts': '主机';
|
|
10
|
+
readonly 'tab.terminal': '终端';
|
|
11
|
+
readonly 'tab.transfer': '传输';
|
|
12
|
+
readonly 'tab.tunnels': '隧道';
|
|
13
|
+
readonly 'tab.cluster': '集群';
|
|
14
|
+
readonly 'hosts.empty': '尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。';
|
|
15
|
+
readonly 'hosts.add': '新增主机';
|
|
16
|
+
readonly 'hosts.import': '导入 ~/.ssh/config';
|
|
17
|
+
readonly 'hosts.search': '搜索别名 / 描述 / 标签…';
|
|
18
|
+
readonly 'hosts.col.alias': '别名';
|
|
19
|
+
readonly 'hosts.col.host': '主机';
|
|
20
|
+
readonly 'hosts.col.user': '用户';
|
|
21
|
+
readonly 'hosts.col.auth': '认证';
|
|
22
|
+
readonly 'hosts.col.environment': '环境';
|
|
23
|
+
readonly 'hosts.col.tags': '标签';
|
|
24
|
+
readonly 'hosts.col.description': '备注';
|
|
25
|
+
readonly 'hosts.col.actions': '操作';
|
|
26
|
+
readonly 'hosts.test': '测试';
|
|
27
|
+
readonly 'hosts.edit': '编辑';
|
|
28
|
+
readonly 'hosts.delete': '删除';
|
|
29
|
+
readonly 'hosts.deleteConfirm': '确定删除主机「{alias}」?将同时停止其隧道。';
|
|
30
|
+
readonly 'hosts.imported': '解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。';
|
|
31
|
+
readonly 'hosts.testing': '连接测试中…';
|
|
32
|
+
readonly 'hosts.testOk': '连接成功({latency} ms)';
|
|
33
|
+
readonly 'hosts.testFail': '连接失败:{error}';
|
|
34
|
+
readonly 'hosts.connected': '连接';
|
|
35
|
+
readonly 'hosts.notConnected': '未连接';
|
|
36
|
+
readonly 'form.title.create': '新增主机';
|
|
37
|
+
readonly 'form.title.edit': '编辑主机 {alias}';
|
|
38
|
+
readonly 'form.alias': '别名';
|
|
39
|
+
readonly 'form.aliasHint': '小写字母、数字与单连字符,操作时使用';
|
|
40
|
+
readonly 'form.host': '主机地址';
|
|
41
|
+
readonly 'form.port': '端口';
|
|
42
|
+
readonly 'form.user': '用户名';
|
|
43
|
+
readonly 'form.auth': '认证方式';
|
|
44
|
+
readonly 'form.auth.key': '密钥';
|
|
45
|
+
readonly 'form.auth.password': '密码';
|
|
46
|
+
readonly 'form.authKeepHint': '编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。';
|
|
47
|
+
readonly 'form.keyPath': '私钥路径';
|
|
48
|
+
readonly 'form.keyPathHint': '如 ~/.ssh/id_ed25519';
|
|
49
|
+
readonly 'form.passphrase': '密钥口令';
|
|
50
|
+
readonly 'form.password': '密码';
|
|
51
|
+
readonly 'form.passwordHint': '明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)';
|
|
52
|
+
readonly 'form.proxyJump': '跳板机别名';
|
|
53
|
+
readonly 'form.proxyJumpHint': '逗号分隔的本地主机别名,按顺序连接';
|
|
54
|
+
readonly 'form.description': '备注';
|
|
55
|
+
readonly 'form.environment': '环境';
|
|
56
|
+
readonly 'form.environmentHint': '如 development / production';
|
|
57
|
+
readonly 'form.tags': '标签';
|
|
58
|
+
readonly 'form.tagsHint': '逗号分隔';
|
|
59
|
+
readonly 'form.location': '位置';
|
|
60
|
+
readonly 'form.save': '保存';
|
|
61
|
+
readonly 'form.cancel': '取消';
|
|
62
|
+
readonly 'form.required': '请填写必填字段:别名、主机地址、用户名。';
|
|
63
|
+
readonly 'form.portInvalid': '端口必须是 1-65535 之间的整数。';
|
|
64
|
+
readonly 'terminal.selectHost': '选择主机';
|
|
65
|
+
readonly 'terminal.connect': '连接';
|
|
66
|
+
readonly 'terminal.disconnect': '断开';
|
|
67
|
+
readonly 'terminal.placeholder': '选择主机后点击「连接」打开远程终端。';
|
|
68
|
+
readonly 'terminal.connecting': '正在连接…';
|
|
69
|
+
readonly 'terminal.ready': '终端已连接({alias})';
|
|
70
|
+
readonly 'terminal.exited': '终端已退出({alias})';
|
|
71
|
+
readonly 'terminal.error': '终端错误:{error}';
|
|
72
|
+
readonly 'transfer.selectHost': '选择主机';
|
|
73
|
+
readonly 'transfer.remotePath': '远程路径';
|
|
74
|
+
readonly 'transfer.remotePathHint': '如 /tmp/ 或 /home/user/app.tar.gz';
|
|
75
|
+
readonly 'transfer.upload': '上传';
|
|
76
|
+
readonly 'transfer.uploading': '正在上传 {file}…';
|
|
77
|
+
readonly 'transfer.download': '下载';
|
|
78
|
+
readonly 'transfer.downloading': '正在下载…';
|
|
79
|
+
readonly 'transfer.done': '完成:{bytes} 字节';
|
|
80
|
+
readonly 'transfer.failed': '传输失败:{error}';
|
|
81
|
+
readonly 'transfer.browseRemote': '浏览远程目录';
|
|
82
|
+
readonly 'transfer.refresh': '刷新';
|
|
83
|
+
readonly 'transfer.name': '名称';
|
|
84
|
+
readonly 'transfer.upLevel': '上级目录';
|
|
85
|
+
readonly 'transfer.size': '大小';
|
|
86
|
+
readonly 'transfer.modified': '修改时间';
|
|
87
|
+
readonly 'transfer.bytes': '{value} B';
|
|
88
|
+
readonly 'transfer.kib': '{value} KB';
|
|
89
|
+
readonly 'transfer.mib': '{value} MB';
|
|
90
|
+
readonly 'transfer.gib': '{value} GB';
|
|
91
|
+
readonly 'transfer.percent': '{value}%';
|
|
92
|
+
readonly 'transfer.speed': '{value}/s';
|
|
93
|
+
readonly 'transfer.dir': '目录';
|
|
94
|
+
readonly 'transfer.file': '文件';
|
|
95
|
+
readonly 'tunnel.empty': '没有活跃的隧道。';
|
|
96
|
+
readonly 'tunnel.start': '新建隧道';
|
|
97
|
+
readonly 'tunnel.alias': '主机别名';
|
|
98
|
+
readonly 'tunnel.remotePort': '远程端口';
|
|
99
|
+
readonly 'tunnel.remoteHost': '远程主机';
|
|
100
|
+
readonly 'tunnel.remoteHostHint': '默认 127.0.0.1(服务器自身)';
|
|
101
|
+
readonly 'tunnel.localPort': '本地端口';
|
|
102
|
+
readonly 'tunnel.localPortHint': '留空自动分配';
|
|
103
|
+
readonly 'tunnel.stop': '停止';
|
|
104
|
+
readonly 'tunnel.stopAll': '全部停止';
|
|
105
|
+
readonly 'tunnel.stopAllConfirm': '确定停止全部隧道吗?';
|
|
106
|
+
readonly 'tunnel.row': '{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}';
|
|
107
|
+
readonly 'tunnel.started': '隧道已建立:127.0.0.1:{localPort}';
|
|
108
|
+
readonly 'tunnel.failed': '隧道启动失败:{error}';
|
|
109
|
+
readonly 'cluster.command': '命令';
|
|
110
|
+
readonly 'cluster.run': '执行';
|
|
111
|
+
readonly 'cluster.confirm': '确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。';
|
|
112
|
+
readonly 'cluster.aliases': '主机别名(逗号分隔,留空为全部)';
|
|
113
|
+
readonly 'cluster.environment': '环境过滤';
|
|
114
|
+
readonly 'cluster.tags': '标签过滤(逗号分隔)';
|
|
115
|
+
readonly 'cluster.results': '执行结果';
|
|
116
|
+
readonly 'cluster.empty': '输入命令后点击「执行」。';
|
|
117
|
+
readonly 'cluster.noMatch': '没有匹配的主机。';
|
|
118
|
+
readonly 'cluster.ok': '成功';
|
|
119
|
+
readonly 'cluster.fail': '失败';
|
|
120
|
+
readonly 'cluster.timeout': '超时';
|
|
121
|
+
readonly 'cluster.col.alias': '主机';
|
|
122
|
+
readonly 'cluster.col.status': '状态';
|
|
123
|
+
readonly 'cluster.col.exit': '退出码';
|
|
124
|
+
readonly 'cluster.col.stdout': '标准输出';
|
|
125
|
+
readonly 'cluster.col.stderr': '标准错误';
|
|
126
|
+
readonly 'cluster.col.error': '错误';
|
|
127
|
+
readonly 'cluster.col.duration': '耗时';
|
|
128
|
+
readonly 'common.loading': '加载中…';
|
|
129
|
+
readonly 'common.error': '出错:{error}';
|
|
130
|
+
readonly 'common.refresh': '刷新';
|
|
131
|
+
readonly 'common.confirm': '确定';
|
|
132
132
|
};
|
|
133
133
|
export declare const en: Record<keyof typeof zh, string>;
|
|
134
134
|
/** Locale key union. */
|
|
@@ -72,6 +72,8 @@ export declare function acquire(engine: PoolEngine, alias: string): Promise<Pool
|
|
|
72
72
|
* is torn down — the connection belongs to someone else now.
|
|
73
73
|
*/
|
|
74
74
|
export declare function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void;
|
|
75
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
76
|
+
export declare function endRecordChain(record: PoolRecord): void;
|
|
75
77
|
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
76
78
|
export declare function sweepPool(engine: PoolEngine): void;
|
|
77
79
|
/**
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type Server as NetServer, type Socket } from 'node:net';
|
|
7
7
|
import type { TunnelInfo } from '../protocol.ts';
|
|
8
|
-
import { type PoolEngine } from './connection-pool.ts';
|
|
9
|
-
/** One active tunnel record (server + pinned
|
|
8
|
+
import { type PoolEngine, type PoolRecord } from './connection-pool.ts';
|
|
9
|
+
/** One active tunnel record (server + pinned connection + live sockets). */
|
|
10
10
|
export interface TunnelRecord {
|
|
11
11
|
info: TunnelInfo;
|
|
12
12
|
server: NetServer;
|
|
13
13
|
alias: string;
|
|
14
|
+
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
15
|
+
record: PoolRecord;
|
|
14
16
|
sockets: Set<Socket>;
|
|
15
17
|
}
|
|
16
18
|
/** The engine slice the tunnel module needs (adds the tunnel registry). */
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -74,6 +74,13 @@ export declare class SshEngine {
|
|
|
74
74
|
stopTunnel(id: string): boolean;
|
|
75
75
|
/** Stop all tunnels (optionally for one alias). */
|
|
76
76
|
stopAllTunnels(alias?: string): number;
|
|
77
|
+
/**
|
|
78
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
79
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
80
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
81
|
+
* connection — the next operation re-connects from the current config.
|
|
82
|
+
*/
|
|
83
|
+
dropAlias(alias: string): void;
|
|
77
84
|
/** Probe connectivity: connect, run `true`, close. */
|
|
78
85
|
test(alias: string): Promise<TestResult>;
|
|
79
86
|
/** Close every pooled connection and tunnel. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -37,4 +37,6 @@ export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63
|
|
|
37
37
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
38
38
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
39
39
|
*/
|
|
40
|
-
export declare
|
|
40
|
+
export declare const apply: typeof applyImpl;
|
|
41
|
+
declare function applyImpl(ctx: Context, config?: Config): void;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
3
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
4
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
5
|
+
* without this guard the second instance would still re-register the same
|
|
6
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
8
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
9
|
+
* package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* The registry rides a global symbol so two module instances of the same
|
|
12
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
13
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
19
|
+
* any later mount of the same package name is a no-op.
|
|
20
|
+
* @param packageName - npm package identity shared by every install source.
|
|
21
|
+
* @param fn - the original plugin apply.
|
|
22
|
+
* @returns an apply of the same shape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T;
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -164,7 +164,7 @@ export type TerminalClientFrame = {
|
|
|
164
164
|
rows: number;
|
|
165
165
|
};
|
|
166
166
|
/** Route paths the client calls (shared literals). */
|
|
167
|
-
export declare const SSH_API_BASE:
|
|
167
|
+
export declare const SSH_API_BASE: '/api/dsh-ssh';
|
|
168
168
|
export declare const SSH_API: {
|
|
169
169
|
readonly hosts: string;
|
|
170
170
|
readonly importSshConfig: string;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface SshRoutesDeps {
|
|
|
17
17
|
engine: SshEngine;
|
|
18
18
|
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
19
19
|
stagingDir?: string;
|
|
20
|
+
/** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
|
|
21
|
+
maxUploadBytes?: number;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
|
package/lib/types/store.d.ts
CHANGED
|
@@ -47,6 +47,12 @@ export declare class HostStore {
|
|
|
47
47
|
*/
|
|
48
48
|
importFromSshConfig(): ImportResult;
|
|
49
49
|
private skippedNames;
|
|
50
|
+
/**
|
|
51
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
52
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
53
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
54
|
+
*/
|
|
55
|
+
private cache;
|
|
50
56
|
private load;
|
|
51
57
|
private save;
|
|
52
58
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-ssh",
|
|
3
3
|
"description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.20",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -213,8 +213,13 @@ export function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRe
|
|
|
213
213
|
if (record !== undefined && current !== record) return
|
|
214
214
|
if (current === undefined) return
|
|
215
215
|
engine.pool.delete(alias)
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
endRecordChain(current)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
220
|
+
export function endRecordChain(record: PoolRecord): void {
|
|
221
|
+
try { record.client.end() } catch { /* already closed */ }
|
|
222
|
+
for (const hop of record.hops) {
|
|
218
223
|
try { hop.end() } catch { /* already closed */ }
|
|
219
224
|
}
|
|
220
225
|
}
|
|
@@ -247,6 +252,14 @@ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (clie
|
|
|
247
252
|
const result = await fn(record.client)
|
|
248
253
|
record.idleAt = Date.now()
|
|
249
254
|
return result
|
|
255
|
+
} catch (error) {
|
|
256
|
+
lastError = error
|
|
257
|
+
// Retry only when the connection actually broke mid-flight: drop the
|
|
258
|
+
// corpse and let the next attempt reconnect (a reconnect may replay a
|
|
259
|
+
// non-idempotent command — the documented trade-off). A failure on a
|
|
260
|
+
// healthy connection is a logic error and is rethrown, not replayed.
|
|
261
|
+
if (!record.broken) throw error
|
|
262
|
+
disposeRecord(engine, alias, record)
|
|
250
263
|
} finally {
|
|
251
264
|
record.inFlight -= 1
|
|
252
265
|
}
|
|
@@ -297,8 +310,15 @@ export async function execCommand(engine: PoolEngine, alias: string, command: st
|
|
|
297
310
|
if (settled) return
|
|
298
311
|
settled = true
|
|
299
312
|
clearTimeout(timer)
|
|
313
|
+
if (typeof code !== 'number' && !timedOut) {
|
|
314
|
+
// The channel closed without an exit status: the connection
|
|
315
|
+
// dropped mid-flight. Reject so withClient can reconnect and
|
|
316
|
+
// retry within the attempt budget.
|
|
317
|
+
reject(new Error('ssh: connection lost mid-flight (channel closed without an exit status)'))
|
|
318
|
+
return
|
|
319
|
+
}
|
|
300
320
|
resolve({
|
|
301
|
-
success: code === 0
|
|
321
|
+
success: code === 0,
|
|
302
322
|
exitCode: code,
|
|
303
323
|
timedOut,
|
|
304
324
|
stdout: stdout.text,
|
package/src/engine/sftp.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* released exactly once so sshd's MaxSessions cap is never exhausted.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
7
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
8
8
|
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
9
9
|
import { Client, type SFTPWrapper } from 'ssh2'
|
|
10
10
|
import type { RemoteDirEntry, TransferProgress } from '../protocol.ts'
|
|
@@ -16,7 +16,11 @@ export function walkLocalDir(root: string): string[] {
|
|
|
16
16
|
const visit = (dir: string): void => {
|
|
17
17
|
for (const name of readdirSync(dir)) {
|
|
18
18
|
const full = join(dir, name)
|
|
19
|
-
|
|
19
|
+
// lstat, not stat: a symlink must never be followed — a link cycle
|
|
20
|
+
// (ln -s . self) recurses forever under stat, and a link to a file
|
|
21
|
+
// would silently upload the target's bytes.
|
|
22
|
+
const stat = lstatSync(full)
|
|
23
|
+
if (stat.isSymbolicLink()) continue
|
|
20
24
|
if (stat.isDirectory()) visit(full)
|
|
21
25
|
else if (stat.isFile()) files.push(relative(root, full))
|
|
22
26
|
}
|
|
@@ -169,13 +173,20 @@ function ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
|
|
|
169
173
|
})
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
/** One fastPut/fastGet transfer with throttled progress (the two directions share everything but the verb). */
|
|
177
|
+
function fastTransfer(sftp: SFTPWrapper, kind: 'put' | 'get', src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
173
178
|
return new Promise((resolve, reject) => {
|
|
179
|
+
// Progress frames name the destination on upload, the source on download;
|
|
180
|
+
// the final size comes from the local side of the transfer.
|
|
181
|
+
const file = kind === 'put' ? dst : src
|
|
182
|
+
const finalSize = (): number => statSync(kind === 'put' ? src : dst).size
|
|
174
183
|
let last = 0
|
|
175
184
|
let lastEmit = 0
|
|
176
185
|
const started = Date.now()
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
if (kind === 'put') {
|
|
187
|
+
onProgress?.({ phase: 'transferring', file, transferred: 0, total: statSync(src).size, percent: 0 })
|
|
188
|
+
}
|
|
189
|
+
const step = (transferred: number, _chunk: number, total: number): void => {
|
|
179
190
|
const now = Date.now()
|
|
180
191
|
// Throttle: high-speed links fire one callback per chunk; the UI only
|
|
181
192
|
// needs ~10 frames per second.
|
|
@@ -184,52 +195,32 @@ function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: numbe
|
|
|
184
195
|
const elapsed = (now - started) / 1000
|
|
185
196
|
onProgress?.({
|
|
186
197
|
phase: 'transferring',
|
|
187
|
-
file
|
|
198
|
+
file,
|
|
188
199
|
transferred,
|
|
189
200
|
total,
|
|
190
201
|
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
191
202
|
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
192
203
|
})
|
|
193
204
|
last = transferred
|
|
194
|
-
}
|
|
205
|
+
}
|
|
206
|
+
const done = (error: unknown): void => {
|
|
195
207
|
if (error !== undefined) {
|
|
196
|
-
onProgress?.({ phase: 'error', file
|
|
208
|
+
onProgress?.({ phase: 'error', file, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
197
209
|
reject(error)
|
|
198
210
|
} else {
|
|
199
|
-
onProgress?.({ phase: 'done', file
|
|
211
|
+
onProgress?.({ phase: 'done', file, transferred: finalSize(), total: finalSize(), percent: 100 })
|
|
200
212
|
resolve()
|
|
201
213
|
}
|
|
202
|
-
}
|
|
214
|
+
}
|
|
215
|
+
if (kind === 'put') sftp.fastPut(src, dst, { concurrency, step }, done)
|
|
216
|
+
else sftp.fastGet(src, dst, { concurrency, step }, done)
|
|
203
217
|
})
|
|
204
218
|
}
|
|
205
219
|
|
|
220
|
+
function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
221
|
+
return fastTransfer(sftp, 'put', src, dst, concurrency, onProgress)
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
function fastGet(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
207
|
-
return
|
|
208
|
-
let last = 0
|
|
209
|
-
let lastEmit = 0
|
|
210
|
-
const started = Date.now()
|
|
211
|
-
sftp.fastGet(src, dst, { concurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
212
|
-
const now = Date.now()
|
|
213
|
-
if (now - lastEmit < 100 && transferred < total) return
|
|
214
|
-
lastEmit = now
|
|
215
|
-
const elapsed = (now - started) / 1000
|
|
216
|
-
onProgress?.({
|
|
217
|
-
phase: 'transferring',
|
|
218
|
-
file: src,
|
|
219
|
-
transferred,
|
|
220
|
-
total,
|
|
221
|
-
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
222
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
223
|
-
})
|
|
224
|
-
last = transferred
|
|
225
|
-
} }, (error) => {
|
|
226
|
-
if (error !== undefined) {
|
|
227
|
-
onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
228
|
-
reject(error)
|
|
229
|
-
} else {
|
|
230
|
-
onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
|
|
231
|
-
resolve()
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
})
|
|
225
|
+
return fastTransfer(sftp, 'get', src, dst, concurrency, onProgress)
|
|
235
226
|
}
|
package/src/engine/tunnel.ts
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
import { createServer, type Server as NetServer, type Socket } from 'node:net'
|
|
8
8
|
import type { TunnelInfo } from '../protocol.ts'
|
|
9
|
-
import { acquire, disposeRecord, type PoolEngine } from './connection-pool.ts'
|
|
9
|
+
import { acquire, disposeRecord, endRecordChain, type PoolEngine, type PoolRecord } from './connection-pool.ts'
|
|
10
10
|
|
|
11
|
-
/** One active tunnel record (server + pinned
|
|
11
|
+
/** One active tunnel record (server + pinned connection + live sockets). */
|
|
12
12
|
export interface TunnelRecord {
|
|
13
13
|
info: TunnelInfo
|
|
14
14
|
server: NetServer
|
|
15
15
|
alias: string
|
|
16
|
+
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
17
|
+
record: PoolRecord
|
|
16
18
|
sockets: Set<Socket>
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -48,7 +50,12 @@ export async function startTunnel(
|
|
|
48
50
|
state: 'connecting',
|
|
49
51
|
startedAt: Date.now(),
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
// Reuse the live pooled connection when one exists so sibling tunnels on
|
|
54
|
+
// one alias multiplex over it (and no orphaned connection leaks); recycle
|
|
55
|
+
// a broken one first.
|
|
56
|
+
const existing = engine.pool.get(alias)
|
|
57
|
+
if (existing !== undefined && existing.broken) disposeRecord(engine, alias, existing)
|
|
58
|
+
const record = engine.pool.get(alias) ?? (await acquire(engine, alias))
|
|
52
59
|
const client = record.client
|
|
53
60
|
const sockets = new Set<Socket>()
|
|
54
61
|
const server = createServer((socket) => {
|
|
@@ -89,7 +96,7 @@ export async function startTunnel(
|
|
|
89
96
|
const address = server.address()
|
|
90
97
|
info.localPort = typeof address === 'object' && address !== null ? address.port : 0
|
|
91
98
|
info.state = 'forwarding'
|
|
92
|
-
engine.tunnels.set(id, { info, server, alias, sockets })
|
|
99
|
+
engine.tunnels.set(id, { info, server, alias, record, sockets })
|
|
93
100
|
return info
|
|
94
101
|
}
|
|
95
102
|
|
|
@@ -108,7 +115,14 @@ export function stopTunnel(engine: TunnelEngine, id: string): boolean {
|
|
|
108
115
|
try { socket.destroy() } catch { /* already closed */ }
|
|
109
116
|
}
|
|
110
117
|
tunnel.sockets.clear()
|
|
111
|
-
|
|
118
|
+
// Sibling tunnels on the same alias share the pinned connection: release it
|
|
119
|
+
// only once the last tunnel using it is gone. A record the pool has since
|
|
120
|
+
// replaced (e.g. after a broken-connection recycle) is ended directly.
|
|
121
|
+
const shared = [...engine.tunnels.values()].some(candidate => candidate.record === tunnel.record)
|
|
122
|
+
if (!shared) {
|
|
123
|
+
if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record)
|
|
124
|
+
else endRecordChain(tunnel.record)
|
|
125
|
+
}
|
|
112
126
|
return true
|
|
113
127
|
}
|
|
114
128
|
|
package/src/engine.ts
CHANGED
|
@@ -135,6 +135,17 @@ export class SshEngine {
|
|
|
135
135
|
return stopAllTunnels(this, alias)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
140
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
141
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
142
|
+
* connection — the next operation re-connects from the current config.
|
|
143
|
+
*/
|
|
144
|
+
dropAlias(alias: string): void {
|
|
145
|
+
stopAllTunnels(this, alias)
|
|
146
|
+
disposeRecord(this, alias)
|
|
147
|
+
}
|
|
148
|
+
|
|
138
149
|
// ------------------------------------------------------------- misc
|
|
139
150
|
|
|
140
151
|
/** Probe connectivity: connect, run `true`, close. */
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { SshEngine } from './engine.ts'
|
|
|
18
18
|
import { makeRoutes } from './routes.ts'
|
|
19
19
|
import { HostStore } from './store.ts'
|
|
20
20
|
import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelTool, sshUploadTool } from './tools.ts'
|
|
21
|
+
import { mountOnce } from './mount-once.ts'
|
|
21
22
|
|
|
22
23
|
/** Stable cordis plugin name. */
|
|
23
24
|
export const name = 'ssh'
|
|
@@ -62,7 +63,9 @@ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运
|
|
|
62
63
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
63
64
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
64
65
|
*/
|
|
65
|
-
export
|
|
66
|
+
export const apply = mountOnce('@linxin666/dsh-ssh', applyImpl)
|
|
67
|
+
|
|
68
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
66
69
|
// The live source the surfaces read: the settings section once the web
|
|
67
70
|
// settings surface is served, the composition entry otherwise.
|
|
68
71
|
let current: () => Config = () => config ?? {}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -78,6 +78,8 @@ export interface SshRoutesDeps {
|
|
|
78
78
|
engine: SshEngine
|
|
79
79
|
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
80
80
|
stagingDir?: string
|
|
81
|
+
/** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
|
|
82
|
+
maxUploadBytes?: number
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/**
|
|
@@ -88,6 +90,7 @@ export interface SshRoutesDeps {
|
|
|
88
90
|
export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
|
|
89
91
|
const { store, engine } = deps
|
|
90
92
|
const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
|
|
93
|
+
const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
91
94
|
// The upload route stages request bodies here; it must exist before the
|
|
92
95
|
// first request (a missing dir would hang the first upload forever).
|
|
93
96
|
mkdirSync(staging, { recursive: true })
|
|
@@ -154,6 +157,13 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
154
157
|
}
|
|
155
158
|
try {
|
|
156
159
|
const entry = store.update(alias, body as unknown as Partial<HostPayload>)
|
|
160
|
+
// Connection-relevant changes invalidate the pooled connection:
|
|
161
|
+
// without this the pool would keep running commands on the old
|
|
162
|
+
// host/credentials until the idle sweep (up to 30 min later).
|
|
163
|
+
const patch = body as Record<string, unknown>
|
|
164
|
+
if (['host', 'port', 'user', 'auth', 'proxyJump'].some(key => patch[key] !== undefined)) {
|
|
165
|
+
engine.dropAlias(alias)
|
|
166
|
+
}
|
|
157
167
|
writeJson(res, 200, { host: store.summarize(entry) })
|
|
158
168
|
} catch (error) {
|
|
159
169
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
@@ -162,7 +172,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
162
172
|
}
|
|
163
173
|
if (method === 'DELETE') {
|
|
164
174
|
try {
|
|
165
|
-
engine.
|
|
175
|
+
engine.dropAlias(alias)
|
|
166
176
|
store.delete(alias)
|
|
167
177
|
writeJson(res, 200, { ok: true })
|
|
168
178
|
} catch (error) {
|
|
@@ -329,7 +339,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
329
339
|
return
|
|
330
340
|
}
|
|
331
341
|
const declared = Number(req.headers['content-length'])
|
|
332
|
-
if (Number.isFinite(declared) && declared >
|
|
342
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
333
343
|
writeJson(res, 413, { error: 'upload body too large' })
|
|
334
344
|
return
|
|
335
345
|
}
|
|
@@ -365,6 +375,17 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
|
|
|
365
375
|
req.on('aborted', () => fail('upload aborted by the client'))
|
|
366
376
|
res.on('error', () => fail('response stream closed'))
|
|
367
377
|
res.on('close', () => { if (!res.writableEnded) fail('connection closed') })
|
|
378
|
+
// The content-length pre-check above can be bypassed by chunked or
|
|
379
|
+
// header-less requests: count the bytes as they actually arrive and
|
|
380
|
+
// abort the moment the cap is exceeded.
|
|
381
|
+
let received = 0
|
|
382
|
+
req.on('data', (chunk: Buffer) => {
|
|
383
|
+
received += chunk.byteLength
|
|
384
|
+
if (received > maxUploadBytes) {
|
|
385
|
+
fail('upload body too large')
|
|
386
|
+
try { req.destroy() } catch { /* closed */ }
|
|
387
|
+
}
|
|
388
|
+
})
|
|
368
389
|
req.pipe(sink)
|
|
369
390
|
sink.on('finish', async () => {
|
|
370
391
|
if (settled) return
|
package/src/store.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* as ssh-skill's annotated ssh-config comments; document it, never log it.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { dirname, join, resolve } from 'node:path'
|
|
12
12
|
import type { HostPayload, ImportResult, SshHostEntry, SshHostSummary } from './protocol.ts'
|
|
@@ -306,18 +306,36 @@ export class HostStore {
|
|
|
306
306
|
|
|
307
307
|
private skippedNames = new Set<string>()
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
311
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
312
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
313
|
+
*/
|
|
314
|
+
private cache: { mtimeMs: number; size: number; file: StoreFile } | undefined
|
|
315
|
+
|
|
309
316
|
private load(): StoreFile {
|
|
310
|
-
|
|
317
|
+
let stats: { mtimeMs: number; size: number }
|
|
318
|
+
try {
|
|
319
|
+
stats = statSync(this.path)
|
|
320
|
+
} catch {
|
|
321
|
+
this.cache = undefined
|
|
322
|
+
return { version: FORMAT_VERSION, hosts: [] }
|
|
323
|
+
}
|
|
324
|
+
if (this.cache !== undefined && this.cache.mtimeMs === stats.mtimeMs && this.cache.size === stats.size) {
|
|
325
|
+
return this.cache.file
|
|
326
|
+
}
|
|
311
327
|
try {
|
|
312
328
|
const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StoreFile
|
|
313
329
|
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) {
|
|
314
330
|
throw new Error('store file shape invalid')
|
|
315
331
|
}
|
|
332
|
+
this.cache = { mtimeMs: stats.mtimeMs, size: stats.size, file: parsed }
|
|
316
333
|
return parsed
|
|
317
334
|
} catch {
|
|
318
335
|
// A corrupt store must not brick the plugin — and must not be silently
|
|
319
336
|
// overwritten by the next save: rename it aside for manual recovery
|
|
320
337
|
// (the plugin then starts from an empty list).
|
|
338
|
+
this.cache = undefined
|
|
321
339
|
try {
|
|
322
340
|
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`)
|
|
323
341
|
} catch { /* best effort */ }
|
|
@@ -333,6 +351,7 @@ export class HostStore {
|
|
|
333
351
|
// tmp file carries the 0600 mode through the rename.
|
|
334
352
|
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
335
353
|
renameSync(tmp, this.path)
|
|
354
|
+
this.cache = undefined
|
|
336
355
|
}
|
|
337
356
|
}
|
|
338
357
|
|