@stacksjs/defaults 0.75.74 → 0.75.75
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/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +42 -7
- package/ai/skills/stacks-payments/SKILL.md +11 -0
- package/app/Actions/Dashboard/DashboardHomeAction.test.ts +33 -1
- package/app/Actions/Dashboard/DashboardHomeAction.ts +63 -25
- package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +3 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalCloseAction.ts +25 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalInputAction.ts +25 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalOpenAction.ts +39 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalResizeAction.ts +25 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalStreamAction.ts +36 -0
- package/app/Actions/Dashboard/Remote/remote-commands.ts +30 -1
- package/app/Actions/Dashboard/Remote/remote-routes.test.ts +4 -4
- package/app/Actions/Dashboard/Remote/remote-terminal.test.ts +463 -0
- package/app/Actions/Dashboard/Remote/remote-terminal.ts +399 -0
- package/app/Actions/Dashboard/Remote/ssh-runner.ts +82 -1
- package/app/Actions/Dashboard/Remote/terminal-sessions.ts +80 -0
- package/functions/dashboard-api.ts +13 -0
- package/functions/remote-terminal-element.ts +287 -0
- package/functions/remote.test.ts +52 -0
- package/functions/remote.ts +136 -0
- package/functions/xterm-styles.d.ts +9 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +5 -3
- package/resources/components/Dashboard/Remote/RemoteConsole.stx +162 -0
- package/resources/functions/dashboard/sidebar.ts +3 -0
- package/routes/dashboard-api.ts +7 -0
- package/views/dashboard/remote/index.stx +8 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type { UserModel } from '@stacksjs/orm'
|
|
2
|
+
import type { RemoteHost } from './remote-commands'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { RemoteCommandError, resolveHost } from './remote-commands'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Interactive terminal sessions into configured hosts (stacksjs/stacks#960).
|
|
8
|
+
*
|
|
9
|
+
* The command runner beside this runs named operations. This is the shell, so
|
|
10
|
+
* every rule the runner has holds here and three more are added:
|
|
11
|
+
*
|
|
12
|
+
* - **A host must opt in** with `terminal: true`. Declaring a host for named
|
|
13
|
+
* commands does not make it a host anyone may open a shell on.
|
|
14
|
+
* - **Its own gate**, `open-remote-terminal`, receiving the host key. Without
|
|
15
|
+
* it defined, every session is refused - the same fail-closed default as
|
|
16
|
+
* `run-remote-command`, and a separate ability because a terminal is every
|
|
17
|
+
* command at once.
|
|
18
|
+
* - **A session belongs to the user who opened it.** Its id is 128 random
|
|
19
|
+
* bits, and another user naming it gets the same 404 as an id that never
|
|
20
|
+
* existed, so neither guessing nor a leaked id reaches someone else's shell.
|
|
21
|
+
*
|
|
22
|
+
* And a session does not outlive its purpose: it ends after `idleTimeoutMs`
|
|
23
|
+
* without input, after `maxDurationMs` in all, and `detachGraceMs` after its
|
|
24
|
+
* last viewer goes away - the remote shell is killed, never left running on
|
|
25
|
+
* the box with nobody attached. Every session is recorded when it opens, what
|
|
26
|
+
* was typed into it line by line, and when and why it closed.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A running terminal process, local PTY or test double. */
|
|
30
|
+
export interface TerminalProcess {
|
|
31
|
+
write: (data: string) => void
|
|
32
|
+
resize: (cols: number, rows: number) => void
|
|
33
|
+
kill: () => void
|
|
34
|
+
/** Resolves with the exit code once the process has ended, however it ended. */
|
|
35
|
+
exited: Promise<number>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Starts the session's process. Injected so the rules are testable without a network. */
|
|
39
|
+
export type TerminalSpawner = (
|
|
40
|
+
host: RemoteHost,
|
|
41
|
+
size: TerminalSize,
|
|
42
|
+
onOutput: (bytes: Uint8Array) => void,
|
|
43
|
+
) => Promise<TerminalProcess> | TerminalProcess
|
|
44
|
+
|
|
45
|
+
export interface TerminalSize {
|
|
46
|
+
cols: number
|
|
47
|
+
rows: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TerminalCloseReason = 'closed' | 'exited' | 'idle' | 'max-duration' | 'detached' | 'shutdown'
|
|
51
|
+
|
|
52
|
+
/** Where sessions are recorded. Injected for the same reason. */
|
|
53
|
+
export interface TerminalAuditSink {
|
|
54
|
+
opened: (entry: { user: string, hostKey: string, session: string, at: string }) => Promise<void> | void
|
|
55
|
+
/** One line of input as typed, or what was pending when the session ended. */
|
|
56
|
+
input: (entry: { user: string, hostKey: string, session: string, at: string, line: string }) => Promise<void> | void
|
|
57
|
+
closed: (entry: {
|
|
58
|
+
user: string
|
|
59
|
+
hostKey: string
|
|
60
|
+
session: string
|
|
61
|
+
at: string
|
|
62
|
+
reason: TerminalCloseReason
|
|
63
|
+
exitCode: number | null
|
|
64
|
+
durationMs: number
|
|
65
|
+
bytesIn: number
|
|
66
|
+
bytesOut: number
|
|
67
|
+
}) => Promise<void> | void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether `user` may open a terminal on the host with this key. */
|
|
71
|
+
export type TerminalAuthorizer = (user: UserModel | null, hostKey: string) => Promise<boolean> | boolean
|
|
72
|
+
|
|
73
|
+
/** What a viewer receives: output in order, then the end. */
|
|
74
|
+
export type TerminalEvent =
|
|
75
|
+
| { type: 'output', seq: number, data: Uint8Array }
|
|
76
|
+
| { type: 'exit', reason: TerminalCloseReason, exitCode: number | null }
|
|
77
|
+
|
|
78
|
+
export interface TerminalSessionOptions {
|
|
79
|
+
spawn: TerminalSpawner
|
|
80
|
+
audit: TerminalAuditSink
|
|
81
|
+
/** End a session after this long without input. Default 15 minutes. */
|
|
82
|
+
idleTimeoutMs?: number
|
|
83
|
+
/** End a session this long after it opened, whatever is happening. Default 2 hours. */
|
|
84
|
+
maxDurationMs?: number
|
|
85
|
+
/** End a session this long after its last viewer left. Default 30 seconds. */
|
|
86
|
+
detachGraceMs?: number
|
|
87
|
+
/** Output kept for a viewer that reconnects. Default 256 KiB. */
|
|
88
|
+
replayBytes?: number
|
|
89
|
+
/** Concurrent sessions one user may hold. Default 4. */
|
|
90
|
+
maxSessionsPerUser?: number
|
|
91
|
+
/** Record what is typed. Default true. */
|
|
92
|
+
recordInput?: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The largest single input a request may carry: a paste, not a file upload. */
|
|
96
|
+
export const MAX_INPUT_CHARS = 64 * 1024
|
|
97
|
+
|
|
98
|
+
const DEFAULTS = {
|
|
99
|
+
idleTimeoutMs: 15 * 60_000,
|
|
100
|
+
maxDurationMs: 2 * 60 * 60_000,
|
|
101
|
+
detachGraceMs: 30_000,
|
|
102
|
+
replayBytes: 256 * 1024,
|
|
103
|
+
maxSessionsPerUser: 4,
|
|
104
|
+
recordInput: true,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface Session {
|
|
108
|
+
id: string
|
|
109
|
+
owner: string
|
|
110
|
+
identity: string
|
|
111
|
+
host: RemoteHost
|
|
112
|
+
process: TerminalProcess
|
|
113
|
+
openedAt: number
|
|
114
|
+
seq: number
|
|
115
|
+
backlog: Array<{ seq: number, data: Uint8Array }>
|
|
116
|
+
backlogBytes: number
|
|
117
|
+
viewers: Set<(event: TerminalEvent) => void>
|
|
118
|
+
pendingInput: string
|
|
119
|
+
bytesIn: number
|
|
120
|
+
bytesOut: number
|
|
121
|
+
ended: boolean
|
|
122
|
+
idleTimer?: ReturnType<typeof setTimeout>
|
|
123
|
+
maxTimer?: ReturnType<typeof setTimeout>
|
|
124
|
+
detachTimer?: ReturnType<typeof setTimeout>
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ownerOf(user: UserModel | null): string {
|
|
128
|
+
if (!user)
|
|
129
|
+
throw new RemoteCommandError('Authentication is required.', 401)
|
|
130
|
+
const id = (user as { id?: unknown }).id
|
|
131
|
+
if (id === undefined || id === null)
|
|
132
|
+
throw new RemoteCommandError('Authentication is required.', 401)
|
|
133
|
+
return String(id)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Columns and rows a real terminal could have; anything else is refused. */
|
|
137
|
+
export function normalizeSize(cols: unknown, rows: unknown): TerminalSize {
|
|
138
|
+
const c = Number(cols)
|
|
139
|
+
const r = Number(rows)
|
|
140
|
+
if (!Number.isInteger(c) || !Number.isInteger(r) || c < 2 || r < 2 || c > 1000 || r > 500)
|
|
141
|
+
throw new RemoteCommandError('A terminal size is whole columns and rows, 2 to 1000 by 2 to 500.', 422)
|
|
142
|
+
return { cols: c, rows: r }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class TerminalSessions {
|
|
146
|
+
private readonly sessions = new Map<string, Session>()
|
|
147
|
+
private readonly options: Required<TerminalSessionOptions>
|
|
148
|
+
|
|
149
|
+
constructor(options: TerminalSessionOptions) {
|
|
150
|
+
this.options = { ...DEFAULTS, ...options }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Sessions open now, for tests and shutdown. */
|
|
154
|
+
get size(): number {
|
|
155
|
+
return this.sessions.size
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve, authorize, record, spawn. The audit entry is written before the
|
|
160
|
+
* process starts, so a session that hangs on connect is still on record.
|
|
161
|
+
*/
|
|
162
|
+
async open(input: {
|
|
163
|
+
user: UserModel | null
|
|
164
|
+
hosts: readonly RemoteHost[]
|
|
165
|
+
hostKey: unknown
|
|
166
|
+
authorizer?: TerminalAuthorizer
|
|
167
|
+
cols: unknown
|
|
168
|
+
rows: unknown
|
|
169
|
+
}): Promise<{ id: string, host: string }> {
|
|
170
|
+
const owner = ownerOf(input.user)
|
|
171
|
+
const host = resolveHost(input.hosts, input.hostKey)
|
|
172
|
+
|
|
173
|
+
if (!host.terminal)
|
|
174
|
+
throw new RemoteCommandError(`Host "${host.key}" does not allow terminal sessions; set \`terminal: true\` on it in config/remote.ts.`, 403)
|
|
175
|
+
|
|
176
|
+
// Fails CLOSED, as `run-remote-command` does.
|
|
177
|
+
if (!input.authorizer)
|
|
178
|
+
throw new RemoteCommandError('Terminal sessions are not authorized on this application.', 403)
|
|
179
|
+
if (!await input.authorizer(input.user, host.key))
|
|
180
|
+
throw new RemoteCommandError(`You are not permitted to open a terminal on "${host.key}".`, 403)
|
|
181
|
+
|
|
182
|
+
const size = normalizeSize(input.cols, input.rows)
|
|
183
|
+
const held = [...this.sessions.values()].filter(session => session.owner === owner).length
|
|
184
|
+
if (held >= this.options.maxSessionsPerUser)
|
|
185
|
+
throw new RemoteCommandError(`You already have ${held} terminal sessions open; close one first.`, 429)
|
|
186
|
+
|
|
187
|
+
const id = randomBytes(16).toString('hex')
|
|
188
|
+
const identity = String((input.user as { email?: unknown }).email ?? owner)
|
|
189
|
+
await this.options.audit.opened({ user: identity, hostKey: host.key, session: id, at: new Date().toISOString() })
|
|
190
|
+
|
|
191
|
+
const session = {
|
|
192
|
+
id,
|
|
193
|
+
owner,
|
|
194
|
+
identity,
|
|
195
|
+
host,
|
|
196
|
+
openedAt: Date.now(),
|
|
197
|
+
seq: 0,
|
|
198
|
+
backlog: [],
|
|
199
|
+
backlogBytes: 0,
|
|
200
|
+
viewers: new Set(),
|
|
201
|
+
pendingInput: '',
|
|
202
|
+
bytesIn: 0,
|
|
203
|
+
bytesOut: 0,
|
|
204
|
+
ended: false,
|
|
205
|
+
} as unknown as Session
|
|
206
|
+
|
|
207
|
+
let process: TerminalProcess
|
|
208
|
+
try {
|
|
209
|
+
process = await this.options.spawn(host, size, bytes => this.output(session, bytes))
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
await this.options.audit.closed({
|
|
213
|
+
user: identity,
|
|
214
|
+
hostKey: host.key,
|
|
215
|
+
session: id,
|
|
216
|
+
at: new Date().toISOString(),
|
|
217
|
+
reason: 'exited',
|
|
218
|
+
exitCode: null,
|
|
219
|
+
durationMs: Date.now() - session.openedAt,
|
|
220
|
+
bytesIn: 0,
|
|
221
|
+
bytesOut: 0,
|
|
222
|
+
})
|
|
223
|
+
throw error
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
session.process = process
|
|
227
|
+
this.sessions.set(id, session)
|
|
228
|
+
|
|
229
|
+
session.maxTimer = setTimeout(() => void this.end(session, 'max-duration'), this.options.maxDurationMs)
|
|
230
|
+
this.touch(session)
|
|
231
|
+
// Nobody is watching yet: if no viewer attaches, the shell does not stay.
|
|
232
|
+
this.armDetach(session)
|
|
233
|
+
|
|
234
|
+
void process.exited.then(code => this.end(session, 'exited', code))
|
|
235
|
+
|
|
236
|
+
return { id, host: host.key }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Type into the session. */
|
|
240
|
+
async input(id: string, user: UserModel | null, data: unknown): Promise<void> {
|
|
241
|
+
const session = this.find(id, user)
|
|
242
|
+
if (typeof data !== 'string' || data.length === 0)
|
|
243
|
+
throw new RemoteCommandError('Input is a non-empty string.', 422)
|
|
244
|
+
if (data.length > MAX_INPUT_CHARS)
|
|
245
|
+
throw new RemoteCommandError(`Input is at most ${MAX_INPUT_CHARS} characters at a time.`, 413)
|
|
246
|
+
|
|
247
|
+
session.process.write(data)
|
|
248
|
+
session.bytesIn += Buffer.byteLength(data)
|
|
249
|
+
this.touch(session)
|
|
250
|
+
if (this.options.recordInput)
|
|
251
|
+
await this.recordInput(session, data)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
resize(id: string, user: UserModel | null, cols: unknown, rows: unknown): void {
|
|
255
|
+
const session = this.find(id, user)
|
|
256
|
+
const size = normalizeSize(cols, rows)
|
|
257
|
+
session.process.resize(size.cols, size.rows)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async close(id: string, user: UserModel | null): Promise<void> {
|
|
261
|
+
await this.end(this.find(id, user), 'closed')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** End every session, for a server shutting down. */
|
|
265
|
+
async closeAll(): Promise<void> {
|
|
266
|
+
await Promise.all([...this.sessions.values()].map(session => this.end(session, 'shutdown')))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Output from `afterSeq` on, then live output until the session ends or the
|
|
271
|
+
* viewer stops reading. A viewer that reconnects passes the last sequence it
|
|
272
|
+
* saw and misses nothing still in the replay buffer.
|
|
273
|
+
*/
|
|
274
|
+
watch(id: string, user: UserModel | null, afterSeq = 0): AsyncIterable<TerminalEvent> {
|
|
275
|
+
const session = this.find(id, user)
|
|
276
|
+
const queue: TerminalEvent[] = session.backlog
|
|
277
|
+
.filter(chunk => chunk.seq > afterSeq)
|
|
278
|
+
.map(chunk => ({ type: 'output' as const, seq: chunk.seq, data: chunk.data }))
|
|
279
|
+
let wake: (() => void) | undefined
|
|
280
|
+
let done = false
|
|
281
|
+
|
|
282
|
+
const viewer = (event: TerminalEvent): void => {
|
|
283
|
+
queue.push(event)
|
|
284
|
+
wake?.()
|
|
285
|
+
}
|
|
286
|
+
session.viewers.add(viewer)
|
|
287
|
+
if (session.detachTimer) {
|
|
288
|
+
clearTimeout(session.detachTimer)
|
|
289
|
+
session.detachTimer = undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const detach = (): void => {
|
|
293
|
+
if (done)
|
|
294
|
+
return
|
|
295
|
+
done = true
|
|
296
|
+
session.viewers.delete(viewer)
|
|
297
|
+
this.armDetach(session)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
[Symbol.asyncIterator]: () => ({
|
|
302
|
+
next: async (): Promise<IteratorResult<TerminalEvent>> => {
|
|
303
|
+
while (queue.length === 0) {
|
|
304
|
+
if (done)
|
|
305
|
+
return { value: undefined, done: true }
|
|
306
|
+
await new Promise<void>((resolve) => { wake = resolve })
|
|
307
|
+
wake = undefined
|
|
308
|
+
}
|
|
309
|
+
const event = queue.shift()!
|
|
310
|
+
if (event.type === 'exit')
|
|
311
|
+
detach()
|
|
312
|
+
return { value: event, done: false }
|
|
313
|
+
},
|
|
314
|
+
return: async (): Promise<IteratorResult<TerminalEvent>> => {
|
|
315
|
+
detach()
|
|
316
|
+
wake?.()
|
|
317
|
+
return { value: undefined, done: true }
|
|
318
|
+
},
|
|
319
|
+
}),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The session with this id, if this user owns it. Anyone else gets the 404 a missing id gets. */
|
|
324
|
+
private find(id: unknown, user: UserModel | null): Session {
|
|
325
|
+
const owner = ownerOf(user)
|
|
326
|
+
const session = typeof id === 'string' ? this.sessions.get(id) : undefined
|
|
327
|
+
if (!session || session.owner !== owner || session.ended)
|
|
328
|
+
throw new RemoteCommandError('No such terminal session.', 404)
|
|
329
|
+
return session
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private output(session: Session, data: Uint8Array): void {
|
|
333
|
+
if (session.ended)
|
|
334
|
+
return
|
|
335
|
+
const seq = ++session.seq
|
|
336
|
+
session.bytesOut += data.byteLength
|
|
337
|
+
session.backlog.push({ seq, data })
|
|
338
|
+
session.backlogBytes += data.byteLength
|
|
339
|
+
while (session.backlogBytes > this.options.replayBytes && session.backlog.length > 1)
|
|
340
|
+
session.backlogBytes -= session.backlog.shift()!.data.byteLength
|
|
341
|
+
for (const viewer of session.viewers)
|
|
342
|
+
viewer({ type: 'output', seq, data })
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private touch(session: Session): void {
|
|
346
|
+
if (session.idleTimer)
|
|
347
|
+
clearTimeout(session.idleTimer)
|
|
348
|
+
session.idleTimer = setTimeout(() => void this.end(session, 'idle'), this.options.idleTimeoutMs)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private armDetach(session: Session): void {
|
|
352
|
+
if (session.ended || session.viewers.size > 0 || session.detachTimer)
|
|
353
|
+
return
|
|
354
|
+
session.detachTimer = setTimeout(() => void this.end(session, 'detached'), this.options.detachGraceMs)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Lines are recorded as they are completed; a partial line waits for its end. */
|
|
358
|
+
private async recordInput(session: Session, data: string): Promise<void> {
|
|
359
|
+
session.pendingInput += data
|
|
360
|
+
const lines = session.pendingInput.split(/\r\n|\r|\n/)
|
|
361
|
+
session.pendingInput = lines.pop() ?? ''
|
|
362
|
+
for (const line of lines)
|
|
363
|
+
await this.options.audit.input({ user: session.identity, hostKey: session.host.key, session: session.id, at: new Date().toISOString(), line })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private async end(session: Session, reason: TerminalCloseReason, exitCode: number | null = null): Promise<void> {
|
|
367
|
+
if (session.ended)
|
|
368
|
+
return
|
|
369
|
+
session.ended = true
|
|
370
|
+
this.sessions.delete(session.id)
|
|
371
|
+
for (const timer of [session.idleTimer, session.maxTimer, session.detachTimer]) {
|
|
372
|
+
if (timer)
|
|
373
|
+
clearTimeout(timer)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (reason !== 'exited')
|
|
377
|
+
session.process.kill()
|
|
378
|
+
|
|
379
|
+
if (this.options.recordInput && session.pendingInput) {
|
|
380
|
+
await this.options.audit.input({ user: session.identity, hostKey: session.host.key, session: session.id, at: new Date().toISOString(), line: session.pendingInput })
|
|
381
|
+
session.pendingInput = ''
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const viewer of session.viewers)
|
|
385
|
+
viewer({ type: 'exit', reason, exitCode })
|
|
386
|
+
|
|
387
|
+
await this.options.audit.closed({
|
|
388
|
+
user: session.identity,
|
|
389
|
+
hostKey: session.host.key,
|
|
390
|
+
session: session.id,
|
|
391
|
+
at: new Date().toISOString(),
|
|
392
|
+
reason,
|
|
393
|
+
exitCode,
|
|
394
|
+
durationMs: Date.now() - session.openedAt,
|
|
395
|
+
bytesIn: session.bytesIn,
|
|
396
|
+
bytesOut: session.bytesOut,
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { RemoteAuditSink, RemoteCommand, RemoteHost, RemoteRunner } from './remote-commands'
|
|
2
|
+
import type { TerminalAuditSink, TerminalSpawner } from './remote-terminal'
|
|
2
3
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
4
|
import { tmpdir } from 'node:os'
|
|
4
5
|
import { join } from 'node:path'
|
|
5
6
|
import { log } from '@stacksjs/logging'
|
|
6
|
-
import { sshArgv } from './remote-commands'
|
|
7
|
+
import { sshArgv, sshTerminalArgv } from './remote-commands'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The SSH transport and audit sink behind `runRemoteCommand`
|
|
@@ -92,3 +93,83 @@ export const loggingAuditSink: RemoteAuditSink = {
|
|
|
92
93
|
await log.info(`[remote] ${entry.user} finished ${entry.commandKey} on ${entry.hostKey}: ${outcome} in ${entry.durationMs}ms`)
|
|
93
94
|
},
|
|
94
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Terminal sessions as a local PTY running `ssh -tt` (stacksjs/stacks#960).
|
|
99
|
+
*
|
|
100
|
+
* The local PTY is what the command runner could not have: Bun's spawn gained
|
|
101
|
+
* one (`terminal`), and with it a window resize in the browser reaches the
|
|
102
|
+
* remote shell - `resize()` here changes the local PTY's size, ssh sees the
|
|
103
|
+
* SIGWINCH and forwards it. Without it the remote side stayed 80x24.
|
|
104
|
+
*
|
|
105
|
+
* The child gets a minimal environment rather than the app server's, which
|
|
106
|
+
* holds the application's secrets: ssh needs a PATH, a HOME for its own files,
|
|
107
|
+
* and a TERM to forward.
|
|
108
|
+
*
|
|
109
|
+
* `argvFor` is the seam a test uses to run a local command under the same PTY.
|
|
110
|
+
*/
|
|
111
|
+
export function createPtySpawner(argvFor: (host: RemoteHost, knownHostsPath: string) => string[] = sshTerminalArgv): TerminalSpawner {
|
|
112
|
+
return (host, size, onOutput) => {
|
|
113
|
+
const dir = mkdtempSync(join(tmpdir(), 'stacks-terminal-'))
|
|
114
|
+
const knownHostsPath = join(dir, 'known_hosts')
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
writeFileSync(knownHostsPath, host.knownHosts.endsWith('\n') ? host.knownHosts : `${host.knownHosts}\n`)
|
|
118
|
+
chmodSync(knownHostsPath, 0o600)
|
|
119
|
+
|
|
120
|
+
const proc = Bun.spawn(argvFor(host, knownHostsPath), {
|
|
121
|
+
env: {
|
|
122
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
123
|
+
HOME: process.env.HOME ?? tmpdir(),
|
|
124
|
+
TERM: 'xterm-256color',
|
|
125
|
+
LANG: process.env.LANG ?? 'en_US.UTF-8',
|
|
126
|
+
},
|
|
127
|
+
terminal: {
|
|
128
|
+
cols: size.cols,
|
|
129
|
+
rows: size.rows,
|
|
130
|
+
// The buffer is the PTY's own and is reused; a viewer may read the
|
|
131
|
+
// chunk after the next one has arrived.
|
|
132
|
+
data: (_terminal, bytes) => onOutput(new Uint8Array(bytes)),
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
const terminal = proc.terminal!
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
write: data => terminal.write(data),
|
|
139
|
+
resize: (cols, rows) => terminal.resize(cols, rows),
|
|
140
|
+
kill: () => proc.kill(),
|
|
141
|
+
exited: proc.exited.finally(() => {
|
|
142
|
+
terminal.close()
|
|
143
|
+
rmSync(dir, { force: true, recursive: true })
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
rmSync(dir, { force: true, recursive: true })
|
|
149
|
+
throw error
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Terminal sessions recorded in the application log, for the reasons the
|
|
156
|
+
* command runner's sink gives: append-only evidence belongs outside the
|
|
157
|
+
* database the dashboard can edit.
|
|
158
|
+
*
|
|
159
|
+
* Input is recorded as typed, line by line. That includes anything typed at a
|
|
160
|
+
* password prompt, which is one more reason the hosts reached this way should
|
|
161
|
+
* authenticate with keys and grant sudo without one - and why the log, not the
|
|
162
|
+
* database, holds it.
|
|
163
|
+
*/
|
|
164
|
+
export const loggingTerminalAuditSink: TerminalAuditSink = {
|
|
165
|
+
async opened(entry) {
|
|
166
|
+
await log.info(`[terminal] ${entry.user} opened ${entry.session} on ${entry.hostKey} at ${entry.at}`)
|
|
167
|
+
},
|
|
168
|
+
async input(entry) {
|
|
169
|
+
await log.info(`[terminal] ${entry.user} ${entry.session} on ${entry.hostKey}: ${JSON.stringify(entry.line)}`)
|
|
170
|
+
},
|
|
171
|
+
async closed(entry) {
|
|
172
|
+
const code = entry.exitCode === null ? '' : `, exit ${entry.exitCode}`
|
|
173
|
+
await log.info(`[terminal] ${entry.user} closed ${entry.session} on ${entry.hostKey}: ${entry.reason}${code} after ${entry.durationMs}ms, ${entry.bytesIn} bytes in, ${entry.bytesOut} out`)
|
|
174
|
+
},
|
|
175
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { TerminalAuthorizer, TerminalEvent } from './remote-terminal'
|
|
2
|
+
import { Gate } from '@stacksjs/auth'
|
|
3
|
+
import { TerminalSessions } from './remote-terminal'
|
|
4
|
+
import { createPtySpawner, loggingTerminalAuditSink } from './ssh-runner'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The server's terminal sessions (stacksjs/stacks#960), and how they reach the
|
|
8
|
+
* browser.
|
|
9
|
+
*
|
|
10
|
+
* Sessions live in this process. Production runs one server process, so the
|
|
11
|
+
* stream and the input for a session land on the same one; during the
|
|
12
|
+
* overlap of a zero-downtime deploy a request can reach the other release,
|
|
13
|
+
* which answers 404 and the dashboard reports the session as ended - closed,
|
|
14
|
+
* not crossed.
|
|
15
|
+
*/
|
|
16
|
+
export const terminalSessions: TerminalSessions = new TerminalSessions({
|
|
17
|
+
spawn: createPtySpawner(),
|
|
18
|
+
audit: loggingTerminalAuditSink,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `open-remote-terminal` gate, or undefined when the app has not defined
|
|
23
|
+
* it - and `TerminalSessions.open` refuses in that case rather than proceeding.
|
|
24
|
+
*/
|
|
25
|
+
export function terminalAuthorizer(): TerminalAuthorizer | undefined {
|
|
26
|
+
return Gate.has('open-remote-terminal')
|
|
27
|
+
? (user, hostKey) => Gate.allows('open-remote-terminal', user, hostKey)
|
|
28
|
+
: undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How often a quiet stream says it is still there, inside every proxy's idle limit. */
|
|
32
|
+
export const HEARTBEAT_MS = 15_000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One terminal event as server-sent events. Output is base64, because PTY
|
|
36
|
+
* output is bytes - a multi-byte character can be split across two chunks,
|
|
37
|
+
* and decoding each chunk as text on its own would corrupt it. The `id` is the
|
|
38
|
+
* sequence number a reconnecting viewer sends back.
|
|
39
|
+
*/
|
|
40
|
+
export function encodeTerminalEvent(event: TerminalEvent): string {
|
|
41
|
+
if (event.type === 'output')
|
|
42
|
+
return `id: ${event.seq}\nevent: output\ndata: ${Buffer.from(event.data).toString('base64')}\n\n`
|
|
43
|
+
return `event: exit\ndata: ${JSON.stringify({ reason: event.reason, exitCode: event.exitCode })}\n\n`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A session's events as an SSE body, with a comment line every
|
|
48
|
+
* {@link HEARTBEAT_MS} so neither Bun's idle timeout nor a proxy closes a
|
|
49
|
+
* terminal nobody is typing in. Cancelling the body - the tab closing - stops
|
|
50
|
+
* watching, which starts the session's detach grace.
|
|
51
|
+
*/
|
|
52
|
+
export function terminalEventStream(events: AsyncIterable<TerminalEvent>, heartbeatMs = HEARTBEAT_MS): ReadableStream<Uint8Array> {
|
|
53
|
+
const encoder = new TextEncoder()
|
|
54
|
+
const iterator = events[Symbol.asyncIterator]()
|
|
55
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
56
|
+
|
|
57
|
+
return new ReadableStream<Uint8Array>({
|
|
58
|
+
start(controller) {
|
|
59
|
+
controller.enqueue(encoder.encode(': connected\n\n'))
|
|
60
|
+
heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), heartbeatMs)
|
|
61
|
+
},
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
const { value, done } = await iterator.next()
|
|
64
|
+
if (done) {
|
|
65
|
+
clearInterval(heartbeat)
|
|
66
|
+
controller.close()
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
controller.enqueue(encoder.encode(encodeTerminalEvent(value)))
|
|
70
|
+
if (value.type === 'exit') {
|
|
71
|
+
clearInterval(heartbeat)
|
|
72
|
+
controller.close()
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
async cancel() {
|
|
76
|
+
clearInterval(heartbeat)
|
|
77
|
+
await iterator.return?.()
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
@@ -110,6 +110,19 @@ export async function dashboardApi<T = unknown>(path: string, options: ApiReques
|
|
|
110
110
|
return payload as T
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Open a guarded dashboard stream - server-sent events read through `fetch`,
|
|
115
|
+
* because `EventSource` cannot send the bearer token the dashboard
|
|
116
|
+
* authenticates with. Throws on a non-2xx response, as `dashboardApi` does;
|
|
117
|
+
* the caller reads `res.body`.
|
|
118
|
+
*/
|
|
119
|
+
export async function dashboardStream(path: string, options: Pick<ApiRequestOptions, 'signal'> = {}): Promise<Response> {
|
|
120
|
+
const res = await dashboardResponse(path, options, 'text/event-stream')
|
|
121
|
+
if (!res.ok)
|
|
122
|
+
throw await dashboardResponseError(res)
|
|
123
|
+
return res
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
/**
|
|
114
127
|
* Download a guarded dashboard resource while preserving bearer and session
|
|
115
128
|
* authentication. The object URL only exists for the duration of the browser
|