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/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
|
+
}
|