dsh-remote-control-plugin 0.3.6 → 0.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/host/qr.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Minimal QR encoder for invite payloads: byte mode, error correction level L,
3
+ * versions 1-5 (up to 106 bytes), mask 0.
4
+ *
5
+ * Deliberately small: no micro-QR, no multi-block interleaving, no mask
6
+ * selection heuristics. Enough for `http://host:port/ <key>#<code>`, and every
7
+ * byte of it is verified by scripts/qr-test.mjs (round trip + RS syndromes).
8
+ */
9
+
10
+ const DATA_CODEWORDS = [19, 34, 55, 80, 108]
11
+ const ECC_CODEWORDS = [7, 10, 15, 20, 26]
12
+
13
+ /* ---- GF(256) ---- */
14
+ const EXP = new Uint8Array(512)
15
+ const LOG = new Uint8Array(256)
16
+ {
17
+ let value = 1
18
+ for (let i = 0; i < 255; i += 1) {
19
+ EXP[i] = value
20
+ LOG[value] = i
21
+ value <<= 1
22
+ if (value & 0x100) value ^= 0x11d
23
+ }
24
+ for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255]
25
+ }
26
+
27
+ function gfMul(a, b) {
28
+ if (a === 0 || b === 0) return 0
29
+ return EXP[LOG[a] + LOG[b]]
30
+ }
31
+
32
+ function rsGenerator(count) {
33
+ // Descending coefficients: generator[0] is the leading (x^count) term, 1.
34
+ let poly = [1]
35
+ for (let i = 0; i < count; i += 1) {
36
+ const root = EXP[i]
37
+ const next = new Array(poly.length + 1).fill(0)
38
+ for (let j = 0; j <= poly.length; j += 1) {
39
+ let value = 0
40
+ if (j < poly.length) value ^= poly[j]
41
+ if (j > 0) value ^= gfMul(poly[j - 1], root)
42
+ next[j] = value
43
+ }
44
+ poly = next
45
+ }
46
+ return poly
47
+ }
48
+
49
+ function rsEncode(data, eccCount) {
50
+ const generator = rsGenerator(eccCount)
51
+ const buffer = new Uint8Array(data.length + eccCount)
52
+ buffer.set(data)
53
+ // Long division: buffer starts as d(x) * x^ecc, subtract g(x) until the
54
+ // leading coefficient is gone. The remainder is the ECC block.
55
+ for (let i = 0; i < data.length; i += 1) {
56
+ const factor = buffer[i]
57
+ if (factor === 0) continue
58
+ for (let j = 0; j < generator.length; j += 1) {
59
+ buffer[i + j] ^= gfMul(generator[j], factor)
60
+ }
61
+ }
62
+ return buffer.slice(data.length)
63
+ }
64
+
65
+ /* ---- bit stream ---- */
66
+ function buildCodewords(bytes) {
67
+ let version = 1
68
+ for (let index = 0; index < DATA_CODEWORDS.length; index += 1) {
69
+ if (bytes.length <= DATA_CODEWORDS[index]) { version = index + 1; break }
70
+ version = index + 1
71
+ }
72
+ const capacity = DATA_CODEWORDS[version - 1]
73
+ if (bytes.length > capacity) throw new Error(`payload too long for QR v${version}`)
74
+
75
+ const bits = []
76
+ const push = (value, length) => { for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1) }
77
+ push(0b0100, 4) // byte mode
78
+ push(bytes.length, 8)
79
+ for (const byte of bytes) push(byte, 8)
80
+ const totalBits = capacity * 8
81
+ push(0, Math.min(4, totalBits - bits.length)) // terminator
82
+ while (bits.length % 8 !== 0) bits.push(0)
83
+ const data = new Uint8Array(capacity)
84
+ for (let i = 0; i < bits.length; i += 8) {
85
+ let value = 0
86
+ for (let j = 0; j < 8; j += 1) value = (value << 1) | bits[i + j]
87
+ data[i / 8] = value
88
+ }
89
+ const pads = [0xec, 0x11]
90
+ for (let i = bits.length / 8, p = 0; i < capacity; i += 1, p += 1) data[i] = pads[p % 2]
91
+
92
+ return { version, data, ecc: rsEncode(data, ECC_CODEWORDS[version - 1]) }
93
+ }
94
+
95
+ /* ---- matrix ---- */
96
+ export function encodeQr(text) {
97
+ const bytes = Buffer.from(String(text), 'utf8')
98
+ const { version, data, ecc } = buildCodewords(bytes)
99
+ const size = 17 + version * 4
100
+ const modules = Array.from({ length: size }, () => new Array(size).fill(false))
101
+ const reserved = Array.from({ length: size }, () => new Array(size).fill(false))
102
+
103
+ const set = (row, col, dark) => { modules[row][col] = dark; reserved[row][col] = true }
104
+
105
+ // finder patterns + separators
106
+ for (const [top, left] of [[0, 0], [0, size - 7], [size - 7, 0]]) {
107
+ for (let r = -1; r <= 7; r += 1) {
108
+ for (let c = -1; c <= 7; c += 1) {
109
+ const row = top + r
110
+ const col = left + c
111
+ if (row < 0 || col < 0 || row >= size || col >= size) continue
112
+ const inside = r >= 0 && r <= 6 && c >= 0 && c <= 6
113
+ const border = r === 0 || r === 6 || c === 0 || c === 6
114
+ const core = r >= 2 && r <= 4 && c >= 2 && c <= 4
115
+ set(row, col, inside && (border || core))
116
+ }
117
+ }
118
+ }
119
+
120
+ // timing patterns
121
+ for (let i = 8; i < size - 8; i += 1) {
122
+ if (!reserved[6][i]) set(6, i, i % 2 === 0)
123
+ if (!reserved[i][6]) set(i, 6, i % 2 === 0)
124
+ }
125
+
126
+ // alignment pattern (one per version 2-5)
127
+ if (version >= 2) {
128
+ const center = size - 7
129
+ for (let r = -2; r <= 2; r += 1) {
130
+ for (let c = -2; c <= 2; c += 1) {
131
+ set(center + r, center + c, Math.max(Math.abs(r), Math.abs(c)) !== 1)
132
+ }
133
+ }
134
+ }
135
+
136
+ // format information: level L (01), mask 0
137
+ const formatData = (1 << 3) | 0
138
+ let remainder = formatData
139
+ for (let i = 0; i < 10; i += 1) remainder = (remainder << 1) ^ ((remainder >> 9) * 0x537)
140
+ const formatBits = ((formatData << 10) | remainder) ^ 0x5412
141
+ for (let i = 0; i < 15; i += 1) {
142
+ const dark = ((formatBits >> i) & 1) === 1
143
+ if (i < 6) set(i, 8, dark)
144
+ else if (i < 8) set(i + 1, 8, dark)
145
+ else set(size - 15 + i, 8, dark)
146
+ if (i < 8) set(8, size - i - 1, dark)
147
+ else if (i === 8) set(8, 15 - i - 1 + 1, dark)
148
+ else set(8, 15 - i - 1, dark)
149
+ }
150
+ set(size - 8, 8, true) // dark module
151
+
152
+ // data placement, right-to-left zigzag, mask 0
153
+ let byteIndex = 0
154
+ let bitIndex = 7
155
+ let row = size - 1
156
+ let direction = -1
157
+ for (let col = size - 1; col > 0; col -= 2) {
158
+ if (col === 6) col -= 1
159
+ for (;;) {
160
+ for (let c = 0; c < 2; c += 1) {
161
+ const target = col - c
162
+ if (reserved[row][target]) continue
163
+ let dark = false
164
+ if (byteIndex < data.length + ecc.length) {
165
+ const block = byteIndex < data.length ? data : ecc
166
+ const offset = byteIndex < data.length ? byteIndex : byteIndex - data.length
167
+ dark = ((block[offset] >>> bitIndex) & 1) === 1
168
+ }
169
+ if ((row + target) % 2 === 0) dark = !dark
170
+ modules[row][target] = dark
171
+ bitIndex -= 1
172
+ if (bitIndex === -1) { byteIndex += 1; bitIndex = 7 }
173
+ }
174
+ row += direction
175
+ if (row < 0 || row >= size) { row -= direction; direction = -direction; break }
176
+ }
177
+ }
178
+
179
+ return { version, size, modules, data, ecc, reserved }
180
+ }
181
+
182
+ /** Reed-Solomon syndromes of a full codeword block; all zero means valid. */
183
+ export function syndromesOf(codewords, eccCount) {
184
+ const out = []
185
+ for (let i = 0; i < eccCount; i += 1) {
186
+ let value = 0
187
+ for (const byte of codewords) value = gfMul(value, EXP[i]) ^ byte
188
+ out.push(value)
189
+ }
190
+ return out
191
+ }
192
+
193
+ export { DATA_CODEWORDS, ECC_CODEWORDS, rsEncode }
194
+
195
+ /** Render an invite string as a standalone SVG (no external service). */
196
+ export function qrSvg(text, moduleSize = 4, margin = 4) {
197
+ const qr = encodeQr(text)
198
+ const dimension = (qr.size + margin * 2) * moduleSize
199
+ let path = ''
200
+ for (let row = 0; row < qr.size; row += 1) {
201
+ for (let col = 0; col < qr.size; col += 1) {
202
+ if (!qr.modules[row][col]) continue
203
+ const x = (col + margin) * moduleSize
204
+ const y = (row + margin) * moduleSize
205
+ path += `M${x} ${y}h${moduleSize}v${moduleSize}h-${moduleSize}z`
206
+ }
207
+ }
208
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${dimension}" height="${dimension}" `
209
+ + `viewBox="0 0 ${dimension} ${dimension}" shape-rendering="crispEdges">`
210
+ + `<rect width="${dimension}" height="${dimension}" fill="#fff"/>`
211
+ + `<path d="${path}" fill="#000"/></svg>`
212
+ }
@@ -0,0 +1,252 @@
1
+ import dgram from 'node:dgram'
2
+ import http from 'node:http'
3
+ import os from 'node:os'
4
+
5
+ /**
6
+ * Relay reachability and address policy.
7
+ *
8
+ * A relay is only usable across NATs when it has a public address. This module
9
+ * tries, in order, to make this Host reachable and to prove it:
10
+ * 1. UPnP IGD port mapping, then the router's external address;
11
+ * 2. an HTTP public-address lookup (display only — no mapping);
12
+ * 3. a self-test that talks to the relay over its public URL.
13
+ *
14
+ * It never adds a third-party dependency: SSDP and SOAP are plain UDP/HTTP.
15
+ */
16
+
17
+ const PRIVATE = [
18
+ /^10\./,
19
+ /^127\./,
20
+ /^169\.254\./,
21
+ /^172\.(1[6-9]|2\d|3[01])\./,
22
+ /^192\.168\./,
23
+ /^::1$/,
24
+ /^f[cd][0-9a-f]{2}:/i,
25
+ ]
26
+
27
+ export function isPrivateHost(host) {
28
+ const value = String(host || '').replace(/^\[|\]$/g, '').toLowerCase()
29
+ if (!value) return false
30
+ if (value === 'localhost') return true
31
+ return PRIVATE.some((pattern) => pattern.test(value))
32
+ }
33
+
34
+ /**
35
+ * Relay addresses must be TLS unless they stay on this machine or a private
36
+ * network. A public plain-HTTP relay would expose the workspace key and every
37
+ * signed call in transit.
38
+ */
39
+ export function checkRelayUrl(rawUrl, { allowInsecure = false } = {}) {
40
+ let url
41
+ try {
42
+ url = new URL(rawUrl)
43
+ } catch {
44
+ return { ok: false, error: 'bad-url', message: 'Relay URL must be absolute.' }
45
+ }
46
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
47
+ return { ok: false, error: 'bad-protocol', message: 'Relay URL must use http or https.' }
48
+ }
49
+ if (url.protocol === 'https:') return { ok: true, url }
50
+ if (isPrivateHost(url.hostname)) return { ok: true, url, insecure: true }
51
+ if (allowInsecure) return { ok: true, url, insecure: true, warning: 'Plain HTTP to a public relay: traffic is readable in transit.' }
52
+ return {
53
+ ok: false,
54
+ error: 'tls-required',
55
+ message: 'A public relay must use https. Enable allowInsecureRelay only for a network you trust.',
56
+ }
57
+ }
58
+
59
+ /** The address this machine would use to reach the internet. */
60
+ export function primaryAddress() {
61
+ return new Promise((resolve) => {
62
+ const socket = dgram.createSocket('udp4')
63
+ let settled = false
64
+ const done = (value) => {
65
+ if (settled) return
66
+ settled = true
67
+ try { socket.close() } catch { /* ignore */ }
68
+ resolve(value)
69
+ }
70
+ socket.on('error', () => done(undefined))
71
+ try {
72
+ socket.connect(53, '8.8.8.8', () => {
73
+ const address = socket.address()
74
+ done(address?.address)
75
+ })
76
+ } catch {
77
+ done(undefined)
78
+ }
79
+ setTimeout(() => done(undefined), 1500).unref?.()
80
+ })
81
+ }
82
+
83
+ const SSDP_SEARCH = Buffer.from(
84
+ 'M-SEARCH * HTTP/1.1\r\n'
85
+ + 'HOST: 239.255.255.250:1900\r\n'
86
+ + 'MAN: "ssdp:discover"\r\n'
87
+ + 'MX: 2\r\n'
88
+ + 'ST: urn:schemas-upnp-org:service:WANIPConnection:1\r\n\r\n',
89
+ )
90
+
91
+ /** Discover UPnP internet-gateway devices (SSDP). */
92
+ export function discoverGateways({ timeoutMs = 2500 } = {}) {
93
+ return new Promise((resolve) => {
94
+ const socket = dgram.createSocket('udp4')
95
+ const locations = new Set()
96
+ socket.on('message', (message) => {
97
+ const location = /LOCATION:\s*(\S+)/i.exec(message.toString('utf8'))?.[1]
98
+ if (location) locations.add(location)
99
+ })
100
+ socket.on('error', () => { try { socket.close() } catch { /* ignore */ } resolve([]) })
101
+ socket.bind(() => {
102
+ try { socket.setBroadcast(true) } catch { /* ignore */ }
103
+ socket.send(SSDP_SEARCH, 0, SSDP_SEARCH.length, 1900, '239.255.255.250')
104
+ })
105
+ setTimeout(() => {
106
+ try { socket.close() } catch { /* ignore */ }
107
+ resolve([...locations])
108
+ }, timeoutMs).unref?.()
109
+ })
110
+ }
111
+
112
+ const fetchText = (url, options = {}, timeoutMs = 4000) => new Promise((resolve, reject) => {
113
+ const request = http.request(url, { ...options, timeout: timeoutMs }, (response) => {
114
+ let body = ''
115
+ response.on('data', (chunk) => { body += chunk })
116
+ response.on('end', () => resolve({ status: response.statusCode ?? 0, body }))
117
+ })
118
+ request.on('timeout', () => request.destroy(new Error('timeout')))
119
+ request.on('error', reject)
120
+ if (options.body) request.write(options.body)
121
+ request.end()
122
+ })
123
+
124
+ function soapRequest(controlUrl, service, action, args) {
125
+ const body = `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${service}">${args}</u:${action}></s:Body></s:Envelope>`
126
+ return fetchText(controlUrl, {
127
+ method: 'POST',
128
+ headers: {
129
+ 'content-type': 'text/xml; charset="utf-8"',
130
+ soapaction: `"${service}#${action}"`,
131
+ 'content-length': Buffer.byteLength(body),
132
+ },
133
+ body,
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Ask the router to forward a TCP port to this machine and report its external
139
+ * address. Returns `{ available: false }` when there is no IGD to talk to.
140
+ */
141
+ export async function mapUpnpPort({ port, internalIp, description = 'DSH remote control', leaseSeconds = 0 }) {
142
+ const locations = await discoverGateways()
143
+ if (locations.length === 0) {
144
+ return { available: false, error: 'no-igd', message: 'No UPnP internet gateway answered on this network.' }
145
+ }
146
+ for (const location of locations) {
147
+ try {
148
+ const description0 = await fetchText(location)
149
+ const service = /urn:schemas-upnp-org:service:(WANIPConnection|WANPPPConnection):\d/.exec(description0.body)?.[1]
150
+ const controlPath = /<controlURL>([^<]+)<\/controlURL>/i.exec(description0.body)?.[1]
151
+ if (!service || !controlPath) continue
152
+ const serviceType = `urn:schemas-upnp-org:service:${service}:1`
153
+ const controlUrl = new URL(controlPath, location).toString()
154
+ const address = await soapRequest(controlUrl, serviceType, 'AddPortMapping', [
155
+ '<NewRemoteHost></NewRemoteHost>',
156
+ `<NewExternalPort>${port}</NewExternalPort>`,
157
+ '<NewProtocol>TCP</NewProtocol>',
158
+ `<NewInternalPort>${port}</NewInternalPort>`,
159
+ `<NewInternalClient>${internalIp}</NewInternalClient>`,
160
+ '<NewEnabled>1</NewEnabled>',
161
+ `<NewPortMappingDescription>${description}</NewPortMappingDescription>`,
162
+ `<NewLeaseDuration>${leaseSeconds}</NewLeaseDuration>`,
163
+ ].join(''))
164
+ if (address.status !== 200) continue
165
+ const ip = await soapRequest(controlUrl, serviceType, 'GetExternalIPAddress', '')
166
+ const externalIp = /<NewExternalIPAddress>([^<]+)<\/NewExternalIPAddress>/.exec(ip.body)?.[1]
167
+ return {
168
+ available: true,
169
+ mapped: true,
170
+ controlUrl,
171
+ serviceType,
172
+ externalIp: externalIp && externalIp !== '0.0.0.0' ? externalIp : undefined,
173
+ }
174
+ } catch { /* try the next gateway */ }
175
+ }
176
+ return { available: true, mapped: false, error: 'map-failed', message: 'The gateway refused the port mapping.' }
177
+ }
178
+
179
+ export async function removeUpnpMapping({ controlUrl, serviceType, port }) {
180
+ try {
181
+ await soapRequest(controlUrl, serviceType, 'DeletePortMapping', [
182
+ '<NewRemoteHost></NewRemoteHost>',
183
+ `<NewExternalPort>${port}</NewExternalPort>`,
184
+ '<NewProtocol>TCP</NewProtocol>',
185
+ ].join(''))
186
+ } catch { /* best effort */ }
187
+ }
188
+
189
+ /** Public address via an HTTP echo service; display only, no mapping. */
190
+ export async function publicAddressViaHttp() {
191
+ try {
192
+ const response = await fetchText('http://api.ipify.org', {}, 3000)
193
+ const value = response.body.trim()
194
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(value) ? value : undefined
195
+ } catch {
196
+ return undefined
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Prove a relay is reachable at `url`: POST /v1/register with no group.
202
+ * A reachable relay answers 400 (bad-group); anything else means we cannot
203
+ * talk to it from outside.
204
+ */
205
+ export async function relaySelfTest(url, timeoutMs = 4000) {
206
+ try {
207
+ const response = await fetchText(`${String(url).replace(/\/+$/, '')}/v1/register`, {
208
+ method: 'POST',
209
+ headers: { 'content-type': 'application/json', 'content-length': 2 },
210
+ body: '{}',
211
+ }, timeoutMs)
212
+ return { ok: response.status === 400, status: response.status }
213
+ } catch (error) {
214
+ return { ok: false, error: error?.message || String(error) }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Full reachability attempt for a relay hosted on `port`.
220
+ * Returns the public URL only when the relay actually answers through it.
221
+ */
222
+ export async function ensureReachable({ port, allowInsecure = false } = {}) {
223
+ const internalIp = await primaryAddress()
224
+ const upnp = await mapUpnpPort({ port, internalIp: internalIp ?? '0.0.0.0' })
225
+ const publicIp = upnp.externalIp ?? (await publicAddressViaHttp())
226
+
227
+ const candidates = []
228
+ if (publicIp) candidates.push(`http://${publicIp}:${port}`)
229
+
230
+ let selfTest = { ok: false, error: 'no-public-address' }
231
+ let reachableUrl
232
+ for (const candidate of candidates) {
233
+ const verdict = await relaySelfTest(candidate)
234
+ selfTest = verdict
235
+ if (verdict.ok) { reachableUrl = candidate; break }
236
+ }
237
+
238
+ const secure = reachableUrl ? checkRelayUrl(reachableUrl, { allowInsecure }) : { ok: false }
239
+
240
+ return {
241
+ ok: Boolean(reachableUrl && secure.ok),
242
+ url: reachableUrl ?? null,
243
+ secure: Boolean(secure.ok),
244
+ publicIp: publicIp ?? null,
245
+ internalIp: internalIp ?? null,
246
+ upnp,
247
+ selfTest,
248
+ hint: upnp.available
249
+ ? (upnp.mapped ? undefined : upnp.message)
250
+ : 'No UPnP gateway on this network; the relay cannot be published from here. Use a public relay or a tunnel.',
251
+ }
252
+ }
@@ -12,7 +12,7 @@ import { randomBytes } from 'node:crypto'
12
12
  * relay never sees the workspace key. Peer control stays end-to-end signed by
13
13
  * the devices themselves; this process only routes.
14
14
  */
15
- export function createRelay({ log = console } = {}) {
15
+ export function createRelay({ log = console, onMessage } = {}) {
16
16
  const devices = new Map() // deviceId -> record
17
17
  const tokens = new Map() // token -> deviceId
18
18
 
@@ -93,6 +93,7 @@ export function createRelay({ log = console } = {}) {
93
93
  return send(res, 413, { error: 'too-large' })
94
94
  }
95
95
  if (to.queue.length >= MAX_QUEUE) to.queue.shift()
96
+ onMessage?.({ from: from.deviceId, to: to.deviceId, message })
96
97
  to.queue.push({ from: from.deviceId, message, at: Date.now() })
97
98
  wake(to)
98
99
  return send(res, 200, { ok: true })
@@ -26,6 +26,7 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
26
26
  let stopped = false
27
27
  let heartbeatTimer
28
28
  let sequence = 0
29
+ let security
29
30
 
30
31
  async function start() {
31
32
  await register()
@@ -118,11 +119,26 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
118
119
  }
119
120
 
120
121
  async function dispatch(from, message) {
122
+ const encryptedInbound = Boolean(message && typeof message === 'object' && typeof message.enc === 'string')
123
+ if (encryptedInbound) {
124
+ // Sealed envelope from a peer that has pinned our keys.
125
+ const opened = security?.open?.(from, message.enc)
126
+ if (typeof opened !== 'string') return
127
+ try { message = JSON.parse(opened) } catch { return }
128
+ }
121
129
  if (!message || typeof message !== 'object') return
122
130
  switch (message.kind) {
123
131
  case 'http': {
124
132
  const result = await localRequest(message.method, message.path, message.headers, message.body)
125
- await send(from, { kind: 'http-response', id: message.id, status: result.status, body: result.body })
133
+ await send(from, {
134
+ kind: 'http-response',
135
+ id: message.id,
136
+ status: result.status,
137
+ body: result.body,
138
+ // Answer in kind: a plaintext request predates the peer knowing our
139
+ // key (the approval response itself), so its reply must stay plain.
140
+ plain: !encryptedInbound,
141
+ })
126
142
  return
127
143
  }
128
144
  case 'http-response': {
@@ -133,7 +149,7 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
133
149
  return
134
150
  }
135
151
  case 'stream-open':
136
- openLocalStream(from, message)
152
+ openLocalStream(from, message, encryptedInbound)
137
153
  return
138
154
  case 'stream-status': {
139
155
  const entry = streams.get(message.id)
@@ -170,7 +186,7 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
170
186
  port: localPort,
171
187
  path: pathname,
172
188
  method,
173
- headers: { ...headers, host: `127.0.0.1:${localPort}` },
189
+ headers: { ...headers, host: `127.0.0.1:${localPort}`, 'x-dsh-via': 'relay' },
174
190
  }, (response) => {
175
191
  let data = ''
176
192
  response.on('data', (chunk) => { data += chunk })
@@ -185,7 +201,7 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
185
201
  })
186
202
  }
187
203
 
188
- function openLocalStream(from, message) {
204
+ function openLocalStream(from, message, encryptedInbound) {
189
205
  const id = message.id
190
206
  const request = http.request({
191
207
  host: '127.0.0.1',
@@ -200,23 +216,23 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
200
216
  const push = (message) => {
201
217
  chain = chain.then(() => send(from, message)).catch(() => {})
202
218
  }
203
- push({ kind: 'stream-status', id, status: response.statusCode ?? 502 })
219
+ push({ kind: 'stream-status', id, status: response.statusCode ?? 502, plain: !encryptedInbound })
204
220
  if ((response.statusCode ?? 500) >= 400) {
205
221
  response.resume()
206
222
  locals.delete(id)
207
- push({ kind: 'stream-end', id })
223
+ push({ kind: 'stream-end', id, plain: !encryptedInbound })
208
224
  return
209
225
  }
210
226
  response.on('data', (chunk) => {
211
- push({ kind: 'stream-chunk', id, chunk: chunk.toString('utf8') })
227
+ push({ kind: 'stream-chunk', id, chunk: chunk.toString('utf8'), plain: !encryptedInbound })
212
228
  })
213
229
  response.on('end', () => {
214
230
  locals.delete(id)
215
- push({ kind: 'stream-end', id })
231
+ push({ kind: 'stream-end', id, plain: !encryptedInbound })
216
232
  })
217
233
  response.on('error', () => {
218
234
  locals.delete(id)
219
- push({ kind: 'stream-end', id })
235
+ push({ kind: 'stream-end', id, plain: !encryptedInbound })
220
236
  })
221
237
  })
222
238
  request.on('error', () => {
@@ -229,10 +245,16 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
229
245
 
230
246
  async function send(to, message) {
231
247
  if (!token) throw new Error('relay not registered')
248
+ const { plain, ...rest } = message ?? {}
249
+ let payload = rest
250
+ if (security && !plain) {
251
+ const sealed = security.seal?.(to, JSON.stringify(rest))
252
+ if (typeof sealed === 'string') payload = { enc: sealed }
253
+ }
232
254
  const response = await fetch(`${base}/v1/send`, {
233
255
  method: 'POST',
234
256
  headers: { 'content-type': 'application/json' },
235
- body: JSON.stringify({ token, to, message }),
257
+ body: JSON.stringify({ token, to, message: payload }),
236
258
  })
237
259
  if (response.status === 401) { token = undefined; throw new Error('relay unauthorized') }
238
260
  if (!response.ok) throw new Error(`relay send failed: ${response.status}`)
@@ -330,6 +352,7 @@ export function createRelayLink({ url, key, deviceId, deviceName, userId, localP
330
352
  stop,
331
353
  request,
332
354
  stream,
355
+ setSecurity(next) { security = next },
333
356
  peers: () => [...peers.values()],
334
357
  url: () => base,
335
358
  connected: () => Boolean(token),