@stacksjs/defaults 0.74.34 → 0.74.36

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.
@@ -112,6 +112,36 @@ Derivatives are written back to the same disk under `.variants/<path>/`. The
112
112
  leading dot keeps them out of the listing, which skips hidden components - a
113
113
  folder of thirty derivatives beside every photo makes the browser useless.
114
114
 
115
+ ### Remote commands (stacksjs/stacks#960)
116
+
117
+ Running a configured operation on a configured host over SSH. Deliberately NOT
118
+ a terminal: the request names a host KEY and a command KEY, both from
119
+ `config/remote.ts`, so there is nothing to escape and no shell to reach. An
120
+ interactive session is tracked separately - `Bun.spawn` has no PTY, and
121
+ `ssh -tt` gives a remote one but cannot propagate a window resize.
122
+
123
+ Four things make it safe to expose, and each is a rule to keep:
124
+
125
+ - **Host keys are verified.** `StrictHostKeyChecking=yes` against the host's
126
+ declared `knownHosts`. Do NOT reuse `sshExec` from `@stacksjs/ts-cloud` for
127
+ anything long-lived: it disables host key checking on purpose, for boxes a
128
+ minute old whose keys cannot be known.
129
+ - **Hosts and commands come from config, never the request.** A `RemoteCommand`
130
+ carries an `argv` ARRAY that is never interpolated.
131
+ - **The routes do NOT use `guard()`.** That helper drops auth entirely under
132
+ `APP_ENV=local|development|test`, which here would be an unauthenticated
133
+ command runner on any dev machine on the network. They use
134
+ `authenticatedGuard`, and `remote-routes.test.ts` asserts it.
135
+ - **Authorization fails CLOSED.** The `run-remote-command` gate receives the
136
+ host and command keys; with no gate defined, every run is refused. This is the
137
+ opposite of the websocket authenticator in `@stacksjs/realtime`, which
138
+ proceeds when none is installed.
139
+
140
+ Runs are recorded before AND after - a run recorded only on completion loses the
141
+ command that hung and the one whose process died with the box. The audit sink
142
+ writes to the application log rather than the dashboard's own database, which is
143
+ the thing an operator with dashboard access could edit.
144
+
115
145
  **There is no ffmpeg.** #2578 asked whether video was in scope given the
116
146
  external binary, its licensing and its provisioning; `@stacksjs/video` is built
117
147
  on `ts-videos`, which encodes itself, so that question was already answered.
