@qodalis/cli-server-node 1.0.0-beta.5 → 1.0.0-beta.6
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.md +175 -19
- package/dist/src/auth/cli-server-auth-gate.d.ts +31 -0
- package/dist/src/auth/cli-server-auth-gate.d.ts.map +1 -0
- package/dist/src/auth/cli-server-auth-gate.js +118 -0
- package/dist/src/auth/cli-server-auth-gate.js.map +1 -0
- package/dist/src/auth/cli-server-auth.d.ts +147 -0
- package/dist/src/auth/cli-server-auth.d.ts.map +1 -0
- package/dist/src/auth/cli-server-auth.js +143 -0
- package/dist/src/auth/cli-server-auth.js.map +1 -0
- package/dist/src/auth/index.d.ts +4 -0
- package/dist/src/auth/index.d.ts.map +1 -0
- package/dist/src/auth/index.js +19 -0
- package/dist/src/auth/index.js.map +1 -0
- package/dist/src/auth/ws-upgrade-auth.d.ts +30 -0
- package/dist/src/auth/ws-upgrade-auth.d.ts.map +1 -0
- package/dist/src/auth/ws-upgrade-auth.js +49 -0
- package/dist/src/auth/ws-upgrade-auth.js.map +1 -0
- package/dist/src/controllers/cli-controller.d.ts +2 -1
- package/dist/src/controllers/cli-controller.d.ts.map +1 -1
- package/dist/src/controllers/cli-controller.js +7 -2
- package/dist/src/controllers/cli-controller.js.map +1 -1
- package/dist/src/controllers/cli-version-controller.d.ts +12 -1
- package/dist/src/controllers/cli-version-controller.d.ts.map +1 -1
- package/dist/src/controllers/cli-version-controller.js +7 -2
- package/dist/src/controllers/cli-version-controller.js.map +1 -1
- package/dist/src/create-cli-server.d.ts +45 -1
- package/dist/src/create-cli-server.d.ts.map +1 -1
- package/dist/src/create-cli-server.js +71 -10
- package/dist/src/create-cli-server.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +15 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/server.js +12 -0
- package/dist/src/server.js.map +1 -1
- package/dist/src/services/cli-event-socket-manager.d.ts +37 -1
- package/dist/src/services/cli-event-socket-manager.d.ts.map +1 -1
- package/dist/src/services/cli-event-socket-manager.js +126 -28
- package/dist/src/services/cli-event-socket-manager.js.map +1 -1
- package/dist/src/services/cli-log-socket-manager.d.ts +15 -1
- package/dist/src/services/cli-log-socket-manager.d.ts.map +1 -1
- package/dist/src/services/cli-log-socket-manager.js +53 -13
- package/dist/src/services/cli-log-socket-manager.js.map +1 -1
- package/dist/src/services/cli-shell-options.d.ts +107 -0
- package/dist/src/services/cli-shell-options.d.ts.map +1 -0
- package/dist/src/services/cli-shell-options.js +139 -0
- package/dist/src/services/cli-shell-options.js.map +1 -0
- package/dist/src/services/cli-shell-session-manager.d.ts +83 -10
- package/dist/src/services/cli-shell-session-manager.d.ts.map +1 -1
- package/dist/src/services/cli-shell-session-manager.js +474 -85
- package/dist/src/services/cli-shell-session-manager.js.map +1 -1
- package/dist/src/services/index.d.ts +2 -1
- package/dist/src/services/index.d.ts.map +1 -1
- package/dist/src/services/index.js +5 -1
- package/dist/src/services/index.js.map +1 -1
- package/dist/src/services/process-tree.d.ts +38 -0
- package/dist/src/services/process-tree.d.ts.map +1 -0
- package/dist/src/services/process-tree.js +249 -0
- package/dist/src/services/process-tree.js.map +1 -0
- package/dist/src/services/session-bound-sockets.d.ts +45 -0
- package/dist/src/services/session-bound-sockets.d.ts.map +1 -0
- package/dist/src/services/session-bound-sockets.js +129 -0
- package/dist/src/services/session-bound-sockets.js.map +1 -0
- package/package.json +1 -1
|
@@ -35,10 +35,16 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CliShellSessionManager = void 0;
|
|
37
37
|
const ws_1 = require("ws");
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
38
39
|
const os = __importStar(require("os"));
|
|
39
40
|
const path = __importStar(require("path"));
|
|
40
41
|
const logger_1 = require("../utils/logger");
|
|
42
|
+
const cli_server_auth_1 = require("../auth/cli-server-auth");
|
|
43
|
+
const cli_shell_options_1 = require("./cli-shell-options");
|
|
44
|
+
const process_tree_1 = require("./process-tree");
|
|
41
45
|
const logger = (0, logger_1.createLogger)('ShellSession');
|
|
46
|
+
/** Longest delay `setTimeout` supports (~24.8 days); longer ones fire immediately. */
|
|
47
|
+
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
42
48
|
/**
|
|
43
49
|
* Lazy-loads node-pty to avoid import failures when the native add-on is unavailable.
|
|
44
50
|
* @throws {Error} If node-pty cannot be loaded.
|
|
@@ -56,16 +62,210 @@ function getPty() {
|
|
|
56
62
|
}
|
|
57
63
|
return _pty;
|
|
58
64
|
}
|
|
59
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Manages interactive PTY shell sessions over WebSocket connections and
|
|
67
|
+
* enforces Appendix C: access mode, session binding, limits, timeouts,
|
|
68
|
+
* minimal environment and audit.
|
|
69
|
+
*/
|
|
60
70
|
class CliShellSessionManager {
|
|
71
|
+
_options;
|
|
72
|
+
_enabled;
|
|
73
|
+
_auth;
|
|
74
|
+
_unsubscribe;
|
|
75
|
+
_sessions = new Set();
|
|
76
|
+
_nextId = 1;
|
|
77
|
+
_checkTimer;
|
|
78
|
+
_trustProxy;
|
|
79
|
+
constructor(options = {}) {
|
|
80
|
+
this._enabled = options.enabled ?? false;
|
|
81
|
+
this._options = options.shell ?? {};
|
|
82
|
+
this._trustProxy = options.trustProxy ?? false;
|
|
83
|
+
}
|
|
61
84
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* @param cols - Initial terminal width in columns.
|
|
65
|
-
* @param rows - Initial terminal height in rows.
|
|
66
|
-
* @param command - Optional command to execute instead of an interactive shell.
|
|
85
|
+
* Trusted reverse-proxy hops (`true` = 1): the remote address is the
|
|
86
|
+
* `X-Forwarded-For` entry at index `len - hops` (`false`/0: the socket address).
|
|
67
87
|
*/
|
|
68
|
-
|
|
88
|
+
setTrustProxy(trust) {
|
|
89
|
+
this._trustProxy = trust;
|
|
90
|
+
}
|
|
91
|
+
get enabled() {
|
|
92
|
+
return this._enabled;
|
|
93
|
+
}
|
|
94
|
+
setEnabled(enabled) {
|
|
95
|
+
this._enabled = enabled;
|
|
96
|
+
}
|
|
97
|
+
get options() {
|
|
98
|
+
return this._options;
|
|
99
|
+
}
|
|
100
|
+
setOptions(options) {
|
|
101
|
+
this._options = options ?? {};
|
|
102
|
+
}
|
|
103
|
+
/** Current access mode (`undefined` while the shell is disabled). */
|
|
104
|
+
get authMode() {
|
|
105
|
+
return this._enabled ? (0, cli_shell_options_1.resolveShellAuthMode)(this._options, !!this._auth) : undefined;
|
|
106
|
+
}
|
|
107
|
+
/** Installs the auth provider (users plugin); subscribes to its session events. */
|
|
108
|
+
setAuth(auth) {
|
|
109
|
+
if (auth === this._auth)
|
|
110
|
+
return;
|
|
111
|
+
this._unsubscribe?.();
|
|
112
|
+
this._unsubscribe = undefined;
|
|
113
|
+
this._auth = auth;
|
|
114
|
+
if (auth?.onSessionEvent) {
|
|
115
|
+
this._unsubscribe = auth.onSessionEvent((event) => this.onSessionEvent(event));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Logs the startup state: ERROR when locked, WARNING when anonymous. */
|
|
119
|
+
logStartupState() {
|
|
120
|
+
const mode = this.authMode;
|
|
121
|
+
if (mode === 'locked') {
|
|
122
|
+
logger.error('Shell is enabled but no access mode is configured, so it is LOCKED (every connection is refused). ' +
|
|
123
|
+
'Mount the users plugin (shell:execute permission), or set shell.token, shell.authorize, ' +
|
|
124
|
+
'or shell.allowAnonymous (not recommended).');
|
|
125
|
+
}
|
|
126
|
+
else if (mode === 'anonymous') {
|
|
127
|
+
logger.warn('WARNING: shell is enabled without authentication (shell.allowAnonymous). Anyone who can reach this server gets a shell.');
|
|
128
|
+
}
|
|
129
|
+
else if (mode) {
|
|
130
|
+
logger.info('Shell enabled (auth=%s)', mode);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Open sessions. */
|
|
134
|
+
getSessions() {
|
|
135
|
+
return [...this._sessions].map((s) => ({
|
|
136
|
+
id: s.id,
|
|
137
|
+
authMode: s.authMode,
|
|
138
|
+
mode: s.mode,
|
|
139
|
+
remoteAddress: s.remoteAddress,
|
|
140
|
+
userId: s.ctx?.userId,
|
|
141
|
+
userName: s.ctx?.userName,
|
|
142
|
+
sessionId: s.ctx?.sessionId,
|
|
143
|
+
startedAt: new Date(s.startedAt).toISOString(),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Takes over an accepted shell WebSocket: authorizes it (closing with
|
|
148
|
+
* 4401/4403/4404/4429 when refused), then bridges it to a PTY.
|
|
149
|
+
*/
|
|
150
|
+
async accept(ws, request, url) {
|
|
151
|
+
if (!this._enabled) {
|
|
152
|
+
ws.close(cli_shell_options_1.ShellCloseCodes.Disabled, 'shell disabled');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const cols = clampDim(url.searchParams.get('cols'), 80);
|
|
156
|
+
const rows = clampDim(url.searchParams.get('rows'), 24);
|
|
157
|
+
const command = url.searchParams.get('cmd') || undefined;
|
|
158
|
+
const mode = command ? 'command' : 'interactive';
|
|
159
|
+
const remoteAddress = this.remoteAddressOf(request);
|
|
160
|
+
const authMode = (0, cli_shell_options_1.resolveShellAuthMode)(this._options, !!this._auth);
|
|
161
|
+
let decision;
|
|
162
|
+
try {
|
|
163
|
+
decision = await this.authorize(authMode, request, url, remoteAddress, mode);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
decision = { ok: false, code: cli_shell_options_1.ShellCloseCodes.Forbidden, reason: 'forbidden' };
|
|
167
|
+
}
|
|
168
|
+
if (!decision.ok) {
|
|
169
|
+
closeWs(ws, decision.code, decision.reason);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (ws.readyState !== ws_1.WebSocket.OPEN)
|
|
173
|
+
return;
|
|
174
|
+
const limitKey = authMode === 'users' ? `user:${decision.ctx.userId}` : `addr:${remoteAddress}`;
|
|
175
|
+
const perUser = this._options.maxSessionsPerUser ?? cli_shell_options_1.SHELL_DEFAULTS.maxSessionsPerUser;
|
|
176
|
+
const total = this._options.maxSessions ?? cli_shell_options_1.SHELL_DEFAULTS.maxSessions;
|
|
177
|
+
let sameKey = 0;
|
|
178
|
+
for (const s of this._sessions)
|
|
179
|
+
if (s.limitKey === limitKey)
|
|
180
|
+
sameKey++;
|
|
181
|
+
if (sameKey >= perUser || this._sessions.size >= total) {
|
|
182
|
+
closeWs(ws, cli_shell_options_1.ShellCloseCodes.TooMany, 'too many shell sessions');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const session = {
|
|
187
|
+
id: `sh-${this._nextId++}`,
|
|
188
|
+
ws,
|
|
189
|
+
authMode,
|
|
190
|
+
mode,
|
|
191
|
+
remoteAddress,
|
|
192
|
+
limitKey,
|
|
193
|
+
ctx: decision.ctx,
|
|
194
|
+
request,
|
|
195
|
+
startedAt: now,
|
|
196
|
+
lastInput: now,
|
|
197
|
+
inputSinceCheck: false,
|
|
198
|
+
lastTouch: 0,
|
|
199
|
+
closed: false,
|
|
200
|
+
};
|
|
201
|
+
this._sessions.add(session);
|
|
202
|
+
this.ensureCheckTimer();
|
|
203
|
+
if (session.ctx) {
|
|
204
|
+
// A logout/revoke/permission change between ticket consumption (or a slow
|
|
205
|
+
// `authorize`) and the registration above emitted its event before this
|
|
206
|
+
// shell could hear it: revalidate once now, before anything is spawned.
|
|
207
|
+
await this.revalidateSession(session);
|
|
208
|
+
if (session.closed)
|
|
209
|
+
return;
|
|
210
|
+
if (ws.readyState !== ws_1.WebSocket.OPEN) {
|
|
211
|
+
this.end(session, undefined, undefined, 'closed');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
this.spawn(session, cols, rows, command);
|
|
216
|
+
}
|
|
217
|
+
/** Decides access for the given mode. */
|
|
218
|
+
async authorize(authMode, request, url, remoteAddress, mode) {
|
|
219
|
+
const o = this._options;
|
|
220
|
+
if (authMode === 'locked') {
|
|
221
|
+
return { ok: false, code: cli_shell_options_1.ShellCloseCodes.LoginRequired, reason: 'shell access is not configured' };
|
|
222
|
+
}
|
|
223
|
+
let ctx;
|
|
224
|
+
if (authMode === 'users') {
|
|
225
|
+
const ticket = url.searchParams.get('ticket');
|
|
226
|
+
try {
|
|
227
|
+
ctx = ticket ? await this._auth.consumeTicket(ticket) : undefined;
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
if (err?.status === 403) {
|
|
231
|
+
return { ok: false, code: cli_shell_options_1.ShellCloseCodes.Forbidden, reason: 'forbidden' };
|
|
232
|
+
}
|
|
233
|
+
ctx = undefined;
|
|
234
|
+
}
|
|
235
|
+
if (!ctx) {
|
|
236
|
+
return { ok: false, code: cli_shell_options_1.ShellCloseCodes.LoginRequired, reason: 'login required' };
|
|
237
|
+
}
|
|
238
|
+
if (!this._auth.hasPermission(ctx, cli_server_auth_1.CliServerPermissions.ShellExecute)) {
|
|
239
|
+
return { ok: false, code: cli_shell_options_1.ShellCloseCodes.Forbidden, reason: 'forbidden' };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Every configured check among token/authorize must pass, whatever the mode.
|
|
243
|
+
if (o.token) {
|
|
244
|
+
const presented = (0, cli_shell_options_1.readBearerToken)(request, url);
|
|
245
|
+
if (!presented) {
|
|
246
|
+
return { ok: false, code: cli_shell_options_1.ShellCloseCodes.LoginRequired, reason: 'login required' };
|
|
247
|
+
}
|
|
248
|
+
if (!(0, cli_shell_options_1.tokensEqual)(presented, o.token)) {
|
|
249
|
+
return { ok: false, code: cli_shell_options_1.ShellCloseCodes.Forbidden, reason: 'forbidden' };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (o.authorize) {
|
|
253
|
+
let allowed = false;
|
|
254
|
+
try {
|
|
255
|
+
allowed = (await o.authorize(request, { url, remoteAddress, mode })) === true;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
logger.warn('Shell authorize callback threw; denying (%s)', err?.message ?? 'error');
|
|
259
|
+
allowed = false;
|
|
260
|
+
}
|
|
261
|
+
if (!allowed) {
|
|
262
|
+
return { ok: false, code: cli_shell_options_1.ShellCloseCodes.Forbidden, reason: 'forbidden' };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, ctx };
|
|
266
|
+
}
|
|
267
|
+
spawn(session, cols, rows, command) {
|
|
268
|
+
const { ws } = session;
|
|
69
269
|
const { shell, args } = this.getShellInfo(command);
|
|
70
270
|
let nodePty;
|
|
71
271
|
try {
|
|
@@ -73,120 +273,261 @@ class CliShellSessionManager {
|
|
|
73
273
|
}
|
|
74
274
|
catch (err) {
|
|
75
275
|
logger.error('node-pty unavailable: %s', err.message ?? err);
|
|
76
|
-
this.send(ws, { type: 'error', message:
|
|
77
|
-
|
|
276
|
+
this.send(ws, { type: 'error', message: 'shell is not available on this server' });
|
|
277
|
+
this.end(session, 1011, 'shell unavailable', 'error');
|
|
78
278
|
return;
|
|
79
279
|
}
|
|
80
|
-
|
|
280
|
+
// Set after the allow/set filtering: configuration can neither override nor drop it.
|
|
281
|
+
session.shellId = (0, process_tree_1.newShellId)();
|
|
81
282
|
try {
|
|
82
|
-
|
|
283
|
+
session.pty = nodePty.spawn(shell, args, {
|
|
83
284
|
name: 'xterm-256color',
|
|
84
285
|
cols,
|
|
85
286
|
rows,
|
|
86
|
-
cwd: os.homedir(),
|
|
87
|
-
env: { ...
|
|
287
|
+
cwd: this._options.cwd ?? os.homedir(),
|
|
288
|
+
env: { ...(0, cli_shell_options_1.buildShellEnv)(this._options.env, { shell, cols, rows }), [process_tree_1.SHELL_ID_ENV]: session.shellId },
|
|
88
289
|
});
|
|
89
290
|
}
|
|
90
291
|
catch (err) {
|
|
91
|
-
logger.error('Shell
|
|
92
|
-
this.send(ws, { type: 'error', message:
|
|
93
|
-
|
|
292
|
+
logger.error('Shell spawn failed: %s', err.message ?? err);
|
|
293
|
+
this.send(ws, { type: 'error', message: 'failed to start shell' });
|
|
294
|
+
this.end(session, 1011, 'shell failed', 'error');
|
|
94
295
|
return;
|
|
95
296
|
}
|
|
96
|
-
|
|
97
|
-
const detectedOs = os.platform() === 'win32'
|
|
98
|
-
? 'win32'
|
|
99
|
-
: os.platform() === 'darwin'
|
|
100
|
-
? 'darwin'
|
|
101
|
-
: 'linux';
|
|
297
|
+
this.onOpened(session);
|
|
102
298
|
this.send(ws, {
|
|
103
299
|
type: 'ready',
|
|
104
300
|
shell: path.basename(shell),
|
|
105
|
-
os:
|
|
106
|
-
});
|
|
107
|
-
ptyProcess.onData((data) => {
|
|
108
|
-
this.send(ws, { type: 'stdout', data });
|
|
301
|
+
os: os.platform() === 'win32' ? 'win32' : os.platform() === 'darwin' ? 'darwin' : 'linux',
|
|
109
302
|
});
|
|
110
|
-
|
|
111
|
-
|
|
303
|
+
session.pty.onData((data) => this.send(ws, { type: 'stdout', data }));
|
|
304
|
+
session.pty.onExit(({ exitCode }) => {
|
|
305
|
+
if (session.closed)
|
|
306
|
+
return;
|
|
112
307
|
this.send(ws, { type: 'exit', code: exitCode });
|
|
113
|
-
|
|
114
|
-
ws.close();
|
|
115
|
-
}
|
|
308
|
+
this.end(session, 1000, 'exit', 'exited', exitCode);
|
|
116
309
|
});
|
|
117
310
|
ws.on('message', (raw) => {
|
|
311
|
+
let msg;
|
|
118
312
|
try {
|
|
119
|
-
|
|
120
|
-
switch (msg.type) {
|
|
121
|
-
case 'stdin':
|
|
122
|
-
ptyProcess.write(msg.data);
|
|
123
|
-
break;
|
|
124
|
-
case 'resize':
|
|
125
|
-
ptyProcess.resize(msg.cols, msg.rows);
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
313
|
+
msg = JSON.parse(raw.toString());
|
|
128
314
|
}
|
|
129
315
|
catch {
|
|
130
|
-
// Ignore malformed messages
|
|
131
316
|
logger.debug('Ignoring malformed WebSocket message');
|
|
317
|
+
return;
|
|
132
318
|
}
|
|
133
|
-
});
|
|
134
|
-
ws.on('close', () => {
|
|
135
319
|
try {
|
|
136
|
-
|
|
320
|
+
if (msg.type === 'stdin' && typeof msg.data === 'string') {
|
|
321
|
+
session.lastInput = Date.now();
|
|
322
|
+
session.inputSinceCheck = true;
|
|
323
|
+
session.pty?.write(msg.data);
|
|
324
|
+
}
|
|
325
|
+
else if (msg.type === 'resize') {
|
|
326
|
+
session.pty?.resize(clampDim(String(msg.cols), cols), clampDim(String(msg.rows), rows));
|
|
327
|
+
}
|
|
137
328
|
}
|
|
138
329
|
catch {
|
|
139
|
-
|
|
140
|
-
logger.debug('Process already exited, cleanup skipped');
|
|
330
|
+
logger.debug('Ignoring shell message after exit');
|
|
141
331
|
}
|
|
142
332
|
});
|
|
333
|
+
ws.on('close', () => this.end(session, undefined, undefined, 'closed'));
|
|
143
334
|
ws.on('error', (error) => {
|
|
144
|
-
logger.error('Shell
|
|
145
|
-
|
|
146
|
-
ptyProcess.kill();
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
// Already exited
|
|
150
|
-
logger.debug('Process already exited, cleanup skipped');
|
|
151
|
-
}
|
|
335
|
+
logger.error('Shell socket error: %s', error.message ?? error);
|
|
336
|
+
this.end(session, undefined, undefined, 'error');
|
|
152
337
|
});
|
|
338
|
+
this.armTimers(session);
|
|
153
339
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
require('fs').accessSync(envShell, require('fs').constants.X_OK);
|
|
173
|
-
return envShell;
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
// $SHELL binary not accessible; try common paths
|
|
177
|
-
}
|
|
340
|
+
/** Idle (no stdin) and absolute duration timers ⇒ 4408. */
|
|
341
|
+
armTimers(session) {
|
|
342
|
+
const idle = this._options.idleTimeoutMs ?? cli_shell_options_1.SHELL_DEFAULTS.idleTimeoutMs;
|
|
343
|
+
if (idle > 0) {
|
|
344
|
+
const check = () => {
|
|
345
|
+
if (session.closed)
|
|
346
|
+
return;
|
|
347
|
+
const remaining = session.lastInput + idle - Date.now();
|
|
348
|
+
if (remaining <= 0) {
|
|
349
|
+
this.end(session, cli_shell_options_1.ShellCloseCodes.TimedOut, 'shell timed out', 'shell timed out');
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
session.idleTimer = setTimeout(check, Math.min(remaining, MAX_TIMEOUT_MS));
|
|
353
|
+
session.idleTimer.unref?.();
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
session.idleTimer = setTimeout(check, Math.min(idle, MAX_TIMEOUT_MS));
|
|
357
|
+
session.idleTimer.unref?.();
|
|
178
358
|
}
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
359
|
+
const max = this._options.maxDurationMs;
|
|
360
|
+
if (max && max > 0) {
|
|
361
|
+
const deadline = Date.now() + max;
|
|
362
|
+
const expire = () => {
|
|
363
|
+
if (session.closed)
|
|
364
|
+
return;
|
|
365
|
+
const remaining = deadline - Date.now();
|
|
366
|
+
if (remaining <= 0) {
|
|
367
|
+
this.end(session, cli_shell_options_1.ShellCloseCodes.TimedOut, 'shell timed out', 'shell timed out');
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
session.durationTimer = setTimeout(expire, Math.min(remaining, MAX_TIMEOUT_MS));
|
|
371
|
+
session.durationTimer.unref?.();
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
session.durationTimer = setTimeout(expire, Math.min(max, MAX_TIMEOUT_MS));
|
|
375
|
+
session.durationTimer.unref?.();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// ----------------------------------------------------- session binding
|
|
379
|
+
onSessionEvent(event) {
|
|
380
|
+
if (event.type === 'session-ended') {
|
|
381
|
+
for (const s of [...this._sessions]) {
|
|
382
|
+
if (s.ctx?.sessionId && s.ctx.sessionId === event.sessionId) {
|
|
383
|
+
this.end(s, cli_shell_options_1.ShellCloseCodes.LoginRequired, 'session ended', 'session ended');
|
|
384
|
+
}
|
|
184
385
|
}
|
|
185
|
-
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
void this.recheck(event.userId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
ensureCheckTimer() {
|
|
392
|
+
if (this._checkTimer || !this._auth)
|
|
393
|
+
return;
|
|
394
|
+
const interval = Math.min(Math.max(this._options.sessionCheckIntervalMs ?? cli_shell_options_1.SHELL_DEFAULTS.sessionCheckIntervalMs, 10), cli_shell_options_1.SHELL_DEFAULTS.maxSessionCheckIntervalMs);
|
|
395
|
+
this._checkTimer = setInterval(() => void this.recheck(), interval);
|
|
396
|
+
this._checkTimer.unref?.();
|
|
397
|
+
}
|
|
398
|
+
stopCheckTimerIfIdle() {
|
|
399
|
+
if (this._checkTimer && ![...this._sessions].some((s) => s.ctx)) {
|
|
400
|
+
clearInterval(this._checkTimer);
|
|
401
|
+
this._checkTimer = undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Re-validates users-mode shells: session alive, user enabled, still holding
|
|
406
|
+
* `shell:execute` (identities from a host auth adapter are re-authenticated
|
|
407
|
+
* from the original upgrade request). Otherwise the shell is closed with 4401
|
|
408
|
+
* `session ended` (audit reason `session ended` or `permission removed`).
|
|
409
|
+
* The check itself never extends the session; stdin received since the
|
|
410
|
+
* previous check touches it first (at most once per 30 s).
|
|
411
|
+
*/
|
|
412
|
+
async recheck(userId) {
|
|
413
|
+
for (const s of [...this._sessions]) {
|
|
414
|
+
if (s.closed || !s.ctx || (userId && s.ctx.userId !== userId))
|
|
186
415
|
continue;
|
|
416
|
+
await this.revalidateSession(s);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** One re-check of a users-mode shell; ends it when the session ended or lost `shell:execute`. */
|
|
420
|
+
async revalidateSession(s) {
|
|
421
|
+
const auth = this._auth;
|
|
422
|
+
if (s.closed || !s.ctx)
|
|
423
|
+
return;
|
|
424
|
+
let fresh = s.ctx;
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
const typed = s.inputSinceCheck;
|
|
427
|
+
s.inputSinceCheck = false;
|
|
428
|
+
try {
|
|
429
|
+
if (typed && auth?.touch && now - s.lastTouch >= cli_shell_options_1.SHELL_DEFAULTS.touchIntervalMs) {
|
|
430
|
+
s.lastTouch = now;
|
|
431
|
+
await auth.touch(s.ctx);
|
|
187
432
|
}
|
|
433
|
+
fresh = auth?.revalidate ? await auth.revalidate(s.ctx, s.request) : s.ctx;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
fresh = undefined;
|
|
437
|
+
}
|
|
438
|
+
if (s.closed)
|
|
439
|
+
return;
|
|
440
|
+
if (!fresh || !auth || !auth.hasPermission(fresh, cli_server_auth_1.CliServerPermissions.ShellExecute)) {
|
|
441
|
+
this.end(s, cli_shell_options_1.ShellCloseCodes.LoginRequired, 'session ended', fresh ? 'permission removed' : 'session ended');
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
s.ctx = { ...s.ctx, permissions: fresh.permissions };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ------------------------------------------------------------ lifecycle
|
|
448
|
+
onOpened(session) {
|
|
449
|
+
if (session.authMode === 'users' && session.ctx) {
|
|
450
|
+
this.audit(session, 'shell_opened', {});
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
logger.info('Shell opened (id=%s, auth=%s, mode=%s, remote=%s)', session.id, session.authMode, session.mode, session.remoteAddress);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Ends a session once (`why` is the audit reason: exited | closed | session ended |
|
|
458
|
+
* permission removed | shell timed out | server shutdown | error): kills the process tree, closes the socket with the
|
|
459
|
+
* given code (when still open) and audits/logs the close.
|
|
460
|
+
*/
|
|
461
|
+
end(session, code, reason, why, exitCode) {
|
|
462
|
+
if (session.closed)
|
|
463
|
+
return;
|
|
464
|
+
session.closed = true;
|
|
465
|
+
this._sessions.delete(session);
|
|
466
|
+
if (session.idleTimer)
|
|
467
|
+
clearTimeout(session.idleTimer);
|
|
468
|
+
if (session.durationTimer)
|
|
469
|
+
clearTimeout(session.durationTimer);
|
|
470
|
+
this.stopCheckTimerIfIdle();
|
|
471
|
+
if (session.pty) {
|
|
472
|
+
killTree(session.pty, session.shellId);
|
|
473
|
+
}
|
|
474
|
+
if (code !== undefined) {
|
|
475
|
+
closeWs(session.ws, code, reason ?? '');
|
|
476
|
+
}
|
|
477
|
+
if (!session.pty)
|
|
478
|
+
return; // never opened
|
|
479
|
+
const durationMs = Date.now() - session.startedAt;
|
|
480
|
+
if (session.authMode === 'users' && session.ctx) {
|
|
481
|
+
this.audit(session, 'shell_closed', { durationMs, reason: why, ...(why === 'exited' ? { exitCode } : {}) });
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
logger.info('Shell closed (id=%s, auth=%s, mode=%s, remote=%s, reason=%s, durationMs=%d)', session.id, session.authMode, session.mode, session.remoteAddress, why, durationMs);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
audit(session, event, extra) {
|
|
488
|
+
const ctx = session.ctx;
|
|
489
|
+
const audit = this._auth?.audit;
|
|
490
|
+
if (!audit)
|
|
491
|
+
return;
|
|
492
|
+
audit
|
|
493
|
+
.call(this._auth, {
|
|
494
|
+
event,
|
|
495
|
+
userId: ctx.userId,
|
|
496
|
+
userName: ctx.userName,
|
|
497
|
+
actorId: ctx.userId,
|
|
498
|
+
actorName: ctx.userName,
|
|
499
|
+
metadata: {
|
|
500
|
+
mode: session.mode,
|
|
501
|
+
sessionId: ctx.sessionId,
|
|
502
|
+
remoteAddress: session.remoteAddress,
|
|
503
|
+
...extra,
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
.catch((err) => logger.error('Shell audit failed: %s', err?.message ?? err));
|
|
507
|
+
}
|
|
508
|
+
/** Closes every open shell (server shutdown). */
|
|
509
|
+
dispose() {
|
|
510
|
+
for (const s of [...this._sessions]) {
|
|
511
|
+
this.end(s, 1001, 'server shutdown', 'server shutdown');
|
|
512
|
+
}
|
|
513
|
+
if (this._checkTimer)
|
|
514
|
+
clearInterval(this._checkTimer);
|
|
515
|
+
this._checkTimer = undefined;
|
|
516
|
+
this._unsubscribe?.();
|
|
517
|
+
this._unsubscribe = undefined;
|
|
518
|
+
}
|
|
519
|
+
// -------------------------------------------------------------- helpers
|
|
520
|
+
remoteAddressOf(request) {
|
|
521
|
+
return (0, cli_server_auth_1.clientAddress)(request, this._trustProxy);
|
|
522
|
+
}
|
|
523
|
+
/** Determines the shell binary and arguments based on options, platform and optional command. */
|
|
524
|
+
getShellInfo(command) {
|
|
525
|
+
if (os.platform() === 'win32') {
|
|
526
|
+
const shell = this._options.shell ?? 'powershell.exe';
|
|
527
|
+
return command ? { shell, args: ['-Command', command] } : { shell, args: [] };
|
|
188
528
|
}
|
|
189
|
-
|
|
529
|
+
const shell = this._options.shell ?? detectShell();
|
|
530
|
+
return command ? { shell, args: ['-c', command] } : { shell, args: [] };
|
|
190
531
|
}
|
|
191
532
|
/** Sends a JSON message to the WebSocket if the connection is open. */
|
|
192
533
|
send(ws, msg) {
|
|
@@ -196,4 +537,52 @@ class CliShellSessionManager {
|
|
|
196
537
|
}
|
|
197
538
|
}
|
|
198
539
|
exports.CliShellSessionManager = CliShellSessionManager;
|
|
540
|
+
/** Detects the best available shell on Unix systems by checking $SHELL and common paths. */
|
|
541
|
+
function detectShell() {
|
|
542
|
+
const candidates = [process.env.SHELL, '/bin/bash', '/usr/bin/bash', '/bin/sh'].filter(Boolean);
|
|
543
|
+
for (const candidate of candidates) {
|
|
544
|
+
try {
|
|
545
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
546
|
+
return candidate;
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return '/bin/sh';
|
|
553
|
+
}
|
|
554
|
+
function clampDim(value, fallback) {
|
|
555
|
+
const n = parseInt(value ?? '', 10);
|
|
556
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
557
|
+
return fallback;
|
|
558
|
+
return Math.min(n, 1000);
|
|
559
|
+
}
|
|
560
|
+
function closeWs(ws, code, reason) {
|
|
561
|
+
if (ws.readyState === ws_1.WebSocket.OPEN || ws.readyState === ws_1.WebSocket.CONNECTING) {
|
|
562
|
+
try {
|
|
563
|
+
ws.close(code, reason);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
ws.terminate();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Kills the shell and everything it started. Unix: the descendant tree and every
|
|
572
|
+
* process carrying the shell's `QCLI_SHELL_ID` marker are collected before
|
|
573
|
+
* signalling (background jobs in their own process groups, `setsid`/`nohup`
|
|
574
|
+
* children, re-parented daemons), then HUP → TERM → (grace) → KILL.
|
|
575
|
+
*/
|
|
576
|
+
function killTree(pty, shellId) {
|
|
577
|
+
if (os.platform() !== 'win32') {
|
|
578
|
+
(0, process_tree_1.terminateProcessTree)(pty.pid, undefined, shellId);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
pty.kill();
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// already exited
|
|
586
|
+
}
|
|
587
|
+
}
|
|
199
588
|
//# sourceMappingURL=cli-shell-session-manager.js.map
|