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/lib/gateway.js ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * DDeck — the gateway.
3
+ *
4
+ * The transport — TLS listener, pairing handshake, device registry, HTTP and WebSocket
5
+ * proxying — is vendored from `dsh-mobile` (Apache-2.0) into `vendor/gateway/` and
6
+ * compiled into `gateway-bundle.js`; the `dsh-mobile` package itself is not installed.
7
+ * What we own is everything the person touches: the pairing page, the code, the mobile
8
+ * page, the app, and the Tailscale-aware origin list.
9
+ *
10
+ * @module dsh-remote/gateway
11
+ */
12
+
13
+ import { randomInt } from 'node:crypto'
14
+ import { networkInterfaces, homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { ensureCertificates } from './tls.js'
17
+ // The gateway is vendored from dsh-mobile and bundled into one module. Importing the
18
+ // package directly does not work: it is built as a plugin entry point, so its peer
19
+ // dependencies are supplied by the DSH loader only for entries it loads itself.
20
+ import { JsonDeviceStore, MobileAccessGateway, parseGatewayConfig } from './gateway-bundle.js'
21
+ import { PAIRING_TTL_MS } from './constants.js'
22
+
23
+ /**
24
+ * Default port the phone dials.
25
+ *
26
+ * The product listener shares the port with the DSH web server, so 3449 is the
27
+ * number a person sees in the pair code; `DSH_REMOTE_PORT` overrides it.
28
+ */
29
+ export const DEFAULT_PORT = 3449
30
+
31
+ /**
32
+ * Address on the local network that a phone can reach.
33
+ *
34
+ * The page is opened on the computer, so its own idea of "where am I" is loopback. A
35
+ * phone needs the machine's address on the Wi-Fi, and that is what pairing should carry.
36
+ *
37
+ * @returns {string | undefined} `192.168.x.y` and similar, or undefined when nothing local
38
+ * was found (a machine on a VPN-only network, for instance).
39
+ */
40
+ export function lanAddress() {
41
+ const preferred = []
42
+ const others = []
43
+ for (const [name, addresses] of Object.entries(networkInterfaces())) {
44
+ if (name === 'lo0' || name.startsWith('utun') || name.startsWith('bridge')) continue
45
+ for (const address of addresses ?? []) {
46
+ if (address.family !== 'IPv4' || address.internal) continue
47
+ const [a, b] = address.address.split('.').map(Number)
48
+ const isPrivate = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
49
+ if (isPrivate) preferred.push(address.address)
50
+ else others.push(address.address)
51
+ }
52
+ }
53
+ return preferred[0] ?? others[0]
54
+ }
55
+
56
+ /**
57
+ * Address of this machine in a Tailscale tailnet.
58
+ *
59
+ * Tailscale hands out `100.64.0.0/10` on a `utun` interface — the very interface the
60
+ * LAN probe above skips. Without this the gateway would advertise a private LAN
61
+ * address that no phone outside the house can reach.
62
+ *
63
+ * @returns {string | undefined} `100.x.y.z`, or undefined when Tailscale is off.
64
+ */
65
+ export function tailscaleAddress() {
66
+ for (const addresses of Object.values(networkInterfaces())) {
67
+ for (const address of addresses ?? []) {
68
+ if (address.family !== 'IPv4' || address.internal) continue
69
+ const [a, b] = address.address.split('.').map(Number)
70
+ if (a === 100 && b >= 64 && b <= 127) return address.address
71
+ }
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ /**
77
+ * Origins a phone can use, most useful first.
78
+ *
79
+ * A tailnet address works from any network, so it becomes the primary origin (and the
80
+ * QR code); the LAN address is kept as a second authority so a phone in the house does
81
+ * not need Tailscale. When neither exists the caller may still pass an explicit
82
+ * origin; there is simply nothing to guess.
83
+ *
84
+ * @param {number} port - gateway port.
85
+ * @returns {string[]} origins such as `["https://100.64.0.1:3449", "https://192.168.1.10:3449"]`.
86
+ */
87
+ export function autoOrigins(port) {
88
+ const origins = []
89
+ const tailscale = tailscaleAddress()
90
+ if (tailscale !== undefined) origins.push(`https://${tailscale}:${String(port)}`)
91
+ const lan = lanAddress()
92
+ if (lan !== undefined && lan !== tailscale) origins.push(`https://${lan}:${String(port)}`)
93
+ return origins
94
+ }
95
+
96
+ /**
97
+ * Normalize one configured origin.
98
+ *
99
+ * The product listens with HTTPS only, and every origin must name the same port as the
100
+ * listener: a phone dials exactly what the QR carries, so a mismatch would be a
101
+ * certificate/Host error the person cannot act on.
102
+ *
103
+ * @param {string} value - URL such as `https://100.64.0.1:3449`.
104
+ * @param {number} port - gateway listen port.
105
+ * @returns {string} normalized origin without a trailing slash.
106
+ */
107
+ function normalizeOrigin(value, port) {
108
+ const text = String(value).trim()
109
+ let url
110
+ try {
111
+ url = new URL(text)
112
+ } catch {
113
+ throw new Error(`dsh-remote: неверный origin "${text}"; нужен абсолютный https-адрес`)
114
+ }
115
+ if (url.protocol !== 'https:') {
116
+ throw new Error(`dsh-remote: origin "${text}" должен быть https:// (HTTP запрещён манифестом телефона)`)
117
+ }
118
+ if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== ''
119
+ || (url.pathname !== '' && url.pathname !== '/')) {
120
+ throw new Error(`dsh-remote: origin "${text}" не должен содержать путь, учётные данные, query или fragment`)
121
+ }
122
+ if (url.port !== '' && Number(url.port) !== port) {
123
+ throw new Error(`dsh-remote: порт origin "${text}" не совпадает с портом шлюза ${String(port)}`)
124
+ }
125
+ return `https://${url.hostname.includes(':') ? `[${url.hostname.replaceAll('[', '').replaceAll(']', '')}]` : url.hostname}:${String(port)}`
126
+ }
127
+
128
+ /**
129
+ * Resolve the origin list from an explicit list, a single origin, or an auto-probe.
130
+ *
131
+ * @param {{ origins?: string[], publicOrigin?: string, port: number }} input - caller's intent.
132
+ * @returns {string[]} non-empty unique origins, primary first.
133
+ */
134
+ function resolveOrigins({ origins, publicOrigin, port }) {
135
+ const raw = origins !== undefined && origins.length > 0
136
+ ? origins
137
+ : (publicOrigin === undefined ? autoOrigins(port) : [publicOrigin])
138
+ const list = [...new Set(raw.map((origin) => normalizeOrigin(origin, port)))]
139
+ if (list.length === 0) {
140
+ throw new Error('dsh-remote: не удалось определить адрес компьютера; задайте DSH_REMOTE_ORIGINS')
141
+ }
142
+ return list
143
+ }
144
+
145
+ /**
146
+ * Build the gateway configuration.
147
+ *
148
+ * @param {object} options
149
+ * @param {number} options.upstreamPort - loopback port of the DeepSeek Harness server.
150
+ * @param {string} options.stateDirectory - where devices and TLS state live.
151
+ * @param {number} [options.port] - port the phone dials.
152
+ * @param {string[]} [options.origins] - origins the phone may use; the first one is primary.
153
+ * @param {string} [options.publicOrigin] - single-origin compatibility form.
154
+ * @param {string} [options.instanceId] - stable identity of this installation.
155
+ * @param {{ caFile: string, certFile: string, keyFile: string }} [options.tls] - certificates.
156
+ * @returns {object} frozen gateway configuration for `MobileAccessGateway`.
157
+ */
158
+ export function gatewayConfig({
159
+ upstreamPort,
160
+ stateDirectory,
161
+ port = DEFAULT_PORT,
162
+ origins,
163
+ publicOrigin,
164
+ instanceId,
165
+ tls,
166
+ }) {
167
+ const resolved = resolveOrigins({ origins, publicOrigin, port })
168
+ const hosts = [...new Set(resolved.map((origin) => new URL(origin).hostname))]
169
+ return parseGatewayConfig({
170
+ listenHost: '0.0.0.0',
171
+ listenPort: port,
172
+ // The harness answers on loopback; the gateway is the only door to the network.
173
+ upstreamOrigin: `http://127.0.0.1:${String(upstreamPort)}`,
174
+ publicAuthorities: hosts.map((host) => `${host}:${String(port)}`),
175
+ // 100.64.0.0/10 is Tailscale's address range: without it the gateway rejects a
176
+ // tailnet phone with 403 even though the TLS listener accepts the connection.
177
+ allowedCidrs: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10'],
178
+ // HTTPS is mandatory: the phone's manifest forbids cleartext, so an HTTP listener
179
+ // would be a door nobody can open.
180
+ tls: tls === undefined
181
+ ? { mode: 'provided' }
182
+ : { mode: 'provided', certFile: tls.certFile, keyFile: tls.keyFile },
183
+ stateFile: join(stateDirectory, 'devices.json'),
184
+ // The panel says five minutes; the gateway used two, so a person reading the code
185
+ // aloud could be beaten by a refresh. Keep the promise in one place.
186
+ pairingTtlMs: PAIRING_TTL_MS,
187
+ // Broadcast and mDNS discovery only matter to a client that listens for them; ours
188
+ // learns the address from the QR code. Turning it off also keeps the gateway from
189
+ // opening a UDP socket on networks where that is unwelcome.
190
+ discovery: false,
191
+ ...(tls === undefined ? {} : { pairingCaFile: tls.caFile }),
192
+ ...(instanceId === undefined ? {} : { instanceId }),
193
+ })
194
+ }
195
+
196
+ /**
197
+ * Start the gateway and return a handle that can close it.
198
+ *
199
+ * @param {object} options
200
+ * @param {number} options.upstreamPort
201
+ * @param {string} options.stateDirectory
202
+ * @param {number} [options.port]
203
+ * @param {string[]} [options.origins] - origins the phone may use; the first one is primary.
204
+ * @param {string} [options.publicOrigin] - single-origin compatibility form.
205
+ * @param {string} [options.instanceId]
206
+ * @param {string} [options.upstreamLoginUrl] - launch-token URL for the upstream harness.
207
+ * @param {(message: string) => void} [options.onWarning] - receives degraded-discovery notes.
208
+ * @returns {Promise<{ gateway: object, config: object, origin: string, origins: string[], close: () => Promise<void> }>}
209
+ */
210
+ export async function startGateway({
211
+ upstreamPort,
212
+ stateDirectory,
213
+ port = DEFAULT_PORT,
214
+ origins,
215
+ publicOrigin,
216
+ instanceId,
217
+ upstreamLoginUrl,
218
+ onWarning,
219
+ }) {
220
+ const resolved = resolveOrigins({ origins, publicOrigin, port })
221
+ const hosts = [...new Set(resolved.map((origin) => new URL(origin).hostname))]
222
+ // One leaf covers every origin; the CA stays the same, so phones keep their pin.
223
+ const tls = await ensureCertificates(stateDirectory, hosts.join(','))
224
+ const config = gatewayConfig({
225
+ upstreamPort,
226
+ stateDirectory,
227
+ port,
228
+ origins: resolved,
229
+ instanceId: tls.instanceId,
230
+ tls,
231
+ })
232
+ const store = new JsonDeviceStore(config.stateFile, config.maxDevices)
233
+ const gateway = new MobileAccessGateway(
234
+ config,
235
+ store,
236
+ undefined,
237
+ upstreamLoginUrl,
238
+ undefined,
239
+ undefined,
240
+ (source, code) => onWarning?.(`${source}: ${code}`),
241
+ )
242
+ await gateway.start()
243
+ return {
244
+ gateway,
245
+ config,
246
+ origin: resolved[0],
247
+ origins: resolved,
248
+ close: async () => { await gateway.close() },
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Open a pairing window that a person can read out loud.
254
+ *
255
+ * The gateway's own tokens are 43 opaque characters — fine for a QR code, useless for a
256
+ * human. The window therefore carries a six-digit code as its token: the same window,
257
+ * the same one-time semantics, a form that survives being typed or dictated. Digits only
258
+ * so there is nothing to confuse between `0` and `O`.
259
+ *
260
+ * @param {{ gateway: object, origin: string }} handle - started gateway.
261
+ * @param {number} [ttlMs] - how long the window lives; omitted means the gateway default.
262
+ * @returns {Promise<{ code: string, expiresAt: number, pairUrl: string }>}
263
+ */
264
+ export async function openReadablePairing(handle, ttlMs) {
265
+ const code = String(randomInt(0, 1_000_000)).padStart(6, '0')
266
+ const window = await handle.gateway.access.openPairing(ttlMs, code)
267
+ const url = new URL('/mobile-access/pair', handle.origin)
268
+ url.hash = `instance=${encodeURIComponent(handle.config.instanceId)}&token=${encodeURIComponent(window.token)}`
269
+ return { code: window.token, expiresAt: window.expiresAt, pairUrl: url.toString() }
270
+ }
271
+
272
+ /** Where the plugin keeps devices and TLS material across restarts. */
273
+ export function defaultStateDirectory() {
274
+ return join(homedir(), '.dsh', 'dsh-remote')
275
+ }