dsh-foreman-node 0.1.1 → 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 +47 -0
- 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 +233 -32
- package/lib/index.js +598 -22
- 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 +112 -34
- 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/tunnel.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device's own tunnel to a hub it cannot reach directly.
|
|
3
|
+
*
|
|
4
|
+
* The hub listens on loopback only, on purpose: its ledger is the one thing worth protecting, and a publicly bound
|
|
5
|
+
* port would make the device token the only thing between it and the internet. So a device on another machine
|
|
6
|
+
* reaches it by opening a local port that `ssh -L` forwards to the hub's own loopback port.
|
|
7
|
+
*
|
|
8
|
+
* This class owns exactly one child process and nothing else. It does not know what the hub is for, does not build
|
|
9
|
+
* frames, and does not retry the connection inside the tunnel — the NodeClient above it does that. Its whole job is
|
|
10
|
+
* "there is a port on localhost that reaches the hub, and it stays that way", reported as a state a page can show.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn as nodeSpawn, type ChildProcess } from 'node:child_process'
|
|
13
|
+
import { connect } from 'node:net'
|
|
14
|
+
import type { SshConfig } from './index.ts'
|
|
15
|
+
|
|
16
|
+
/** Where the tunnel is, in the only terms a person needs: not yet, trying, working, broken. */
|
|
17
|
+
export type TunnelState = 'idle' | 'connecting' | 'connected' | 'failed'
|
|
18
|
+
|
|
19
|
+
/** The minimum of a spawned process this class uses, so a test never starts a real ssh. */
|
|
20
|
+
export interface TunnelChild {
|
|
21
|
+
killed: boolean
|
|
22
|
+
kill(signal?: NodeJS.Signals): boolean
|
|
23
|
+
on(event: 'exit', listener: (code: number | null) => void): unknown
|
|
24
|
+
on(event: 'error', listener: (error: Error) => void): unknown
|
|
25
|
+
stderr?: { on(event: 'data', listener: (chunk: Buffer) => void): unknown } | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SpawnTunnel = (argv: readonly string[]) => TunnelChild
|
|
29
|
+
|
|
30
|
+
/** Resolves whether something accepts a TCP connection on a local port. */
|
|
31
|
+
export type ProbePort = (port: number) => Promise<boolean>
|
|
32
|
+
|
|
33
|
+
export interface TunnelInput {
|
|
34
|
+
ssh: SshConfig
|
|
35
|
+
spawn?: SpawnTunnel
|
|
36
|
+
probe?: ProbePort
|
|
37
|
+
/** Called on every state change, so the page and the log see the same transitions the client acts on. */
|
|
38
|
+
onState?: (state: TunnelState, failure?: string) => void
|
|
39
|
+
log?: (text: string) => void
|
|
40
|
+
/** Delay before the first retry, in milliseconds; doubles each time up to `maxRetryMs`. */
|
|
41
|
+
retryMs?: number
|
|
42
|
+
maxRetryMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How long to wait for the local port to start answering before calling the attempt a failure. */
|
|
46
|
+
const READY_TIMEOUT_MS = 10_000
|
|
47
|
+
/** How often to ask whether the port answers yet. */
|
|
48
|
+
const READY_POLL_MS = 200
|
|
49
|
+
/** How long a stopped process may take to honour SIGTERM before it is killed. */
|
|
50
|
+
const STOP_GRACE_MS = 2_000
|
|
51
|
+
/** How much of ssh's stderr to keep: enough for the reason, not enough to grow without bound. */
|
|
52
|
+
const STDERR_KEEP = 2_048
|
|
53
|
+
/** `-N` forks no command, `-T` allocates no terminal: this process exists only to carry the forward. */
|
|
54
|
+
export const TUNNEL_FLAGS = ['-N', '-T'] as const
|
|
55
|
+
/**
|
|
56
|
+
* The options that make a failure a failure.
|
|
57
|
+
*
|
|
58
|
+
* `BatchMode=yes` refuses to prompt, because nothing is there to answer and a prompt would hang forever.
|
|
59
|
+
* `ExitOnForwardFailure=yes` makes a forward that cannot be set up kill the process instead of leaving a live ssh
|
|
60
|
+
* that forwards nothing — the failure that would otherwise only show up as "the hub is offline".
|
|
61
|
+
* The keepalives let a dead link be noticed rather than waiting for a TCP timeout nobody is watching.
|
|
62
|
+
*/
|
|
63
|
+
export const TUNNEL_OPTIONS = ['-o', 'BatchMode=yes', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=30',
|
|
64
|
+
'-o', 'ServerAliveCountMax=3'] as const
|
|
65
|
+
|
|
66
|
+
/** The argv of the forwarding process, in order, exactly as the tests pin it. */
|
|
67
|
+
export function tunnelArgs(ssh: SshConfig): string[] {
|
|
68
|
+
return ['ssh', ...TUNNEL_FLAGS, ...TUNNEL_OPTIONS, '-p', String(ssh.port), '-i', ssh.keyPath,
|
|
69
|
+
'-L', `${ssh.localPort}:127.0.0.1:${ssh.remotePort}`, `${ssh.user}@${ssh.host}`]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Turns ssh's own words into something a person can act on.
|
|
74
|
+
*
|
|
75
|
+
* Both of these would otherwise read as a bare "the tunnel stopped", and both have a specific fix. Reprinting the
|
|
76
|
+
* ssh text is not enough: it names the mechanism, not the move.
|
|
77
|
+
*/
|
|
78
|
+
export function explain(ssh: SshConfig, stderr: string): string {
|
|
79
|
+
const said = stderr.trim().split('\n').slice(-3).join(' ').trim()
|
|
80
|
+
const tail = said === '' ? '' : ` (ssh said: ${said})`
|
|
81
|
+
if (/Permission denied \(publickey/.test(stderr)) {
|
|
82
|
+
return `the hub refused this key; if it has a passphrase, add it to ssh-agent or pick a key without one${tail}`
|
|
83
|
+
}
|
|
84
|
+
if (/Address already in use|bind \[.*\]:\d+: Address already in use|Could not request local forwarding/.test(stderr)) {
|
|
85
|
+
return `local port ${ssh.localPort} is already in use; close whatever holds it, or set a different localPort${tail}`
|
|
86
|
+
}
|
|
87
|
+
if (/Could not resolve hostname/.test(stderr)) {
|
|
88
|
+
return `the hub machine ${ssh.host} could not be resolved; check the host name${tail}`
|
|
89
|
+
}
|
|
90
|
+
if (/Host key verification failed/.test(stderr)) {
|
|
91
|
+
return `the hub machine's host key is not in this machine's known_hosts; connect once from a terminal to accept it${tail}`
|
|
92
|
+
}
|
|
93
|
+
return `the tunnel to ${ssh.host} stopped${said === '' ? '' : `: ${said}`}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The default spawn: a real ssh, with stderr piped because that is where the reason lives. */
|
|
97
|
+
const realSpawn: SpawnTunnel = argv => nodeSpawn(argv[0]!, argv.slice(1),
|
|
98
|
+
{ stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true }) as unknown as ChildProcess & TunnelChild
|
|
99
|
+
|
|
100
|
+
/** The default probe: can something be connected to on this local port right now? */
|
|
101
|
+
const realProbe: ProbePort = port => new Promise(resolve => {
|
|
102
|
+
const socket = connect({ host: '127.0.0.1', port })
|
|
103
|
+
const done = (result: boolean) => { socket.destroy(); resolve(result) }
|
|
104
|
+
socket.setTimeout(2_000, () => done(false))
|
|
105
|
+
socket.once('connect', () => done(true))
|
|
106
|
+
socket.once('error', () => done(false))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Keeps one forwarding process alive, reporting what it is doing.
|
|
111
|
+
*
|
|
112
|
+
* A retry is always a *new* process: an ssh that exited cannot be revived, and reusing the object would report a
|
|
113
|
+
* state no process is in. `stop()` is idempotent and leaves nothing behind, including a pending retry timer.
|
|
114
|
+
*/
|
|
115
|
+
export class Tunnel {
|
|
116
|
+
private current: TunnelState = 'idle'
|
|
117
|
+
private lastFailure: string | undefined
|
|
118
|
+
private child: TunnelChild | undefined
|
|
119
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
120
|
+
/** The pending escalation from SIGTERM to SIGKILL after `stop()`. */
|
|
121
|
+
private killTimer: ReturnType<typeof setTimeout> | undefined
|
|
122
|
+
/** Whether the local port was free when the current attempt spawned; only then can an answer be our own forward. */
|
|
123
|
+
private portWasFree = false
|
|
124
|
+
/**
|
|
125
|
+
* The child that has actually exited, if any.
|
|
126
|
+
*
|
|
127
|
+
* `ChildProcess.killed` means "a signal was sent", not "the process is gone", so it cannot answer "is it still
|
|
128
|
+
* running" — and using it that way makes the SIGKILL escalation unreachable. The exit event is the only truth.
|
|
129
|
+
*/
|
|
130
|
+
private exitedChild: TunnelChild | undefined
|
|
131
|
+
/** A generation counter, so a late callback from a superseded attempt cannot overwrite the current state. */
|
|
132
|
+
private generation = 0
|
|
133
|
+
private retryMs: number
|
|
134
|
+
private readonly maxRetryMs: number
|
|
135
|
+
private readonly spawn: SpawnTunnel
|
|
136
|
+
private readonly probe: ProbePort
|
|
137
|
+
|
|
138
|
+
constructor(private readonly input: TunnelInput) {
|
|
139
|
+
this.spawn = input.spawn ?? realSpawn
|
|
140
|
+
this.probe = input.probe ?? realProbe
|
|
141
|
+
this.retryMs = input.retryMs ?? 1_000
|
|
142
|
+
this.maxRetryMs = input.maxRetryMs ?? 60_000
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
state(): TunnelState { return this.current }
|
|
146
|
+
/** Why the last attempt failed, already rewritten into something a person can act on. */
|
|
147
|
+
failure(): string | undefined { return this.lastFailure }
|
|
148
|
+
|
|
149
|
+
/** Start the first attempt, or do nothing if one is already running. */
|
|
150
|
+
start(): void {
|
|
151
|
+
if (this.current === 'connecting' || this.current === 'connected') return
|
|
152
|
+
void this.attempt()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* End the process and any pending retry; safe to call more than once.
|
|
157
|
+
*
|
|
158
|
+
* SIGTERM first, then SIGKILL: an ssh that ignores or is slow to handle the polite signal would otherwise outlive
|
|
159
|
+
* the plugin, keep `localPort` bound, and make the next activation's first attempt fail on a port it still owns.
|
|
160
|
+
*/
|
|
161
|
+
stop(): void {
|
|
162
|
+
this.generation++
|
|
163
|
+
if (this.timer !== undefined) { clearTimeout(this.timer); this.timer = undefined }
|
|
164
|
+
if (this.killTimer !== undefined) { clearTimeout(this.killTimer); this.killTimer = undefined }
|
|
165
|
+
const child = this.child
|
|
166
|
+
this.child = undefined
|
|
167
|
+
this.set('idle')
|
|
168
|
+
if (child === undefined || this.exitedChild === child) return
|
|
169
|
+
child.kill('SIGTERM')
|
|
170
|
+
this.killTimer = setTimeout(() => {
|
|
171
|
+
if (this.exitedChild !== child) child.kill('SIGKILL')
|
|
172
|
+
}, STOP_GRACE_MS)
|
|
173
|
+
this.killTimer.unref?.()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One attempt: check the port is free, spawn, wait for the local port, then either stay connected or fail. */
|
|
177
|
+
private async attempt(): Promise<void> {
|
|
178
|
+
const generation = ++this.generation
|
|
179
|
+
this.set('connecting')
|
|
180
|
+
// The port has to be free when we spawn, so that a later answer is ours rather than a neighbour's. Without this,
|
|
181
|
+
// a port already bound by something else reads as success on the very first probe — precisely the case
|
|
182
|
+
// ExitOnForwardFailure exists to surface, reported as working.
|
|
183
|
+
const startedFree = !await this.probe(this.input.ssh.localPort)
|
|
184
|
+
if (generation !== this.generation) return
|
|
185
|
+
this.portWasFree = startedFree
|
|
186
|
+
const argv = tunnelArgs(this.input.ssh)
|
|
187
|
+
let stderr = ''
|
|
188
|
+
let settled = false
|
|
189
|
+
let child: TunnelChild
|
|
190
|
+
try { child = this.spawn(argv) }
|
|
191
|
+
catch (error) { this.fail(generation, `could not start ssh: ${String(error)}`); return }
|
|
192
|
+
this.child = child
|
|
193
|
+
const collect = (chunk: Buffer) => { stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_KEEP) }
|
|
194
|
+
child.stderr?.on('data', collect)
|
|
195
|
+
child.on('error', (error: Error) => { if (!settled) { settled = true; this.fail(generation, `could not start ssh: ${error.message}`) } })
|
|
196
|
+
child.on('exit', code => {
|
|
197
|
+
// Recorded before the guards: `stop()` needs to know the process is gone even though the generation moved on.
|
|
198
|
+
this.exitedChild = child
|
|
199
|
+
if (settled || generation !== this.generation) return
|
|
200
|
+
settled = true
|
|
201
|
+
// A clean exit still means the tunnel is gone; only its wording differs.
|
|
202
|
+
this.fail(generation, code === null || code === 0 ? `the tunnel to ${this.input.ssh.host} closed` : explain(this.input.ssh, stderr))
|
|
203
|
+
})
|
|
204
|
+
void this.waitReady(generation, startedFree, () => settled, () => { settled = true })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Poll the local port until it answers or the attempt is superseded; a first answer means the forward works.
|
|
209
|
+
*
|
|
210
|
+
* Reaching `connected` deliberately does NOT mark the attempt settled. The process is still running, and it is
|
|
211
|
+
* exactly the exit that follows a working tunnel — a dropped link, a restarted hub — that has to reach `fail` and
|
|
212
|
+
* schedule a retry. Marking it settled here would leave a device that connects once and then silently stops
|
|
213
|
+
* taking work, which is the hardest failure to notice.
|
|
214
|
+
*/
|
|
215
|
+
private async waitReady(generation: number, startedFree: boolean, settled: () => boolean, markSettled: () => void): Promise<void> {
|
|
216
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
217
|
+
while (Date.now() < deadline) {
|
|
218
|
+
if (generation !== this.generation || settled()) return
|
|
219
|
+
if (await this.probe(this.input.ssh.localPort)) {
|
|
220
|
+
if (generation !== this.generation || settled()) return
|
|
221
|
+
// A port that was already answering when we spawned is somebody else's listener, not our forward. Reporting
|
|
222
|
+
// `connected` there would open the client against a stranger and then flip to `failed`, twice per retry.
|
|
223
|
+
if (!startedFree) {
|
|
224
|
+
markSettled()
|
|
225
|
+
this.fail(generation, `local port ${this.input.ssh.localPort} was already in use before this attempt,`
|
|
226
|
+
+ ` so the device cannot tell its own tunnel from whatever owns that port; close it or set a different localPort`)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
// The forward works, so the retry delay starts over: one good minute should not be followed by a long wait.
|
|
230
|
+
this.retryMs = this.input.retryMs ?? 1_000
|
|
231
|
+
this.lastFailure = undefined
|
|
232
|
+
this.set('connected')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
await new Promise(resolve => setTimeout(resolve, READY_POLL_MS))
|
|
236
|
+
}
|
|
237
|
+
if (generation !== this.generation || settled()) return
|
|
238
|
+
markSettled()
|
|
239
|
+
this.fail(generation, `the tunnel to ${this.input.ssh.host} did not start listening on port ${this.input.ssh.localPort}`
|
|
240
|
+
+ ` within ${READY_TIMEOUT_MS / 1000} seconds`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Record a failure, release the process, and schedule the next attempt with a growing delay. */
|
|
244
|
+
private fail(generation: number, message: string): void {
|
|
245
|
+
if (generation !== this.generation) return
|
|
246
|
+
this.lastFailure = message
|
|
247
|
+
const child = this.child
|
|
248
|
+
this.child = undefined
|
|
249
|
+
if (child !== undefined && !child.killed) child.kill('SIGKILL')
|
|
250
|
+
this.set('failed', message)
|
|
251
|
+
this.input.log?.(`dsh-foreman-node tunnel: ${message}; retrying in ${Math.round(this.retryMs / 1000)}s`)
|
|
252
|
+
const delay = this.retryMs
|
|
253
|
+
this.retryMs = Math.min(this.retryMs * 2, this.maxRetryMs)
|
|
254
|
+
this.timer = setTimeout(() => {
|
|
255
|
+
this.timer = undefined
|
|
256
|
+
if (generation !== this.generation) return
|
|
257
|
+
void this.attempt()
|
|
258
|
+
}, delay)
|
|
259
|
+
// A pending retry must not be the only thing keeping the host process alive.
|
|
260
|
+
this.timer.unref?.()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private set(state: TunnelState, failure?: string): void {
|
|
264
|
+
this.current = state
|
|
265
|
+
this.input.onState?.(state, failure)
|
|
266
|
+
}
|
|
267
|
+
}
|