dsh-remote-tunnel-easy 1.4.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/LICENSE +21 -0
- package/README.md +166 -0
- package/assets/logo.svg +81 -0
- package/cordis.patch.yml +12 -0
- package/index.js +792 -0
- package/lib/client.js +617 -0
- package/lib/cloudflared.js +196 -0
- package/lib/qrcode.js +591 -0
- package/lib/update.js +221 -0
- package/package.json +51 -0
package/index.js
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote-tunnel-easy node half — tunnel + Settings-page QR + same-session
|
|
3
|
+
* handoff for the DSH Web API.
|
|
4
|
+
*
|
|
5
|
+
* On every start this bundle:
|
|
6
|
+
* 1. spawns `cloudflared tunnel --no-autoupdate --url http://127.0.0.1:<ephemeral>`
|
|
7
|
+
* where the ephemeral port is a loopback edge proxy this bundle also
|
|
8
|
+
* spawns, forwarding every request and upgrade to the harness target,
|
|
9
|
+
* 2. extracts the random trycloudflare.com URL from cloudflared's stderr,
|
|
10
|
+
* 3. resolves this process's launch token through the connection service's
|
|
11
|
+
* authenticatedUrl,
|
|
12
|
+
* 4. computes the combined URL + a QR matrix and publishes them to a
|
|
13
|
+
* loopback-only bridge the Settings page card renders — the dsh web
|
|
14
|
+
* terminal prints nothing,
|
|
15
|
+
* 5. seeds the harness's most recently active session into the served index
|
|
16
|
+
* page itself, so ANY entry path — the QR link, the handoff route, or a
|
|
17
|
+
* manually typed tunnel URL — lands inside the exact session the desktop
|
|
18
|
+
* is working in: same workspace, same chat.
|
|
19
|
+
*
|
|
20
|
+
* The edge proxy exists because the harness /api browser-trust fence
|
|
21
|
+
* (packages/client/connection/src/api-request-trust.ts) requires an attached
|
|
22
|
+
* Origin to equal the request Host, and browser-auth binds the session cookie
|
|
23
|
+
* to the Host authority. cloudflared rewrote Host to the loopback target, but
|
|
24
|
+
* the phone browser's Origin stayed `https://<tunnel>` — the app loaded
|
|
25
|
+
* (token login and cookie succeeded over the rewritten Host) while every
|
|
26
|
+
* /api call 403'd: sessions lists rendered empty through the tunnel. The
|
|
27
|
+
* proxy equalizes the pair per request — Host → `localhost:3080`, Origin →
|
|
28
|
+
* `http://localhost:3080` — and passes WebSocket upgrades through the same
|
|
29
|
+
* rewrite, so tunneled traffic is indistinguishable from desktop traffic.
|
|
30
|
+
*
|
|
31
|
+
* Seeding rides the webserver's index-injection table, applied by the static
|
|
32
|
+
* fallback owner on every index render. Two gates keep it surgical:
|
|
33
|
+
* - Host gate: the seed script reads the page's own location and no-ops on
|
|
34
|
+
* loopback/lan origins, so desktop use of the app is never touched. The
|
|
35
|
+
* session id is resolved on the Host at render time (cached, refreshed
|
|
36
|
+
* on a short interval), so the phone browser makes no /api call for the
|
|
37
|
+
* seed itself.
|
|
38
|
+
* - One-shot gate: the script stamps sessionStorage after seeding, so the
|
|
39
|
+
* write happens once per tab visit; afterwards the app's own navigation
|
|
40
|
+
* is authoritative and refreshes keep whatever the user chose.
|
|
41
|
+
*
|
|
42
|
+
* No database, no Firebase, no persisted state. A tree without
|
|
43
|
+
* webServer/connection/sessionController keeps the rows pending, matching
|
|
44
|
+
* the lan-access bundle contract. Disposal kills the cloudflared child, the
|
|
45
|
+
* proxy, and the refresh timer.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { installSettingsSection, settingsNamespace, SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
|
49
|
+
import z from '@deepseek-ai/schemastery'
|
|
50
|
+
import { resolveCloudflared } from './lib/cloudflared.js'
|
|
51
|
+
import { encodeQrMatrix } from './lib/qrcode.js'
|
|
52
|
+
import {
|
|
53
|
+
locateProfileDir, dependencySpec, classifySpec, currentVersion, isNewer,
|
|
54
|
+
fetchLatestVersion, runPnpmAdd, PACKAGE_NAME,
|
|
55
|
+
} from './lib/update.js'
|
|
56
|
+
|
|
57
|
+
const TOKEN_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
58
|
+
const HANDOFF_PATH = '/remote-handoff'
|
|
59
|
+
/** Settings namespace the browser card edits (Settings → Plugins → configurable). */
|
|
60
|
+
const REMOTE_HANDOFF_NS = settingsNamespace('remote-handoff')
|
|
61
|
+
/** Loopback-only HTTP bridge the card reads config + tunnel status through. */
|
|
62
|
+
const BRIDGE_PREFIX = '/api/dsh-remote-handoff-settings'
|
|
63
|
+
/** How often the Host re-resolves the active session for index seeding. */
|
|
64
|
+
const SESSION_REFRESH_MS = 15_000
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bundle config schema. The same object the Settings page edits: `tunnelTarget`
|
|
68
|
+
* is the local harness the quick tunnel forwards to (empty means "auto" — the
|
|
69
|
+
* running webserver's loopback URL), `cloudflaredPath` names the cloudflared
|
|
70
|
+
* binary (empty means "probe PATH, else auto-download"; any non-empty value is
|
|
71
|
+
* an override that disables both), `autoInstallCloudflared` gates the lazy
|
|
72
|
+
* download, and `sessionHandoff` gates the token-gated handoff route and index
|
|
73
|
+
* seed. The QR/link it emits is never printed to the terminal — the browser
|
|
74
|
+
* card fetches it from the loopback bridge and draws the matrix.
|
|
75
|
+
*/
|
|
76
|
+
const Config = z.object({
|
|
77
|
+
tunnelTarget: z.string().default(''),
|
|
78
|
+
cloudflaredPath: z.string().default(''),
|
|
79
|
+
autoInstallCloudflared: z.boolean().default(true),
|
|
80
|
+
sessionHandoff: z.boolean().default(true),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/** Launch token of this process, extracted from the authenticated index URL. */
|
|
84
|
+
function launchToken(connection) {
|
|
85
|
+
const authenticated = connection.authenticatedUrl('http://localhost:3080')
|
|
86
|
+
return new URL(authenticated).searchParams.get('token') ?? ''
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the tunnel upstream from config: an explicit `tunnelTarget` wins,
|
|
91
|
+
* otherwise the running webserver's canonical loopback URL. `webServer` port
|
|
92
|
+
* is the same source `web-app` uses for its own `dsh web` URL, so the auto
|
|
93
|
+
* value can never drift from where the harness actually listens.
|
|
94
|
+
*/
|
|
95
|
+
function resolveTarget(config, webServer) {
|
|
96
|
+
if (typeof config.tunnelTarget === 'string' && config.tunnelTarget !== '') return config.tunnelTarget
|
|
97
|
+
return `http://127.0.0.1:${String(webServer.port)}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function apply(ctx, config) {
|
|
101
|
+
// The live settings source: composition `config` until a settings provider
|
|
102
|
+
// attaches, then its resolved scope (see installSettingsSection below).
|
|
103
|
+
let current = () => config ?? {}
|
|
104
|
+
|
|
105
|
+
// Tunnel status shared with the loopback bridge. The browser card polls
|
|
106
|
+
// /status to draw the QR and link; nothing is ever written to the terminal.
|
|
107
|
+
const status = {
|
|
108
|
+
state: 'starting',
|
|
109
|
+
tunnelUrl: null,
|
|
110
|
+
accessUrl: null,
|
|
111
|
+
matrix: null,
|
|
112
|
+
error: null,
|
|
113
|
+
phase: null,
|
|
114
|
+
resolvedTarget: null,
|
|
115
|
+
}
|
|
116
|
+
/** Registered regenerate handler; replaced by the tunnel effect once ready. */
|
|
117
|
+
let regenerate = null
|
|
118
|
+
|
|
119
|
+
// Self-update state shared with the loopback bridge. The card polls
|
|
120
|
+
// /update-status and drives /check-update + /update. Nothing is printed here.
|
|
121
|
+
const updateStatus = {
|
|
122
|
+
state: 'idle', // idle | checking | available | up-to-date | updating | updated | failed
|
|
123
|
+
currentVersion: currentVersion(),
|
|
124
|
+
latestVersion: null,
|
|
125
|
+
output: null,
|
|
126
|
+
error: null,
|
|
127
|
+
requiresRestart: false,
|
|
128
|
+
}
|
|
129
|
+
let updateInFlight = false
|
|
130
|
+
|
|
131
|
+
/** Locate the owning profile and classify its dependency spec for this package. */
|
|
132
|
+
const resolveInstall = () => {
|
|
133
|
+
const dir = locateProfileDir()
|
|
134
|
+
const spec = dependencySpec(dir)
|
|
135
|
+
return { dir, spec, kind: classifySpec(spec) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Poll the registry and compare versions; surfaces "available" / "up-to-date" / "failed". */
|
|
139
|
+
const checkUpdate = async () => {
|
|
140
|
+
if (updateInFlight) return { ok: false, code: 'busy', message: 'update already in progress' }
|
|
141
|
+
updateStatus.state = 'checking'
|
|
142
|
+
updateStatus.error = null
|
|
143
|
+
try {
|
|
144
|
+
const latest = await fetchLatestVersion()
|
|
145
|
+
updateStatus.latestVersion = latest
|
|
146
|
+
updateStatus.currentVersion = currentVersion()
|
|
147
|
+
const current = updateStatus.currentVersion
|
|
148
|
+
updateStatus.state = current !== undefined && isNewer(latest, current) ? 'available' : 'up-to-date'
|
|
149
|
+
return { ok: true, value: { ...updateStatus } }
|
|
150
|
+
} catch (err) {
|
|
151
|
+
updateStatus.state = 'failed'
|
|
152
|
+
updateStatus.error = err instanceof Error ? err.message : String(err)
|
|
153
|
+
return { ok: false, code: 'failed', message: updateStatus.error }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Install the newest version inside the owning profile, then report "restart required". */
|
|
158
|
+
const updateNow = async () => {
|
|
159
|
+
if (updateInFlight) return { ok: false, code: 'busy', message: 'update already in progress' }
|
|
160
|
+
updateInFlight = true
|
|
161
|
+
try {
|
|
162
|
+
const { dir, spec, kind } = resolveInstall()
|
|
163
|
+
if (dir === undefined) {
|
|
164
|
+
updateStatus.state = 'failed'
|
|
165
|
+
updateStatus.error = 'owning profile not found — run dsh plugin --profile web add dsh-remote-tunnel-easy to reinstall'
|
|
166
|
+
return { ok: false, code: 'failed', message: updateStatus.error }
|
|
167
|
+
}
|
|
168
|
+
const latest = await fetchLatestVersion()
|
|
169
|
+
updateStatus.latestVersion = latest
|
|
170
|
+
updateStatus.currentVersion = currentVersion()
|
|
171
|
+
if (updateStatus.currentVersion !== undefined && !isNewer(latest, updateStatus.currentVersion)) {
|
|
172
|
+
updateStatus.state = 'up-to-date'
|
|
173
|
+
return { ok: true, value: { ...updateStatus } }
|
|
174
|
+
}
|
|
175
|
+
if (kind === 'local') {
|
|
176
|
+
updateStatus.state = 'failed'
|
|
177
|
+
updateStatus.error = `installed via "${spec}" — update that checkout, or re-add from the registry`
|
|
178
|
+
return { ok: false, code: 'local-install', message: updateStatus.error }
|
|
179
|
+
}
|
|
180
|
+
updateStatus.state = 'updating'
|
|
181
|
+
updateStatus.error = null
|
|
182
|
+
const result = await runPnpmAdd(dir)
|
|
183
|
+
updateStatus.state = 'updated'
|
|
184
|
+
updateStatus.requiresRestart = true
|
|
185
|
+
updateStatus.output = result.output ?? null
|
|
186
|
+
return { ok: true, value: { ...updateStatus } }
|
|
187
|
+
} catch (err) {
|
|
188
|
+
updateStatus.state = 'failed'
|
|
189
|
+
updateStatus.error = err instanceof Error ? err.message : String(err)
|
|
190
|
+
return { ok: false, code: 'failed', message: updateStatus.error }
|
|
191
|
+
} finally {
|
|
192
|
+
updateInFlight = false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
installSettingsSection(ctx, REMOTE_HANDOFF_NS, Config, config ?? {}, {
|
|
197
|
+
setSource: (source) => { current = source },
|
|
198
|
+
onChange: () => {},
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
ctx.inject(['webServer', 'settings'], (sctx) => {
|
|
202
|
+
sctx.effect(() => {
|
|
203
|
+
const disposers = makeBridgeRoutes(sctx.settings, () => status, () => regenerate, () => ({ checkUpdate, updateNow, updateStatus })).map((route) => sctx.webServer.register(route))
|
|
204
|
+
return () => { disposers.forEach((dispose) => dispose()) }
|
|
205
|
+
}, 'dsh-remote-tunnel-easy: settings + status bridge')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
ctx.effect(() => {
|
|
209
|
+
// Resolve the startup config once: tunnel target, cloudflared path, and the
|
|
210
|
+
// handoff switch are read when the tunnel/handoff/seed registrations begin,
|
|
211
|
+
// matching the original startup-time semantics. Live settings edits update
|
|
212
|
+
// `current` for the bridge, but do not restart an already-running tunnel
|
|
213
|
+
// (an explicit "Regenerate" is the only restart trigger).
|
|
214
|
+
const startup = current()
|
|
215
|
+
const disposers = [registerHandoff(ctx, startup), registerIndexSeed(ctx, startup)]
|
|
216
|
+
let torn = false
|
|
217
|
+
let active = null
|
|
218
|
+
let inFlight = false
|
|
219
|
+
|
|
220
|
+
const resetStatus = () => {
|
|
221
|
+
status.state = 'starting'
|
|
222
|
+
status.tunnelUrl = null
|
|
223
|
+
status.accessUrl = null
|
|
224
|
+
status.matrix = null
|
|
225
|
+
status.error = null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const resolveBinary = async () => {
|
|
229
|
+
status.phase = 'downloading cloudflared…'
|
|
230
|
+
return resolveCloudflared(startup, (phase) => { status.phase = phase })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Start a tunnel to the resolved target using the resolved binary path.
|
|
234
|
+
const launch = async () => {
|
|
235
|
+
const target = resolveTarget(startup, ctx.webServer)
|
|
236
|
+
status.resolvedTarget = target
|
|
237
|
+
const binary = await resolveBinary().finally(() => { status.phase = null })
|
|
238
|
+
status.phase = 'starting tunnel…'
|
|
239
|
+
return startTunnel(ctx, { ...startup, tunnelTarget: target }, status, binary.path)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Replace the live tunnel with a fresh one (regenerate). Safe against
|
|
243
|
+
// concurrent calls: a second request while one is in flight is rejected.
|
|
244
|
+
regenerate = async () => {
|
|
245
|
+
if (torn) return { ok: false, code: 'disposed', message: 'plugin is disposed' }
|
|
246
|
+
if (inFlight) return { ok: false, code: 'busy', message: 'already regenerating' }
|
|
247
|
+
inFlight = true
|
|
248
|
+
try {
|
|
249
|
+
if (active !== null) { active(); active = null }
|
|
250
|
+
resetStatus()
|
|
251
|
+
active = await launch()
|
|
252
|
+
return { ok: true, value: { state: 'starting' } }
|
|
253
|
+
} catch (err) {
|
|
254
|
+
status.state = 'failed'
|
|
255
|
+
status.phase = null
|
|
256
|
+
status.error = err instanceof Error ? err.message : String(err)
|
|
257
|
+
console.error(`dsh-remote-tunnel-easy: ${err.message}`)
|
|
258
|
+
return { ok: false, code: 'failed', message: status.error }
|
|
259
|
+
} finally {
|
|
260
|
+
inFlight = false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
void launch().then((disposer) => { active = disposer }).catch((err) => {
|
|
265
|
+
status.state = 'failed'
|
|
266
|
+
status.phase = null
|
|
267
|
+
status.error = err instanceof Error ? err.message : String(err)
|
|
268
|
+
console.error(`dsh-remote-tunnel-easy: ${err.message}`)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
return () => {
|
|
272
|
+
torn = true
|
|
273
|
+
regenerate = null
|
|
274
|
+
if (active !== null) active()
|
|
275
|
+
disposers.forEach((dispose) => { dispose() })
|
|
276
|
+
}
|
|
277
|
+
}, 'dsh-remote-tunnel-easy: tunnel + QR + session handoff')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024
|
|
281
|
+
|
|
282
|
+
/** Only requests originating from this machine's loopback are served. */
|
|
283
|
+
function isLoopbackRequest(request) {
|
|
284
|
+
const address = request.socket.remoteAddress
|
|
285
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
286
|
+
const host = request.headers.host
|
|
287
|
+
if (typeof host !== 'string') return false
|
|
288
|
+
let hostUrl
|
|
289
|
+
try {
|
|
290
|
+
hostUrl = new URL('http://' + host)
|
|
291
|
+
} catch {
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
295
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
296
|
+
const origin = request.headers.origin
|
|
297
|
+
if (origin === undefined) return true
|
|
298
|
+
try {
|
|
299
|
+
return new URL(origin).host === hostUrl.host
|
|
300
|
+
} catch {
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function writeJson(res, statusCode, body) {
|
|
306
|
+
const payload = JSON.stringify(body)
|
|
307
|
+
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
308
|
+
res.end(payload)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readJsonBody(req) {
|
|
312
|
+
const chunks = []
|
|
313
|
+
let size = 0
|
|
314
|
+
for await (const chunk of req) {
|
|
315
|
+
const buffer = chunk
|
|
316
|
+
size += buffer.length
|
|
317
|
+
if (size > MAX_JSON_BODY_BYTES) return undefined
|
|
318
|
+
chunks.push(buffer)
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
322
|
+
} catch {
|
|
323
|
+
return undefined
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Project a settings descriptor to the wire view the card consumes. */
|
|
328
|
+
function toView(descriptor) {
|
|
329
|
+
return {
|
|
330
|
+
ns: String(descriptor.ns),
|
|
331
|
+
schema: descriptor.schema,
|
|
332
|
+
value: descriptor.value,
|
|
333
|
+
...(descriptor.base === undefined ? {} : { base: descriptor.base }),
|
|
334
|
+
...(descriptor.user === undefined ? {} : { user: descriptor.user }),
|
|
335
|
+
revision: descriptor.revision,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Build the loopback-only bridge routes (settings describe/mutate + tunnel status + regenerate + self-update). */
|
|
340
|
+
function makeBridgeRoutes(settings, getStatus, getRegenerate, getUpdate) {
|
|
341
|
+
const allowlisted = () =>
|
|
342
|
+
settings
|
|
343
|
+
.describe({ redactSecrets: true })
|
|
344
|
+
.filter((descriptor) => String(descriptor.ns) === String(REMOTE_HANDOFF_NS))
|
|
345
|
+
.map((descriptor) => String(descriptor.ns))
|
|
346
|
+
|
|
347
|
+
const handlers = {
|
|
348
|
+
async describe() {
|
|
349
|
+
const descriptors = settings.describe({ redactSecrets: true })
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
value: {
|
|
353
|
+
namespaces: allowlisted()
|
|
354
|
+
.map((ns) => descriptors.find((descriptor) => String(descriptor.ns) === ns))
|
|
355
|
+
.filter((descriptor) => descriptor !== undefined)
|
|
356
|
+
.map(toView),
|
|
357
|
+
writable: settings.writable !== false,
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
async mutate(request) {
|
|
362
|
+
const body = request
|
|
363
|
+
if (body === null || typeof body !== 'object' || typeof body.ns !== 'string' || !Array.isArray(body.ops)) {
|
|
364
|
+
return { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' }
|
|
365
|
+
}
|
|
366
|
+
const { ns } = body
|
|
367
|
+
if (!allowlisted().includes(ns)) {
|
|
368
|
+
return { ok: false, code: 'settings-not-exposed', message: `settings namespace "${ns}" is not exposed` }
|
|
369
|
+
}
|
|
370
|
+
const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
|
|
371
|
+
try {
|
|
372
|
+
await settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error instanceof SettingsConflictError) {
|
|
375
|
+
return { ok: false, code: 'settings-conflict', message: error.message }
|
|
376
|
+
}
|
|
377
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
378
|
+
return { ok: false, code: 'internal', message }
|
|
379
|
+
}
|
|
380
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === ns)
|
|
381
|
+
if (descriptor === undefined) {
|
|
382
|
+
return { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` }
|
|
383
|
+
}
|
|
384
|
+
return { ok: true, value: toView(descriptor) }
|
|
385
|
+
},
|
|
386
|
+
async status() {
|
|
387
|
+
const s = getStatus()
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
value: {
|
|
391
|
+
state: s.state,
|
|
392
|
+
...(s.tunnelUrl === null ? {} : { tunnelUrl: s.tunnelUrl }),
|
|
393
|
+
...(s.accessUrl === null ? {} : { accessUrl: s.accessUrl }),
|
|
394
|
+
...(s.matrix === null ? {} : { matrix: s.matrix }),
|
|
395
|
+
...(s.error === null ? {} : { error: s.error }),
|
|
396
|
+
...(s.phase === null ? {} : { phase: s.phase }),
|
|
397
|
+
...(s.resolvedTarget === null ? {} : { resolvedTarget: s.resolvedTarget }),
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
async regenerate() {
|
|
402
|
+
// Serialize through the effect's mutex; concurrent clicks fail with "busy".
|
|
403
|
+
if (typeof getRegenerate() !== 'function') {
|
|
404
|
+
return { ok: false, code: 'not-ready', message: 'regenerate is not available yet' }
|
|
405
|
+
}
|
|
406
|
+
return getRegenerate()()
|
|
407
|
+
},
|
|
408
|
+
async updateStatusHandler() {
|
|
409
|
+
const { updateStatus: u } = getUpdate()
|
|
410
|
+
return { ok: true, value: { ...u } }
|
|
411
|
+
},
|
|
412
|
+
async checkUpdate() {
|
|
413
|
+
return getUpdate().checkUpdate()
|
|
414
|
+
},
|
|
415
|
+
async doUpdate() {
|
|
416
|
+
return getUpdate().updateNow()
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const guard = (req, res) => {
|
|
421
|
+
if (!isLoopbackRequest(req)) {
|
|
422
|
+
writeJson(res, 403, { error: 'loopback requests only' })
|
|
423
|
+
return false
|
|
424
|
+
}
|
|
425
|
+
if (req.method !== 'POST') {
|
|
426
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method ?? ''}` })
|
|
427
|
+
return false
|
|
428
|
+
}
|
|
429
|
+
return true
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return [
|
|
433
|
+
{
|
|
434
|
+
kind: 'exact',
|
|
435
|
+
path: `${BRIDGE_PREFIX}/describe`,
|
|
436
|
+
handler: async (req, res) => {
|
|
437
|
+
if (!guard(req, res)) return
|
|
438
|
+
writeJson(res, 200, await handlers.describe())
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
kind: 'exact',
|
|
443
|
+
path: `${BRIDGE_PREFIX}/mutate`,
|
|
444
|
+
handler: async (req, res) => {
|
|
445
|
+
if (!guard(req, res)) return
|
|
446
|
+
const body = await readJsonBody(req)
|
|
447
|
+
if (body === undefined) {
|
|
448
|
+
writeJson(res, 400, { ok: false, code: 'settings-rejected', message: 'malformed JSON body' })
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
writeJson(res, 200, await handlers.mutate(body))
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
kind: 'exact',
|
|
456
|
+
path: `${BRIDGE_PREFIX}/status`,
|
|
457
|
+
handler: async (req, res) => {
|
|
458
|
+
if (!guard(req, res)) return
|
|
459
|
+
writeJson(res, 200, await handlers.status())
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
kind: 'exact',
|
|
464
|
+
path: `${BRIDGE_PREFIX}/regenerate`,
|
|
465
|
+
handler: async (req, res) => {
|
|
466
|
+
if (!guard(req, res)) return
|
|
467
|
+
writeJson(res, 200, await handlers.regenerate())
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
kind: 'exact',
|
|
472
|
+
path: `${BRIDGE_PREFIX}/update-status`,
|
|
473
|
+
handler: async (req, res) => {
|
|
474
|
+
if (!guard(req, res)) return
|
|
475
|
+
writeJson(res, 200, await handlers.updateStatusHandler())
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
kind: 'exact',
|
|
480
|
+
path: `${BRIDGE_PREFIX}/check-update`,
|
|
481
|
+
handler: async (req, res) => {
|
|
482
|
+
if (!guard(req, res)) return
|
|
483
|
+
writeJson(res, 200, await handlers.checkUpdate())
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
kind: 'exact',
|
|
488
|
+
path: `${BRIDGE_PREFIX}/update`,
|
|
489
|
+
handler: async (req, res) => {
|
|
490
|
+
if (!guard(req, res)) return
|
|
491
|
+
writeJson(res, 200, await handlers.doUpdate())
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Encode `text` into a QR matrix using the vendored encoder (see lib/qrcode.js).
|
|
499
|
+
* Returns the module count and a count×count grid of 0/1 cells the browser
|
|
500
|
+
* renders — nothing is drawn here. The encoder is shipped in-tree so the QR
|
|
501
|
+
* step needs no runtime dependency (a `link:`/`file:` install cannot require a
|
|
502
|
+
* dependency package from inside the checkout).
|
|
503
|
+
*/
|
|
504
|
+
function qrMatrix(text) {
|
|
505
|
+
return encodeQrMatrix(text)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Pick the session the QR should open: the newest ordinary, non-blank
|
|
510
|
+
* summary by updatedAt (the sort is explicit because the wire's
|
|
511
|
+
* activity ordering is a courtesy, not a contract). Blank sessions are
|
|
512
|
+
* skipped so a just-created empty session never wins; subagent rows are
|
|
513
|
+
* skipped because the client selects them through their parent's catalog,
|
|
514
|
+
* not as bare session ids.
|
|
515
|
+
* @returns session id, or undefined when no ordinary session exists.
|
|
516
|
+
*/
|
|
517
|
+
export function pickActiveSession(summaries) {
|
|
518
|
+
const candidates = summaries.filter(s => s.blank !== true && s.origin === undefined)
|
|
519
|
+
if (candidates.length === 0) return undefined
|
|
520
|
+
candidates.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
521
|
+
return candidates[0]?.sessionId
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Length-safe constant-time equality of two token strings via their SHA-256 digests. */
|
|
525
|
+
function tokensMatch(candidate, expected) {
|
|
526
|
+
const crypto = process.getBuiltinModule('node:crypto')
|
|
527
|
+
const a = crypto.createHash('sha256').update(candidate).digest()
|
|
528
|
+
const b = crypto.createHash('sha256').update(expected).digest()
|
|
529
|
+
return crypto.timingSafeEqual(a, b)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** The handoff page: seed the persisted selection, then continue the normal login. */
|
|
533
|
+
function handoffHtml(sessionId, continueUrl) {
|
|
534
|
+
// Stringify twice: the inner JSON is the storage cell text, the outer
|
|
535
|
+
// literal embeds it (and the URL) without any markup-breaking characters.
|
|
536
|
+
const cell = JSON.stringify({ sessionId }).replaceAll('<', '\\u003c')
|
|
537
|
+
return [
|
|
538
|
+
'<!doctype html><html><head><meta charset="utf-8"><title>Opening harness…</title></head><body>',
|
|
539
|
+
`<script>try{localStorage.setItem('dsh.sessions.current',${JSON.stringify(cell)})}catch(e){}`,
|
|
540
|
+
`location.replace(${JSON.stringify(continueUrl)})</script>`,
|
|
541
|
+
`<noscript><a href="${continueUrl.replaceAll('"', '"')}">Continue to the harness</a></noscript>`,
|
|
542
|
+
'</body></html>',
|
|
543
|
+
].join('')
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Serve the token-gated handoff over a plain webServer route (no fence, no
|
|
548
|
+
* cookie requirement): the launch token in the query is the credential, the
|
|
549
|
+
* same secret that guards the index. Prefix match so both /remote-handoff
|
|
550
|
+
* and /remote-handoff/ reach the handler — the exact match refused the
|
|
551
|
+
* trailing slash, which phones downloaded as a 404 .txt. Every scan
|
|
552
|
+
* re-resolves the live active session; plain index visits never seed here
|
|
553
|
+
* (the index injection covers them), so desktop use is untouched.
|
|
554
|
+
*/
|
|
555
|
+
function registerHandoff(ctx, config) {
|
|
556
|
+
if (config.sessionHandoff === false) return () => {}
|
|
557
|
+
const expected = launchToken(ctx.connection)
|
|
558
|
+
const handler = async (req, res) => {
|
|
559
|
+
const url = new URL(req.url ?? '/', 'http://dsh.invalid')
|
|
560
|
+
const token = url.searchParams.get('token') ?? ''
|
|
561
|
+
if (expected === '' || !tokensMatch(token, expected)) {
|
|
562
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
563
|
+
res.end('not found\n')
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
let sessionId
|
|
567
|
+
try {
|
|
568
|
+
const value = await ctx.sessionController.list({}, AbortSignal.timeout(5000))
|
|
569
|
+
sessionId = pickActiveSession(value.items ?? [])
|
|
570
|
+
} catch {
|
|
571
|
+
// List unavailable (e.g. during boot): fall through to the plain login
|
|
572
|
+
// — the QR still works, it just opens the app's default state.
|
|
573
|
+
}
|
|
574
|
+
const continueUrl = `/?token=${encodeURIComponent(token)}`
|
|
575
|
+
if (sessionId === undefined) {
|
|
576
|
+
res.writeHead(303, { 'cache-control': 'no-store', location: continueUrl, 'referrer-policy': 'no-referrer' })
|
|
577
|
+
res.end()
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
|
|
581
|
+
res.end(handoffHtml(sessionId, continueUrl))
|
|
582
|
+
}
|
|
583
|
+
// Prefix match (not exact) so both /remote-handoff and /remote-handoff/…
|
|
584
|
+
// reach the handler — the webserver's exact match refuses the trailing
|
|
585
|
+
// slash, which phones previously downloaded as a 404 .txt. The handler
|
|
586
|
+
// ignores the pathname and parses the query itself, and no other route
|
|
587
|
+
// begins with /remote-handoff, so the wider match cannot shadow anything.
|
|
588
|
+
return ctx.effect(
|
|
589
|
+
() => ctx.webServer.register({ kind: 'prefix', path: HANDOFF_PATH, handler }),
|
|
590
|
+
`dsh-remote-tunnel-easy: ${HANDOFF_PATH} handoff route`,
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Seed the app's persisted selection directly into the served index page so
|
|
596
|
+
* ANY tunnel entry (QR link, handoff, or a manually typed tunnel URL) opens
|
|
597
|
+
* the harness's active session. The Host resolves the session id at render
|
|
598
|
+
* time through a short-interval cache; the injected script applies it only
|
|
599
|
+
* for non-loopback origins (desktop localhost is never touched) and only
|
|
600
|
+
* once per browser tab (sessionStorage flag), after which the app's own
|
|
601
|
+
* navigation is authoritative.
|
|
602
|
+
*/
|
|
603
|
+
function registerIndexSeed(ctx, config) {
|
|
604
|
+
if (config.sessionHandoff === false) return () => {}
|
|
605
|
+
let cachedSessionId
|
|
606
|
+
const refresh = async () => {
|
|
607
|
+
try {
|
|
608
|
+
const value = await ctx.sessionController.list({}, AbortSignal.timeout(5000))
|
|
609
|
+
cachedSessionId = pickActiveSession(value.items ?? [])
|
|
610
|
+
} catch {
|
|
611
|
+
// List unavailable (e.g. mid-boot): keep the previous value; an
|
|
612
|
+
// undefined cache simply renders without the seed.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
void refresh()
|
|
616
|
+
const timer = setInterval(() => { void refresh() }, SESSION_REFRESH_MS)
|
|
617
|
+
const row = () => ({
|
|
618
|
+
kind: 'script',
|
|
619
|
+
placement: 'head',
|
|
620
|
+
// Loopback gate: desktop origins the app is normally served on
|
|
621
|
+
// (localhost names, 127/8, ::1) never seed, so LAN-IP desktop use via
|
|
622
|
+
// the lan-access bundle and plain localhost use are both untouched.
|
|
623
|
+
text: [
|
|
624
|
+
'try{(function(){',
|
|
625
|
+
'var h=location.hostname;',
|
|
626
|
+
'var loopback=(h==="localhost"||h.slice(-10)===".localhost"||h.startsWith("127.")||h==="::1"||h==="[::1]");',
|
|
627
|
+
'if(loopback)return;',
|
|
628
|
+
'if(sessionStorage.getItem("__dsh_qr_seeded"))return;',
|
|
629
|
+
'var sid=globalThis.__DSH_QR_SESSION__;',
|
|
630
|
+
'if(!sid)return;',
|
|
631
|
+
'sessionStorage.setItem("__dsh_qr_seeded","1");',
|
|
632
|
+
"localStorage.setItem('dsh.sessions.current',",
|
|
633
|
+
'JSON.stringify({sessionId:sid}));',
|
|
634
|
+
'})()}catch(e){}',
|
|
635
|
+
].join(''),
|
|
636
|
+
})
|
|
637
|
+
ctx.on('webserver/index-inject', (table) => {
|
|
638
|
+
if (cachedSessionId !== undefined) {
|
|
639
|
+
table.push({ kind: 'global', name: '__DSH_QR_SESSION__', value: cachedSessionId })
|
|
640
|
+
table.push(row())
|
|
641
|
+
}
|
|
642
|
+
})
|
|
643
|
+
return () => clearInterval(timer)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Loopback edge proxy between cloudflared and the harness. Equalizes the
|
|
648
|
+
* Host/Origin pair the /api trust fence checks (see the module docblock) and
|
|
649
|
+
* forwards everything else untouched. Binds 127.0.0.1 only — the tunnel is
|
|
650
|
+
* the sole remote client, and no LAN attacker can reach the rewrite without
|
|
651
|
+
* first possessing the loopback boundary. WebSocket upgrades are piped raw
|
|
652
|
+
* after the same header rewrite.
|
|
653
|
+
* @param config - bundle config with `tunnelTarget` already resolved to the upstream.
|
|
654
|
+
* @returns disposer closing the server, its sockets, and tracked upstreams.
|
|
655
|
+
*/
|
|
656
|
+
function startEdgeProxy(config) {
|
|
657
|
+
const http = process.getBuiltinModule('node:http')
|
|
658
|
+
const target = new URL(config.tunnelTarget ?? 'http://localhost:3080')
|
|
659
|
+
const upstreamAuthority = target.host
|
|
660
|
+
const webSocketKey = 'sec-websocket-key'
|
|
661
|
+
|
|
662
|
+
const server = http.createServer((req, res) => {
|
|
663
|
+
// cloudflared → proxy is a fixed loopback hop carrying public-request
|
|
664
|
+
// headers only; replace (never merge) the two fence-checked values.
|
|
665
|
+
req.headers.host = upstreamAuthority
|
|
666
|
+
req.headers.origin = `http://${upstreamAuthority}`
|
|
667
|
+
// setHost:false keeps the rewritten Host header verbatim — the options
|
|
668
|
+
// host otherwise overrides it and the fence sees the wrong authority.
|
|
669
|
+
const upstream = http.request({ host: target.hostname, port: target.port, path: req.url, method: req.method, headers: req.headers, setHost: false }, (up) => {
|
|
670
|
+
res.writeHead(up.statusCode, up.headers)
|
|
671
|
+
up.pipe(res)
|
|
672
|
+
})
|
|
673
|
+
upstream.on('error', () => {
|
|
674
|
+
if (res.headersSent) { res.destroy(); return }
|
|
675
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
676
|
+
res.end('harness upstream unreachable\n')
|
|
677
|
+
})
|
|
678
|
+
req.pipe(upstream)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
server.on('upgrade', (req, socket) => {
|
|
682
|
+
req.headers.host = upstreamAuthority
|
|
683
|
+
req.headers.origin = `http://${upstreamAuthority}`
|
|
684
|
+
// setHost:false keeps the rewritten Host verbatim (see the GET path).
|
|
685
|
+
const upstream = http.request({
|
|
686
|
+
host: target.hostname,
|
|
687
|
+
port: target.port,
|
|
688
|
+
path: req.url,
|
|
689
|
+
headers: { ...req.headers, connection: 'Upgrade', upgrade: req.headers.upgrade },
|
|
690
|
+
setHost: false,
|
|
691
|
+
})
|
|
692
|
+
upstream.on('upgrade', (upRes, upSocket, upHead) => {
|
|
693
|
+
// Tunnel to tunnel: forward the 101 and raw bytes both ways.
|
|
694
|
+
const reply = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage}`]
|
|
695
|
+
for (const [name, value] of Object.entries(upRes.headers)) {
|
|
696
|
+
if (value !== undefined) reply.push(`${name}: ${Array.isArray(value) ? value.join(', ') : value}`)
|
|
697
|
+
}
|
|
698
|
+
socket.write(`${reply.join('\r\n')}\r\n\r\n`)
|
|
699
|
+
if (upHead.length > 0) socket.write(upHead)
|
|
700
|
+
upSocket.pipe(socket)
|
|
701
|
+
socket.pipe(upSocket)
|
|
702
|
+
const die = () => { socket.destroy(); upSocket.destroy() }
|
|
703
|
+
socket.on('error', die)
|
|
704
|
+
upSocket.on('error', die)
|
|
705
|
+
socket.on('close', die)
|
|
706
|
+
upSocket.on('close', die)
|
|
707
|
+
})
|
|
708
|
+
upstream.on('error', () => { socket.destroy() })
|
|
709
|
+
upstream.end()
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
return new Promise((resolve) => {
|
|
713
|
+
server.listen(0, '127.0.0.1', () => {
|
|
714
|
+
resolve({
|
|
715
|
+
port: server.address().port,
|
|
716
|
+
dispose: () => {
|
|
717
|
+
server.closeAllConnections()
|
|
718
|
+
server.close()
|
|
719
|
+
},
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Run cloudflared for the fiber's lifetime and publish the QR matrix + link
|
|
727
|
+
* into the `status` store once the random URL appears on stderr. Nothing is
|
|
728
|
+
* printed to the terminal — the browser card reads /status and draws the QR.
|
|
729
|
+
* cloudflared points at the loopback edge proxy (not the harness directly) so
|
|
730
|
+
* tunneled requests arrive fence-clean. Reconnect loops are unnecessary:
|
|
731
|
+
* quick tunnels live for the child's lifetime and the effect dies with the
|
|
732
|
+
* plugin.
|
|
733
|
+
*/
|
|
734
|
+
async function startTunnel(ctx, config, status, cloudflaredBin) {
|
|
735
|
+
const cp = process.getBuiltinModule('node:child_process')
|
|
736
|
+
const upstream = await startEdgeProxy(config)
|
|
737
|
+
const child = cp.spawn(cloudflaredBin, [
|
|
738
|
+
'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${String(upstream.port)}`,
|
|
739
|
+
], { windowsHide: true })
|
|
740
|
+
|
|
741
|
+
let published = false
|
|
742
|
+
|
|
743
|
+
const publish = async (tunnelUrl) => {
|
|
744
|
+
const token = launchToken(ctx.connection) ?? ''
|
|
745
|
+
const accessUrl = `${tunnelUrl}${HANDOFF_PATH}?token=${encodeURIComponent(token)}`
|
|
746
|
+
// Lazy-load the bundled QR vendor so a tunnel failure never prevents the
|
|
747
|
+
// route tree from mounting. Only the matrix is computed here; the browser
|
|
748
|
+
// renders it inside the Settings card.
|
|
749
|
+
const { count, modules } = await qrMatrix(accessUrl)
|
|
750
|
+
status.state = 'ready'
|
|
751
|
+
status.phase = null
|
|
752
|
+
status.tunnelUrl = tunnelUrl
|
|
753
|
+
status.accessUrl = accessUrl
|
|
754
|
+
status.matrix = { count, modules }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
child.stderr.setEncoding('utf8')
|
|
758
|
+
child.stderr.on('data', (chunk) => {
|
|
759
|
+
const match = TOKEN_RE.exec(chunk)
|
|
760
|
+
if (match !== null && !published) {
|
|
761
|
+
published = true
|
|
762
|
+
void publish(match[0]).catch((err) => {
|
|
763
|
+
status.state = 'failed'
|
|
764
|
+
status.error = err instanceof Error ? err.message : String(err)
|
|
765
|
+
console.error(`dsh-remote-tunnel-easy: ${err.message}`)
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
})
|
|
769
|
+
child.on('exit', (code) => {
|
|
770
|
+
if (!published) {
|
|
771
|
+
status.state = 'failed'
|
|
772
|
+
status.error = `cloudflared exited early (code ${String(code)}) — no tunnel URL`
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
const retry = setTimeout(() => {
|
|
777
|
+
if (!published) {
|
|
778
|
+
status.state = 'failed'
|
|
779
|
+
status.error = 'no tunnel URL after 60s — cloudflared may be offline'
|
|
780
|
+
}
|
|
781
|
+
}, 60_000)
|
|
782
|
+
|
|
783
|
+
return () => {
|
|
784
|
+
clearTimeout(retry)
|
|
785
|
+
child.kill()
|
|
786
|
+
upstream.dispose()
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export const name = 'dsh-remote-tunnel-easy'
|
|
791
|
+
export const inject = ['webServer', 'connection', 'sessionController']
|
|
792
|
+
export { apply, startEdgeProxy, Config }
|