@linxin666/dsh-ssh 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +371 -461
- package/lib/client.js.map +1 -1
- package/lib/index.js +707 -466
- package/lib/types/client/api.d.ts +55 -4
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/native-panel.d.ts +70 -0
- package/lib/types/client/panel/SshPanel.d.ts +1 -3
- package/lib/types/client/panel/TerminalTab.d.ts +6 -1
- package/lib/types/client/panel/controller.d.ts +71 -4
- package/lib/types/engine/terminal-sessions.d.ts +67 -0
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +12 -1
- package/lib/types/routes.d.ts +2 -0
- package/package.json +19 -16
- package/src/client/api.ts +137 -24
- package/src/client/index.ts +31 -9
- package/src/client/locales.ts +2 -0
- package/src/client/native-panel.tsx +141 -0
- package/src/client/panel/SshPanel.tsx +25 -28
- package/src/client/panel/TerminalTab.tsx +54 -14
- package/src/client/panel/controller.ts +126 -7
- package/src/client/panel/panel.module.css +8 -128
- package/src/engine/terminal-sessions.ts +280 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +6 -1
- package/src/routes.ts +26 -107
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/mount.d.ts +0 -27
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -26
- package/src/client/body-mutations.ts +0 -133
- package/src/client/mount.tsx +0 -47
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -51
package/lib/index.js
CHANGED
|
@@ -1673,6 +1673,265 @@ var SshEngine = class {
|
|
|
1673
1673
|
}
|
|
1674
1674
|
};
|
|
1675
1675
|
//#endregion
|
|
1676
|
+
//#region src/engine/terminal-sessions.ts
|
|
1677
|
+
/**
|
|
1678
|
+
* Host-side terminal sessions for the web terminal.
|
|
1679
|
+
*
|
|
1680
|
+
* The PTY shell is owned here, not by the browser view: a view can detach
|
|
1681
|
+
* (panel switch, page unmount, a dropped socket) and reattach by session id
|
|
1682
|
+
* without tearing the remote shell down, which is what makes the terminal
|
|
1683
|
+
* survival property the old DOM takeover used to provide. A session with no
|
|
1684
|
+
* attached socket is reaped after an idle window, and an exited one is kept
|
|
1685
|
+
* for a short grace window so a reattaching view still sees the final screen.
|
|
1686
|
+
*/
|
|
1687
|
+
/** Pause the shell when an attached socket's send buffer exceeds this… */
|
|
1688
|
+
const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
|
|
1689
|
+
/** …and resume once every attached socket drains below this. */
|
|
1690
|
+
const BACKPRESSURE_LOW_WATER = 512 * 1024;
|
|
1691
|
+
/** Scrollback retained for a reattaching view (bytes of UTF-8 output). */
|
|
1692
|
+
const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024;
|
|
1693
|
+
/** A detached session is reaped after this long. */
|
|
1694
|
+
const DEFAULT_IDLE_MS = 600 * 1e3;
|
|
1695
|
+
/** An exited session is kept this long for a reattaching view. */
|
|
1696
|
+
const DEFAULT_EXIT_GRACE_MS = 60 * 1e3;
|
|
1697
|
+
/**
|
|
1698
|
+
* The host's terminal session table: open one session per connect, attach any
|
|
1699
|
+
* number of sockets, and keep the shell alive across detach.
|
|
1700
|
+
*/
|
|
1701
|
+
var TerminalSessionRegistry = class {
|
|
1702
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1703
|
+
deps;
|
|
1704
|
+
maxBufferBytes;
|
|
1705
|
+
idleMs;
|
|
1706
|
+
exitGraceMs;
|
|
1707
|
+
constructor(deps) {
|
|
1708
|
+
this.deps = deps;
|
|
1709
|
+
this.maxBufferBytes = deps.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
1710
|
+
this.idleMs = deps.idleMs ?? DEFAULT_IDLE_MS;
|
|
1711
|
+
this.exitGraceMs = deps.exitGraceMs ?? DEFAULT_EXIT_GRACE_MS;
|
|
1712
|
+
}
|
|
1713
|
+
/** Open a new session for the alias and attach the requesting socket to it. */
|
|
1714
|
+
async open(ws, alias, size) {
|
|
1715
|
+
const session = {
|
|
1716
|
+
id: randomBytes(9).toString("hex"),
|
|
1717
|
+
alias,
|
|
1718
|
+
sockets: /* @__PURE__ */ new Set(),
|
|
1719
|
+
buffer: [],
|
|
1720
|
+
bufferBytes: 0,
|
|
1721
|
+
exited: false
|
|
1722
|
+
};
|
|
1723
|
+
this.sessions.set(session.id, session);
|
|
1724
|
+
this.bind(ws, session);
|
|
1725
|
+
try {
|
|
1726
|
+
const shell = await this.deps.openShell(alias, size, this.keyboardInteractive(session));
|
|
1727
|
+
if (!this.sessions.has(session.id)) {
|
|
1728
|
+
shell.close();
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
session.shell = shell;
|
|
1732
|
+
shell.onData = (data) => {
|
|
1733
|
+
this.pushOutput(session, data.toString("utf8"));
|
|
1734
|
+
};
|
|
1735
|
+
shell.onExit = (code, error) => {
|
|
1736
|
+
this.finish(session, code, error);
|
|
1737
|
+
};
|
|
1738
|
+
this.send(session, {
|
|
1739
|
+
type: "ready",
|
|
1740
|
+
alias,
|
|
1741
|
+
sessionId: session.id
|
|
1742
|
+
});
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
this.sessions.delete(session.id);
|
|
1745
|
+
this.send(session, {
|
|
1746
|
+
type: "exit",
|
|
1747
|
+
code: null,
|
|
1748
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* Attach a socket to an existing session, replaying its scrollback first.
|
|
1754
|
+
* @returns false when the session is unknown (expired or already reaped).
|
|
1755
|
+
*/
|
|
1756
|
+
attach(ws, sessionId, size) {
|
|
1757
|
+
const session = this.sessions.get(sessionId);
|
|
1758
|
+
if (session === void 0) return false;
|
|
1759
|
+
this.bind(ws, session);
|
|
1760
|
+
this.clearReap(session);
|
|
1761
|
+
for (const chunk of session.buffer) this.sendTo(session, ws, {
|
|
1762
|
+
type: "output",
|
|
1763
|
+
data: chunk
|
|
1764
|
+
});
|
|
1765
|
+
this.sendTo(session, ws, {
|
|
1766
|
+
type: "ready",
|
|
1767
|
+
alias: session.alias,
|
|
1768
|
+
sessionId: session.id
|
|
1769
|
+
});
|
|
1770
|
+
if (session.exited) {
|
|
1771
|
+
this.sendTo(session, ws, {
|
|
1772
|
+
type: "exit",
|
|
1773
|
+
code: session.exit?.code ?? null,
|
|
1774
|
+
error: session.exit?.error
|
|
1775
|
+
});
|
|
1776
|
+
this.scheduleReap(session, this.exitGraceMs);
|
|
1777
|
+
} else {
|
|
1778
|
+
session.shell?.resize(Math.max(2, size.cols), Math.max(1, size.rows));
|
|
1779
|
+
session.shell?.resume();
|
|
1780
|
+
}
|
|
1781
|
+
return true;
|
|
1782
|
+
}
|
|
1783
|
+
/** End every session (host teardown, plugin dispose). */
|
|
1784
|
+
dispose() {
|
|
1785
|
+
for (const session of [...this.sessions.values()]) this.end(session, false);
|
|
1786
|
+
this.sessions.clear();
|
|
1787
|
+
}
|
|
1788
|
+
/** Number of live sessions (introspection for tests and diagnostics). */
|
|
1789
|
+
size() {
|
|
1790
|
+
return this.sessions.size;
|
|
1791
|
+
}
|
|
1792
|
+
/** Wire one socket's frames and lifecycle onto a session. */
|
|
1793
|
+
bind(ws, session) {
|
|
1794
|
+
session.sockets.add(ws);
|
|
1795
|
+
ws.on("message", (data) => {
|
|
1796
|
+
let frame;
|
|
1797
|
+
try {
|
|
1798
|
+
frame = JSON.parse(String(data));
|
|
1799
|
+
} catch {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (frame.type === "input") session.shell?.send(frame.data ?? "");
|
|
1803
|
+
else if (frame.type === "resize") session.shell?.resize(Math.max(2, frame.cols ?? 80), Math.max(1, frame.rows ?? 24));
|
|
1804
|
+
else if (frame.type === "auth_response") {
|
|
1805
|
+
const finish = session.pendingAuthFinish;
|
|
1806
|
+
session.pendingAuthFinish = void 0;
|
|
1807
|
+
try {
|
|
1808
|
+
finish?.(frame.responses ?? []);
|
|
1809
|
+
} catch {}
|
|
1810
|
+
} else if (frame.type === "close") this.end(session, true);
|
|
1811
|
+
else if (frame.type === "detach") this.detach(ws);
|
|
1812
|
+
});
|
|
1813
|
+
ws.on("close", () => {
|
|
1814
|
+
this.detach(ws);
|
|
1815
|
+
});
|
|
1816
|
+
ws.on("error", () => {
|
|
1817
|
+
this.detach(ws);
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
/** Remove a socket from its session, keeping the shell alive. */
|
|
1821
|
+
detach(ws) {
|
|
1822
|
+
for (const session of this.sessions.values()) {
|
|
1823
|
+
if (!session.sockets.delete(ws)) continue;
|
|
1824
|
+
if (session.sockets.size === 0) this.scheduleReap(session, session.exited ? this.exitGraceMs : this.idleMs);
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/** End the session a socket is attached to (explicit close from the view). */
|
|
1829
|
+
end(session, notify) {
|
|
1830
|
+
this.clearReap(session);
|
|
1831
|
+
if (this.sessions.delete(session.id) && notify) {
|
|
1832
|
+
if (session.pendingAuthFinish !== void 0) {
|
|
1833
|
+
const finish = session.pendingAuthFinish;
|
|
1834
|
+
session.pendingAuthFinish = void 0;
|
|
1835
|
+
try {
|
|
1836
|
+
finish([]);
|
|
1837
|
+
} catch {}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
try {
|
|
1841
|
+
session.shell?.close();
|
|
1842
|
+
} catch {}
|
|
1843
|
+
}
|
|
1844
|
+
/** Mark a session exited and keep it briefly so a reattaching view sees it. */
|
|
1845
|
+
finish(session, code, error) {
|
|
1846
|
+
if (session.exited) return;
|
|
1847
|
+
session.exited = true;
|
|
1848
|
+
session.exit = {
|
|
1849
|
+
code,
|
|
1850
|
+
error
|
|
1851
|
+
};
|
|
1852
|
+
this.send(session, {
|
|
1853
|
+
type: "exit",
|
|
1854
|
+
code,
|
|
1855
|
+
error
|
|
1856
|
+
});
|
|
1857
|
+
for (const ws of [...session.sockets]) {
|
|
1858
|
+
session.sockets.delete(ws);
|
|
1859
|
+
try {
|
|
1860
|
+
ws.close(1e3);
|
|
1861
|
+
} catch {}
|
|
1862
|
+
}
|
|
1863
|
+
this.scheduleReap(session, this.exitGraceMs);
|
|
1864
|
+
}
|
|
1865
|
+
/** Append output to the replay buffer, trimmed to the byte cap. */
|
|
1866
|
+
pushOutput(session, data) {
|
|
1867
|
+
session.buffer.push(data);
|
|
1868
|
+
session.bufferBytes += Buffer.byteLength(data);
|
|
1869
|
+
while (session.bufferBytes > this.maxBufferBytes && session.buffer.length > 1) {
|
|
1870
|
+
const dropped = session.buffer.shift();
|
|
1871
|
+
session.bufferBytes -= Buffer.byteLength(dropped ?? "");
|
|
1872
|
+
}
|
|
1873
|
+
this.send(session, {
|
|
1874
|
+
type: "output",
|
|
1875
|
+
data
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
/** Broadcast one frame to every attached socket, honoring backpressure. */
|
|
1879
|
+
send(session, frame) {
|
|
1880
|
+
for (const ws of session.sockets) this.sendTo(session, ws, frame);
|
|
1881
|
+
this.applyBackpressure(session);
|
|
1882
|
+
}
|
|
1883
|
+
sendTo(session, ws, frame) {
|
|
1884
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1885
|
+
try {
|
|
1886
|
+
ws.send(JSON.stringify(frame));
|
|
1887
|
+
} catch {}
|
|
1888
|
+
}
|
|
1889
|
+
/** Pause while any attached socket is backed up; resume once all drained. */
|
|
1890
|
+
applyBackpressure(session) {
|
|
1891
|
+
const shell = session.shell;
|
|
1892
|
+
if (shell === void 0) return;
|
|
1893
|
+
let high = false;
|
|
1894
|
+
let low = true;
|
|
1895
|
+
for (const ws of session.sockets) {
|
|
1896
|
+
if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) high = true;
|
|
1897
|
+
if (ws.bufferedAmount > BACKPRESSURE_LOW_WATER) low = false;
|
|
1898
|
+
}
|
|
1899
|
+
if (high) shell.pause();
|
|
1900
|
+
else if (low) shell.resume();
|
|
1901
|
+
}
|
|
1902
|
+
/** Answer a keyboard-interactive challenge through whoever is attached. */
|
|
1903
|
+
keyboardInteractive(session) {
|
|
1904
|
+
return (name, instructions, _lang, prompts, finish) => {
|
|
1905
|
+
session.pendingAuthFinish = finish;
|
|
1906
|
+
this.send(session, {
|
|
1907
|
+
type: "auth_prompt",
|
|
1908
|
+
name,
|
|
1909
|
+
instructions,
|
|
1910
|
+
prompts: prompts.map((p) => ({
|
|
1911
|
+
prompt: p.prompt,
|
|
1912
|
+
echo: p.echo
|
|
1913
|
+
}))
|
|
1914
|
+
});
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
scheduleReap(session, delayMs) {
|
|
1918
|
+
this.clearReap(session);
|
|
1919
|
+
const timer = setTimeout(() => {
|
|
1920
|
+
session.reapTimer = void 0;
|
|
1921
|
+
if (session.sockets.size > 0) return;
|
|
1922
|
+
this.end(session, false);
|
|
1923
|
+
}, delayMs);
|
|
1924
|
+
timer.unref?.();
|
|
1925
|
+
session.reapTimer = timer;
|
|
1926
|
+
}
|
|
1927
|
+
clearReap(session) {
|
|
1928
|
+
if (session.reapTimer !== void 0) {
|
|
1929
|
+
clearTimeout(session.reapTimer);
|
|
1930
|
+
session.reapTimer = void 0;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
};
|
|
1934
|
+
//#endregion
|
|
1676
1935
|
//#region src/http.ts
|
|
1677
1936
|
/** Default body cap for readJsonBody: 64 KiB. */
|
|
1678
1937
|
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
|
|
@@ -1806,10 +2065,6 @@ const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
|
|
|
1806
2065
|
* frames (the webserver hands us the raw upgraded socket).
|
|
1807
2066
|
*/
|
|
1808
2067
|
const terminalWss = new WebSocketServer({ noServer: true });
|
|
1809
|
-
/** Pause the shell when the socket's send buffer exceeds this… */
|
|
1810
|
-
const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
|
|
1811
|
-
/** …and resume once it drains below this. */
|
|
1812
|
-
const BACKPRESSURE_LOW_WATER = 512 * 1024;
|
|
1813
2068
|
/** URL query helper (first value, decoded). */
|
|
1814
2069
|
function queryParam(url, name) {
|
|
1815
2070
|
const value = url.searchParams.get(name);
|
|
@@ -1840,391 +2095,393 @@ function makeRoutes(deps) {
|
|
|
1840
2095
|
}
|
|
1841
2096
|
return true;
|
|
1842
2097
|
};
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
return;
|
|
1853
|
-
}
|
|
1854
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1855
|
-
if (method === "GET") {
|
|
1856
|
-
writeJson(res, 200, { hosts: engine.list(queryParam(url, "query")) });
|
|
1857
|
-
return;
|
|
1858
|
-
}
|
|
1859
|
-
if (method === "POST") {
|
|
1860
|
-
const body = await readJsonBody(req);
|
|
1861
|
-
if (body === null) {
|
|
1862
|
-
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1863
|
-
return;
|
|
1864
|
-
}
|
|
1865
|
-
try {
|
|
1866
|
-
const entry = store.create(body);
|
|
1867
|
-
writeJson(res, 201, { host: store.summarize(entry) });
|
|
1868
|
-
} catch (error) {
|
|
1869
|
-
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1870
|
-
}
|
|
1871
|
-
return;
|
|
1872
|
-
}
|
|
1873
|
-
if (method !== "PATCH" && method !== "DELETE") {
|
|
1874
|
-
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
const alias = queryParam(url, "alias");
|
|
1878
|
-
if (alias === void 0 || alias === "") {
|
|
1879
|
-
writeJson(res, 400, { error: "alias query parameter is required" });
|
|
1880
|
-
return;
|
|
1881
|
-
}
|
|
1882
|
-
if (method === "PATCH") {
|
|
1883
|
-
const body = await readJsonBody(req);
|
|
1884
|
-
if (body === null) {
|
|
1885
|
-
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1886
|
-
return;
|
|
1887
|
-
}
|
|
1888
|
-
try {
|
|
1889
|
-
const entry = store.update(alias, body);
|
|
1890
|
-
const patch = body;
|
|
1891
|
-
if ([
|
|
1892
|
-
"host",
|
|
1893
|
-
"port",
|
|
1894
|
-
"user",
|
|
1895
|
-
"auth",
|
|
1896
|
-
"proxyJump",
|
|
1897
|
-
"proxyCommand"
|
|
1898
|
-
].some((key) => patch[key] !== void 0)) engine.dropAlias(alias);
|
|
1899
|
-
writeJson(res, 200, { host: store.summarize(entry) });
|
|
1900
|
-
} catch (error) {
|
|
1901
|
-
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1902
|
-
}
|
|
1903
|
-
return;
|
|
1904
|
-
}
|
|
1905
|
-
if (method === "DELETE") {
|
|
1906
|
-
try {
|
|
1907
|
-
engine.dropAlias(alias);
|
|
1908
|
-
store.delete(alias);
|
|
1909
|
-
writeJson(res, 200, { ok: true });
|
|
1910
|
-
} catch (error) {
|
|
1911
|
-
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1912
|
-
}
|
|
1913
|
-
return;
|
|
1914
|
-
}
|
|
1915
|
-
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
2098
|
+
const routes = [
|
|
2099
|
+
{
|
|
2100
|
+
kind: "exact",
|
|
2101
|
+
path: SSH_API.hosts,
|
|
2102
|
+
handler: async (req, res) => {
|
|
2103
|
+
const method = req.method ?? "GET";
|
|
2104
|
+
if (!isLoopbackRequest(req)) {
|
|
2105
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2106
|
+
return;
|
|
1916
2107
|
}
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
handler: async (req, res) => {
|
|
1922
|
-
if (!guard(req, res, "POST")) return;
|
|
1923
|
-
try {
|
|
1924
|
-
writeJson(res, 200, { result: store.importFromSshConfig() });
|
|
1925
|
-
} catch (error) {
|
|
1926
|
-
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1927
|
-
}
|
|
2108
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2109
|
+
if (method === "GET") {
|
|
2110
|
+
writeJson(res, 200, { hosts: engine.list(queryParam(url, "query")) });
|
|
2111
|
+
return;
|
|
1928
2112
|
}
|
|
1929
|
-
|
|
1930
|
-
{
|
|
1931
|
-
kind: "exact",
|
|
1932
|
-
path: SSH_API.test,
|
|
1933
|
-
handler: async (req, res) => {
|
|
1934
|
-
if (!guard(req, res, "POST")) return;
|
|
2113
|
+
if (method === "POST") {
|
|
1935
2114
|
const body = await readJsonBody(req);
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
writeJson(res, 400, { error: "alias is required" });
|
|
2115
|
+
if (body === null) {
|
|
2116
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1939
2117
|
return;
|
|
1940
2118
|
}
|
|
1941
2119
|
try {
|
|
1942
|
-
|
|
2120
|
+
const entry = store.create(body);
|
|
2121
|
+
writeJson(res, 201, { host: store.summarize(entry) });
|
|
1943
2122
|
} catch (error) {
|
|
1944
|
-
writeJson(res,
|
|
2123
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1945
2124
|
}
|
|
2125
|
+
return;
|
|
1946
2126
|
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2127
|
+
if (method !== "PATCH" && method !== "DELETE") {
|
|
2128
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
const alias = queryParam(url, "alias");
|
|
2132
|
+
if (alias === void 0 || alias === "") {
|
|
2133
|
+
writeJson(res, 400, { error: "alias query parameter is required" });
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (method === "PATCH") {
|
|
1953
2137
|
const body = await readJsonBody(req);
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
if (alias === "" || command === "") {
|
|
1957
|
-
writeJson(res, 400, { error: "alias and command are required" });
|
|
2138
|
+
if (body === null) {
|
|
2139
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1958
2140
|
return;
|
|
1959
2141
|
}
|
|
1960
|
-
const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
|
|
1961
2142
|
try {
|
|
1962
|
-
|
|
2143
|
+
const entry = store.update(alias, body);
|
|
2144
|
+
const patch = body;
|
|
2145
|
+
if ([
|
|
2146
|
+
"host",
|
|
2147
|
+
"port",
|
|
2148
|
+
"user",
|
|
2149
|
+
"auth",
|
|
2150
|
+
"proxyJump",
|
|
2151
|
+
"proxyCommand"
|
|
2152
|
+
].some((key) => patch[key] !== void 0)) engine.dropAlias(alias);
|
|
2153
|
+
writeJson(res, 200, { host: store.summarize(entry) });
|
|
1963
2154
|
} catch (error) {
|
|
1964
|
-
writeJson(res,
|
|
2155
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1965
2156
|
}
|
|
2157
|
+
return;
|
|
1966
2158
|
}
|
|
1967
|
-
|
|
1968
|
-
{
|
|
1969
|
-
kind: "exact",
|
|
1970
|
-
path: SSH_API.cluster,
|
|
1971
|
-
handler: async (req, res) => {
|
|
1972
|
-
if (!guard(req, res, "POST")) return;
|
|
1973
|
-
const body = await readJsonBody(req);
|
|
1974
|
-
const command = typeof body?.command === "string" ? body.command : "";
|
|
1975
|
-
if (command === "") {
|
|
1976
|
-
writeJson(res, 400, { error: "command is required" });
|
|
1977
|
-
return;
|
|
1978
|
-
}
|
|
1979
|
-
const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x) => typeof x === "string") : void 0;
|
|
1980
|
-
const tags = Array.isArray(body?.tags) ? body.tags.filter((x) => typeof x === "string") : void 0;
|
|
1981
|
-
const environment = typeof body?.environment === "string" ? body.environment : void 0;
|
|
1982
|
-
const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
|
|
1983
|
-
const maxWorkers = typeof body?.maxWorkers === "number" ? body.maxWorkers : void 0;
|
|
1984
|
-
const hasAliases = aliases?.some((alias) => alias.trim() !== "") === true;
|
|
1985
|
-
const hasEnvironment = typeof environment === "string" && environment.trim() !== "";
|
|
1986
|
-
const hasTags = tags?.some((tag) => tag.trim() !== "") === true;
|
|
1987
|
-
if (!hasAliases && !hasEnvironment && !hasTags) {
|
|
1988
|
-
writeJson(res, 400, { error: "ssh_cluster requires aliases, environment, or tags to limit the target set" });
|
|
1989
|
-
return;
|
|
1990
|
-
}
|
|
2159
|
+
if (method === "DELETE") {
|
|
1991
2160
|
try {
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
environment,
|
|
1996
|
-
tags,
|
|
1997
|
-
timeoutMs,
|
|
1998
|
-
maxWorkers
|
|
1999
|
-
}) });
|
|
2161
|
+
engine.dropAlias(alias);
|
|
2162
|
+
store.delete(alias);
|
|
2163
|
+
writeJson(res, 200, { ok: true });
|
|
2000
2164
|
} catch (error) {
|
|
2001
|
-
writeJson(res,
|
|
2165
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2002
2166
|
}
|
|
2167
|
+
return;
|
|
2003
2168
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2169
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
2170
|
+
}
|
|
2171
|
+
},
|
|
2172
|
+
{
|
|
2173
|
+
kind: "exact",
|
|
2174
|
+
path: SSH_API.importSshConfig,
|
|
2175
|
+
handler: async (req, res) => {
|
|
2176
|
+
if (!guard(req, res, "POST")) return;
|
|
2177
|
+
try {
|
|
2178
|
+
writeJson(res, 200, { result: store.importFromSshConfig() });
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
},
|
|
2184
|
+
{
|
|
2185
|
+
kind: "exact",
|
|
2186
|
+
path: SSH_API.test,
|
|
2187
|
+
handler: async (req, res) => {
|
|
2188
|
+
if (!guard(req, res, "POST")) return;
|
|
2189
|
+
const body = await readJsonBody(req);
|
|
2190
|
+
const alias = typeof body?.alias === "string" ? body.alias : "";
|
|
2191
|
+
if (alias === "") {
|
|
2192
|
+
writeJson(res, 400, { error: "alias is required" });
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
try {
|
|
2196
|
+
writeJson(res, 200, { result: await engine.test(alias) });
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
},
|
|
2202
|
+
{
|
|
2203
|
+
kind: "exact",
|
|
2204
|
+
path: SSH_API.exec,
|
|
2205
|
+
handler: async (req, res) => {
|
|
2206
|
+
if (!guard(req, res, "POST")) return;
|
|
2207
|
+
const body = await readJsonBody(req);
|
|
2208
|
+
const alias = typeof body?.alias === "string" ? body.alias : "";
|
|
2209
|
+
const command = typeof body?.command === "string" ? body.command : "";
|
|
2210
|
+
if (alias === "" || command === "") {
|
|
2211
|
+
writeJson(res, 400, { error: "alias and command are required" });
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
|
|
2215
|
+
try {
|
|
2216
|
+
writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) });
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
},
|
|
2222
|
+
{
|
|
2223
|
+
kind: "exact",
|
|
2224
|
+
path: SSH_API.cluster,
|
|
2225
|
+
handler: async (req, res) => {
|
|
2226
|
+
if (!guard(req, res, "POST")) return;
|
|
2227
|
+
const body = await readJsonBody(req);
|
|
2228
|
+
const command = typeof body?.command === "string" ? body.command : "";
|
|
2229
|
+
if (command === "") {
|
|
2230
|
+
writeJson(res, 400, { error: "command is required" });
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x) => typeof x === "string") : void 0;
|
|
2234
|
+
const tags = Array.isArray(body?.tags) ? body.tags.filter((x) => typeof x === "string") : void 0;
|
|
2235
|
+
const environment = typeof body?.environment === "string" ? body.environment : void 0;
|
|
2236
|
+
const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
|
|
2237
|
+
const maxWorkers = typeof body?.maxWorkers === "number" ? body.maxWorkers : void 0;
|
|
2238
|
+
const hasAliases = aliases?.some((alias) => alias.trim() !== "") === true;
|
|
2239
|
+
const hasEnvironment = typeof environment === "string" && environment.trim() !== "";
|
|
2240
|
+
const hasTags = tags?.some((tag) => tag.trim() !== "") === true;
|
|
2241
|
+
if (!hasAliases && !hasEnvironment && !hasTags) {
|
|
2242
|
+
writeJson(res, 400, { error: "ssh_cluster requires aliases, environment, or tags to limit the target set" });
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
try {
|
|
2246
|
+
writeJson(res, 200, { results: await engine.cluster({
|
|
2247
|
+
command,
|
|
2248
|
+
aliases,
|
|
2249
|
+
environment,
|
|
2250
|
+
tags,
|
|
2251
|
+
timeoutMs,
|
|
2252
|
+
maxWorkers
|
|
2253
|
+
}) });
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
},
|
|
2259
|
+
{
|
|
2260
|
+
kind: "exact",
|
|
2261
|
+
path: SSH_API.ls,
|
|
2262
|
+
handler: async (req, res) => {
|
|
2263
|
+
if (!guard(req, res, "GET")) return;
|
|
2264
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2265
|
+
const alias = queryParam(url, "alias");
|
|
2266
|
+
const path = queryParam(url, "path") ?? "/";
|
|
2267
|
+
if (alias === void 0 || alias === "") {
|
|
2268
|
+
writeJson(res, 400, { error: "alias query parameter is required" });
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
try {
|
|
2272
|
+
writeJson(res, 200, { entries: await engine.ls(alias, path) });
|
|
2273
|
+
} catch (error) {
|
|
2274
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
},
|
|
2278
|
+
{
|
|
2279
|
+
kind: "exact",
|
|
2280
|
+
path: SSH_API.tunnel,
|
|
2281
|
+
handler: async (req, res) => {
|
|
2282
|
+
if (!guard(req, res, "POST")) return;
|
|
2283
|
+
const body = await readJsonBody(req);
|
|
2284
|
+
const action = typeof body?.action === "string" ? body.action : "";
|
|
2285
|
+
if (action === "list") {
|
|
2286
|
+
writeJson(res, 200, { tunnels: engine.listTunnels() });
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
if (action === "start") {
|
|
2290
|
+
const alias = typeof body?.alias === "string" ? body.alias : "";
|
|
2291
|
+
const remotePort = typeof body?.remotePort === "number" ? body.remotePort : void 0;
|
|
2292
|
+
if (alias === "" || remotePort === void 0) {
|
|
2293
|
+
writeJson(res, 400, { error: "alias and remotePort are required" });
|
|
2015
2294
|
return;
|
|
2016
2295
|
}
|
|
2017
2296
|
try {
|
|
2018
|
-
writeJson(res, 200, {
|
|
2297
|
+
writeJson(res, 200, { tunnel: await engine.startTunnel(alias, {
|
|
2298
|
+
remotePort,
|
|
2299
|
+
remoteHost: typeof body?.remoteHost === "string" && body.remoteHost !== "" ? body.remoteHost : void 0,
|
|
2300
|
+
localPort: typeof body?.localPort === "number" ? body.localPort : void 0
|
|
2301
|
+
}) });
|
|
2019
2302
|
} catch (error) {
|
|
2020
|
-
writeJson(res,
|
|
2303
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2021
2304
|
}
|
|
2305
|
+
return;
|
|
2022
2306
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
handler: async (req, res) => {
|
|
2028
|
-
if (!guard(req, res, "POST")) return;
|
|
2029
|
-
const body = await readJsonBody(req);
|
|
2030
|
-
const action = typeof body?.action === "string" ? body.action : "";
|
|
2031
|
-
if (action === "list") {
|
|
2032
|
-
writeJson(res, 200, { tunnels: engine.listTunnels() });
|
|
2307
|
+
if (action === "stop") {
|
|
2308
|
+
const id = typeof body?.tunnelId === "string" ? body.tunnelId : "";
|
|
2309
|
+
if (id === "") {
|
|
2310
|
+
writeJson(res, 400, { error: "tunnelId is required" });
|
|
2033
2311
|
return;
|
|
2034
2312
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
const remotePort = typeof body?.remotePort === "number" ? body.remotePort : void 0;
|
|
2038
|
-
if (alias === "" || remotePort === void 0) {
|
|
2039
|
-
writeJson(res, 400, { error: "alias and remotePort are required" });
|
|
2040
|
-
return;
|
|
2041
|
-
}
|
|
2042
|
-
try {
|
|
2043
|
-
writeJson(res, 200, { tunnel: await engine.startTunnel(alias, {
|
|
2044
|
-
remotePort,
|
|
2045
|
-
remoteHost: typeof body?.remoteHost === "string" && body.remoteHost !== "" ? body.remoteHost : void 0,
|
|
2046
|
-
localPort: typeof body?.localPort === "number" ? body.localPort : void 0
|
|
2047
|
-
}) });
|
|
2048
|
-
} catch (error) {
|
|
2049
|
-
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2050
|
-
}
|
|
2051
|
-
return;
|
|
2052
|
-
}
|
|
2053
|
-
if (action === "stop") {
|
|
2054
|
-
const id = typeof body?.tunnelId === "string" ? body.tunnelId : "";
|
|
2055
|
-
if (id === "") {
|
|
2056
|
-
writeJson(res, 400, { error: "tunnelId is required" });
|
|
2057
|
-
return;
|
|
2058
|
-
}
|
|
2059
|
-
writeJson(res, 200, { ok: engine.stopTunnel(id) });
|
|
2060
|
-
return;
|
|
2061
|
-
}
|
|
2062
|
-
if (action === "stop-all") {
|
|
2063
|
-
const alias = typeof body?.alias === "string" ? body.alias : void 0;
|
|
2064
|
-
writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === "" ? void 0 : alias) });
|
|
2065
|
-
return;
|
|
2066
|
-
}
|
|
2067
|
-
writeJson(res, 400, { error: `unknown action '${action}'` });
|
|
2313
|
+
writeJson(res, 200, { ok: engine.stopTunnel(id) });
|
|
2314
|
+
return;
|
|
2068
2315
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2316
|
+
if (action === "stop-all") {
|
|
2317
|
+
const alias = typeof body?.alias === "string" ? body.alias : void 0;
|
|
2318
|
+
writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === "" ? void 0 : alias) });
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
writeJson(res, 400, { error: `unknown action '${action}'` });
|
|
2322
|
+
}
|
|
2323
|
+
},
|
|
2324
|
+
{
|
|
2325
|
+
kind: "exact",
|
|
2326
|
+
path: SSH_API.upload,
|
|
2327
|
+
handler: async (req, res) => {
|
|
2328
|
+
if (!guard(req, res, "POST")) return;
|
|
2329
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2330
|
+
const alias = queryParam(url, "alias");
|
|
2331
|
+
const remotePath = queryParam(url, "remotePath");
|
|
2332
|
+
if (alias === void 0 || remotePath === void 0) {
|
|
2333
|
+
writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
const declared = Number(req.headers["content-length"]);
|
|
2337
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
2338
|
+
writeJson(res, 413, { error: "upload body too large" });
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
res.writeHead(200, {
|
|
2342
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
2343
|
+
"cache-control": "no-cache",
|
|
2344
|
+
"referrer-policy": "no-referrer"
|
|
2345
|
+
});
|
|
2346
|
+
const emit = (line) => {
|
|
2347
|
+
try {
|
|
2348
|
+
res.write(JSON.stringify(line) + "\n");
|
|
2349
|
+
} catch {}
|
|
2350
|
+
};
|
|
2351
|
+
const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
|
|
2352
|
+
const sink = createWriteStream(tmp, { mode: 384 });
|
|
2353
|
+
let settled = false;
|
|
2354
|
+
const fail = (error) => {
|
|
2355
|
+
if (settled) return;
|
|
2356
|
+
settled = true;
|
|
2357
|
+
emit({
|
|
2358
|
+
type: "result",
|
|
2359
|
+
ok: false,
|
|
2360
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2091
2361
|
});
|
|
2092
|
-
const
|
|
2093
|
-
|
|
2094
|
-
res.write(JSON.stringify(line) + "\n");
|
|
2095
|
-
} catch {}
|
|
2096
|
-
};
|
|
2097
|
-
const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
|
|
2098
|
-
const sink = createWriteStream(tmp, { mode: 384 });
|
|
2099
|
-
let settled = false;
|
|
2100
|
-
const fail = (error) => {
|
|
2101
|
-
if (settled) return;
|
|
2102
|
-
settled = true;
|
|
2103
|
-
emit({
|
|
2104
|
-
type: "result",
|
|
2105
|
-
ok: false,
|
|
2106
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2107
|
-
});
|
|
2108
|
-
const cleanup = () => {
|
|
2109
|
-
unlink(tmp).catch(() => void 0).finally(() => {
|
|
2110
|
-
try {
|
|
2111
|
-
res.end();
|
|
2112
|
-
} catch {}
|
|
2113
|
-
});
|
|
2114
|
-
};
|
|
2115
|
-
if (sink.destroyed) cleanup();
|
|
2116
|
-
else {
|
|
2117
|
-
sink.once("close", cleanup);
|
|
2362
|
+
const cleanup = () => {
|
|
2363
|
+
unlink(tmp).catch(() => void 0).finally(() => {
|
|
2118
2364
|
try {
|
|
2119
|
-
|
|
2120
|
-
} catch {
|
|
2121
|
-
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2365
|
+
res.end();
|
|
2366
|
+
} catch {}
|
|
2367
|
+
});
|
|
2124
2368
|
};
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2369
|
+
if (sink.destroyed) cleanup();
|
|
2370
|
+
else {
|
|
2371
|
+
sink.once("close", cleanup);
|
|
2128
2372
|
try {
|
|
2129
|
-
|
|
2130
|
-
} catch {
|
|
2131
|
-
|
|
2132
|
-
sink.on("error", (error) => fail(error));
|
|
2133
|
-
req.on("error", (error) => fail(error));
|
|
2134
|
-
req.on("aborted", () => fail("upload aborted by the client"));
|
|
2135
|
-
res.on("error", () => fail("response stream closed"));
|
|
2136
|
-
res.on("close", () => {
|
|
2137
|
-
if (!res.writableEnded) fail("connection closed");
|
|
2138
|
-
});
|
|
2139
|
-
let received = 0;
|
|
2140
|
-
let capped = false;
|
|
2141
|
-
req.on("data", (chunk) => {
|
|
2142
|
-
received += chunk.byteLength;
|
|
2143
|
-
if (received > maxUploadBytes && !capped) {
|
|
2144
|
-
capped = true;
|
|
2145
|
-
fail("upload body too large");
|
|
2146
|
-
res.on("finish", () => {
|
|
2147
|
-
try {
|
|
2148
|
-
req.destroy();
|
|
2149
|
-
} catch {}
|
|
2150
|
-
});
|
|
2151
|
-
req.resume();
|
|
2373
|
+
sink.destroy();
|
|
2374
|
+
} catch {
|
|
2375
|
+
cleanup();
|
|
2152
2376
|
}
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
const done = () => {
|
|
2380
|
+
if (settled) return;
|
|
2381
|
+
settled = true;
|
|
2382
|
+
try {
|
|
2383
|
+
res.end();
|
|
2384
|
+
} catch {}
|
|
2385
|
+
};
|
|
2386
|
+
sink.on("error", (error) => fail(error));
|
|
2387
|
+
req.on("error", (error) => fail(error));
|
|
2388
|
+
req.on("aborted", () => fail("upload aborted by the client"));
|
|
2389
|
+
res.on("error", () => fail("response stream closed"));
|
|
2390
|
+
res.on("close", () => {
|
|
2391
|
+
if (!res.writableEnded) fail("connection closed");
|
|
2392
|
+
});
|
|
2393
|
+
let received = 0;
|
|
2394
|
+
let capped = false;
|
|
2395
|
+
req.on("data", (chunk) => {
|
|
2396
|
+
received += chunk.byteLength;
|
|
2397
|
+
if (received > maxUploadBytes && !capped) {
|
|
2398
|
+
capped = true;
|
|
2399
|
+
fail("upload body too large");
|
|
2400
|
+
res.on("finish", () => {
|
|
2401
|
+
try {
|
|
2402
|
+
req.destroy();
|
|
2403
|
+
} catch {}
|
|
2166
2404
|
});
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2182
|
-
});
|
|
2183
|
-
} finally {
|
|
2184
|
-
await unlink(tmp).catch(() => void 0);
|
|
2185
|
-
done();
|
|
2405
|
+
req.resume();
|
|
2406
|
+
}
|
|
2407
|
+
});
|
|
2408
|
+
req.pipe(sink);
|
|
2409
|
+
sink.on("finish", async () => {
|
|
2410
|
+
if (settled) return;
|
|
2411
|
+
emit({
|
|
2412
|
+
type: "progress",
|
|
2413
|
+
progress: {
|
|
2414
|
+
phase: "connecting",
|
|
2415
|
+
file: remotePath,
|
|
2416
|
+
transferred: 0,
|
|
2417
|
+
total: 0,
|
|
2418
|
+
percent: 0
|
|
2186
2419
|
}
|
|
2187
2420
|
});
|
|
2188
|
-
}
|
|
2189
|
-
},
|
|
2190
|
-
{
|
|
2191
|
-
kind: "exact",
|
|
2192
|
-
path: SSH_API.download,
|
|
2193
|
-
handler: async (req, res) => {
|
|
2194
|
-
if (!guard(req, res, "GET")) return;
|
|
2195
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2196
|
-
const alias = queryParam(url, "alias");
|
|
2197
|
-
const remotePath = queryParam(url, "remotePath");
|
|
2198
|
-
if (alias === void 0 || remotePath === void 0) {
|
|
2199
|
-
writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
|
|
2200
|
-
return;
|
|
2201
|
-
}
|
|
2202
|
-
const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
|
|
2203
2421
|
try {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
await new Promise((resolve, reject) => {
|
|
2213
|
-
const source = createReadStream(tmp);
|
|
2214
|
-
source.on("error", reject);
|
|
2215
|
-
res.on("error", reject);
|
|
2216
|
-
source.pipe(res);
|
|
2217
|
-
source.on("end", resolve);
|
|
2422
|
+
const outcome = await engine.upload(alias, tmp, remotePath, false, (progress) => emit({
|
|
2423
|
+
type: "progress",
|
|
2424
|
+
progress
|
|
2425
|
+
}));
|
|
2426
|
+
emit({
|
|
2427
|
+
type: "result",
|
|
2428
|
+
ok: true,
|
|
2429
|
+
transferredBytes: outcome.bytes
|
|
2218
2430
|
});
|
|
2219
2431
|
} catch (error) {
|
|
2220
|
-
|
|
2221
|
-
|
|
2432
|
+
emit({
|
|
2433
|
+
type: "result",
|
|
2434
|
+
ok: false,
|
|
2435
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2436
|
+
});
|
|
2222
2437
|
} finally {
|
|
2223
2438
|
await unlink(tmp).catch(() => void 0);
|
|
2439
|
+
done();
|
|
2224
2440
|
}
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
},
|
|
2444
|
+
{
|
|
2445
|
+
kind: "exact",
|
|
2446
|
+
path: SSH_API.download,
|
|
2447
|
+
handler: async (req, res) => {
|
|
2448
|
+
if (!guard(req, res, "GET")) return;
|
|
2449
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2450
|
+
const alias = queryParam(url, "alias");
|
|
2451
|
+
const remotePath = queryParam(url, "remotePath");
|
|
2452
|
+
if (alias === void 0 || remotePath === void 0) {
|
|
2453
|
+
writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
|
|
2457
|
+
try {
|
|
2458
|
+
closeSync(openSync(tmp, "w", 384));
|
|
2459
|
+
const outcome = await engine.download(alias, remotePath, tmp);
|
|
2460
|
+
res.writeHead(200, {
|
|
2461
|
+
"content-type": "application/octet-stream",
|
|
2462
|
+
"content-length": String(outcome.bytes),
|
|
2463
|
+
"content-disposition": `attachment; filename="${basename(remotePath).replace(/"/g, "")}"`,
|
|
2464
|
+
"referrer-policy": "no-referrer"
|
|
2465
|
+
});
|
|
2466
|
+
await new Promise((resolve, reject) => {
|
|
2467
|
+
const source = createReadStream(tmp);
|
|
2468
|
+
source.on("error", reject);
|
|
2469
|
+
res.on("error", reject);
|
|
2470
|
+
source.pipe(res);
|
|
2471
|
+
source.on("end", resolve);
|
|
2472
|
+
});
|
|
2473
|
+
} catch (error) {
|
|
2474
|
+
if (!res.headersSent) writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2475
|
+
else res.destroy();
|
|
2476
|
+
} finally {
|
|
2477
|
+
await unlink(tmp).catch(() => void 0);
|
|
2225
2478
|
}
|
|
2226
2479
|
}
|
|
2227
|
-
|
|
2480
|
+
}
|
|
2481
|
+
];
|
|
2482
|
+
const terminalSessions = new TerminalSessionRegistry({ openShell: (alias, size, onKeyboardInteractive) => engine.openShell(alias, size, onKeyboardInteractive) });
|
|
2483
|
+
return {
|
|
2484
|
+
routes,
|
|
2228
2485
|
upgrade: {
|
|
2229
2486
|
path: SSH_API.terminal,
|
|
2230
2487
|
handler: (req, socket, head) => {
|
|
@@ -2235,129 +2492,39 @@ function makeRoutes(deps) {
|
|
|
2235
2492
|
}
|
|
2236
2493
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2237
2494
|
const alias = queryParam(url, "alias");
|
|
2238
|
-
|
|
2495
|
+
const sessionId = queryParam(url, "session");
|
|
2496
|
+
if (alias === void 0 && sessionId === void 0) {
|
|
2239
2497
|
socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
2240
2498
|
socket.destroy();
|
|
2241
2499
|
return;
|
|
2242
2500
|
}
|
|
2243
2501
|
const cols = Number.parseInt(queryParam(url, "cols") ?? "80", 10);
|
|
2244
2502
|
const rows = Number.parseInt(queryParam(url, "rows") ?? "24", 10);
|
|
2503
|
+
const size = {
|
|
2504
|
+
cols: Number.isFinite(cols) ? cols : 80,
|
|
2505
|
+
rows: Number.isFinite(rows) ? rows : 24
|
|
2506
|
+
};
|
|
2245
2507
|
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
let pendingAuthFinish;
|
|
2250
|
-
const resume = () => {
|
|
2251
|
-
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
|
|
2252
|
-
paused = false;
|
|
2253
|
-
session?.resume();
|
|
2254
|
-
}
|
|
2255
|
-
};
|
|
2256
|
-
const sendFrame = (frame) => {
|
|
2257
|
-
if (closed || ws.readyState !== WebSocket.OPEN) return;
|
|
2258
|
-
ws.send(JSON.stringify(frame), resume);
|
|
2259
|
-
if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
2260
|
-
paused = true;
|
|
2261
|
-
session?.pause();
|
|
2262
|
-
}
|
|
2263
|
-
};
|
|
2264
|
-
const closeSession = () => {
|
|
2265
|
-
if (pendingAuthFinish !== void 0) {
|
|
2266
|
-
const fn = pendingAuthFinish;
|
|
2267
|
-
pendingAuthFinish = void 0;
|
|
2268
|
-
try {
|
|
2269
|
-
fn([]);
|
|
2270
|
-
} catch {}
|
|
2271
|
-
}
|
|
2272
|
-
const opened = session;
|
|
2273
|
-
session = void 0;
|
|
2274
|
-
if (opened !== void 0) opened.close();
|
|
2275
|
-
};
|
|
2276
|
-
const onKeyboardInteractive = (name, instructions, _lang, prompts, finish) => {
|
|
2277
|
-
const entry = store.find(alias);
|
|
2278
|
-
if (entry?.auth.kind === "password" && entry.auth.password !== void 0 && prompts.length > 0 && prompts.every((p) => /password/i.test(p.prompt))) {
|
|
2279
|
-
const password = entry.auth.password;
|
|
2280
|
-
finish(prompts.map(() => password));
|
|
2281
|
-
return;
|
|
2282
|
-
}
|
|
2283
|
-
pendingAuthFinish = finish;
|
|
2284
|
-
sendFrame({
|
|
2285
|
-
type: "auth_prompt",
|
|
2286
|
-
name,
|
|
2287
|
-
instructions,
|
|
2288
|
-
prompts: prompts.map((p) => ({
|
|
2289
|
-
prompt: p.prompt,
|
|
2290
|
-
echo: p.echo
|
|
2291
|
-
}))
|
|
2292
|
-
});
|
|
2293
|
-
};
|
|
2294
|
-
engine.openShell(alias, {
|
|
2295
|
-
cols: Number.isFinite(cols) ? cols : 80,
|
|
2296
|
-
rows: Number.isFinite(rows) ? rows : 24
|
|
2297
|
-
}, onKeyboardInteractive).then((opened) => {
|
|
2298
|
-
if (ws.readyState !== WebSocket.OPEN) {
|
|
2299
|
-
opened.close();
|
|
2300
|
-
return;
|
|
2301
|
-
}
|
|
2302
|
-
session = opened;
|
|
2303
|
-
sendFrame({
|
|
2304
|
-
type: "ready",
|
|
2305
|
-
alias
|
|
2306
|
-
});
|
|
2307
|
-
opened.onData = (data) => sendFrame({
|
|
2308
|
-
type: "output",
|
|
2309
|
-
data: data.toString("utf8")
|
|
2310
|
-
});
|
|
2311
|
-
opened.onExit = (code, error) => {
|
|
2312
|
-
sendFrame({
|
|
2313
|
-
type: "exit",
|
|
2314
|
-
code,
|
|
2315
|
-
error
|
|
2316
|
-
});
|
|
2317
|
-
closed = true;
|
|
2318
|
-
try {
|
|
2319
|
-
ws.close(1e3);
|
|
2320
|
-
} catch {}
|
|
2321
|
-
};
|
|
2322
|
-
}).catch((error) => {
|
|
2323
|
-
sendFrame({
|
|
2508
|
+
if (sessionId !== void 0) {
|
|
2509
|
+
if (terminalSessions.attach(ws, sessionId, size)) return;
|
|
2510
|
+
const expired = {
|
|
2324
2511
|
type: "exit",
|
|
2325
2512
|
code: null,
|
|
2326
|
-
error:
|
|
2327
|
-
}
|
|
2328
|
-
closed = true;
|
|
2513
|
+
error: "terminal session expired"
|
|
2514
|
+
};
|
|
2329
2515
|
try {
|
|
2330
|
-
ws.
|
|
2516
|
+
ws.send(JSON.stringify(expired));
|
|
2331
2517
|
} catch {}
|
|
2332
|
-
});
|
|
2333
|
-
ws.on("message", (data) => {
|
|
2334
|
-
let frame;
|
|
2335
2518
|
try {
|
|
2336
|
-
|
|
2337
|
-
} catch {
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
else if (frame.type === "resize") session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows));
|
|
2342
|
-
else if (frame.type === "auth_response") {
|
|
2343
|
-
if (pendingAuthFinish !== void 0) {
|
|
2344
|
-
const fn = pendingAuthFinish;
|
|
2345
|
-
pendingAuthFinish = void 0;
|
|
2346
|
-
fn(frame.responses);
|
|
2347
|
-
}
|
|
2348
|
-
}
|
|
2349
|
-
});
|
|
2350
|
-
ws.on("close", () => {
|
|
2351
|
-
closed = true;
|
|
2352
|
-
closeSession();
|
|
2353
|
-
});
|
|
2354
|
-
ws.on("error", () => {
|
|
2355
|
-
closed = true;
|
|
2356
|
-
closeSession();
|
|
2357
|
-
});
|
|
2519
|
+
ws.close(1e3);
|
|
2520
|
+
} catch {}
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
terminalSessions.open(ws, alias, size);
|
|
2358
2524
|
});
|
|
2359
2525
|
}
|
|
2360
|
-
}
|
|
2526
|
+
},
|
|
2527
|
+
terminalSessions
|
|
2361
2528
|
};
|
|
2362
2529
|
}
|
|
2363
2530
|
//#endregion
|
|
@@ -2961,38 +3128,111 @@ function sshClusterTool(engine) {
|
|
|
2961
3128
|
* the loader accepts a standalone install of the same package side by side;
|
|
2962
3129
|
* without this guard the second instance would still re-register the same
|
|
2963
3130
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
2964
|
-
* and fail the boot. mountOnce makes
|
|
2965
|
-
*
|
|
2966
|
-
* package name in the client module host).
|
|
3131
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
3132
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
3133
|
+
* deduped by package name in the client module host).
|
|
3134
|
+
*
|
|
3135
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
3136
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
3137
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
3138
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
3139
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
3140
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
3141
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
3142
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
3143
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
3144
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
3145
|
+
* restart recovered it).
|
|
3146
|
+
*
|
|
3147
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
3148
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
3149
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
3150
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
3151
|
+
* dedupes against it.
|
|
2967
3152
|
*
|
|
2968
3153
|
* The registry rides a global symbol so two module instances of the same
|
|
2969
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
2970
|
-
*
|
|
3154
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
3155
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
3156
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
3157
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
3158
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
3159
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
3160
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
3161
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
3162
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
3163
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
3164
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
3165
|
+
* mounted after it failed with "claims.get is not a function".
|
|
3166
|
+
*
|
|
3167
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
2971
3168
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
2972
3169
|
*/
|
|
3170
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
2973
3171
|
const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
|
|
3172
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
3173
|
+
const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
|
|
3174
|
+
/**
|
|
3175
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
3176
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
3177
|
+
* family plugin down with it.
|
|
3178
|
+
* @returns the process-wide set of mounted package names.
|
|
3179
|
+
*/
|
|
2974
3180
|
function mountedSet() {
|
|
2975
3181
|
const registry = globalThis;
|
|
2976
|
-
|
|
3182
|
+
const existing = registry[MOUNTED];
|
|
3183
|
+
if (existing instanceof Set) return existing;
|
|
3184
|
+
const created = /* @__PURE__ */ new Set();
|
|
3185
|
+
registry[MOUNTED] = created;
|
|
3186
|
+
return created;
|
|
3187
|
+
}
|
|
3188
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
3189
|
+
function mountWaiters() {
|
|
3190
|
+
const registry = globalThis;
|
|
3191
|
+
return registry[WAITERS] ??= /* @__PURE__ */ new Map();
|
|
2977
3192
|
}
|
|
2978
3193
|
/**
|
|
2979
3194
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
2980
|
-
* The first mount registers normally and
|
|
2981
|
-
*
|
|
3195
|
+
* The first mount registers normally and releases the name when its fiber
|
|
3196
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
3197
|
+
* then runs, unless its own fiber disposes first.
|
|
2982
3198
|
* @param packageName - npm package identity shared by every install source.
|
|
2983
3199
|
* @param fn - the original plugin apply.
|
|
2984
3200
|
* @returns an apply of the same shape.
|
|
2985
3201
|
*/
|
|
2986
3202
|
function mountOnce(packageName, fn) {
|
|
2987
|
-
|
|
3203
|
+
const mount = (...args) => {
|
|
2988
3204
|
const mounted = mountedSet();
|
|
2989
|
-
|
|
3205
|
+
const ctx = args[0];
|
|
3206
|
+
if (mounted.has(packageName)) {
|
|
3207
|
+
const waiters = mountWaiters();
|
|
3208
|
+
const queue = waiters.get(packageName) ?? [];
|
|
3209
|
+
let alive = true;
|
|
3210
|
+
const pending = { run: () => {
|
|
3211
|
+
if (alive) mount(...args);
|
|
3212
|
+
} };
|
|
3213
|
+
ctx?.effect?.(() => () => {
|
|
3214
|
+
alive = false;
|
|
3215
|
+
const index = queue.indexOf(pending);
|
|
3216
|
+
if (index >= 0) queue.splice(index, 1);
|
|
3217
|
+
});
|
|
3218
|
+
queue.push(pending);
|
|
3219
|
+
waiters.set(packageName, queue);
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
2990
3222
|
mounted.add(packageName);
|
|
2991
|
-
|
|
3223
|
+
ctx?.effect?.(() => () => {
|
|
2992
3224
|
mounted.delete(packageName);
|
|
3225
|
+
const waiters = mountWaiters();
|
|
3226
|
+
const queue = waiters.get(packageName);
|
|
3227
|
+
if (queue === void 0) return;
|
|
3228
|
+
waiters.delete(packageName);
|
|
3229
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => {
|
|
3230
|
+
waiter.run();
|
|
3231
|
+
});
|
|
2993
3232
|
});
|
|
2994
3233
|
return fn(...args);
|
|
2995
|
-
}
|
|
3234
|
+
};
|
|
3235
|
+
return mount;
|
|
2996
3236
|
}
|
|
2997
3237
|
//#endregion
|
|
2998
3238
|
//#region src/index.ts
|
|
@@ -3059,7 +3299,7 @@ function applyImpl(ctx, config) {
|
|
|
3059
3299
|
enabled
|
|
3060
3300
|
};
|
|
3061
3301
|
};
|
|
3062
|
-
const { routes, upgrade } = makeRoutes({
|
|
3302
|
+
const { routes, upgrade, terminalSessions } = makeRoutes({
|
|
3063
3303
|
store,
|
|
3064
3304
|
engine
|
|
3065
3305
|
});
|
|
@@ -3100,6 +3340,7 @@ function applyImpl(ctx, config) {
|
|
|
3100
3340
|
return () => {
|
|
3101
3341
|
for (const dispose of disposers) dispose();
|
|
3102
3342
|
upgradeDisposer();
|
|
3343
|
+
terminalSessions.dispose();
|
|
3103
3344
|
};
|
|
3104
3345
|
}, "dsh-ssh: routes");
|
|
3105
3346
|
disposeTools = ctx.effect(() => {
|