@stacksjs/defaults 0.74.33 → 0.74.35
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-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +90 -0
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-storage/SKILL.md +58 -4
- package/app/Actions/Dashboard/Content/FileFavoriteAction.ts +25 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +31 -0
- package/app/Actions/Dashboard/Content/FileTagsAction.ts +27 -0
- package/app/Actions/Dashboard/Content/file-manager.test.ts +47 -27
- package/app/Actions/Dashboard/Content/file-manager.ts +338 -12
- package/app/Actions/Dashboard/Content/file-metadata-store.ts +432 -0
- package/app/Actions/Dashboard/Content/file-metadata.test.ts +357 -0
- package/app/Actions/Dashboard/Content/file-metadata.ts +550 -0
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +344 -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/app/Jobs/OptimizeStorageImageJob.ts +74 -0
- package/app/Jobs/TagStorageMediaJob.ts +122 -0
- package/app/Jobs/TranscodeStorageVideoJob.ts +88 -0
- package/app/Models/StorageItem.ts +123 -0
- package/app/Models/StorageItemTask.ts +134 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/routes/dashboard-api.ts +25 -0
- package/vcs/github/workflows/buddy-bot.yml +109 -0
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- package/vcs/github/renovate.json +0 -5
|
@@ -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
|
+
})
|