@@ -0,0 +1,29 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { commands, hosts } from '~/config/remote'
3
+ import { response } from '@stacksjs/router'
4
+
5
+ export default new Action({
6
+ name: 'RemoteCommandIndexAction',
7
+ description: 'Lists the hosts and operations the dashboard is configured to run.',
8
+ method: 'GET',
9
+ async handle() {
10
+ // The registry, not the credentials. `identityFile` and `knownHosts` are
11
+ // deliberately not returned: a listing endpoint should not disclose which
12
+ // key file a server uses or the fingerprint an attacker would need to
13
+ // impersonate.
14
+ return response.json({
15
+ hosts: hosts.map(host => ({
16
+ key: host.key,
17
+ host: host.host,
18
+ user: host.user,
19
+ port: host.port ?? 22,
20
+ })),
21
+ commands: commands.map(command => ({
22
+ key: command.key,
23
+ description: command.description,
24
+ argv: command.argv,
25
+ hosts: command.hosts ?? null,
26
+ })),
27
+ })
28
+ },
29
+ })
@@ -0,0 +1,57 @@
1
+ import type { UserModel } from '@stacksjs/orm'
2
+ import type { RequestInstance } from '@stacksjs/types'
3
+ import { Action } from '@stacksjs/actions'
4
+ import { Gate } from '@stacksjs/auth'
5
+ import { commands, hosts } from '~/config/remote'
6
+ import { response } from '@stacksjs/router'
7
+ import { RemoteCommandError, resolveCommand, resolveHost, runRemoteCommand } from './remote-commands'
8
+ import { createSshRunner, loggingAuditSink } from './ssh-runner'
9
+
10
+ /**
11
+ * Run one configured operation on one configured host (stacksjs/stacks#960).
12
+ *
13
+ * Routed WITHOUT the dashboard's `guard()` helper. That helper drops auth
14
+ * entirely when `APP_ENV` is local, development or test, which for this surface
15
+ * would be an unauthenticated command runner on every developer machine
16
+ * reachable on the network. See `routes/dashboard-api.ts`.
17
+ */
18
+ export default new Action({
19
+ name: 'RemoteCommandRunAction',
20
+ description: 'Runs a configured operation on a configured host over SSH.',
21
+ method: 'POST',
22
+ async handle(request: RequestInstance) {
23
+ try {
24
+ // `request.user()` answers `AuthenticatedUser | undefined`; the gate and
25
+ // the audit identity both want the model or an explicit null.
26
+ const user = (await request.user() ?? null) as UserModel | null
27
+ const hostKey = request.get('host')
28
+ const commandKey = request.get('command')
29
+
30
+ // Resolved before authorization so the gate is asked about real keys
31
+ // rather than whatever the request said.
32
+ const host = resolveHost(hosts, hostKey)
33
+ const command = resolveCommand(commands, commandKey, host)
34
+
35
+ const result = await runRemoteCommand({ hostKey, commandKey }, {
36
+ user,
37
+ hosts,
38
+ commands,
39
+ // The gate receives both keys, so an application can scope by host, by
40
+ // command, or by both. Undefined when the app has not defined it, and
41
+ // `authorize` refuses in that case rather than proceeding.
42
+ authorizer: Gate.has('run-remote-command')
43
+ ? (candidate, forHost, forCommand) => Gate.allows('run-remote-command', candidate, forHost, forCommand)
44
+ : undefined,
45
+ run: createSshRunner(host, command),
46
+ audit: loggingAuditSink,
47
+ })
48
+
49
+ return response.json(result)
50
+ }
51
+ catch (error) {
52
+ if (error instanceof RemoteCommandError)
53
+ return response.json({ message: error.message }, error.status)
54
+ throw error
55
+ }
56
+ },
57
+ })
@@ -0,0 +1,272 @@
1
+ // Running a known operation on a configured host (stacksjs/stacks#960).
2
+ //
3
+ // What is tested here is the security model, not SSH. The transport is
4
+ // injected, so every rule below is asserted without a network: which hosts can
5
+ // be reached, who may reach them, what is recorded, and what happens when any
6
+ // of that is not configured.
7
+ //
8
+ // The properties worth stating up front, because each is a way this surface
9
+ // becomes remote code execution if it is wrong:
10
+ //
11
+ // - a request cannot name a host, only a key in the registry
12
+ // - a request cannot name a command, only a key in the registry
13
+ // - a host without a pinned key is refused rather than trusted
14
+ // - authorization fails CLOSED when no gate is configured
15
+ // - a run is recorded before it starts, not only when it finishes
16
+
17
+ import type { UserModel } from '@stacksjs/orm'
18
+ import type { RemoteAuditSink, RemoteCommand, RemoteHost } from './remote-commands'
19
+ import { beforeEach, describe, expect, it } from 'bun:test'
20
+ import {
21
+ authorize,
22
+ clampOutput,
23
+ normalizeTimeout,
24
+ RemoteCommandError,
25
+ resolveCommand,
26
+ resolveHost,
27
+ runRemoteCommand,
28
+ sshArgv,
29
+ } from './remote-commands'
30
+
31
+ const host: RemoteHost = {
32
+ key: 'app',
33
+ host: 'app.example.com',
34
+ user: 'deploy',
35
+ knownHosts: 'app.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample',
36
+ }
37
+
38
+ const hosts: RemoteHost[] = [
39
+ host,
40
+ { key: 'db', host: 'db.example.com', user: 'deploy', port: 2222, identityFile: '/keys/db', knownHosts: 'db.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIdb' },
41
+ { key: 'unpinned', host: 'new.example.com', user: 'deploy', knownHosts: '' },
42
+ ]
43
+
44
+ const commands: RemoteCommand[] = [
45
+ { key: 'disk', description: 'Free space', argv: ['df', '-h'] },
46
+ { key: 'restart-api', description: 'Restart the API', argv: ['systemctl', 'restart', 'api'], hosts: ['app'] },
47
+ ]
48
+
49
+ const user = { id: 1, email: 'ops@example.com' } as unknown as UserModel
50
+
51
+ let recorded: string[] = []
52
+ const audit: RemoteAuditSink = {
53
+ async started(entry) {
54
+ recorded.push(`started:${entry.user}:${entry.hostKey}:${entry.commandKey}`)
55
+ },
56
+ async finished(entry) {
57
+ recorded.push(`finished:${entry.hostKey}:${entry.commandKey}:${entry.exitCode}:${entry.timedOut}`)
58
+ },
59
+ }
60
+
61
+ beforeEach(() => {
62
+ recorded = []
63
+ })
64
+
65
+ const ok = async (): Promise<{ exitCode: number, stdout: string, stderr: string, timedOut: boolean }> =>
66
+ ({ exitCode: 0, stdout: 'out', stderr: '', timedOut: false })
67
+
68
+ describe('resolveHost', () => {
69
+ it('resolves a declared host by key', () => {
70
+ expect(resolveHost(hosts, 'app')).toBe(host)
71
+ })
72
+
73
+ it('404s a key nobody declared', () => {
74
+ // The property that matters: a request names a KEY, and a key that is not
75
+ // in config reaches nothing. There is no path from the request to a
76
+ // hostname.
77
+ expect(() => resolveHost(hosts, 'evil.example.com')).toThrow(/No configured host/)
78
+ try {
79
+ resolveHost(hosts, 'nope')
80
+ }
81
+ catch (error) {
82
+ expect((error as RemoteCommandError).status).toBe(404)
83
+ }
84
+ })
85
+
86
+ it('refuses a host with no pinned key rather than trusting one on first contact', () => {
87
+ // Accepting a key on first contact is what makes a session into a
88
+ // long-lived box MITM-open. `sshExec` in ts-cloud does exactly that, on
89
+ // purpose, for boxes a minute old - which is why this does not reuse it.
90
+ expect(() => resolveHost(hosts, 'unpinned')).toThrow(/no pinned host key/)
91
+ })
92
+
93
+ it('rejects a non-string key', () => {
94
+ for (const key of [undefined, null, 42, {}, ''])
95
+ expect(() => resolveHost(hosts, key)).toThrow(RemoteCommandError)
96
+ })
97
+ })
98
+
99
+ describe('resolveCommand', () => {
100
+ it('resolves a declared command', () => {
101
+ expect(resolveCommand(commands, 'disk', host).argv).toEqual(['df', '-h'])
102
+ })
103
+
104
+ it('404s a command nobody declared', () => {
105
+ // No request carries a command, so there is nothing to escape and no shell
106
+ // to reach.
107
+ expect(() => resolveCommand(commands, 'rm -rf /', host)).toThrow(/No configured command/)
108
+ })
109
+
110
+ it('403s a command that is scoped away from this host', () => {
111
+ // 403 rather than 404: the command exists, this host is not one of its
112
+ // targets. Scoping `restart-api` to the app box is a deliberate
113
+ // restriction, and reporting it as "not found" would read as a typo.
114
+ const db = resolveHost(hosts, 'db')
115
+ expect(() => resolveCommand(commands, 'restart-api', db)).toThrow(/not permitted on host/)
116
+ try {
117
+ resolveCommand(commands, 'restart-api', db)
118
+ }
119
+ catch (error) {
120
+ expect((error as RemoteCommandError).status).toBe(403)
121
+ }
122
+ })
123
+
124
+ it('lets an unscoped command run anywhere', () => {
125
+ expect(resolveCommand(commands, 'disk', resolveHost(hosts, 'db')).key).toBe('disk')
126
+ })
127
+ })
128
+
129
+ describe('sshArgv', () => {
130
+ it('verifies the host key against the pinned file', () => {
131
+ const argv = sshArgv(host, commands[0]!, '/tmp/known_hosts')
132
+ expect(argv).toContain('StrictHostKeyChecking=yes')
133
+ expect(argv).toContain('UserKnownHostsFile=/tmp/known_hosts')
134
+ })
135
+
136
+ it('fails rather than prompting for a password', () => {
137
+ // A prompt on a server has nobody to answer it, so the request would hang
138
+ // until it timed out with no useful message.
139
+ expect(sshArgv(host, commands[0]!, '/tmp/kh')).toContain('BatchMode=yes')
140
+ })
141
+
142
+ it('passes the remote argv after `--`, as separate arguments', () => {
143
+ // Never joined into a string. A joined command is a shell command, and a
144
+ // shell command is where an injection would live if one could reach it.
145
+ const argv = sshArgv(host, commands[1]!, '/tmp/kh')
146
+ expect(argv.slice(argv.indexOf('--'))).toEqual(['--', 'systemctl', 'restart', 'api'])
147
+ })
148
+
149
+ it('carries the port and identity when the host declares them', () => {
150
+ const argv = sshArgv(hosts[1]!, commands[0]!, '/tmp/kh')
151
+ expect(argv).toContain('-p')
152
+ expect(argv[argv.indexOf('-p') + 1]).toBe('2222')
153
+ expect(argv[argv.indexOf('-i') + 1]).toBe('/keys/db')
154
+ // Without this ssh may offer an agent key instead of the configured one.
155
+ expect(argv).toContain('IdentitiesOnly=yes')
156
+ })
157
+
158
+ it('addresses the host as user@host', () => {
159
+ expect(sshArgv(host, commands[0]!, '/tmp/kh')).toContain('deploy@app.example.com')
160
+ })
161
+ })
162
+
163
+ describe('authorize', () => {
164
+ it('401s without a user', async () => {
165
+ expect(authorize(() => true, null, host, commands[0]!)).rejects.toThrow(/Authentication is required/)
166
+ })
167
+
168
+ it('fails CLOSED when no authorizer is configured', async () => {
169
+ // The opposite of the websocket authenticator next door, which proceeds
170
+ // when none is installed. For a surface that runs commands on a server, an
171
+ // app that has not defined the gate must get a refusal rather than a shell.
172
+ expect(authorize(undefined, user, host, commands[0]!)).rejects.toThrow(/not authorized on this application/)
173
+ })
174
+
175
+ it('403s when the gate says no', async () => {
176
+ expect(authorize(() => false, user, host, commands[0]!)).rejects.toThrow(/not permitted to run/)
177
+ })
178
+
179
+ it('passes both keys to the gate, so an app can scope by either', async () => {
180
+ const seen: string[] = []
181
+ await authorize((_user, hostKey, commandKey) => {
182
+ seen.push(hostKey, commandKey)
183
+ return true
184
+ }, user, host, commands[1]!)
185
+
186
+ expect(seen).toEqual(['app', 'restart-api'])
187
+ })
188
+ })
189
+
190
+ describe('runRemoteCommand', () => {
191
+ const context = {
192
+ user,
193
+ hosts,
194
+ commands,
195
+ authorizer: () => true,
196
+ run: ok,
197
+ audit,
198
+ }
199
+
200
+ it('records the run before it starts and again when it ends', async () => {
201
+ // A run recorded only on completion loses the two worth having: the command
202
+ // that hung, and the one whose process died with the box.
203
+ await runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, context)
204
+
205
+ expect(recorded).toEqual([
206
+ 'started:ops@example.com:app:disk',
207
+ 'finished:app:disk:0:false',
208
+ ])
209
+ })
210
+
211
+ it('records nothing for a run that was refused', async () => {
212
+ // Authorization happens before the audit entry, so a refusal is not an
213
+ // audit record saying somebody ran something they did not.
214
+ expect(runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, { ...context, authorizer: () => false }))
215
+ .rejects.toThrow(/not permitted/)
216
+
217
+ await Bun.sleep(0)
218
+ expect(recorded).toEqual([])
219
+ })
220
+
221
+ it('records nothing for a host that does not exist', async () => {
222
+ expect(runRemoteCommand({ hostKey: 'ghost', commandKey: 'disk' }, context)).rejects.toThrow(/No configured host/)
223
+ await Bun.sleep(0)
224
+ expect(recorded).toEqual([])
225
+ })
226
+
227
+ it('reports the outcome, including a timeout', async () => {
228
+ const result = await runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, {
229
+ ...context,
230
+ run: async () => ({ exitCode: 143, stdout: 'partial', stderr: 'killed', timedOut: true }),
231
+ })
232
+
233
+ expect(result.timedOut).toBeTrue()
234
+ expect(result.exitCode).toBe(143)
235
+ expect(recorded.at(-1)).toBe('finished:app:disk:143:true')
236
+ })
237
+
238
+ it('reports a non-zero exit as a result rather than an error', async () => {
239
+ // `systemctl restart` failing is information the operator wants, not an
240
+ // exception - the run happened and the audit record says what it did.
241
+ const result = await runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, {
242
+ ...context,
243
+ run: async () => ({ exitCode: 1, stdout: '', stderr: 'no such unit', timedOut: false }),
244
+ })
245
+
246
+ expect(result.exitCode).toBe(1)
247
+ expect(result.stderr).toBe('no such unit')
248
+ })
249
+ })
250
+
251
+ describe('output and timeout bounds', () => {
252
+ it('truncates with a marker rather than cutting silently', () => {
253
+ const clamped = clampOutput('x'.repeat(100), 10)
254
+ expect(clamped).toStartWith('xxxxxxxxxx')
255
+ expect(clamped).toContain('truncated at 10 characters')
256
+ })
257
+
258
+ it('leaves output within the limit alone', () => {
259
+ expect(clampOutput('short', 10)).toBe('short')
260
+ })
261
+
262
+ it('caps a timeout rather than honouring an unbounded one', () => {
263
+ expect(normalizeTimeout(undefined)).toBe(30_000)
264
+ expect(normalizeTimeout(5_000)).toBe(5_000)
265
+ expect(normalizeTimeout(10_000_000)).toBe(300_000)
266
+ })
267
+
268
+ it('rejects a timeout that is not a positive number', () => {
269
+ for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY])
270
+ expect(() => normalizeTimeout(value)).toThrow(RemoteCommandError)
271
+ })
272
+ })
@@ -0,0 +1,297 @@
1
+ import type { UserModel } from '@stacksjs/orm'
2
+ import type { ResponseStatus } from '@stacksjs/bun-router'
3
+
4
+ /**
5
+ * Running a known operation on a configured host, from the dashboard
6
+ * (stacksjs/stacks#960).
7
+ *
8
+ * ## Why this is not a terminal
9
+ *
10
+ * The request asked for SSH in the dashboard, and named Termius. A browser
11
+ * terminal into a production box is remote code execution as a feature - the
12
+ * highest-privilege surface a dashboard can have - so what ships first is the
13
+ * part that carries most of the value and cannot become an arbitrary shell:
14
+ * run *this named operation* on *that host* and show me the output. Restart a
15
+ * service, tail a log, check disk.
16
+ *
17
+ * The command is chosen from a registry the config declares. The request names
18
+ * a KEY; it never carries a command, so there is no argument to escape and no
19
+ * shell to reach. An interactive session is a strictly larger problem and is
20
+ * tracked separately - `Bun.spawn` has no PTY, and `ssh -tt` gives a remote one
21
+ * but cannot propagate a window resize without a local TTY.
22
+ *
23
+ * ## What makes it safe to expose at all
24
+ *
25
+ * Four things, none of which the existing infrastructure gave for free:
26
+ *
27
+ * 1. **Host keys are verified.** `sshExec` in `@stacksjs/ts-cloud` disables host
28
+ * key checking on purpose - it targets boxes created a minute ago whose keys
29
+ * cannot be known. For a long-lived production host that is MITM-open, so
30
+ * this does not reuse it. `StrictHostKeyChecking=yes` against a configured
31
+ * known-hosts file, and a host with no pinned key is refused rather than
32
+ * trusted on first contact.
33
+ * 2. **Hosts come from config, never the request.** The request names a key in
34
+ * the registry. An unknown key is a 404, so no request can reach a host the
35
+ * operator did not declare.
36
+ * 3. **Authorization is per host, and has no local bypass.** The dashboard's
37
+ * `guard()` helper drops auth entirely when `APP_ENV` is local or test,
38
+ * which for this surface would be an unauthenticated shell on every
39
+ * developer machine reachable on the network. This checks a gate instead.
40
+ * 4. **Every run is recorded** - who, which host, which command, when, exit
41
+ * code - before it starts and again when it ends.
42
+ */
43
+
44
+ /** A host the dashboard may reach, as `config/cloud.ts` declares it. */
45
+ export interface RemoteHost {
46
+ /** Stable key the request names. Never a hostname. */
47
+ key: string
48
+ /** Hostname or IP. */
49
+ host: string
50
+ /** SSH user. */
51
+ user: string
52
+ /** SSH port. Omit for 22. */
53
+ port?: number
54
+ /** Private key passed as `ssh -i`. Omit to use the ambient agent. */
55
+ identityFile?: string
56
+ /**
57
+ * Pinned host keys for this host, in `known_hosts` format.
58
+ *
59
+ * Required. A host without one cannot be reached: accepting a key on first
60
+ * contact is what makes an interactive session into a production box
61
+ * MITM-open, and there is no reason to do it here - the operator declaring
62
+ * the host can declare its fingerprint at the same time.
63
+ */
64
+ knownHosts: string
65
+ }
66
+
67
+ /** An operation that may be run, as config declares it. */
68
+ export interface RemoteCommand {
69
+ /** Stable key the request names. */
70
+ key: string
71
+ /** What it does, shown in the dashboard. */
72
+ description: string
73
+ /**
74
+ * The argv to run on the host.
75
+ *
76
+ * An ARRAY, and never interpolated. A string would be a shell command, and a
77
+ * shell command with any caller-supplied part in it is an injection - which
78
+ * is the whole reason the request cannot carry one.
79
+ */
80
+ argv: readonly string[]
81
+ /** Host keys this command may run on. Omit for every host. */
82
+ hosts?: readonly string[]
83
+ }
84
+
85
+ export class RemoteCommandError extends Error {
86
+ readonly status: ResponseStatus
87
+
88
+ constructor(message: string, status: ResponseStatus = 422) {
89
+ super(message)
90
+ this.name = 'RemoteCommandError'
91
+ this.status = status
92
+ }
93
+ }
94
+
95
+ /** What a completed run produced. */
96
+ export interface RemoteCommandResult {
97
+ host: string
98
+ command: string
99
+ exitCode: number
100
+ stdout: string
101
+ stderr: string
102
+ /** Milliseconds from spawn to exit. */
103
+ durationMs: number
104
+ /** Whether the run was cut short by {@link RemoteRunOptions.timeoutMs}. */
105
+ timedOut: boolean
106
+ }
107
+
108
+ /** How a run reaches the host. Injected so the rules can be tested without a network. */
109
+ export type RemoteRunner = (
110
+ argv: readonly string[],
111
+ options: { timeoutMs: number },
112
+ ) => Promise<{ exitCode: number, stdout: string, stderr: string, timedOut: boolean }>
113
+
114
+ /** Where a run is recorded. Injected for the same reason. */
115
+ export interface RemoteAuditSink {
116
+ started: (entry: { user: string, hostKey: string, commandKey: string, at: string }) => Promise<void>
117
+ finished: (entry: { user: string, hostKey: string, commandKey: string, at: string, exitCode: number, durationMs: number, timedOut: boolean }) => Promise<void>
118
+ }
119
+
120
+ export interface RemoteRunOptions {
121
+ /** Give up after this long and report `timedOut`. */
122
+ timeoutMs?: number
123
+ /** Trim output beyond this many characters, so one run cannot fill a response. */
124
+ maxOutputChars?: number
125
+ }
126
+
127
+ /** A run that has not finished in this long is not going to be useful in a dashboard. */
128
+ const DEFAULT_TIMEOUT_MS = 30_000
129
+ const MAX_TIMEOUT_MS = 300_000
130
+ const DEFAULT_MAX_OUTPUT = 64 * 1024
131
+
132
+ /**
133
+ * The `ssh` argv for a command on a host.
134
+ *
135
+ * Every option here is load bearing:
136
+ *
137
+ * - `StrictHostKeyChecking=yes` and a per-host `UserKnownHostsFile` are what
138
+ * distinguish this from `sshExec`. Without them a changed host key is
139
+ * accepted silently, which is the entire attack.
140
+ * - `BatchMode=yes` means a host that wants a password fails instead of hanging
141
+ * on a prompt no one can answer.
142
+ * - `--` separates the ssh options from the remote argv, and the remote argv is
143
+ * passed as separate arguments rather than joined, so nothing in it is
144
+ * interpreted by a local shell.
145
+ */
146
+ export function sshArgv(host: RemoteHost, command: RemoteCommand, knownHostsPath: string): string[] {
147
+ const argv = [
148
+ 'ssh',
149
+ '-o', 'StrictHostKeyChecking=yes',
150
+ '-o', `UserKnownHostsFile=${knownHostsPath}`,
151
+ '-o', 'BatchMode=yes',
152
+ '-o', 'ConnectTimeout=10',
153
+ ]
154
+
155
+ if (host.port !== undefined)
156
+ argv.push('-p', String(host.port))
157
+ if (host.identityFile)
158
+ argv.push('-i', host.identityFile, '-o', 'IdentitiesOnly=yes')
159
+
160
+ argv.push(`${host.user}@${host.host}`, '--', ...command.argv)
161
+ return argv
162
+ }
163
+
164
+ /** The host with this key, or a 404. Hosts never come from the request itself. */
165
+ export function resolveHost(hosts: readonly RemoteHost[], key: unknown): RemoteHost {
166
+ if (typeof key !== 'string' || !key.trim())
167
+ throw new RemoteCommandError('A host key is required.', 422)
168
+
169
+ const host = hosts.find(entry => entry.key === key)
170
+ if (!host)
171
+ throw new RemoteCommandError(`No configured host named "${key}".`, 404)
172
+
173
+ // Refused rather than trusted on first contact. A host declared without its
174
+ // fingerprint is a configuration mistake, and the failure mode of guessing is
175
+ // silent.
176
+ if (!host.knownHosts.trim())
177
+ throw new RemoteCommandError(`Host "${key}" has no pinned host key; add one to its \`knownHosts\` before it can be reached.`, 422)
178
+
179
+ return host
180
+ }
181
+
182
+ /** The command with this key, if it may run on this host. */
183
+ export function resolveCommand(commands: readonly RemoteCommand[], key: unknown, host: RemoteHost): RemoteCommand {
184
+ if (typeof key !== 'string' || !key.trim())
185
+ throw new RemoteCommandError('A command key is required.', 422)
186
+
187
+ const command = commands.find(entry => entry.key === key)
188
+ if (!command)
189
+ throw new RemoteCommandError(`No configured command named "${key}".`, 404)
190
+
191
+ // A command scoped to some hosts is a deliberate restriction - `deploy` on
192
+ // the app box and not on the database box - so an unscoped run of it is a 403
193
+ // rather than a 404: the command exists, this host is not one of its targets.
194
+ if (command.hosts && !command.hosts.includes(host.key))
195
+ throw new RemoteCommandError(`Command "${command.key}" is not permitted on host "${host.key}".`, 403)
196
+
197
+ return command
198
+ }
199
+
200
+ /**
201
+ * Whether `user` may run `command` on `host`.
202
+ *
203
+ * A gate rather than a role, because the request was for "certain users" and
204
+ * `role:admin` is one bit. The gate receives both keys, so an application can
205
+ * scope by host, by command, or by both without this file knowing how.
206
+ */
207
+ export type RemoteAuthorizer = (user: UserModel | null, hostKey: string, commandKey: string) => Promise<boolean> | boolean
208
+
209
+ /** Refuses when no authorizer is configured. */
210
+ export async function authorize(
211
+ authorizer: RemoteAuthorizer | undefined,
212
+ user: UserModel | null,
213
+ host: RemoteHost,
214
+ command: RemoteCommand,
215
+ ): Promise<void> {
216
+ if (!user)
217
+ throw new RemoteCommandError('Authentication is required.', 401)
218
+
219
+ // Fails CLOSED. An app that has not defined the gate gets a refusal, not a
220
+ // shell - the opposite of the websocket authenticator next door, which
221
+ // proceeds when none is installed for backwards-compatibility.
222
+ if (!authorizer)
223
+ throw new RemoteCommandError('Remote commands are not authorized on this application.', 403)
224
+
225
+ if (!await authorizer(user, host.key, command.key))
226
+ throw new RemoteCommandError(`You are not permitted to run "${command.key}" on "${host.key}".`, 403)
227
+ }
228
+
229
+ /** Output beyond `limit`, with a marker rather than a silent cut. */
230
+ export function clampOutput(value: string, limit: number = DEFAULT_MAX_OUTPUT): string {
231
+ if (value.length <= limit)
232
+ return value
233
+ return `${value.slice(0, limit)}\n… output truncated at ${limit} characters`
234
+ }
235
+
236
+ /** A timeout inside the bounds a dashboard request can wait for. */
237
+ export function normalizeTimeout(value: number | undefined): number {
238
+ if (value === undefined)
239
+ return DEFAULT_TIMEOUT_MS
240
+ if (!Number.isFinite(value) || value <= 0)
241
+ throw new RemoteCommandError('The timeout must be a positive number of milliseconds.', 422)
242
+ return Math.min(value, MAX_TIMEOUT_MS)
243
+ }
244
+
245
+ /**
246
+ * Resolve, authorize, record, run, record.
247
+ *
248
+ * The audit entry is written BEFORE the command runs and again after. A run
249
+ * recorded only on completion loses exactly the ones worth having: the command
250
+ * that hung, and the one whose process died with the box.
251
+ */
252
+ export async function runRemoteCommand(
253
+ input: { hostKey: unknown, commandKey: unknown },
254
+ context: {
255
+ user: UserModel | null
256
+ hosts: readonly RemoteHost[]
257
+ commands: readonly RemoteCommand[]
258
+ authorizer?: RemoteAuthorizer
259
+ run: RemoteRunner
260
+ audit: RemoteAuditSink
261
+ },
262
+ options: RemoteRunOptions = {},
263
+ ): Promise<RemoteCommandResult> {
264
+ const host = resolveHost(context.hosts, input.hostKey)
265
+ const command = resolveCommand(context.commands, input.commandKey, host)
266
+ await authorize(context.authorizer, context.user, host, command)
267
+
268
+ const timeoutMs = normalizeTimeout(options.timeoutMs)
269
+ const identity = String(context.user?.email ?? context.user?.id ?? 'unknown')
270
+ const startedAt = new Date().toISOString()
271
+
272
+ await context.audit.started({ user: identity, hostKey: host.key, commandKey: command.key, at: startedAt })
273
+
274
+ const began = Date.now()
275
+ const outcome = await context.run(command.argv, { timeoutMs })
276
+ const durationMs = Date.now() - began
277
+
278
+ await context.audit.finished({
279
+ user: identity,
280
+ hostKey: host.key,
281
+ commandKey: command.key,
282
+ at: new Date().toISOString(),
283
+ exitCode: outcome.exitCode,
284
+ durationMs,
285
+ timedOut: outcome.timedOut,
286
+ })
287
+
288
+ return {
289
+ host: host.key,
290
+ command: command.key,
291
+ exitCode: outcome.exitCode,
292
+ stdout: clampOutput(outcome.stdout, options.maxOutputChars),
293
+ stderr: clampOutput(outcome.stderr, options.maxOutputChars),
294
+ durationMs,
295
+ timedOut: outcome.timedOut,
296
+ }
297
+ }
@@ -0,0 +1,58 @@
1
+ // The remote-command routes must not inherit the dashboard's local-env bypass
2
+ // (stacksjs/stacks#960).
3
+ //
4
+ // `guard()` in `routes/dashboard-api.ts` reads:
5
+ //
6
+ // if (!IS_LOCAL_ENV)
7
+ // r.middleware('auth').middleware('role:admin')
8
+ //
9
+ // and `IS_LOCAL_ENV` covers local, development, dev, test, testing and empty.
10
+ // That is a reasonable trade for operational telemetry on a developer machine.
11
+ // For an endpoint that runs commands on a server it is an unauthenticated
12
+ // command runner on every developer machine reachable on the network.
13
+ //
14
+ // Asserted against the route file as text, because that is where the mistake
15
+ // would be made - somebody adding a route next to the others and reaching for
16
+ // the helper they see used around it.
17
+
18
+ import { describe, expect, it } from 'bun:test'
19
+ import { readFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ const routes = readFileSync(join(import.meta.dir, '../../../../routes/dashboard-api.ts'), 'utf8')
23
+
24
+ /** The route lines for this feature, whatever helper they are wrapped in. */
25
+ const remoteRoutes = routes
26
+ .split('\n')
27
+ .filter(line => line.includes('Actions/Dashboard/Remote/'))
28
+
29
+ describe('remote-command routes', () => {
30
+ it('registers both endpoints', () => {
31
+ expect(remoteRoutes).toHaveLength(2)
32
+ expect(remoteRoutes.join('\n')).toContain('RemoteCommandIndexAction')
33
+ expect(remoteRoutes.join('\n')).toContain('RemoteCommandRunAction')
34
+ })
35
+
36
+ it('never uses the guard that drops auth in local environments', () => {
37
+ // The whole point. `guard(` would mean no authentication at all under
38
+ // APP_ENV=local, on a route that runs commands over SSH.
39
+ for (const line of remoteRoutes)
40
+ expect(line).not.toContain('guard(route')
41
+ })
42
+
43
+ it('uses the guard that keeps auth in every environment', () => {
44
+ for (const line of remoteRoutes)
45
+ expect(line).toContain('authenticatedGuard(route')
46
+ })
47
+
48
+ it('the two guards really do differ, so this is not vacuous', () => {
49
+ // If `guard` and `authenticatedGuard` were the same, everything above would
50
+ // pass for a route with no protection at all. They differ in exactly one
51
+ // way: whether `auth` survives a local environment.
52
+ const guardBody = routes.slice(routes.indexOf('function guard('), routes.indexOf('function authenticatedGuard('))
53
+ const authenticatedBody = routes.slice(routes.indexOf('function authenticatedGuard('))
54
+
55
+ expect(guardBody).toContain('if (!IS_LOCAL_ENV)\n r.middleware(\'auth\')')
56
+ expect(authenticatedBody.slice(0, 400)).toContain('r.middleware(\'auth\')\n if (!IS_LOCAL_ENV)')
57
+ })
58
+ })
@@ -0,0 +1,94 @@
1
+ import type { RemoteAuditSink, RemoteCommand, RemoteHost, RemoteRunner } from './remote-commands'
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { log } from '@stacksjs/logging'
6
+ import { sshArgv } from './remote-commands'
7
+
8
+ /**
9
+ * The SSH transport and audit sink behind `runRemoteCommand`
10
+ * (stacksjs/stacks#960).
11
+ *
12
+ * Split from `remote-commands.ts` so the resolution, authorization and audit
13
+ * ordering can be tested without a network or a database - the same split
14
+ * `file-manager.ts` makes with its `Manager` parameter.
15
+ */
16
+
17
+ /**
18
+ * A runner for one host, with that host's pinned keys written to a file only
19
+ * this process can read.
20
+ *
21
+ * The known-hosts file is created per run and removed afterwards rather than
22
+ * kept in `~/.ssh`: the app server may reach several hosts with different
23
+ * operators, and a shared file is one where a stale entry for a decommissioned
24
+ * box silently authorizes whoever picked up its address.
25
+ */
26
+ export function createSshRunner(host: RemoteHost, command: RemoteCommand): RemoteRunner {
27
+ return async (_argv, options) => {
28
+ const dir = mkdtempSync(join(tmpdir(), 'stacks-remote-'))
29
+ const knownHostsPath = join(dir, 'known_hosts')
30
+
31
+ try {
32
+ writeFileSync(knownHostsPath, host.knownHosts.endsWith('\n') ? host.knownHosts : `${host.knownHosts}\n`)
33
+ // ssh refuses a known-hosts file others can write.
34
+ chmodSync(knownHostsPath, 0o600)
35
+
36
+ const proc = Bun.spawn(sshArgv(host, command, knownHostsPath), {
37
+ stdin: 'ignore',
38
+ stdout: 'pipe',
39
+ stderr: 'pipe',
40
+ })
41
+
42
+ // A run that has not finished is killed rather than left. Without this a
43
+ // hung ssh holds a process and a request until the server restarts.
44
+ let timedOut = false
45
+ const timer = setTimeout(() => {
46
+ timedOut = true
47
+ proc.kill()
48
+ }, options.timeoutMs)
49
+
50
+ try {
51
+ const [stdout, stderr, exitCode] = await Promise.all([
52
+ new Response(proc.stdout).text(),
53
+ new Response(proc.stderr).text(),
54
+ proc.exited,
55
+ ])
56
+
57
+ return { exitCode, stdout, stderr, timedOut }
58
+ }
59
+ finally {
60
+ clearTimeout(timer)
61
+ }
62
+ }
63
+ finally {
64
+ // Always, including on the failure path: a pinned-key file left in the
65
+ // temp directory on every run is both a leak and a growing surface.
66
+ rmSync(dir, { force: true, recursive: true })
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The audit sink that writes to the application log.
73
+ *
74
+ * The log rather than a table, deliberately. A remote-command record is
75
+ * append-only evidence about who did what, and the dashboard's own database is
76
+ * the thing an operator with dashboard access could edit. Shipping to whatever
77
+ * the app's logging is already configured to ship to keeps it outside the blast
78
+ * radius of the surface it audits.
79
+ *
80
+ * An application that wants these queryable can pass its own sink; the
81
+ * interface is two functions.
82
+ */
83
+ export const loggingAuditSink: RemoteAuditSink = {
84
+ async started(entry) {
85
+ // Not `log.debug`: this is the record, and a level nobody ships in
86
+ // production is the same as not recording it.
87
+ await log.info(`[remote] ${entry.user} started ${entry.commandKey} on ${entry.hostKey} at ${entry.at}`)
88
+ },
89
+
90
+ async finished(entry) {
91
+ const outcome = entry.timedOut ? 'timed out' : `exited ${entry.exitCode}`
92
+ await log.info(`[remote] ${entry.user} finished ${entry.commandKey} on ${entry.hostKey}: ${outcome} in ${entry.durationMs}ms`)
93
+ },
94
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.34",
5
+ "version": "0.74.36",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.34",
5
+ "version": "0.74.36",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.27",
58
- "@stacksjs/mobile": "^0.74.34",
58
+ "@stacksjs/mobile": "^0.74.36",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -260,6 +260,21 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
260
260
  guard(route.post('/files/duplicates', 'Actions/Dashboard/Content/FileDuplicateAction'))
261
261
  guard(route.delete('/files', 'Actions/Dashboard/Content/FileDestroyAction'))
262
262
 
263
+ /*
264
+ * Remote commands (stacksjs/stacks#960).
265
+ *
266
+ * Deliberately NOT behind `guard()`. That helper drops auth entirely when
267
+ * `APP_ENV` is local, development or test - which is a reasonable trade for
268
+ * operational telemetry on a developer machine, and an unauthenticated
269
+ * command runner for this. `authenticatedGuard` keeps `auth` in every
270
+ * environment, the same treatment billing gets and for the same reason.
271
+ *
272
+ * Authorization proper is the `run-remote-command` gate, checked per host and
273
+ * per command inside the action. Being authenticated is not being allowed.
274
+ */
275
+ authenticatedGuard(route.get('/remote/commands', 'Actions/Dashboard/Remote/RemoteCommandIndexAction'))
276
+ authenticatedGuard(route.post('/remote/run', 'Actions/Dashboard/Remote/RemoteCommandRunAction'))
277
+
263
278
  guard(route.get('/ci/status', 'Actions/Dashboard/Ci/StatusAction'))
264
279
  // CI drilldown (stacksjs/stacks#1848): per-repo run history + per-run
265
280
  // job detail. On-demand reads so the polled snapshot stays cheap.