@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.
- package/ai/skills/stacks-dashboard/SKILL.md +30 -0
- package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +29 -0
- package/app/Actions/Dashboard/Remote/RemoteCommandRunAction.ts +57 -0
- package/app/Actions/Dashboard/Remote/remote-commands.test.ts +272 -0
- package/app/Actions/Dashboard/Remote/remote-commands.ts +297 -0
- package/app/Actions/Dashboard/Remote/remote-routes.test.ts +58 -0
- package/app/Actions/Dashboard/Remote/ssh-runner.ts +94 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/routes/dashboard-api.ts +15 -0
|
@@ -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
|
+
}
|
package/ide/vscode/package.json
CHANGED
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.
|
|
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.
|
|
58
|
+
"@stacksjs/mobile": "^0.74.36",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
package/routes/dashboard-api.ts
CHANGED
|
@@ -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.
|