@linxin666/dsh-ssh 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1328,13 +1328,13 @@ const DEFAULTS = {
1328
1328
  sftpConcurrency: 8
1329
1329
  };
1330
1330
  /** Build the ssh2 connect config for one entry (key read from disk). */
1331
- function buildConnectConfig(entry, sock) {
1331
+ function buildConnectConfig(entry, sock, opts) {
1332
1332
  const config = {
1333
1333
  host: entry.host,
1334
1334
  port: entry.port,
1335
1335
  username: entry.user,
1336
- readyTimeout: 15e3,
1337
- keepaliveInterval: 15e3,
1336
+ readyTimeout: opts.connectTimeoutMs,
1337
+ keepaliveInterval: opts.keepaliveIntervalMs,
1338
1338
  keepaliveCountMax: 3
1339
1339
  };
1340
1340
  if (sock !== void 0) config.sock = sock;
@@ -1401,7 +1401,7 @@ async function connectChain(engine, entry) {
1401
1401
  for (const client of hops) client.end();
1402
1402
  throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1403
1403
  }
1404
- const hopClient = await connectClient(buildConnectConfig(hop, sock));
1404
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
1405
1405
  hops.push(hopClient);
1406
1406
  const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1407
1407
  const nextHost = next !== void 0 ? next.host : entry.host;
@@ -1417,7 +1417,7 @@ async function connectChain(engine, entry) {
1417
1417
  }
1418
1418
  let target;
1419
1419
  try {
1420
- target = await connectClient(buildConnectConfig(entry, sock));
1420
+ target = await connectClient(buildConnectConfig(entry, sock, engine.opts));
1421
1421
  return {
1422
1422
  client: target,
1423
1423
  hops
@@ -2145,6 +2145,51 @@ var SshEngine = class {
2145
2145
  }
2146
2146
  };
2147
2147
  //#endregion
2148
+ //#region src/loopback.ts
2149
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
2150
+ function isIPv4Loopback(v4) {
2151
+ const parts = v4.split(".");
2152
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
2153
+ }
2154
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
2155
+ function isLoopbackAddress(address) {
2156
+ if (address === void 0) return false;
2157
+ const normalized = address.toLowerCase();
2158
+ if (normalized === "::1") return true;
2159
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
2160
+ return isIPv4Loopback(normalized);
2161
+ }
2162
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
2163
+ function isLoopbackHostname(hostname) {
2164
+ if (hostname === "localhost" || hostname === "[::1]") return true;
2165
+ return isIPv4Loopback(hostname);
2166
+ }
2167
+ /**
2168
+ * Request-level trust fence: a loopback socket address AND a loopback Host
2169
+ * header, plus browser same-origin markers. The socket address is
2170
+ * authoritative; X-Forwarded-For is never trusted.
2171
+ */
2172
+ function isLoopbackRequest(request) {
2173
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
2174
+ const host = request.headers.host;
2175
+ if (typeof host !== "string") return false;
2176
+ let hostUrl;
2177
+ try {
2178
+ hostUrl = new URL("http://" + host);
2179
+ } catch {
2180
+ return false;
2181
+ }
2182
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
2183
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
2184
+ const origin = request.headers.origin;
2185
+ if (origin === void 0) return true;
2186
+ try {
2187
+ return new URL(origin).host === hostUrl.host;
2188
+ } catch {
2189
+ return false;
2190
+ }
2191
+ }
2192
+ //#endregion
2148
2193
  //#region src/protocol.ts
2149
2194
  const SSH_API = {
2150
2195
  hosts: "/api/dsh-ssh/hosts",
@@ -2182,28 +2227,6 @@ const terminalWss = new WebSocketServer({ noServer: true });
2182
2227
  const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
2183
2228
  /** …and resume once it drains below this. */
2184
2229
  const BACKPRESSURE_LOW_WATER = 512 * 1024;
2185
- /** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
2186
- function isLoopbackRequest(request) {
2187
- const address = request.socket.remoteAddress;
2188
- if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
2189
- const host = request.headers.host;
2190
- if (typeof host !== "string") return false;
2191
- let hostUrl;
2192
- try {
2193
- hostUrl = new URL(`http://${host}`);
2194
- } catch {
2195
- return false;
2196
- }
2197
- if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
2198
- if (request.headers["sec-fetch-site"] === "cross-site") return false;
2199
- const origin = request.headers.origin;
2200
- if (origin === void 0) return true;
2201
- try {
2202
- return new URL(origin).host === hostUrl.host;
2203
- } catch {
2204
- return false;
2205
- }
2206
- }
2207
2230
  /** One JSON response. */
2208
2231
  function writeJson(res, status, body) {
2209
2232
  const payload = JSON.stringify(body);
@@ -5,6 +5,7 @@ export declare const zh: {
5
5
  readonly 'entry.label': "SSH";
6
6
  readonly 'entry.tooltip': "远程 SSH 运维面板";
7
7
  readonly 'panel.title': "远程 SSH";
8
+ readonly 'panel.backToConversation': "返回会话";
8
9
  readonly 'tab.hosts': "主机";
9
10
  readonly 'tab.terminal': "终端";
10
11
  readonly 'tab.transfer': "传输";
@@ -127,7 +128,6 @@ export declare const zh: {
127
128
  readonly 'common.loading': "加载中…";
128
129
  readonly 'common.error': "出错:{error}";
129
130
  readonly 'common.refresh': "刷新";
130
- readonly 'common.close': "关闭";
131
131
  readonly 'common.confirm': "确定";
132
132
  };
133
133
  export declare const en: Record<keyof typeof zh, string>;
@@ -47,7 +47,7 @@ export interface PoolEngine {
47
47
  readonly acquireQueue: Map<string, Promise<PoolRecord>>;
48
48
  }
49
49
  /** Build the ssh2 connect config for one entry (key read from disk). */
50
- export declare function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig;
50
+ export declare function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig;
51
51
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
52
52
  export declare function connectClient(config: ConnectConfig): Promise<Client>;
53
53
  /** Cap captured output at the configured byte budget (marks truncation). */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Loopback trust fence shared by the host route families: socket address,
3
+ * Host header, and browser same-origin markers. Packages receive this file as
4
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
5
+ * re-run the sync instead of editing a copy.
6
+ *
7
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
8
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
9
+ * markers (sec-fetch-site and Origin) for the request-level fence.
10
+ * @module dsh-web-ui-shared/host/loopback
11
+ */
12
+ import type { IncomingMessage } from 'node:http';
13
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
14
+ export declare function isIPv4Loopback(v4: string): boolean;
15
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
16
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
17
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
18
+ export declare function isLoopbackHostname(hostname: string): boolean;
19
+ /**
20
+ * Request-level trust fence: a loopback socket address AND a loopback Host
21
+ * header, plus browser same-origin markers. The socket address is
22
+ * authoritative; X-Forwarded-For is never trusted.
23
+ */
24
+ export declare function isLoopbackRequest(request: IncomingMessage): boolean;
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.17",
4
+ "version": "0.1.19",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -6,6 +6,7 @@ export const zh = {
6
6
  'entry.label': 'SSH',
7
7
  'entry.tooltip': '远程 SSH 运维面板',
8
8
  'panel.title': '远程 SSH',
9
+ 'panel.backToConversation': '返回会话',
9
10
  'tab.hosts': '主机',
10
11
  'tab.terminal': '终端',
11
12
  'tab.transfer': '传输',
@@ -135,7 +136,6 @@ export const zh = {
135
136
  'common.loading': '加载中…',
136
137
  'common.error': '出错:{error}',
137
138
  'common.refresh': '刷新',
138
- 'common.close': '关闭',
139
139
  'common.confirm': '确定',
140
140
  } as const
141
141
 
@@ -143,6 +143,7 @@ export const en: Record<keyof typeof zh, string> = {
143
143
  'entry.label': 'SSH',
144
144
  'entry.tooltip': 'Remote SSH operations panel',
145
145
  'panel.title': 'Remote SSH',
146
+ 'panel.backToConversation': 'Back to chat',
146
147
  'tab.hosts': 'Hosts',
147
148
  'tab.terminal': 'Terminal',
148
149
  'tab.transfer': 'Transfer',
@@ -265,7 +266,6 @@ export const en: Record<keyof typeof zh, string> = {
265
266
  'common.loading': 'Loading…',
266
267
  'common.error': 'Error: {error}',
267
268
  'common.refresh': 'Refresh',
268
- 'common.close': 'Close',
269
269
  'common.confirm': 'Confirm',
270
270
  }
271
271
 
@@ -55,8 +55,16 @@ export function SshPanel({ controller, api }: SshPanelProps) {
55
55
  return (
56
56
  <div className={css.panel}>
57
57
  <div className={css.panelHeader}>
58
+ <button
59
+ type="button"
60
+ className={`${css.ghostButton} ${css.backButton}`}
61
+ aria-label={tt('panel.backToConversation')}
62
+ onClick={() => { controller.close() }}
63
+ >
64
+ <span aria-hidden="true">‹</span>
65
+ <span>{tt('panel.backToConversation')}</span>
66
+ </button>
58
67
  <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
59
- <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
60
68
  </div>
61
69
  <div className={css.tabBar} role="tablist">
62
70
  {TABS.map(tab => (
@@ -133,6 +133,12 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
133
133
  white-space: nowrap;
134
134
  }
135
135
 
136
+ .backButton {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ gap: 4px;
140
+ }
141
+
136
142
  /* --- tab bar ------------------------------------------------------------------ */
137
143
 
138
144
  .tabBar {
@@ -62,13 +62,13 @@ export interface PoolEngine {
62
62
  }
63
63
 
64
64
  /** Build the ssh2 connect config for one entry (key read from disk). */
65
- export function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig {
65
+ export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig {
66
66
  const config: ConnectConfig = {
67
67
  host: entry.host,
68
68
  port: entry.port,
69
69
  username: entry.user,
70
- readyTimeout: 15_000,
71
- keepaliveInterval: 15_000,
70
+ readyTimeout: opts.connectTimeoutMs,
71
+ keepaliveInterval: opts.keepaliveIntervalMs,
72
72
  keepaliveCountMax: 3,
73
73
  }
74
74
  if (sock !== undefined) config.sock = sock
@@ -147,7 +147,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
147
147
  for (const client of hops) client.end()
148
148
  throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
149
149
  }
150
- const hopClient = await connectClient(buildConnectConfig(hop, sock))
150
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
151
151
  hops.push(hopClient)
152
152
  const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
153
153
  const nextHost = next !== undefined ? next.host : entry.host
@@ -165,7 +165,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
165
165
  }
166
166
  let target: Client | undefined
167
167
  try {
168
- target = await connectClient(buildConnectConfig(entry, sock))
168
+ target = await connectClient(buildConnectConfig(entry, sock, engine.opts))
169
169
  return { client: target, hops }
170
170
  } catch (error) {
171
171
  for (const client of hops) client.end()
@@ -0,0 +1,63 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
package/src/routes.ts CHANGED
@@ -16,6 +16,7 @@ import { randomBytes } from 'node:crypto'
16
16
  import { WebSocket, WebSocketServer } from 'ws'
17
17
  import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
18
18
  import type { SshEngine, ShellSession } from './engine.ts'
19
+ import { isLoopbackRequest } from './loopback.ts'
19
20
  import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
20
21
  import type { HostStore } from './store.ts'
21
22
 
@@ -38,29 +39,6 @@ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
38
39
  /** …and resume once it drains below this. */
39
40
  const BACKPRESSURE_LOW_WATER = 512 * 1024
40
41
 
41
- /** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
42
- function isLoopbackRequest(request: IncomingMessage): boolean {
43
- const address = request.socket.remoteAddress
44
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
45
- const host = request.headers.host
46
- if (typeof host !== 'string') return false
47
- let hostUrl: URL
48
- try {
49
- hostUrl = new URL(`http://${host}`)
50
- } catch {
51
- return false
52
- }
53
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
54
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
55
- const origin = request.headers.origin
56
- if (origin === undefined) return true
57
- try {
58
- return new URL(origin).host === hostUrl.host
59
- } catch {
60
- return false
61
- }
62
- }
63
-
64
42
  /** One JSON response. */
65
43
  function writeJson(res: ServerResponse, status: number, body: unknown): void {
66
44
  const payload = JSON.stringify(body)