ddeck-remote 0.0.0-stage → 0.9.7-beta
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 +202 -0
- package/NOTICE +40 -0
- package/README.md +96 -2
- package/THIRD-PARTY-LICENSES.md +69 -0
- package/cordis.patch.yml +22 -0
- package/icon.svg +6 -0
- package/install.cmd +4 -0
- package/install.ps1 +119 -0
- package/install.sh +95 -0
- package/lib/admin-trust.js +64 -0
- package/lib/client.js +751 -0
- package/lib/constants.js +11 -0
- package/lib/gateway-bundle.js +7063 -0
- package/lib/gateway.js +275 -0
- package/lib/index.js +1088 -0
- package/lib/mobile/index.js +82 -0
- package/lib/mobile/skin.css +1904 -0
- package/lib/mobile/skin.js +3614 -0
- package/lib/node-forge-bundle.js +17841 -0
- package/lib/qr-bundle.js +1990 -0
- package/lib/tls.js +173 -0
- package/locale/en.json +4 -0
- package/locale/ru.json +4 -0
- package/package.json +63 -4
- package/uninstall.ps1 +60 -0
- package/uninstall.sh +31 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DDeck — the computer half.
|
|
3
|
+
*
|
|
4
|
+
* One job the person can see: show a code that turns their phone into a remote control
|
|
5
|
+
* for this computer. Everything a phone needs beyond that — the pairing handshake, the
|
|
6
|
+
* session credential, the proxy to the harness, the mobile page — lives behind it.
|
|
7
|
+
*
|
|
8
|
+
* The pairing window is deliberately short-lived and rotates on its own. A wedged QR on
|
|
9
|
+
* a screen is a credential sitting in the open; one that refreshes every few minutes is
|
|
10
|
+
* a credential with a shelf life, and the person never has to press "regenerate" because
|
|
11
|
+
* they were reading the instructions.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-remote
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
17
|
+
import { execFile, spawn } from 'node:child_process'
|
|
18
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { homedir, networkInterfaces } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { autoOrigins, defaultStateDirectory, openReadablePairing, startGateway } from './gateway.js'
|
|
23
|
+
import { renderQr as renderQrSvg } from './qr-bundle.js'
|
|
24
|
+
import { isTrustedAdminRequest } from './admin-trust.js'
|
|
25
|
+
import { PAIRING_TTL_MS } from './constants.js'
|
|
26
|
+
|
|
27
|
+
/** Stable Cordis plugin name; must equal the package name (see cordis.patch.yml). */
|
|
28
|
+
export const name = 'ddeck-remote'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The web server hosts our routes; `connection` supplies the launch-token URL the
|
|
32
|
+
* gateway exchanges for an upstream session. Without that exchange the harness answers
|
|
33
|
+
* every proxied request with 401 and the phone shows `upstream_unavailable`.
|
|
34
|
+
*/
|
|
35
|
+
export const inject = ['webServer', 'connection']
|
|
36
|
+
|
|
37
|
+
/** Prefix every route of this plugin lives under. */
|
|
38
|
+
export const ROUTE_PREFIX = '/dsh-remote'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Prefix of the phone-facing page.
|
|
42
|
+
*
|
|
43
|
+
* The split is what protects the pairing window. A gateway in front of DSH proxies to
|
|
44
|
+
* it from loopback, so an address check cannot tell the person at the keyboard from a
|
|
45
|
+
* phone that is already paired — both arrive from 127.0.0.1. The split therefore has to
|
|
46
|
+
* be by *path*: the phone loads `${MOBILE_PREFIX}`, and the gateway refuses the whole of
|
|
47
|
+
* `${ROUTE_PREFIX}` for proxied traffic. Without that refusal a paired phone could open
|
|
48
|
+
* the pairing page and add itself a companion, which is the one escalation this whole
|
|
49
|
+
* scheme exists to prevent.
|
|
50
|
+
*/
|
|
51
|
+
export const MOBILE_PREFIX = '/m'
|
|
52
|
+
|
|
53
|
+
/** How long a pairing window stays valid. */
|
|
54
|
+
const WINDOW_MS = PAIRING_TTL_MS
|
|
55
|
+
|
|
56
|
+
/** Версия плагина из единственного источника — plugin/package.json. */
|
|
57
|
+
const PLUGIN_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where the plugin keeps its own small state.
|
|
61
|
+
*
|
|
62
|
+
* Not the gateway's state directory: this file holds the instance identity and the
|
|
63
|
+
* secret that derives pairing codes, and it belongs to the plugin that owns it.
|
|
64
|
+
*
|
|
65
|
+
* @returns {string} absolute path to the state file.
|
|
66
|
+
*/
|
|
67
|
+
function statePath() {
|
|
68
|
+
return join(homedir(), '.dsh', 'dsh-remote', 'state.json')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** File with the password that unlocks pairing from an already paired phone. */
|
|
72
|
+
function mobilePinPath() {
|
|
73
|
+
return join(homedir(), '.dsh', 'dsh-remote', 'mobile-pin.json')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the stored phone-pairing password record.
|
|
78
|
+
*
|
|
79
|
+
* The password is never stored in clear text: only a random salt and a scrypt
|
|
80
|
+
* hash. Missing or unreadable file means the feature is not configured.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<{salt: string, hash: string} | undefined>}
|
|
83
|
+
*/
|
|
84
|
+
async function readMobilePin() {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(await readFile(mobilePinPath(), 'utf8'))
|
|
87
|
+
if (typeof parsed.salt !== 'string' || typeof parsed.hash !== 'string') return undefined
|
|
88
|
+
return { salt: parsed.salt, hash: parsed.hash }
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compare a typed password with the stored scrypt record.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} password typed password.
|
|
98
|
+
* @param {{salt: string, hash: string}} record stored salt/hash.
|
|
99
|
+
* @returns {boolean} true when the password matches.
|
|
100
|
+
*/
|
|
101
|
+
function verifyMobilePin(password, record) {
|
|
102
|
+
const candidate = scryptSync(password, Buffer.from(record.salt, 'hex'), 32)
|
|
103
|
+
const expected = Buffer.from(record.hash, 'hex')
|
|
104
|
+
return candidate.length === expected.length && timingSafeEqual(candidate, expected)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Load or create the persistent identity.
|
|
109
|
+
*
|
|
110
|
+
* The instance id names this installation in a QR payload, and the secret turns a
|
|
111
|
+
* pairing token into a six-digit code. Both survive restarts: a phone that scanned a
|
|
112
|
+
* code must keep recognising the same computer, and a code shown a minute ago must
|
|
113
|
+
* still verify after a plugin reload.
|
|
114
|
+
*
|
|
115
|
+
* @returns {Promise<{ instanceId: string, secret: string, devices: object[] }>}
|
|
116
|
+
*/
|
|
117
|
+
async function loadState() {
|
|
118
|
+
const file = statePath()
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'))
|
|
121
|
+
if (typeof parsed.instanceId === 'string' && typeof parsed.secret === 'string') {
|
|
122
|
+
return { devices: [], ...parsed }
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// First run, or a file we cannot read: a fresh identity is the safe answer.
|
|
126
|
+
}
|
|
127
|
+
const fresh = {
|
|
128
|
+
instanceId: randomBytes(16).toString('hex'),
|
|
129
|
+
secret: randomBytes(32).toString('hex'),
|
|
130
|
+
devices: [],
|
|
131
|
+
}
|
|
132
|
+
await mkdir(dirname(file), { recursive: true })
|
|
133
|
+
await writeFile(file, `${JSON.stringify(fresh, null, 2)}\n`, { mode: 0o600 })
|
|
134
|
+
return fresh
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Derive the six-digit code for a pairing token.
|
|
139
|
+
*
|
|
140
|
+
* The code is not a second secret: it is the same window in a form a person can read
|
|
141
|
+
* aloud or type when the camera will not cooperate. Digits only, no letters — the
|
|
142
|
+
* alphabet question disappears, and there is nothing for a reader to confuse.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} secret - per-installation secret.
|
|
145
|
+
* @param {string} token - pairing token of the open window.
|
|
146
|
+
* @returns {string} six digits, zero-padded.
|
|
147
|
+
*/
|
|
148
|
+
function newCode() {
|
|
149
|
+
// Independent of the QR ticket on purpose: the ticket rotates every minute or so, and
|
|
150
|
+
// a code that changed with it could never be typed from a screenshot. Six digits from
|
|
151
|
+
// the whole range, zero-padded, so `000042` is a valid code.
|
|
152
|
+
return String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, '0')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The live pairing window, if one is open.
|
|
157
|
+
*
|
|
158
|
+
* Held in memory on purpose: closing the plugin, restarting DSH or a crash must not
|
|
159
|
+
* leave a usable credential behind, and a window is cheap to open again.
|
|
160
|
+
*/
|
|
161
|
+
class PairingWindow {
|
|
162
|
+
constructor() {
|
|
163
|
+
/** @type {{ token: string, code: string, expiresAt: number, attempts: number } | undefined} */
|
|
164
|
+
this.current = undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Return the open window, opening a fresh one when the old one has expired.
|
|
169
|
+
*
|
|
170
|
+
* @returns {{ token: string, code: string, expiresAt: number }}
|
|
171
|
+
*/
|
|
172
|
+
open() {
|
|
173
|
+
const now = Date.now()
|
|
174
|
+
if (this.current !== undefined && this.current.expiresAt > now) {
|
|
175
|
+
return this.current
|
|
176
|
+
}
|
|
177
|
+
// 24 bytes of entropy: a token that cannot be guessed from a photograph of the
|
|
178
|
+
// screen next to it, and short enough for a compact QR.
|
|
179
|
+
const token = randomBytes(24).toString('base64url')
|
|
180
|
+
this.current = { token, code: newCode(), expiresAt: now + WINDOW_MS }
|
|
181
|
+
return this.current
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render the phone's entry page.
|
|
188
|
+
*
|
|
189
|
+
* Two languages are served from one document: the page picks by `Accept-Language` and
|
|
190
|
+
* carries a switch, because the person holding the phone decides which language they
|
|
191
|
+
* read, and a product that guesses once and never asks gets it wrong for someone.
|
|
192
|
+
*
|
|
193
|
+
* @param {'ru' | 'en'} language
|
|
194
|
+
* @param {{ pairing: { code: string }, qrSvg: string }} view
|
|
195
|
+
* @returns {string} complete HTML document.
|
|
196
|
+
*/
|
|
197
|
+
function entryPage(language, view) {
|
|
198
|
+
const ru = language === 'ru'
|
|
199
|
+
const text = {
|
|
200
|
+
heading: ru ? 'Подключение телефона' : 'Connect your phone',
|
|
201
|
+
lead: ru
|
|
202
|
+
? 'Откройте приложение DDeck и наведите камеру на код.'
|
|
203
|
+
: 'Open the DDeck app and point its camera at the code.',
|
|
204
|
+
or: ru ? 'или введите код на телефоне' : 'or type this code on the phone',
|
|
205
|
+
where: ru ? 'телефон подключится к' : 'the phone will connect to',
|
|
206
|
+
alsoReachable: ru ? 'также доступен по' : 'also reachable at',
|
|
207
|
+
status: ru ? 'Плагин работает' : 'Plugin running',
|
|
208
|
+
noGateway: ru
|
|
209
|
+
? 'Шлюз не запустился — телефон не подключится. Подробности в журнале DSH.'
|
|
210
|
+
: 'The gateway did not start — the phone cannot connect. See the DSH log.',
|
|
211
|
+
connected: ru ? 'Телефон подключён' : 'Phone connected',
|
|
212
|
+
connectedLead: ru ? 'Этот компьютер открыт для перечисленных устройств.' : 'This computer is open to the devices listed below.',
|
|
213
|
+
revoke: ru ? 'Отключить' : 'Disconnect',
|
|
214
|
+
pairAnother: ru ? 'Подключить ещё один телефон' : 'Connect another phone',
|
|
215
|
+
advanced: ru ? 'Дополнительно' : 'Advanced',
|
|
216
|
+
switchLabel: ru ? 'English' : 'Русский',
|
|
217
|
+
}
|
|
218
|
+
const switchHref = `${ROUTE_PREFIX}?lang=${ru ? 'en' : 'ru'}`
|
|
219
|
+
const paired = view.devices.length > 0
|
|
220
|
+
const deviceWord = (count) => {
|
|
221
|
+
if (!ru) return count === 1 ? 'device' : 'devices'
|
|
222
|
+
const mod10 = count % 10
|
|
223
|
+
const mod100 = count % 100
|
|
224
|
+
if (mod10 === 1 && mod100 !== 11) return 'устройство'
|
|
225
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'устройства'
|
|
226
|
+
return 'устройств'
|
|
227
|
+
}
|
|
228
|
+
const when = (time) => {
|
|
229
|
+
const locale = ru ? 'ru-RU' : 'en-GB'
|
|
230
|
+
return new Date(time).toLocaleString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
231
|
+
}
|
|
232
|
+
const advanced = [
|
|
233
|
+
[ru ? 'Адрес' : 'Address', view.origin],
|
|
234
|
+
[ru ? 'Отпечаток' : 'Fingerprint', String(view.instanceId).slice(0, 16) + '…'],
|
|
235
|
+
[ru ? 'Шлюз' : 'Gateway', view.gateway ? (ru ? 'работает' : 'running') : (ru ? 'не запущен' : 'not running')],
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
return `<!doctype html>
|
|
239
|
+
<html lang="${language}">
|
|
240
|
+
<head>
|
|
241
|
+
<meta charset="utf-8">
|
|
242
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
243
|
+
<title>DDeck</title>
|
|
244
|
+
<style>
|
|
245
|
+
:root {
|
|
246
|
+
color-scheme: dark;
|
|
247
|
+
--bg: #12151c; --fg: #e6e8eb; --muted: #a8adb7; --line: #2a2f3a; --ok: #4caf7d;
|
|
248
|
+
}
|
|
249
|
+
* { box-sizing: border-box }
|
|
250
|
+
/* Сетка с одной колонкой фиксированной ширины: без этого колонка тянется по самому
|
|
251
|
+
* широкому содержимому, и в узком окне список устройств уводил всю страницу вправо —
|
|
252
|
+
* заголовок и адрес обрезались.
|
|
253
|
+
*/
|
|
254
|
+
body { margin: 0; min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1fr);
|
|
255
|
+
place-items: center; padding: 0 1.5rem;
|
|
256
|
+
background: var(--bg); color: var(--fg);
|
|
257
|
+
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif }
|
|
258
|
+
main { width: 100%; max-width: 26rem; min-width: 0; text-align: center; padding: 2rem 0 }
|
|
259
|
+
h1 { font-size: 1.375rem; font-weight: 600; margin: 0 0 .5rem }
|
|
260
|
+
p.lead { margin: 0 0 1.75rem; color: var(--muted) }
|
|
261
|
+
.qr { display: inline-block; padding: .75rem; border-radius: 1rem; background: #fff; line-height: 0 }
|
|
262
|
+
.qr svg { width: 15rem; height: 15rem; display: block }
|
|
263
|
+
.code-label { margin: 1.5rem 0 .375rem; font-size: .8125rem; letter-spacing: .04em;
|
|
264
|
+
text-transform: uppercase; color: var(--muted) }
|
|
265
|
+
.code { font: 600 2rem/1.1 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
266
|
+
letter-spacing: .28em; padding-left: .28em }
|
|
267
|
+
.where { margin: 1.5rem 0 0; font-size: .8125rem; color: var(--muted) }
|
|
268
|
+
.where code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg) }
|
|
269
|
+
.status { margin-top: 1rem; display: inline-flex; gap: .5rem; align-items: center;
|
|
270
|
+
padding: .4375rem .875rem; border: 1px solid var(--line); border-radius: 999px;
|
|
271
|
+
font-size: .8125rem; color: #8fbf9f }
|
|
272
|
+
.dot { width: .5rem; height: .5rem; border-radius: 50%; background: var(--ok) }
|
|
273
|
+
.bad { color: #e0a3a3; border-color: #4a2f2f }
|
|
274
|
+
.bad .dot { background: #d9534f }
|
|
275
|
+
footer { margin-top: 2rem }
|
|
276
|
+
a { color: var(--muted) }
|
|
277
|
+
.devices { list-style: none; margin: 0 0 .5rem; padding: 0; text-align: left;
|
|
278
|
+
border: 1px solid var(--line); border-radius: .875rem; overflow: hidden }
|
|
279
|
+
.devices li { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .75rem; padding: .75rem 1rem }
|
|
280
|
+
.devices .name { overflow-wrap: anywhere }
|
|
281
|
+
.devices li + li { border-top: 1px solid var(--line) }
|
|
282
|
+
.devices .name { flex: 1 1 auto; font-weight: 500 }
|
|
283
|
+
.devices .meta { color: var(--muted); font-size: .8125rem }
|
|
284
|
+
.devices .revoke { border: 1px solid var(--line); border-radius: .5rem; background: transparent;
|
|
285
|
+
color: #e0a3a3; padding: .375rem .625rem; font: inherit; font-size: .8125rem; cursor: pointer;
|
|
286
|
+
/* 44 px — тот же минимум, что и в мобильном слое: палец не должен прицеливаться. */
|
|
287
|
+
min-height: 2.75rem }
|
|
288
|
+
.count { color: var(--muted); font-size: .8125rem; margin: 0 0 1.5rem }
|
|
289
|
+
details { margin-top: 1.5rem; text-align: left }
|
|
290
|
+
details > summary { cursor: pointer; color: var(--muted); font-size: .875rem;
|
|
291
|
+
list-style: none; display: flex; align-items: center; gap: .375rem; min-height: 2.75rem }
|
|
292
|
+
details > summary::-webkit-details-marker { display: none }
|
|
293
|
+
details > summary::after { content: '›'; transform: rotate(90deg); transition: transform .15s ease }
|
|
294
|
+
details[open] > summary::after { transform: rotate(-90deg) }
|
|
295
|
+
.pair-more > summary { justify-content: center }
|
|
296
|
+
.pair-more .qr { margin-top: .5rem }
|
|
297
|
+
.advanced dl { margin: .5rem 0 0; display: grid; grid-template-columns: auto 1fr; gap: .375rem .75rem }
|
|
298
|
+
.advanced dt { color: var(--muted); font-size: .8125rem }
|
|
299
|
+
.advanced dd { margin: 0; font-size: .8125rem; overflow-wrap: anywhere }
|
|
300
|
+
</style>
|
|
301
|
+
</head>
|
|
302
|
+
<body>
|
|
303
|
+
<main>
|
|
304
|
+
<h1>${paired ? text.connected : text.heading}</h1>
|
|
305
|
+
<p class="lead">${paired ? text.connectedLead : text.lead}</p>
|
|
306
|
+
${paired
|
|
307
|
+
? `<ul class="devices">${view.devices.map((device) => `<li><span class="name">${escapeHtml(device.label)}</span><span class="meta">${when(device.lastSeenAt)}</span><button type="button" class="revoke" data-id="${escapeHtml(device.id)}">${text.revoke}</button></li>`).join('')}</ul>
|
|
308
|
+
<p class="count">${view.devices.length} ${deviceWord(view.devices.length)}</p>
|
|
309
|
+
<details class="pair-more"><summary>${text.pairAnother}</summary><div class="qr" id="qr">${view.qrSvg}</div><p class="code-label">${text.or}</p><div class="code" id="code">${view.pairing.code}</div></details>`
|
|
310
|
+
: `<div class="qr" id="qr">${view.qrSvg}</div>
|
|
311
|
+
<p class="code-label">${text.or}</p>
|
|
312
|
+
<div class="code" id="code">${view.pairing.code}</div>`}
|
|
313
|
+
<p class="where">${text.where} <code>${view.origin}</code></p>
|
|
314
|
+
${view.altOrigins && view.altOrigins.length > 0
|
|
315
|
+
? `<p class="where alt">${text.alsoReachable} ${view.altOrigins.map((origin) => `<code>${escapeHtml(origin)}</code>`).join(', ')}</p>`
|
|
316
|
+
: ''}
|
|
317
|
+
<div class="status${view.gateway ? '' : ' bad'}"><span class="dot"></span>${view.gateway ? text.status : text.noGateway}</div>
|
|
318
|
+
<details class="advanced">
|
|
319
|
+
<summary>${text.advanced}</summary>
|
|
320
|
+
<dl>${advanced.map(([key, value]) => `<dt>${key}</dt><dd><code>${escapeHtml(String(value))}</code></dd>`).join('')}</dl>
|
|
321
|
+
</details>
|
|
322
|
+
<footer><a href="${switchHref}" rel="alternate" hreflang="${ru ? 'en' : 'ru'}">${text.switchLabel}</a></footer>
|
|
323
|
+
</main>
|
|
324
|
+
<script>
|
|
325
|
+
// The window rotates on its own; the page follows it instead of showing a dead code.
|
|
326
|
+
// A person reading the instructions should never come back to "code expired" and have
|
|
327
|
+
// to press something to continue.
|
|
328
|
+
(function () {
|
|
329
|
+
var qr = document.getElementById('qr')
|
|
330
|
+
var code = document.getElementById('code')
|
|
331
|
+
var shown = code ? code.textContent.trim() : ''
|
|
332
|
+
function refresh() {
|
|
333
|
+
fetch('${ROUTE_PREFIX}/pairing', { cache: 'no-store' })
|
|
334
|
+
.then(function (response) { return response.ok ? response.json() : null })
|
|
335
|
+
.then(function (next) {
|
|
336
|
+
if (!next || !next.code || next.code === shown) return
|
|
337
|
+
shown = next.code
|
|
338
|
+
code.textContent = next.code
|
|
339
|
+
if (next.qrSvg) qr.innerHTML = next.qrSvg
|
|
340
|
+
})
|
|
341
|
+
.catch(function () { /* offline: keep what is on screen */ })
|
|
342
|
+
}
|
|
343
|
+
document.addEventListener('click', function (event) {
|
|
344
|
+
var button = event.target && event.target.closest ? event.target.closest('.revoke') : null
|
|
345
|
+
if (!button) return
|
|
346
|
+
button.disabled = true
|
|
347
|
+
fetch('${ROUTE_PREFIX}/revoke', {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: { 'content-type': 'application/json' },
|
|
350
|
+
body: JSON.stringify({ deviceId: button.getAttribute('data-id') }),
|
|
351
|
+
}).then(function () { location.reload() }).catch(function () { button.disabled = false })
|
|
352
|
+
})
|
|
353
|
+
setInterval(refresh, 2000)
|
|
354
|
+
document.addEventListener('visibilitychange', function () {
|
|
355
|
+
if (!document.hidden) refresh()
|
|
356
|
+
})
|
|
357
|
+
}())
|
|
358
|
+
</script>
|
|
359
|
+
</body>
|
|
360
|
+
</html>`
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Origins the phone may dial, from the environment or the network probe.
|
|
365
|
+
*
|
|
366
|
+
* `DSH_REMOTE_ORIGINS` (comma-separated) wins over `DSH_REMOTE_ORIGIN`; without either
|
|
367
|
+
* the gateway auto-detects a Tailscale address (works from any network) and adds the
|
|
368
|
+
* LAN address second (fast at home). The first origin is primary and lands in the QR.
|
|
369
|
+
*
|
|
370
|
+
* @param {number} port - gateway port.
|
|
371
|
+
* @returns {string[]} origins in priority order; empty when nothing is known yet.
|
|
372
|
+
*/
|
|
373
|
+
function configuredOrigins(port) {
|
|
374
|
+
const multiple = process.env.DSH_REMOTE_ORIGINS
|
|
375
|
+
if (typeof multiple === 'string' && multiple.trim() !== '') {
|
|
376
|
+
return multiple.split(',').map((value) => value.trim()).filter(Boolean)
|
|
377
|
+
}
|
|
378
|
+
const single = process.env.DSH_REMOTE_ORIGIN
|
|
379
|
+
if (typeof single === 'string' && single.trim() !== '') return [single.trim()]
|
|
380
|
+
// «Свой сервер»: установщик кладёт публичный origin в state-каталог. Это
|
|
381
|
+
// переживает перезапуск DSH и не требует правки launchctl/окружения.
|
|
382
|
+
const fromFile = readOriginsFile()
|
|
383
|
+
if (fromFile.length > 0) return fromFile
|
|
384
|
+
return autoOrigins(port)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Origins, сохранённые установщиком своего сервера.
|
|
389
|
+
*
|
|
390
|
+
* @returns {string[]} origins или пустой массив.
|
|
391
|
+
*/
|
|
392
|
+
function readOriginsFile() {
|
|
393
|
+
const file = join(homedir(), '.dsh', 'dsh-remote', 'origins.json')
|
|
394
|
+
try {
|
|
395
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
396
|
+
const values = Array.isArray(parsed?.origins) ? parsed.origins : []
|
|
397
|
+
return values
|
|
398
|
+
.filter((value) => typeof value === 'string' && /^https?:\/\//u.test(value))
|
|
399
|
+
.map((value) => value.trim())
|
|
400
|
+
} catch {
|
|
401
|
+
return []
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** SSH-хосты из ~/.ssh/config: alias -> HostName. */
|
|
406
|
+
function readSshEntries() {
|
|
407
|
+
const file = join(homedir(), '.ssh', 'config')
|
|
408
|
+
try {
|
|
409
|
+
const text = readFileSync(file, 'utf8')
|
|
410
|
+
const hosts = new Map()
|
|
411
|
+
let aliases = []
|
|
412
|
+
for (const line of text.split('\n')) {
|
|
413
|
+
const host = /^\s*Host\s+(.+)$/iu.exec(line)
|
|
414
|
+
if (host !== null) {
|
|
415
|
+
aliases = host[1].trim().split(/\s+/u)
|
|
416
|
+
.filter((value) => value !== '' && !value.startsWith('!') && !/[*?]/u.test(value))
|
|
417
|
+
for (const alias of aliases) if (!hosts.has(alias)) hosts.set(alias, alias)
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
const hostName = /^\s*HostName\s+(.+)$/iu.exec(line)
|
|
421
|
+
if (hostName !== null) {
|
|
422
|
+
for (const alias of aliases) hosts.set(alias, hostName[1].trim())
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return [...hosts].map(([alias, host]) => ({ alias, host })).slice(0, 30)
|
|
426
|
+
} catch {
|
|
427
|
+
return []
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Публичный хост для введённого адреса или SSH-alias. */
|
|
432
|
+
function deriveServerAddress(input) {
|
|
433
|
+
const value = input.trim()
|
|
434
|
+
const entry = readSshEntries().find((item) => item.alias === value)
|
|
435
|
+
if (entry !== undefined) return { sshHost: entry.alias, origin: `https://${entry.host}:3449` }
|
|
436
|
+
if (/^https:\/\//iu.test(value)) {
|
|
437
|
+
try {
|
|
438
|
+
const url = new URL(value)
|
|
439
|
+
return { sshHost: url.hostname, origin: value.replace(/\/$/u, '') }
|
|
440
|
+
} catch {
|
|
441
|
+
return undefined
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (value.includes('://') || /[/?#\s]/u.test(value)) return undefined
|
|
445
|
+
if (value.includes('@')) {
|
|
446
|
+
const host = value.split('@').pop() ?? ''
|
|
447
|
+
return { sshHost: value, origin: `https://${host}:3449` }
|
|
448
|
+
}
|
|
449
|
+
return { sshHost: value.split(':')[0], origin: `https://${value.includes(':') ? value : `${value}:3449`}` }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Запущен ли LaunchAgent туннеля (если ставился setup.sh). */
|
|
453
|
+
function tunnelRunning() {
|
|
454
|
+
return new Promise((resolve) => {
|
|
455
|
+
if (process.platform === 'darwin') {
|
|
456
|
+
execFile('launchctl', ['print', `gui/${String(process.getuid())}/app.ddeck.relay`], { timeout: 2000 }, (error) => resolve(error === null))
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
// Linux/Windows: setup.sh запускает туннель через nohup и пишет pid.
|
|
460
|
+
// LaunchAgent есть только на macOS; здесь проверяем живой pid.
|
|
461
|
+
const pidFile = join(homedir(), '.dsh', 'dsh-remote', 'relay-tunnel.pid')
|
|
462
|
+
readFile(pidFile, 'utf8').then((text) => {
|
|
463
|
+
const pid = Number.parseInt(text.trim(), 10)
|
|
464
|
+
if (!Number.isInteger(pid) || pid <= 0) { resolve(false); return }
|
|
465
|
+
try { process.kill(pid, 0); resolve(true) } catch { resolve(false) }
|
|
466
|
+
}).catch(() => resolve(false))
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Find an address the phone can actually reach when the gateway could not start.
|
|
472
|
+
*
|
|
473
|
+
* This is the degraded pairing page (no TLS listener): it needs a plain HTTP origin and
|
|
474
|
+
* only runs when the gateway failed. In the normal path `configuredOrigins` is what the
|
|
475
|
+
* QR carries.
|
|
476
|
+
*
|
|
477
|
+
* @param {number} port - port this DSH instance listens on.
|
|
478
|
+
* @returns {string | undefined} origin such as `http://192.168.1.10:3449`, or undefined.
|
|
479
|
+
*/
|
|
480
|
+
function reachableOrigin(port) {
|
|
481
|
+
const configured = configuredOrigins(port)[0]
|
|
482
|
+
if (configured !== undefined) return configured
|
|
483
|
+
const candidates = []
|
|
484
|
+
for (const [name, addresses] of Object.entries(networkInterfaces())) {
|
|
485
|
+
if (name === 'lo0' || name.startsWith('utun') || name.startsWith('bridge')) continue
|
|
486
|
+
for (const address of addresses ?? []) {
|
|
487
|
+
if (address.family !== 'IPv4' || address.internal) continue
|
|
488
|
+
const [a, b] = address.address.split('.').map(Number)
|
|
489
|
+
const isPrivate = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
490
|
+
if (isPrivate) candidates.push(address.address)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return candidates.length > 0 ? `http://${candidates[0]}:${String(port)}` : undefined
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Список сессий с компьютера.
|
|
498
|
+
*
|
|
499
|
+
* Читаем кэш проекций DSH: у каждой сессии там есть `record.identity.cwd` (рабочая
|
|
500
|
+
* область) и `record.rows.title.val` (заголовок). Разбирать журналы сессий не нужно —
|
|
501
|
+
* кэш уже содержит то, что показывает настольное приложение.
|
|
502
|
+
*
|
|
503
|
+
* @returns {Promise<Array<{ id: string, title: string, workspace: string, createdAt: number }>>}
|
|
504
|
+
*/
|
|
505
|
+
async function readSessions() {
|
|
506
|
+
const root = join(homedir(), '.dsh', 'storages', 'session_projcache', 'sessions')
|
|
507
|
+
let names
|
|
508
|
+
try {
|
|
509
|
+
names = await readdir(root)
|
|
510
|
+
} catch {
|
|
511
|
+
return []
|
|
512
|
+
}
|
|
513
|
+
const sessions = []
|
|
514
|
+
for (const name of names) {
|
|
515
|
+
if (!name.endsWith('.json')) continue
|
|
516
|
+
try {
|
|
517
|
+
const parsed = JSON.parse(await readFile(join(root, name), 'utf8'))
|
|
518
|
+
const record = parsed?.record
|
|
519
|
+
const identity = record?.identity
|
|
520
|
+
const title = record?.rows?.title?.val
|
|
521
|
+
if (typeof identity?.cwd !== 'string') continue
|
|
522
|
+
sessions.push({
|
|
523
|
+
id: name.replace(/\.json$/u, ''),
|
|
524
|
+
title: typeof title === 'string' && title.trim() !== '' ? title.trim() : 'Без названия',
|
|
525
|
+
workspace: identity.cwd,
|
|
526
|
+
createdAt: typeof identity.createdAt === 'number' ? identity.createdAt : 0,
|
|
527
|
+
})
|
|
528
|
+
} catch {
|
|
529
|
+
// Битый или недописанный файл кэша — пропускаем, а не роняем список.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Сначала названные, потом свежие безымянные.
|
|
533
|
+
//
|
|
534
|
+
// Раньше брались просто 40 самых новых файлов кэша — и в списке оказывались одни
|
|
535
|
+
// пустые заготовки (title = null), а настоящие сессии с названиями не попадали вовсе:
|
|
536
|
+
// в кэше их 269, названия есть у старых.
|
|
537
|
+
const named = sessions.filter((session) => session.title !== 'Без названия')
|
|
538
|
+
const blank = sessions.filter((session) => session.title === 'Без названия')
|
|
539
|
+
named.sort((a, b) => b.createdAt - a.createdAt)
|
|
540
|
+
blank.sort((a, b) => b.createdAt - a.createdAt)
|
|
541
|
+
return [...named.slice(0, 30), ...blank.slice(0, 6)]
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Choose the language for a request.
|
|
546
|
+
*
|
|
547
|
+
* An explicit `?lang=` wins — it is the switch being used. Otherwise the first
|
|
548
|
+
* `Accept-Language` entry decides, and anything that is not Russian gets English:
|
|
549
|
+
* a wrong guess is recoverable with one tap, a missing page is not.
|
|
550
|
+
*
|
|
551
|
+
* @param {URL} url
|
|
552
|
+
* @param {string} headerValue
|
|
553
|
+
* @returns {'ru' | 'en'}
|
|
554
|
+
*/
|
|
555
|
+
/**
|
|
556
|
+
* Escape a value for HTML.
|
|
557
|
+
*
|
|
558
|
+
* Device labels are chosen by the person and come back from the phone, and the page
|
|
559
|
+
* prints them. An unescaped label is a scripting hole with a friendly face.
|
|
560
|
+
*
|
|
561
|
+
* @param {string} value
|
|
562
|
+
* @returns {string}
|
|
563
|
+
*/
|
|
564
|
+
function escapeHtml(value) {
|
|
565
|
+
return String(value)
|
|
566
|
+
.replaceAll('&', '&')
|
|
567
|
+
.replaceAll('<', '<')
|
|
568
|
+
.replaceAll('>', '>')
|
|
569
|
+
.replaceAll('"', '"')
|
|
570
|
+
.replaceAll("'", ''')
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function pickLanguage(url, headerValue) {
|
|
574
|
+
const chosen = url.searchParams.get('lang')
|
|
575
|
+
if (chosen === 'ru' || chosen === 'en') return chosen
|
|
576
|
+
return /^\s*ru\b/i.test(headerValue) ? 'ru' : 'en'
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Render the QR for a pairing payload.
|
|
581
|
+
*
|
|
582
|
+
* @param {string} payload - what the phone will read.
|
|
583
|
+
* @returns {Promise<string>} inline SVG, or an empty string when rendering fails.
|
|
584
|
+
*/
|
|
585
|
+
async function renderQr(payload) {
|
|
586
|
+
try {
|
|
587
|
+
return renderQrSvg(payload, { margin: 1, errorCorrectionLevel: 'M' })
|
|
588
|
+
} catch {
|
|
589
|
+
// The code below the QR is the fallback; a failed render must not break the page.
|
|
590
|
+
return ''
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Build the pairing payload the phone reads.
|
|
596
|
+
*
|
|
597
|
+
* One object, versioned, carrying everything the app needs: where the computer is and
|
|
598
|
+
* which window is open. No separate fields for the person to copy.
|
|
599
|
+
*
|
|
600
|
+
* @param {string} origin - origin the phone should use.
|
|
601
|
+
* @param {string} instanceId
|
|
602
|
+
* @param {string} token
|
|
603
|
+
* @returns {string} URI for the QR.
|
|
604
|
+
*/
|
|
605
|
+
function pairingUri(origin, instanceId, token, expiresAt) {
|
|
606
|
+
const url = new URL('dshremote://pair')
|
|
607
|
+
url.searchParams.set('v', '1')
|
|
608
|
+
url.searchParams.set('u', origin)
|
|
609
|
+
url.searchParams.set('i', instanceId)
|
|
610
|
+
url.searchParams.set('t', token)
|
|
611
|
+
// A hint, not a rule: the phone can reject an old code before spending a request,
|
|
612
|
+
// and the server still checks the window itself because a phone's clock is not a
|
|
613
|
+
// trustworthy authority.
|
|
614
|
+
url.searchParams.set('e', String(Math.floor(expiresAt / 1000)))
|
|
615
|
+
return url.toString()
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Activate the plugin.
|
|
621
|
+
*
|
|
622
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - Cordis context provided by DSH.
|
|
623
|
+
*/
|
|
624
|
+
export function apply(ctx) {
|
|
625
|
+
const logger = ctx.logger('dsh-remote')
|
|
626
|
+
const pairing = new PairingWindow()
|
|
627
|
+
const statePromise = loadState()
|
|
628
|
+
/** Recent password attempts for the phone-to-phone pairing route. */
|
|
629
|
+
const mobilePairAttempts = new Map()
|
|
630
|
+
|
|
631
|
+
/** Started gateway, or undefined while it is still coming up or after a failure. */
|
|
632
|
+
let gateway
|
|
633
|
+
/** Перезапуск шлюза после смены публичного origin; назначается effect-ом ниже. */
|
|
634
|
+
let restartGateway
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Devices that may right now open this computer.
|
|
638
|
+
*
|
|
639
|
+
* Revoked and expired entries are dropped: a list that shows dead entries teaches the
|
|
640
|
+
* person to ignore it.
|
|
641
|
+
*/
|
|
642
|
+
const pairedDevices = () => {
|
|
643
|
+
if (gateway === undefined) return []
|
|
644
|
+
const now = Date.now()
|
|
645
|
+
return gateway.gateway.access.listDevices()
|
|
646
|
+
.filter((device) => device.revokedAt === undefined && device.expiresAt > now)
|
|
647
|
+
.sort((a, b) => b.lastSeenAt - a.lastSeenAt)
|
|
648
|
+
.map((device) => ({
|
|
649
|
+
id: device.id,
|
|
650
|
+
label: device.label,
|
|
651
|
+
lastSeenAt: device.lastSeenAt,
|
|
652
|
+
expiresAt: device.expiresAt,
|
|
653
|
+
}))
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Read a small JSON body without trusting its size. */
|
|
657
|
+
const readBody = async (request) => {
|
|
658
|
+
const chunks = []
|
|
659
|
+
let bytes = 0
|
|
660
|
+
for await (const chunk of request) {
|
|
661
|
+
bytes += chunk.length
|
|
662
|
+
if (bytes > 8 * 1024) throw new Error('body too large')
|
|
663
|
+
chunks.push(chunk)
|
|
664
|
+
}
|
|
665
|
+
if (bytes === 0) return {}
|
|
666
|
+
try {
|
|
667
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
668
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
669
|
+
} catch {
|
|
670
|
+
return {}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The pairing window the page currently shows.
|
|
676
|
+
*
|
|
677
|
+
* The page polls every two seconds; opening a fresh window on every poll rotated the
|
|
678
|
+
* six-digit code out from under the person reading it and made the QR unreliable.
|
|
679
|
+
* One window is reused until it is close to expiry or the paired-device count
|
|
680
|
+
* changes (which means it was just consumed).
|
|
681
|
+
*/
|
|
682
|
+
let shownPairing
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* What the page shows right now: a code and the QR that carries it.
|
|
686
|
+
*
|
|
687
|
+
* The gateway is asked first because only it can pair a phone — its window is the one
|
|
688
|
+
* `native-pair` accepts. Without a gateway the page falls back to a local window and
|
|
689
|
+
* says so, which is more useful than an empty screen: the person learns that pairing
|
|
690
|
+
* is unavailable instead of pointing a camera at nothing.
|
|
691
|
+
*/
|
|
692
|
+
const currentPairing = async (state, options = {}) => {
|
|
693
|
+
// Явный запрос «новый код»: инвалидируем кэш и открываем новое окно, иначе
|
|
694
|
+
// панель «Обновить» показывала тот же QR до пяти минут.
|
|
695
|
+
if (options.refresh === true) shownPairing = undefined
|
|
696
|
+
// Устройства отдаём вместе с кодом: панель на телефоне показывает список подключённых
|
|
697
|
+
// рядом с QR. Раньше их здесь не было, и панель всегда писала «Пока ни одного
|
|
698
|
+
// устройства», хотя телефон уже был подключён (в /status он есть).
|
|
699
|
+
const devices = pairedDevices()
|
|
700
|
+
if (gateway !== undefined) {
|
|
701
|
+
const now = Date.now()
|
|
702
|
+
const stale = shownPairing === undefined
|
|
703
|
+
|| shownPairing.gateway !== gateway
|
|
704
|
+
|| shownPairing.expiresAt - now <= 5_000
|
|
705
|
+
|| shownPairing.deviceCount !== devices.length
|
|
706
|
+
|| gateway.gateway.access.hasPairingWindow?.() === false
|
|
707
|
+
if (stale) {
|
|
708
|
+
const opened = await openReadablePairing(gateway, PAIRING_TTL_MS)
|
|
709
|
+
shownPairing = {
|
|
710
|
+
gateway,
|
|
711
|
+
code: opened.code,
|
|
712
|
+
expiresAt: opened.expiresAt,
|
|
713
|
+
pairUrl: opened.pairUrl,
|
|
714
|
+
deviceCount: devices.length,
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
code: shownPairing.code,
|
|
719
|
+
expiresAt: shownPairing.expiresAt,
|
|
720
|
+
pairUri: shownPairing.pairUrl,
|
|
721
|
+
origin: gateway.origin,
|
|
722
|
+
origins: gateway.origins,
|
|
723
|
+
gateway: true,
|
|
724
|
+
// CA fingerprint from the invitation; the app pins it instead of trusting the
|
|
725
|
+
// first certificate it sees (TOFU) when it pairs from a link or an intent.
|
|
726
|
+
instance: gateway.config.instanceId,
|
|
727
|
+
devices,
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const window = pairing.open()
|
|
731
|
+
const origin = reachableOrigin(ctx.webServer.port) ?? `http://127.0.0.1:${String(ctx.webServer.port)}`
|
|
732
|
+
return {
|
|
733
|
+
code: window.code,
|
|
734
|
+
expiresAt: window.expiresAt,
|
|
735
|
+
pairUri: pairingUri(origin, state.instanceId, window.token, window.expiresAt),
|
|
736
|
+
origin,
|
|
737
|
+
origins: [origin],
|
|
738
|
+
gateway: false,
|
|
739
|
+
instance: state.instanceId,
|
|
740
|
+
devices,
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Origin the phone should use: the address the person reached DSH on.
|
|
746
|
+
*
|
|
747
|
+
* Behind a proxy the scheme comes from `x-forwarded-proto`; otherwise the Host the
|
|
748
|
+
* request arrived with is the truth, because that is the address the phone will use
|
|
749
|
+
* too.
|
|
750
|
+
*/
|
|
751
|
+
const originOf = (request) => {
|
|
752
|
+
const host = request.headers.host
|
|
753
|
+
const forwarded = request.headers['x-forwarded-proto']
|
|
754
|
+
const scheme = typeof forwarded === 'string' && forwarded.startsWith('https') ? 'https' : 'http'
|
|
755
|
+
return typeof host === 'string' && host !== '' ? `${scheme}://${host}` : `http://127.0.0.1:${ctx.webServer.port}`
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// `ctx.effect` owns the registration: DSH may disable or reload the plugin at any
|
|
759
|
+
// time, and the routes have to disappear with it rather than answering from a dead
|
|
760
|
+
// plugin.
|
|
761
|
+
ctx.effect(() => ctx.webServer.register({
|
|
762
|
+
kind: 'prefix',
|
|
763
|
+
path: ROUTE_PREFIX,
|
|
764
|
+
handler: async (request, response) => {
|
|
765
|
+
const state = await statePromise
|
|
766
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
767
|
+
|
|
768
|
+
// Сопряжение второго телефона с уже подключённого телефона.
|
|
769
|
+
//
|
|
770
|
+
// Это не админ-маршрут: его вызывает мобильная страница. Доступ закрыт
|
|
771
|
+
// паролем, который задаёт владелец на компьютере; без настроенного пароля
|
|
772
|
+
// маршрут отвечает 403 и окно сопряжения не открывает.
|
|
773
|
+
if (url.pathname === `${ROUTE_PREFIX}/mobile-pair` && request.method === 'GET') {
|
|
774
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
775
|
+
response.end(JSON.stringify({ available: true, pinSet: (await readMobilePin()) !== undefined }))
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (url.pathname === `${ROUTE_PREFIX}/mobile-pair` && request.method === 'POST') {
|
|
780
|
+
if (mobilePairAttempts.size > 512) mobilePairAttempts.clear()
|
|
781
|
+
const clientKey = String(request.socket?.remoteAddress ?? 'unknown')
|
|
782
|
+
const attempts = mobilePairAttempts.get(clientKey) ?? { at: 0, count: 0 }
|
|
783
|
+
if (Date.now() - attempts.at > 5 * 60_000) { attempts.at = Date.now(); attempts.count = 0 }
|
|
784
|
+
attempts.count += 1
|
|
785
|
+
mobilePairAttempts.set(clientKey, attempts)
|
|
786
|
+
const pin = await readMobilePin()
|
|
787
|
+
const body = await readBody(request)
|
|
788
|
+
const password = typeof body.password === 'string' ? body.password : ''
|
|
789
|
+
if (pin === undefined) {
|
|
790
|
+
response.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
791
|
+
response.end(JSON.stringify({ error: 'pin_not_set' }))
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
if (attempts.count > 8 || password.length === 0 || password.length > 128 || !verifyMobilePin(password, pin)) {
|
|
795
|
+
response.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
796
|
+
response.end(JSON.stringify({ error: 'invalid_password' }))
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const current = await currentPairing(state, { refresh: true })
|
|
801
|
+
const qrSvg = await renderQrSvg(current.pairUri)
|
|
802
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
803
|
+
response.end(JSON.stringify({ ok: true, code: current.code, expiresAt: current.expiresAt, pairUri: current.pairUri, qrSvg, origin: current.origin, instance: current.instance }))
|
|
804
|
+
} catch (error) {
|
|
805
|
+
response.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
806
|
+
response.end(JSON.stringify({ error: 'pairing_failed', message: error instanceof Error ? error.message : String(error) }))
|
|
807
|
+
}
|
|
808
|
+
return
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (!isTrustedAdminRequest(request)) {
|
|
812
|
+
// No QR, no code, no hint of one: this address is for the person at the machine.
|
|
813
|
+
response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
814
|
+
response.end('dsh-remote: open this page on the computer itself\n')
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (url.pathname === `${ROUTE_PREFIX}/mobile-pin`) {
|
|
819
|
+
if (request.method === 'POST') {
|
|
820
|
+
const body = await readBody(request)
|
|
821
|
+
const password = typeof body.password === 'string' ? body.password : ''
|
|
822
|
+
if (password.length < 6 || password.length > 64) {
|
|
823
|
+
response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
824
|
+
response.end(JSON.stringify({ error: 'invalid_password', message: 'пароль должен быть от 6 до 64 символов' }))
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const salt = randomBytes(16).toString('hex')
|
|
828
|
+
const hash = scryptSync(password, Buffer.from(salt, 'hex'), 32).toString('hex')
|
|
829
|
+
await writeFile(mobilePinPath(), `${JSON.stringify({ salt, hash }, null, 2)}\n`, { mode: 0o600 })
|
|
830
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
831
|
+
response.end(JSON.stringify({ set: true }))
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
835
|
+
response.end(JSON.stringify({ set: (await readMobilePin()) !== undefined }))
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (url.pathname === `${ROUTE_PREFIX}/pairing`) {
|
|
840
|
+
let current
|
|
841
|
+
try {
|
|
842
|
+
// «Новый код» — только POST: GET-ротация позволяет чужой странице
|
|
843
|
+
// вращать окно сопряжения через CSRF/DNS-rebinding.
|
|
844
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
845
|
+
current = await currentPairing(state, { refresh: method === 'POST' })
|
|
846
|
+
} catch (error) {
|
|
847
|
+
// A failure here is the difference between a working phone and a blank screen;
|
|
848
|
+
// say what happened instead of letting the server answer 400 with no body.
|
|
849
|
+
response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
850
|
+
response.end(JSON.stringify({ error: 'pairing_failed', message: error instanceof Error ? error.message : String(error) }))
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
const qrSvg = await renderQr(current.pairUri)
|
|
854
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
855
|
+
// The URI travels with the image on purpose: the app can be handed either one,
|
|
856
|
+
// and a test can assert what the QR holds without decoding a picture.
|
|
857
|
+
response.end(JSON.stringify({ ...current, qrSvg }))
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (url.pathname === `${ROUTE_PREFIX}/sessions`) {
|
|
862
|
+
// Свой список сессий: настольный клиент на нашей странице переходы не выполняет,
|
|
863
|
+
// поэтому список и открытие делаем сами. Заголовки берём из кэша проекций DSH —
|
|
864
|
+
// это те же данные, что видит приложение на компьютере.
|
|
865
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
866
|
+
response.end(JSON.stringify({ sessions: await readSessions() }))
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (url.pathname === `${ROUTE_PREFIX}/devices`) {
|
|
871
|
+
// Только список сопряжённых устройств: инструменты гигиены не должны
|
|
872
|
+
// открывать новое окно сопряжения ради чтения списка.
|
|
873
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
874
|
+
response.end(JSON.stringify({ devices: pairedDevices() }))
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (url.pathname === `${ROUTE_PREFIX}/status`) {
|
|
879
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
880
|
+
response.end(JSON.stringify({
|
|
881
|
+
plugin: 'dsh-remote',
|
|
882
|
+
version: PLUGIN_VERSION,
|
|
883
|
+
instanceId: gateway?.config.instanceId ?? state.instanceId,
|
|
884
|
+
origin: gateway?.origin,
|
|
885
|
+
gateway: gateway !== undefined,
|
|
886
|
+
// Four is what fits above the fold on a laptop panel without pushing the QR out
|
|
887
|
+
// of sight; the count line still says how many there are in total.
|
|
888
|
+
devices: pairedDevices().slice(0, 4),
|
|
889
|
+
}))
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Disconnecting a phone is an ordinary action, not a corner case: phones get lost,
|
|
894
|
+
// sold and replaced, and the person needs a way to take access away.
|
|
895
|
+
if (url.pathname === `${ROUTE_PREFIX}/revoke` && request.method === 'POST') {
|
|
896
|
+
const body = await readBody(request)
|
|
897
|
+
const deviceId = typeof body.deviceId === 'string' ? body.deviceId : ''
|
|
898
|
+
if (gateway === undefined || !/^[a-f\d]{32}$/u.test(deviceId)) {
|
|
899
|
+
response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
900
|
+
response.end(JSON.stringify({ error: 'invalid_request' }))
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
const removed = await gateway.gateway.access.revokeDevice(deviceId)
|
|
904
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
905
|
+
response.end(JSON.stringify({ removed }))
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (url.pathname === `${ROUTE_PREFIX}/server` && (request.method ?? 'GET').toUpperCase() === 'GET') {
|
|
910
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
911
|
+
response.end(JSON.stringify({
|
|
912
|
+
origin: gateway?.origin,
|
|
913
|
+
origins: gateway?.origins ?? [],
|
|
914
|
+
sshHosts: readSshEntries(),
|
|
915
|
+
tunnel: await tunnelRunning(),
|
|
916
|
+
}))
|
|
917
|
+
return
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (url.pathname === `${ROUTE_PREFIX}/server` && request.method === 'POST') {
|
|
921
|
+
const body = await readBody(request)
|
|
922
|
+
const address = typeof body.address === 'string' ? body.address.trim() : ''
|
|
923
|
+
const suppliedOrigin = typeof body.origin === 'string' ? body.origin.trim().replace(/\/$/u, '') : ''
|
|
924
|
+
let suppliedSsh = typeof body.sshHost === 'string' ? body.sshHost.trim() : ''
|
|
925
|
+
let origin = suppliedOrigin
|
|
926
|
+
if (origin === '' && address !== '') {
|
|
927
|
+
const derived = deriveServerAddress(address)
|
|
928
|
+
if (derived === undefined) {
|
|
929
|
+
response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
930
|
+
response.end(JSON.stringify({ error: 'invalid_address', message: 'укажите hostname, SSH-alias, user@host или https://host:port' }))
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
origin = derived.origin
|
|
934
|
+
suppliedSsh = suppliedSsh || derived.sshHost
|
|
935
|
+
}
|
|
936
|
+
let parsed
|
|
937
|
+
try {
|
|
938
|
+
parsed = new URL(origin)
|
|
939
|
+
} catch {
|
|
940
|
+
parsed = undefined
|
|
941
|
+
}
|
|
942
|
+
if (parsed === undefined || parsed.protocol !== 'https:' || parsed.pathname !== '/' || parsed.search !== '' || parsed.username !== '' || parsed.password !== '') {
|
|
943
|
+
response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
944
|
+
response.end(JSON.stringify({ error: 'invalid_origin', message: 'origin must be https://host[:port] without path' }))
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
if (suppliedSsh !== '' && (!/^[\w.@:-]+$/u.test(suppliedSsh) || suppliedSsh.startsWith('-'))) {
|
|
948
|
+
response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
949
|
+
response.end(JSON.stringify({ error: 'invalid_ssh_host' }))
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
let output = ''
|
|
954
|
+
let steps = []
|
|
955
|
+
if (suppliedSsh !== '') {
|
|
956
|
+
steps.push('SSH-соединение и установка relay')
|
|
957
|
+
const script = new URL('../relay/setup.sh', import.meta.url)
|
|
958
|
+
output = await new Promise((resolve, reject) => {
|
|
959
|
+
const child = spawn(script.pathname, ['--ssh', suppliedSsh, '--origin', origin, '--gateway-origin', origin], {
|
|
960
|
+
cwd: dirname(script.pathname),
|
|
961
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
962
|
+
})
|
|
963
|
+
let text = ''
|
|
964
|
+
const append = (chunk) => { text = `${text}${String(chunk)}`.slice(-8_000) }
|
|
965
|
+
child.stdout.on('data', append)
|
|
966
|
+
child.stderr.on('data', append)
|
|
967
|
+
const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('relay setup timed out')) }, 120_000)
|
|
968
|
+
child.on('error', (error) => { clearTimeout(timer); reject(error) })
|
|
969
|
+
child.on('close', (code) => {
|
|
970
|
+
clearTimeout(timer)
|
|
971
|
+
if (code === 0) resolve(text.trim())
|
|
972
|
+
else reject(new Error(text.trim() || `relay setup exited ${String(code)}`))
|
|
973
|
+
})
|
|
974
|
+
})
|
|
975
|
+
steps.push('Туннель запущен')
|
|
976
|
+
} else {
|
|
977
|
+
steps.push('Сервер уже настроен: сохраняю адрес')
|
|
978
|
+
const file = join(homedir(), '.dsh', 'dsh-remote', 'origins.json')
|
|
979
|
+
await mkdir(dirname(file), { recursive: true })
|
|
980
|
+
await writeFile(file, `${JSON.stringify({ origins: [origin] }, null, 2)}\n`, 'utf8')
|
|
981
|
+
}
|
|
982
|
+
if (restartGateway === undefined) throw new Error('gateway is not ready; restart DSH and try again')
|
|
983
|
+
steps.push('Обновляю QR и сертификат')
|
|
984
|
+
await restartGateway([origin])
|
|
985
|
+
steps.push('Готово')
|
|
986
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
987
|
+
response.end(JSON.stringify({ ok: true, origin, output, steps }))
|
|
988
|
+
} catch (error) {
|
|
989
|
+
response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
990
|
+
response.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }))
|
|
991
|
+
}
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// An address under our prefix that we did not define is a mistake, not a page:
|
|
996
|
+
// answering with HTML here would hide a typo behind a working-looking screen.
|
|
997
|
+
if (url.pathname.startsWith(`${ROUTE_PREFIX}/`)) {
|
|
998
|
+
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
999
|
+
response.end('dsh-remote: not found\n')
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const language = pickLanguage(url, String(request.headers['accept-language'] ?? ''))
|
|
1004
|
+
const current = await currentPairing(state)
|
|
1005
|
+
const view = {
|
|
1006
|
+
pairing: { code: current.code },
|
|
1007
|
+
origin: current.origin,
|
|
1008
|
+
altOrigins: (current.origins ?? []).filter((value) => value !== current.origin),
|
|
1009
|
+
gateway: current.gateway,
|
|
1010
|
+
instanceId: gateway?.config.instanceId ?? state.instanceId,
|
|
1011
|
+
// Four is what fits above the fold on a laptop panel without pushing the QR out
|
|
1012
|
+
// of sight; the count line still says how many there are in total.
|
|
1013
|
+
devices: pairedDevices().slice(0, 4),
|
|
1014
|
+
qrSvg: await renderQr(current.pairUri),
|
|
1015
|
+
}
|
|
1016
|
+
response.writeHead(200, {
|
|
1017
|
+
'content-type': 'text/html; charset=utf-8',
|
|
1018
|
+
'content-language': language,
|
|
1019
|
+
'cache-control': 'no-store',
|
|
1020
|
+
})
|
|
1021
|
+
response.end(entryPage(language, view))
|
|
1022
|
+
},
|
|
1023
|
+
}), 'dsh-remote: phone entry page')
|
|
1024
|
+
|
|
1025
|
+
ctx.effect(() => {
|
|
1026
|
+
let handle
|
|
1027
|
+
let closed = false
|
|
1028
|
+
const upstreamOrigin = `http://127.0.0.1:${String(ctx.webServer.port)}`
|
|
1029
|
+
// The harness authenticates its browser client with a one-time launch token. The
|
|
1030
|
+
// gateway needs that same credential to proxy on the phone's behalf.
|
|
1031
|
+
let upstreamLoginUrl
|
|
1032
|
+
try {
|
|
1033
|
+
const authenticated = ctx.connection?.authenticatedUrl?.(upstreamOrigin)
|
|
1034
|
+
if (typeof authenticated === 'string' && new URL(authenticated).search !== '') {
|
|
1035
|
+
upstreamLoginUrl = authenticated
|
|
1036
|
+
}
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
logger.warn('connection.authenticatedUrl failed: %s', error instanceof Error ? error.message : String(error))
|
|
1039
|
+
}
|
|
1040
|
+
if (upstreamLoginUrl === undefined) {
|
|
1041
|
+
logger.warn('no upstream login URL: proxied requests will likely fail')
|
|
1042
|
+
}
|
|
1043
|
+
const port = Number(process.env.DSH_REMOTE_PORT ?? '3449')
|
|
1044
|
+
|
|
1045
|
+
const start = async (origins) => {
|
|
1046
|
+
const started = await startGateway({
|
|
1047
|
+
upstreamPort: ctx.webServer.port,
|
|
1048
|
+
upstreamLoginUrl,
|
|
1049
|
+
stateDirectory: defaultStateDirectory(),
|
|
1050
|
+
port,
|
|
1051
|
+
...(origins.length > 0 ? { origins } : {}),
|
|
1052
|
+
onWarning: (message) => { logger.warn('%s', message) },
|
|
1053
|
+
})
|
|
1054
|
+
if (closed) {
|
|
1055
|
+
await started.close()
|
|
1056
|
+
return undefined
|
|
1057
|
+
}
|
|
1058
|
+
handle = started
|
|
1059
|
+
gateway = started
|
|
1060
|
+
logger.info('gateway listening on %s', started.origin)
|
|
1061
|
+
return started
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
restartGateway = async (origins) => {
|
|
1065
|
+
if (closed) throw new Error('gateway is closed')
|
|
1066
|
+
const old = handle
|
|
1067
|
+
handle = undefined
|
|
1068
|
+
if (gateway === old) gateway = undefined
|
|
1069
|
+
await old?.close()
|
|
1070
|
+
return start(origins)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
void start(configuredOrigins(port)).catch((error) => {
|
|
1074
|
+
// A gateway that cannot start must not take the page down with it: the page still
|
|
1075
|
+
// shows a code and says what is wrong, and the person can act on that.
|
|
1076
|
+
logger.error('gateway failed: %s', error instanceof Error ? error.message : String(error))
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
return () => {
|
|
1080
|
+
closed = true
|
|
1081
|
+
restartGateway = undefined
|
|
1082
|
+
if (gateway === handle) gateway = undefined
|
|
1083
|
+
void handle?.close()
|
|
1084
|
+
}
|
|
1085
|
+
}, 'dsh-remote: gateway')
|
|
1086
|
+
|
|
1087
|
+
logger.info('dsh-remote ready: %s (pairing window %ds)', ROUTE_PREFIX, WINDOW_MS / 1000)
|
|
1088
|
+
}
|