dsh-foreman-node 0.1.0 → 0.2.0
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/CHANGELOG.md +64 -3
- package/LICENSE +21 -21
- package/README.md +29 -0
- package/cordis.patch.yml +0 -0
- package/lib/client.js +611 -0
- package/lib/index.d.ts +245 -28
- package/lib/index.js +620 -16
- package/package.json +27 -3
- package/src/client/index.tsx +211 -0
- package/src/client/locales.ts +95 -0
- package/src/client/page-state.ts +99 -0
- package/src/client.ts +0 -0
- package/src/config.ts +100 -0
- package/src/index.ts +141 -33
- package/src/keys.ts +49 -0
- package/src/protocol.ts +0 -0
- package/src/report-tool.ts +0 -0
- package/src/routes.ts +157 -0
- package/src/session.ts +0 -0
- package/src/tunnel.ts +267 -0
package/src/index.ts
CHANGED
|
@@ -5,54 +5,123 @@
|
|
|
5
5
|
* It imports no domain core, opens no database, and reads no policy, because a device holds no ledger — the hub is
|
|
6
6
|
* the only place work is recorded, and this side's job is to do it and say what happened.
|
|
7
7
|
*/
|
|
8
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import type { Context, Volatile } from '@deepseek-ai/cordis'
|
|
9
9
|
import s from '@deepseek-ai/schemastery'
|
|
10
10
|
import type {} from '@deepseek-ai/dsh-api-session-controller'
|
|
11
11
|
import type {} from '@deepseek-ai/dsh-session-persistence'
|
|
12
12
|
import type {} from '@deepseek-ai/dsh-workspace'
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-permission-presets'
|
|
14
14
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
15
17
|
import { NodeClient } from './client.ts'
|
|
18
|
+
import { configured, SSH_DEFAULTS, type NodeConfig, type NodeRoot, type SshConfig } from './config.ts'
|
|
19
|
+
import { registerNodeRoutes, type RouteHost } from './routes.ts'
|
|
20
|
+
import { Tunnel } from './tunnel.ts'
|
|
16
21
|
import { JobSessions } from './session.ts'
|
|
17
22
|
import type { Frame } from './protocol.ts'
|
|
18
23
|
|
|
19
24
|
/** Version reported in the hello, so the hub can tell which device build is talking. */
|
|
20
|
-
export const VERSION = '0.
|
|
21
|
-
|
|
22
|
-
/** One project this device can work on: the hub's logical name and this machine's own absolute path. */
|
|
23
|
-
export interface NodeRoot { project: string; path: string }
|
|
24
|
-
|
|
25
|
-
/** What the `dsh-foreman-node` entry in a profile's patch configures. */
|
|
26
|
-
export interface NodeConfig {
|
|
27
|
-
/** The hub's stable address; the device dials out to it. */
|
|
28
|
-
url: string
|
|
29
|
-
/** This device's id, as registered on the hub with `Devices.add`. */
|
|
30
|
-
deviceId: string
|
|
31
|
-
/** This device's token; sent in the hello and nowhere else. */
|
|
32
|
-
token: string
|
|
33
|
-
/** The projects this device can work on. */
|
|
34
|
-
roots: NodeRoot[]
|
|
35
|
-
/** How often to tell the hub this device is alive, in milliseconds. */
|
|
36
|
-
heartbeatMs: number
|
|
37
|
-
/** Delay before the first reconnect attempt, in milliseconds. */
|
|
38
|
-
reconnectMs: number
|
|
39
|
-
}
|
|
25
|
+
export const VERSION = '0.2.0'
|
|
40
26
|
|
|
41
27
|
/**
|
|
42
28
|
* Configuration of one device, from the `dsh-foreman-node` entry in the profile's patch.
|
|
43
29
|
*
|
|
30
|
+
* Everything is optional on purpose. A bundle patch inserts this row the moment the package is added to a profile,
|
|
31
|
+
* so `apply` runs before anyone has filled the values in — and a schema that demanded them would turn the whole
|
|
32
|
+
* profile red with "invalid config" on a fresh install. `apply` treats a blank address as "not configured yet":
|
|
33
|
+
* it says what is missing and stops, instead of dialling nowhere or refusing to activate.
|
|
34
|
+
*
|
|
44
35
|
* The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
|
|
45
36
|
* an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
|
|
46
37
|
*/
|
|
47
|
-
|
|
48
|
-
url: s.string().
|
|
49
|
-
deviceId: s.string().
|
|
50
|
-
token: s.string().
|
|
51
|
-
roots: s.array(s.object({ project: s.string().required(), path: s.string().required() })).default([])
|
|
38
|
+
const ConfigSchema = s.object({
|
|
39
|
+
url: s.string().default('').volatile().description('The hub\'s address, such as wss://foreman.example.com. The device dials out to it.'),
|
|
40
|
+
deviceId: s.string().default('').volatile().description('This device\'s id, as registered on the hub with `Devices.add`.'),
|
|
41
|
+
token: s.string().default('').role('secret').volatile().description('This device\'s token; it is sent in the hello frame and nowhere else.'),
|
|
42
|
+
roots: s.array(s.object({ project: s.string().required(), path: s.string().required() })).default([]).volatile()
|
|
52
43
|
.description('Projects this device can work on: a logical name and this machine\'s own absolute path, kept verbatim.'),
|
|
53
|
-
heartbeatMs: s.natural().default(15_000).description('How often to tell the hub this device is alive, in milliseconds.'),
|
|
54
|
-
reconnectMs: s.natural().default(1_000).description('Delay before the first reconnect attempt, in milliseconds.'),
|
|
44
|
+
heartbeatMs: s.natural().default(15_000).volatile().description('How often to tell the hub this device is alive, in milliseconds.'),
|
|
45
|
+
reconnectMs: s.natural().default(1_000).volatile().description('Delay before the first reconnect attempt, in milliseconds.'),
|
|
46
|
+
// Every field carries a default, and the object itself does too: a `cordis.patch.yml` written before this block
|
|
47
|
+
// existed has no `ssh` key, and a schema that demanded one would report the whole profile invalid on upgrade.
|
|
48
|
+
// Every field here is volatile for the same reason as the top level: `isVolatilePath` walks into this object, so a
|
|
49
|
+
// non-volatile child makes the whole section unwritable and the page reports "could not save". Marked per field
|
|
50
|
+
// rather than on the object, which would replace this block's default with `{}`.
|
|
51
|
+
ssh: s.object({
|
|
52
|
+
enabled: s.boolean().default(false).volatile().description('Reach the hub through an SSH tunnel this device starts itself.'),
|
|
53
|
+
host: s.string().default('').volatile().description('The hub machine, such as 203.0.113.10 or its ~/.ssh/config alias.'),
|
|
54
|
+
// min(1), not just natural(): an empty port box becomes 0, and 0 is a port the tunnel would happily dial.
|
|
55
|
+
port: s.natural().min(1).max(65535).default(22).volatile().description('The hub machine\'s SSH port.'),
|
|
56
|
+
user: s.string().default('').volatile().description('The SSH user on the hub machine.'),
|
|
57
|
+
keyPath: s.string().default('').volatile().description('The private key this device authenticates with; the page lists these from ~/.ssh.'),
|
|
58
|
+
remotePort: s.natural().min(1).max(65535).default(8790).volatile().description('The port the hub listens on, on the hub machine.'),
|
|
59
|
+
localPort: s.natural().min(1).max(65535).default(8790).volatile().description('The local port the tunnel listens on; the device dials this.'),
|
|
60
|
+
}).default({ enabled: false, host: '', port: 22, user: '', keyPath: '', remotePort: 8790, localPort: 8790 })
|
|
61
|
+
.description('How to reach the hub when it is not directly reachable: a tunnel this device opens and keeps open.'),
|
|
55
62
|
})
|
|
63
|
+
/**
|
|
64
|
+
* Why every field is volatile.
|
|
65
|
+
*
|
|
66
|
+
* Not for hot reload: it is the switch that makes the entry visible to DSH's settings system at all. `dsh-settings`
|
|
67
|
+
* skips every plugin entry whose `volatileForm(schema)` is undefined, and that only finds fields reachable through a
|
|
68
|
+
* volatile ancestor. Without it the configuration page has no form to render and sits on "reading configuration"
|
|
69
|
+
* forever — no fields, no test button, no save.
|
|
70
|
+
*
|
|
71
|
+
* A volatile field arrives wrapped, so its value is read with `.get()`; `configured()` is what unwraps a whole
|
|
72
|
+
* `NodeConfig` for the code that would rather not know. Marking the object instead of each field is not an option:
|
|
73
|
+
* `.volatile()` on the object replaces every default with `{}`, so `Config({})` loses all of them.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What `apply` actually receives: every field wrapped, since each one is volatile.
|
|
78
|
+
*
|
|
79
|
+
* Declared as an alias rather than inferred so the plugin's public signature does not name a `schemastery` symbol the
|
|
80
|
+
* emitted declaration cannot reference portably.
|
|
81
|
+
*/
|
|
82
|
+
export type ConfigInput = { [K in keyof NodeConfig]: NodeConfig[K] | Volatile<NodeConfig[K]> }
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The schema, asserted rather than annotated.
|
|
86
|
+
*
|
|
87
|
+
* An annotation makes the declaration portable but cannot express what a volatile field does to a field's type, so the
|
|
88
|
+
* mismatch moves into the annotation. The assertion keeps the published `.d.ts` free of `schemastery` internals and
|
|
89
|
+
* puts the shape in one place; `configured()` is what re-establishes the real types at every read.
|
|
90
|
+
*/
|
|
91
|
+
const Typed = ConfigSchema as unknown as Schemastery<NodeConfig>
|
|
92
|
+
export { Typed as Config }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What this configuration still needs before the device can dial its hub, or undefined when it is complete.
|
|
96
|
+
*
|
|
97
|
+
* A missing value is reported by name so an operator can fill it in without reading the source; a value that is only
|
|
98
|
+
* whitespace counts as missing, because a device that dials a blank address retries forever and looks merely offline.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* The address this device dials.
|
|
102
|
+
*
|
|
103
|
+
* With the tunnel on, that is the local end of it: `url` names the hub at its own address, which the tunnel exists to
|
|
104
|
+
* make reachable, so dialling `url` would bypass the tunnel entirely and look like an offline device. Keeping the two
|
|
105
|
+
* cases in one function is what stops `apply` from picking the wrong one.
|
|
106
|
+
*/
|
|
107
|
+
export function hubUrlOf(config: NodeConfig): string {
|
|
108
|
+
return config.ssh.enabled ? `ws://127.0.0.1:${config.ssh.localPort}` : config.url
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function missingConfiguration(config: NodeConfig): string | undefined {
|
|
112
|
+
const missing: string[] = []
|
|
113
|
+
// The address is only required when there is no tunnel: with one, the device dials its local end and `url` is unused.
|
|
114
|
+
if (config.ssh.enabled) {
|
|
115
|
+
if (config.ssh.host.trim() === '') missing.push('ssh.host')
|
|
116
|
+
if (config.ssh.user.trim() === '') missing.push('ssh.user')
|
|
117
|
+
if (config.ssh.keyPath.trim() === '') missing.push('ssh.keyPath')
|
|
118
|
+
} else if (config.url.trim() === '') missing.push('url')
|
|
119
|
+
if (config.deviceId.trim() === '') missing.push('deviceId')
|
|
120
|
+
if (config.token.trim() === '') missing.push('token')
|
|
121
|
+
if (missing.length === 0) return undefined
|
|
122
|
+
return `dsh-foreman-node is not configured yet: ${missing.join(', ')} ` +
|
|
123
|
+
`missing. Set them on the dsh-foreman-node row in the profile patch (the hub prints the token when you register the device).`
|
|
124
|
+
}
|
|
56
125
|
|
|
57
126
|
export const name = 'dsh-foreman-node'
|
|
58
127
|
/** DSH services a job needs; all are the host's own, and none is a foreman one. */
|
|
@@ -61,13 +130,33 @@ export const inject = ['sessionController', 'sessionPersistence', 'workspaceRegi
|
|
|
61
130
|
/**
|
|
62
131
|
* Start the device.
|
|
63
132
|
*
|
|
133
|
+
* A package that has just been installed has its row in the profile patch with no values in it yet. Activating
|
|
134
|
+
* cleanly and saying what is still missing keeps the whole profile healthy; throwing here is what made a fresh
|
|
135
|
+
* install report the profile as broken.
|
|
136
|
+
*
|
|
64
137
|
* The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
|
|
65
138
|
* while its hub is unreachable, and the client keeps retrying in the background.
|
|
66
139
|
*/
|
|
67
|
-
export async function apply(ctx: Context,
|
|
140
|
+
export async function apply(ctx: Context, raw: ConfigInput): Promise<void> {
|
|
141
|
+
// Every field is volatile, so `raw` holds wrappers; everything below wants plain values.
|
|
142
|
+
const config = configured(raw)
|
|
143
|
+
// The configuration page has to work on a device that is not configured yet — that is precisely when someone opens
|
|
144
|
+
// it — so the routes go up before the check below, not after it. `webServer` is injected optionally rather than
|
|
145
|
+
// listed in `inject`: a headless or CLI DSH has no web server and must still take work, losing only the page.
|
|
146
|
+
ctx.inject(['webServer'], child => {
|
|
147
|
+
// The service shape is declared locally rather than imported from `@deepseek-ai/dsh-host-webserver`: the device
|
|
148
|
+
// package stays thin, and this plugin needs two members of that service, not its whole surface.
|
|
149
|
+
const host = (child as unknown as { webServer: RouteHost }).webServer
|
|
150
|
+
child.effect(() => registerNodeRoutes(host, sshDirectory()))
|
|
151
|
+
})
|
|
152
|
+
const missing = missingConfiguration(config)
|
|
153
|
+
if (missing !== undefined) {
|
|
154
|
+
ctx.logger.info('%s', missing)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
68
157
|
const jobs = new JobSessions(ctx)
|
|
69
158
|
const client = new NodeClient({
|
|
70
|
-
url: config
|
|
159
|
+
url: hubUrlOf(config),
|
|
71
160
|
deviceId: config.deviceId,
|
|
72
161
|
token: config.token,
|
|
73
162
|
version: VERSION,
|
|
@@ -78,9 +167,24 @@ export async function apply(ctx: Context, config: NodeConfig): Promise<void> {
|
|
|
78
167
|
log: text => ctx.logger.warn('%s', text),
|
|
79
168
|
onFrame: frame => { void receive(ctx, jobs, client, frame) },
|
|
80
169
|
})
|
|
81
|
-
client
|
|
82
|
-
|
|
83
|
-
|
|
170
|
+
// With a tunnel, the client waits for the local port to answer before dialling it: a client that starts early spends
|
|
171
|
+
// its first attempts on a port nothing is listening on, which reads in the log like the hub being down.
|
|
172
|
+
const tunnel = config.ssh.enabled
|
|
173
|
+
? new Tunnel({ ssh: config.ssh, log: text => ctx.logger.warn('%s', text),
|
|
174
|
+
onState: (state, failure) => {
|
|
175
|
+
ctx.logger.info('dsh-foreman-node tunnel: %s', failure === undefined ? state : `${state} (${failure})`)
|
|
176
|
+
if (state === 'connected') client.start()
|
|
177
|
+
} })
|
|
178
|
+
: undefined
|
|
179
|
+
if (tunnel === undefined) client.start()
|
|
180
|
+
else tunnel.start()
|
|
181
|
+
ctx.effect(() => () => { tunnel?.stop(); client.stop() })
|
|
182
|
+
ctx.logger.info('dsh-foreman-node %s is dialling %s as %s', VERSION, hubUrlOf(config), config.deviceId)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The directory the key list reads; `~/.ssh` on a machine where the current user is the one configuring it. */
|
|
186
|
+
export function sshDirectory(): string {
|
|
187
|
+
return join(homedir(), '.ssh')
|
|
84
188
|
}
|
|
85
189
|
|
|
86
190
|
/**
|
|
@@ -123,7 +227,11 @@ async function receive(ctx: Context, jobs: JobSessions, client: NodeClient, fram
|
|
|
123
227
|
}
|
|
124
228
|
}
|
|
125
229
|
|
|
230
|
+
export { configured, SSH_DEFAULTS, type NodeConfig, type NodeRoot, type SshConfig } from './config.ts'
|
|
126
231
|
export { NodeClient } from './client.ts'
|
|
232
|
+
export { Tunnel, tunnelArgs } from './tunnel.ts'
|
|
233
|
+
export { listKeys } from './keys.ts'
|
|
234
|
+
export { registerNodeRoutes, testArgs } from './routes.ts'
|
|
127
235
|
export { JobSessions } from './session.ts'
|
|
128
236
|
export { foremanReportTool, REPORT_STATES } from './report-tool.ts'
|
|
129
237
|
export type { Frame, Root, FrameAttachment } from './protocol.ts'
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH keys this machine already has, named so a person can pick one.
|
|
3
|
+
*
|
|
4
|
+
* A browser cannot list `~/.ssh`: the page runs in the DSH web UI, which has no filesystem. The device process does,
|
|
5
|
+
* so the listing happens here and the page renders it. Nothing on this path reads a private key: the answer is a
|
|
6
|
+
* filename, a path, and the comment from the public half, which is enough to tell one key from another and is all a
|
|
7
|
+
* page needs.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { basename, join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** One key a person may choose: never its contents, only what tells it apart from the others. */
|
|
13
|
+
export interface SshKey {
|
|
14
|
+
/** The filename, such as `id_ed25519`. */
|
|
15
|
+
name: string
|
|
16
|
+
/** Its absolute path, which is what the configuration stores. */
|
|
17
|
+
path: string
|
|
18
|
+
/** The trailing comment of the public half, usually `user@machine`; empty when it has none. */
|
|
19
|
+
comment: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The `.pub` file that sits beside a private key. */
|
|
23
|
+
const publicOf = (path: string): string => `${path}.pub`
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Lists the usable private keys in a directory.
|
|
27
|
+
*
|
|
28
|
+
* A key counts as usable only when its public half is beside it: that is how `ssh-keygen` writes them, and a private
|
|
29
|
+
* key without one is either half-deleted or was never finished, so offering it would only fail later at the tunnel.
|
|
30
|
+
* Anything unreadable is skipped rather than thrown, because a permissions problem on one file must not empty the list.
|
|
31
|
+
*/
|
|
32
|
+
export function listKeys(directory: string): SshKey[] {
|
|
33
|
+
let names: string[]
|
|
34
|
+
try { names = readdirSync(directory) } catch { return [] /* No ~/.ssh on this machine is a normal answer, not a failure. */ }
|
|
35
|
+
const keys: SshKey[] = []
|
|
36
|
+
for (const name of names.sort()) {
|
|
37
|
+
if (!name.startsWith('id_') || name.endsWith('.pub')) continue
|
|
38
|
+
const path = join(directory, name)
|
|
39
|
+
if (!existsSync(publicOf(path))) continue
|
|
40
|
+
let comment = ''
|
|
41
|
+
try {
|
|
42
|
+
// `ssh-ed25519 AAAAC3... user@host`: the comment is the third field, and it may be absent.
|
|
43
|
+
const fields = readFileSync(publicOf(path), 'utf8').trim().split(/\s+/)
|
|
44
|
+
comment = fields.slice(2).join(' ')
|
|
45
|
+
} catch { comment = '' /* An unreadable comment costs a label, not the key. */ }
|
|
46
|
+
keys.push({ name: basename(name), path, comment })
|
|
47
|
+
}
|
|
48
|
+
return keys
|
|
49
|
+
}
|
package/src/protocol.ts
CHANGED
|
File without changes
|
package/src/report-tool.ts
CHANGED
|
File without changes
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device's two loopback routes: what keys exist, and whether a given one reaches a given host.
|
|
3
|
+
*
|
|
4
|
+
* These exist because the configuration page is a web page: it cannot read `~/.ssh` and cannot start ssh. The device
|
|
5
|
+
* process can, so it answers both and the page renders the answers. Nothing here writes configuration — the page
|
|
6
|
+
* saves through DSH's own configuration form — and nothing here reads a private key or keeps a session open.
|
|
7
|
+
*
|
|
8
|
+
* They are registered by the caller through `ctx.inject(['webServer'], …)` rather than listed in `inject`, and that is
|
|
9
|
+
* deliberate: a device with no web UI (a headless or CLI DSH) must still take work. It simply has no configuration
|
|
10
|
+
* page, which is a missing convenience, not a broken device.
|
|
11
|
+
*/
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { listKeys, type SshKey } from './keys.ts'
|
|
16
|
+
|
|
17
|
+
/** What one authentication test found. `ok` false is a normal answer, not an error. */
|
|
18
|
+
export interface TestResult { ok: boolean; output: string }
|
|
19
|
+
|
|
20
|
+
/** Runs one command and reports its exit status with its output; injectable so a test never starts ssh. */
|
|
21
|
+
export type RunCommand = (argv: readonly string[]) => Promise<{ code: number | null; output: string }>
|
|
22
|
+
|
|
23
|
+
/** The connection the page is testing, exactly the four fields it collects. */
|
|
24
|
+
export interface TestInput { host: string; port: number; user: string; keyPath: string }
|
|
25
|
+
|
|
26
|
+
/** How long one authentication test may take before it is reported as a timeout. */
|
|
27
|
+
const TEST_TIMEOUT_MS = 15_000
|
|
28
|
+
/** How much of ssh's output to keep: the reason is at the end, and a page shows a few lines. */
|
|
29
|
+
const OUTPUT_KEEP = 2_048
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The argv of an authentication test.
|
|
33
|
+
*
|
|
34
|
+
* It asks for a command that does nothing (`true`) rather than opening a forward, because the question is only
|
|
35
|
+
* "does this key authenticate", and `BatchMode=yes` keeps it from stopping to ask for a passphrase nobody can type.
|
|
36
|
+
*/
|
|
37
|
+
export function testArgs({ host, port, user, keyPath }: TestInput): string[] {
|
|
38
|
+
return ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '-o', 'StrictHostKeyChecking=accept-new',
|
|
39
|
+
'-p', String(port), '-i', keyPath, `${user}@${host}`, 'true']
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The default runner: a real ssh, resolving to its exit status either way. */
|
|
43
|
+
const realRun: RunCommand = argv => new Promise(resolve => {
|
|
44
|
+
const child = spawn(argv[0]!, argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
45
|
+
let output = ''
|
|
46
|
+
const collect = (chunk: Buffer) => { output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEEP) }
|
|
47
|
+
child.stdout.on('data', collect)
|
|
48
|
+
child.stderr.on('data', collect)
|
|
49
|
+
const timer = setTimeout(() => { output = `${output}\n(timed out after ${TEST_TIMEOUT_MS / 1000}s)`.trim(); child.kill('SIGKILL') }, TEST_TIMEOUT_MS)
|
|
50
|
+
child.on('error', error => { clearTimeout(timer); resolve({ code: -1, output: `could not start ssh: ${error.message}` }) })
|
|
51
|
+
child.on('exit', code => { clearTimeout(timer); resolve({ code, output: output.trim() }) })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** Writes one JSON answer. */
|
|
55
|
+
function json(res: ServerResponse, status: number, value: unknown): void {
|
|
56
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store',
|
|
57
|
+
'x-content-type-options': 'nosniff' })
|
|
58
|
+
res.end(JSON.stringify(value))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reads a JSON body, refusing anything larger than a form would send. */
|
|
62
|
+
async function body(req: IncomingMessage, maxBytes = 16 * 1024): Promise<unknown> {
|
|
63
|
+
const chunks: Buffer[] = []
|
|
64
|
+
let size = 0
|
|
65
|
+
for await (const chunk of req) {
|
|
66
|
+
const bytes = Buffer.from(chunk)
|
|
67
|
+
size += bytes.length
|
|
68
|
+
if (size > maxBytes) throw new Error(`request exceeds ${maxBytes} bytes`)
|
|
69
|
+
chunks.push(bytes)
|
|
70
|
+
}
|
|
71
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown }
|
|
72
|
+
catch { throw new Error('the request body is not JSON') }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The same-origin guard the hub uses, for the same reason.
|
|
77
|
+
*
|
|
78
|
+
* The marker header alone proves nothing: any local process can send it. What the browser will not let another origin
|
|
79
|
+
* do is send it *with* its own `Origin` intact, so an `Origin` that is present must be this page's. A request with no
|
|
80
|
+
* `Origin` at all (a local tool, a test) is allowed and the marker still has to be there.
|
|
81
|
+
*/
|
|
82
|
+
/** The loopback names a host header may carry; DSH's desktop build normalizes`::1` and `localhost` to each other. */
|
|
83
|
+
const LOOPBACK = ['127.0.0.1', 'localhost', '::1', '[::1]']
|
|
84
|
+
|
|
85
|
+
export function sameOrigin(req: Pick<IncomingMessage, 'headers'>, port: number, protocol = 'http:'): boolean {
|
|
86
|
+
const host = req.headers.host
|
|
87
|
+
if (host === undefined) return false
|
|
88
|
+
// `host:port` for IPv4 and names, `[::1]:port` for a bracketed IPv6 literal.
|
|
89
|
+
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(host)
|
|
90
|
+
if (match === null) return false
|
|
91
|
+
const [, name, seen] = match
|
|
92
|
+
// A request addressed to another host is a DNS-rebinding attempt, whatever its Origin says.
|
|
93
|
+
if (!LOOPBACK.includes(name!) || Number(seen) !== port) return false
|
|
94
|
+
// The scheme comes from the service: a TLS-terminated DSH sends `https://` and would otherwise be refused here.
|
|
95
|
+
return req.headers['x-orchestrator-request'] === '1'
|
|
96
|
+
&& (req.headers.origin === undefined || req.headers.origin === `${protocol}//${host}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The prefix both routes live under; it never collides with the hub's `/orchestrator`. */
|
|
100
|
+
export const NODE_PREFIX = '/foreman-node'
|
|
101
|
+
|
|
102
|
+
export interface RouteHost {
|
|
103
|
+
/** The web server service: the only thing this module needs from the host. */
|
|
104
|
+
register(route: { kind: 'prefix'; path: string
|
|
105
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }): () => void
|
|
106
|
+
/** The port the web server listens on, used by the same-origin check. */
|
|
107
|
+
port: number
|
|
108
|
+
/** The scheme the web server serves, so a TLS-terminated host's `https://` origin is accepted. */
|
|
109
|
+
protocol?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Registers the routes and returns their disposer.
|
|
114
|
+
*
|
|
115
|
+
* `listKeys` and `run` are injectable so tests can answer without a filesystem or a child process; production passes
|
|
116
|
+
* a directory and the real runner.
|
|
117
|
+
*/
|
|
118
|
+
export function registerNodeRoutes(host: RouteHost, directory: string, run: RunCommand = realRun): () => void {
|
|
119
|
+
return host.register({ kind: 'prefix', path: NODE_PREFIX, handler: async (req, res) => {
|
|
120
|
+
// The guard runs before routing: an unauthenticated caller learns nothing about what exists here.
|
|
121
|
+
if (!sameOrigin(req, host.port, host.protocol)) { json(res, 403, { error: 'Same-origin request required' }); return }
|
|
122
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
123
|
+
const method = req.method ?? 'GET'
|
|
124
|
+
try {
|
|
125
|
+
if (url.pathname === `${NODE_PREFIX}/api/keys` && method === 'GET') {
|
|
126
|
+
const keys: SshKey[] = listKeys(directory)
|
|
127
|
+
json(res, 200, { keys, directory })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (url.pathname === `${NODE_PREFIX}/api/test` && method === 'POST') {
|
|
131
|
+
const input = (await body(req)) as Partial<TestInput>
|
|
132
|
+
if (typeof input.host !== 'string' || input.host.trim() === '' || typeof input.user !== 'string' || input.user.trim() === ''
|
|
133
|
+
|| typeof input.keyPath !== 'string' || input.keyPath.trim() === '') {
|
|
134
|
+
json(res, 400, { error: 'host, user and keyPath are required' })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const port = typeof input.port === 'number' && Number.isInteger(input.port) && input.port > 0 && input.port <= 65535 ? input.port : 22
|
|
138
|
+
// Only a key this machine already offers may be tested. The page can only pick from that list, so anything
|
|
139
|
+
// else is either a stale form or a caller asking this process to run ssh against a path of its choosing;
|
|
140
|
+
// neither is a reason to start a process.
|
|
141
|
+
const keyPath = input.keyPath.trim()
|
|
142
|
+
const known = listKeys(directory).find(key => key.path === keyPath)
|
|
143
|
+
if (known === undefined) {
|
|
144
|
+
json(res, 400, { error: `no key at ${keyPath}; the page lists the ones this machine has` })
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const result = await run(testArgs({ host: input.host.trim(), port, user: input.user.trim(), keyPath }))
|
|
148
|
+
// A refused key is an answer the page shows, not a server error: 200 with `ok: false` and ssh's own words.
|
|
149
|
+
json(res, 200, { ok: result.code === 0, output: result.output })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
json(res, 404, { error: 'Unknown operation' })
|
|
153
|
+
} catch (error) {
|
|
154
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
155
|
+
}
|
|
156
|
+
} })
|
|
157
|
+
}
|
package/src/session.ts
CHANGED
|
File without changes
|