dsh-remote-control-plugin 0.3.6 → 0.4.1
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/INSTALL.md +5 -4
- package/README.md +67 -18
- package/client.js +163 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +923 -49
- package/host/handshake.js +39 -0
- package/host/identity.js +112 -0
- package/host/lane.js +10 -0
- package/host/nat.js +113 -0
- package/host/p2p.js +169 -0
- package/host/punch.js +69 -0
- package/host/qr.js +212 -0
- package/host/reachability.js +252 -0
- package/host/relay-core.js +2 -1
- package/host/relay-link.js +33 -10
- package/host/udp-channel.js +176 -0
- package/host/uploads.js +171 -0
- package/index.js +212 -30
- package/package.json +1 -1
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
|
+
}
|
package/host/relay-core.js
CHANGED
|
@@ -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 })
|
package/host/relay-link.js
CHANGED
|
@@ -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, {
|
|
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),
|