dsh-remote-control-plugin 0.3.5 → 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/INSTALL.md +5 -4
- package/client.js +125 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +711 -40
- 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 +170 -0
- package/index.js +218 -34
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { signPayload, verifyPayload } from './identity.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pairing handshake messages for forward secrecy.
|
|
5
|
+
*
|
|
6
|
+
* Each side mints a fresh X25519 ephemeral key for one pairing attempt and
|
|
7
|
+
* signs the public half together with the device id and the invite nonce:
|
|
8
|
+
*
|
|
9
|
+
* payload = `${deviceId}\n${pairCode}\n${ephemeralPublicPem}`
|
|
10
|
+
*
|
|
11
|
+
* The signature proves the ephemeral belongs to the identity that was approved,
|
|
12
|
+
* so a relay cannot substitute its own ephemeral key. The private half never
|
|
13
|
+
* leaves the process and is dropped once both lanes are derived.
|
|
14
|
+
*
|
|
15
|
+
* This is a message format, not a protocol: ordering and retry live in hello.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export function ephemeralPayload(deviceId, pairCode, publicPem) {
|
|
19
|
+
return `${deviceId}\n${pairCode}\n${publicPem}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mintEphemeral(identity, deviceId, pairCode, keypair) {
|
|
23
|
+
const publicPem = keypair.publicKey.export({ type: 'spki', format: 'pem' })
|
|
24
|
+
return {
|
|
25
|
+
publicPem,
|
|
26
|
+
signature: signPayload(identity.ed25519.privateKey, ephemeralPayload(deviceId, pairCode, publicPem)),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function verifyEphemeral(identityPublicKeyPem, deviceId, pairCode, message) {
|
|
31
|
+
if (!identityPublicKeyPem || !message || typeof message.publicPem !== 'string' || typeof message.signature !== 'string') {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
return verifyPayload(
|
|
35
|
+
identityPublicKeyPem,
|
|
36
|
+
ephemeralPayload(deviceId, pairCode, message.publicPem),
|
|
37
|
+
message.signature,
|
|
38
|
+
)
|
|
39
|
+
}
|
package/host/identity.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Device identity: one Ed25519 signing key and one X25519 key-agreement key per
|
|
7
|
+
* install, generated once and persisted owner-only.
|
|
8
|
+
*
|
|
9
|
+
* The Ed25519 key is the device's long-term identity: its fingerprint goes into
|
|
10
|
+
* invites and approval records, and peer calls are signed with it. The X25519
|
|
11
|
+
* key is reserved for the encrypted channel (phase A2); it is stored now so a
|
|
12
|
+
* device never has to rotate identity to gain encryption.
|
|
13
|
+
*
|
|
14
|
+
* The short authentication string (SAS) binds two fingerprints and the invite
|
|
15
|
+
* nonce, so both screens show the same six digits only when both devices hold
|
|
16
|
+
* the keys they claim.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function fingerprintOf(publicKeyPem) {
|
|
20
|
+
const key = createPublicKey(publicKeyPem)
|
|
21
|
+
const der = key.export({ type: 'spki', format: 'der' })
|
|
22
|
+
return createHash('sha256').update(der).digest('hex').slice(0, 32)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sasCode(fingerprintA, fingerprintB, nonce) {
|
|
26
|
+
const ordered = [fingerprintA, fingerprintB].sort().join(':')
|
|
27
|
+
const digest = createHash('sha256').update(`${ordered}:${nonce}`).digest()
|
|
28
|
+
const value = digest.readUInt32BE(0) % 1_000_000
|
|
29
|
+
return String(value).padStart(6, '0')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function signPayload(privateKey, payload) {
|
|
33
|
+
return sign(null, Buffer.from(payload), privateKey).toString('base64url')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function verifyPayload(publicKeyPem, payload, signature) {
|
|
37
|
+
try {
|
|
38
|
+
return verify(null, Buffer.from(payload), createPublicKey(publicKeyPem), Buffer.from(signature, 'base64url'))
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function loadOrCreateIdentity(file, log = console, store) {
|
|
45
|
+
let stored
|
|
46
|
+
if (store) {
|
|
47
|
+
try {
|
|
48
|
+
stored = await store.load()
|
|
49
|
+
} catch (error) {
|
|
50
|
+
log.warn?.(`[remote-control] could not read the identity record: ${error?.message || error}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!stored) {
|
|
54
|
+
try {
|
|
55
|
+
stored = JSON.parse(await readFile(file, 'utf8'))
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error?.code !== 'ENOENT') log.warn?.(`[remote-control] could not read identity: ${error?.message || error}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (stored?.ed25519?.privateKey && stored?.ed25519?.publicKey) {
|
|
62
|
+
try {
|
|
63
|
+
const ed25519 = {
|
|
64
|
+
publicKey: createPublicKey(stored.ed25519.publicKey),
|
|
65
|
+
privateKey: createPrivateKey(stored.ed25519.privateKey),
|
|
66
|
+
publicKeyPem: stored.ed25519.publicKey,
|
|
67
|
+
}
|
|
68
|
+
let x25519 = null
|
|
69
|
+
if (stored?.x25519?.privateKey) {
|
|
70
|
+
x25519 = {
|
|
71
|
+
publicKey: createPublicKey(stored.x25519.publicKey),
|
|
72
|
+
privateKey: createPrivateKey(stored.x25519.privateKey),
|
|
73
|
+
publicKeyPem: stored.x25519.publicKey,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ed25519, x25519, fingerprint: fingerprintOf(ed25519.publicKeyPem), createdAt: stored.createdAt ?? Date.now(), fresh: false }
|
|
77
|
+
} catch {
|
|
78
|
+
log.warn?.('[remote-control] stored identity is unusable; generating a new one')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ed = generateKeyPairSync('ed25519')
|
|
83
|
+
const x = generateKeyPairSync('x25519')
|
|
84
|
+
const record = {
|
|
85
|
+
createdAt: Date.now(),
|
|
86
|
+
ed25519: {
|
|
87
|
+
publicKey: ed.publicKey.export({ type: 'spki', format: 'pem' }),
|
|
88
|
+
privateKey: ed.privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
89
|
+
},
|
|
90
|
+
x25519: {
|
|
91
|
+
publicKey: x.publicKey.export({ type: 'spki', format: 'pem' }),
|
|
92
|
+
privateKey: x.privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
if (store) {
|
|
97
|
+
await store.save(record)
|
|
98
|
+
} else {
|
|
99
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
100
|
+
await writeFile(file, JSON.stringify(record, null, 2), { mode: 0o600 })
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
log.warn?.(`[remote-control] could not persist identity: ${error?.message || error}`)
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ed25519: { publicKey: ed.publicKey, privateKey: ed.privateKey, publicKeyPem: record.ed25519.publicKey },
|
|
107
|
+
x25519: { publicKey: x.publicKey, privateKey: x.privateKey, publicKeyPem: record.x25519.publicKey },
|
|
108
|
+
fingerprint: fingerprintOf(record.ed25519.publicKey),
|
|
109
|
+
createdAt: record.createdAt,
|
|
110
|
+
fresh: true,
|
|
111
|
+
}
|
|
112
|
+
}
|
package/host/lane.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane preference: a same-LAN peer always wins, then a punched P2P path, then
|
|
3
|
+
* the relay. Kept pure so the ordering is testable without any network.
|
|
4
|
+
*/
|
|
5
|
+
export function selectLane({ sameLan = false, punched = false, relayAvailable = false } = {}) {
|
|
6
|
+
if (sameLan) return 'lan'
|
|
7
|
+
if (punched) return 'p2p'
|
|
8
|
+
if (relayAvailable) return 'relay'
|
|
9
|
+
return 'none'
|
|
10
|
+
}
|
package/host/nat.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import dgram from 'node:dgram'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Phase B building blocks: IPv6-first candidate discovery and a minimal STUN
|
|
7
|
+
* binding client.
|
|
8
|
+
*
|
|
9
|
+
* Only this half is verifiable on the current network (which has no IPv6 and no
|
|
10
|
+
* second NAT): the binding request/response codec and candidate filtering are
|
|
11
|
+
* exercised against an in-process STUN responder. Hole punching itself needs
|
|
12
|
+
* two real NATs and is not attempted here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MAGIC = 0x2112a442
|
|
16
|
+
const BINDING_REQUEST = 0x0001
|
|
17
|
+
const BINDING_SUCCESS = 0x0101
|
|
18
|
+
const XOR_MAPPED_ADDRESS = 0x0020
|
|
19
|
+
|
|
20
|
+
export function buildBindingRequest(transactionId = randomBytes(12)) {
|
|
21
|
+
const buffer = Buffer.alloc(20)
|
|
22
|
+
buffer.writeUInt16BE(BINDING_REQUEST, 0)
|
|
23
|
+
buffer.writeUInt16BE(0, 2)
|
|
24
|
+
buffer.writeUInt32BE(MAGIC, 4)
|
|
25
|
+
transactionId.copy(buffer, 8, 0, 12)
|
|
26
|
+
return buffer
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseBindingResponse(buffer) {
|
|
30
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 20) return undefined
|
|
31
|
+
if (buffer.readUInt16BE(0) !== BINDING_SUCCESS) return undefined
|
|
32
|
+
if (buffer.readUInt32BE(4) !== MAGIC) return undefined
|
|
33
|
+
const length = buffer.readUInt16BE(2)
|
|
34
|
+
if (20 + length > buffer.length) return undefined
|
|
35
|
+
let offset = 20
|
|
36
|
+
const end = 20 + length
|
|
37
|
+
while (offset + 4 <= end) {
|
|
38
|
+
const type = buffer.readUInt16BE(offset)
|
|
39
|
+
const size = buffer.readUInt16BE(offset + 2)
|
|
40
|
+
const valueStart = offset + 4
|
|
41
|
+
if (valueStart + size > end) return undefined
|
|
42
|
+
if (type === XOR_MAPPED_ADDRESS && size >= 8) {
|
|
43
|
+
const family = buffer[valueStart + 1]
|
|
44
|
+
const port = buffer.readUInt16BE(valueStart + 2) ^ (MAGIC >>> 16)
|
|
45
|
+
if (family === 0x01) {
|
|
46
|
+
const raw = buffer.readUInt32BE(valueStart + 4)
|
|
47
|
+
const address = (raw ^ MAGIC) >>> 0
|
|
48
|
+
return {
|
|
49
|
+
family: 'IPv4',
|
|
50
|
+
address: `${(address >>> 24) & 0xff}.${(address >>> 16) & 0xff}.${(address >>> 8) & 0xff}.${address & 0xff}`,
|
|
51
|
+
port,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (family === 0x02 && size >= 20) {
|
|
55
|
+
const raw = buffer.subarray(valueStart + 4, valueStart + 20)
|
|
56
|
+
const mask = Buffer.alloc(16)
|
|
57
|
+
mask.writeUInt32BE(MAGIC, 0)
|
|
58
|
+
buffer.copy(mask, 4, 4, 20) // transaction id
|
|
59
|
+
const address = Buffer.from(raw.map((byte, index) => byte ^ mask[index]))
|
|
60
|
+
const groups = []
|
|
61
|
+
for (let i = 0; i < 16; i += 2) groups.push(address.readUInt16BE(i).toString(16))
|
|
62
|
+
return { family: 'IPv6', address: groups.join(':'), port }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
offset = valueStart + size + ((4 - (size % 4)) % 4)
|
|
66
|
+
}
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function stunBinding({ host = 'stun.l.google.com', port = 19302, timeoutMs = 2000 } = {}) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const transactionId = randomBytes(12)
|
|
73
|
+
const request = buildBindingRequest(transactionId)
|
|
74
|
+
const socket = dgram.createSocket('udp4')
|
|
75
|
+
let settled = false
|
|
76
|
+
const finish = (value) => {
|
|
77
|
+
if (settled) return
|
|
78
|
+
settled = true
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
try { socket.close() } catch { /* ignore */ }
|
|
81
|
+
resolve(value)
|
|
82
|
+
}
|
|
83
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs)
|
|
84
|
+
socket.on('error', () => finish(undefined))
|
|
85
|
+
socket.on('message', (message) => {
|
|
86
|
+
if (message.length >= 20 && message.readUInt32BE(4) === MAGIC && message.subarray(8, 20).equals(transactionId)) {
|
|
87
|
+
finish(parseBindingResponse(message))
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
socket.send(request, 0, request.length, port, host, (error) => {
|
|
91
|
+
if (error) finish(undefined)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** IPv6 candidates first, then IPv4; loopback and link-local are excluded. */
|
|
97
|
+
export function localCandidates(interfaces = os.networkInterfaces()) {
|
|
98
|
+
const ipv6 = []
|
|
99
|
+
const ipv4 = []
|
|
100
|
+
for (const entries of Object.values(interfaces ?? {})) {
|
|
101
|
+
for (const entry of entries ?? []) {
|
|
102
|
+
if (!entry || entry.internal) continue
|
|
103
|
+
if (entry.family === 'IPv6' || entry.family === 6) {
|
|
104
|
+
if (entry.address.startsWith('fe80') || entry.address === '::1') continue
|
|
105
|
+
ipv6.push({ family: 'IPv6', address: entry.address })
|
|
106
|
+
} else if (entry.family === 'IPv4' || entry.family === 4) {
|
|
107
|
+
if (entry.address.startsWith('127.')) continue
|
|
108
|
+
ipv4.push({ family: 'IPv4', address: entry.address })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [...ipv6, ...ipv4]
|
|
113
|
+
}
|
package/host/p2p.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { createBoxCipher } from './crypto.js'
|
|
3
|
+
import { createUdpChannel } from './udp-channel.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* P2P proxy for the punched lane: carries HTTP-shaped peer calls and SSE frames
|
|
7
|
+
* over the UDP channel between two DSH instances and replays them into the
|
|
8
|
+
* local listener, so authentication, sealing and scope checks stay in one place.
|
|
9
|
+
*
|
|
10
|
+
* Unary calls use request/response (ACKed). Stream frames are one-way datagrams:
|
|
11
|
+
* a lost frame is acceptable because the transcript resynchronizes from the next
|
|
12
|
+
* snapshot, and retransmitting ordered frames over an unreliable transport would
|
|
13
|
+
* need a much larger protocol than this lane is worth.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export async function createP2pNode({ localPort, bindPort = 0, keyFor, selfDeviceId, log = console }) {
|
|
17
|
+
const streams = new Map() // streamId -> { onChunk, onEnd }
|
|
18
|
+
let nextStreamId = 1
|
|
19
|
+
|
|
20
|
+
function forward(inner) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const text = typeof inner?.bodyText === 'string' ? inner.bodyText : ''
|
|
23
|
+
const request = http.request({
|
|
24
|
+
host: '127.0.0.1',
|
|
25
|
+
port: localPort,
|
|
26
|
+
path: typeof inner?.path === 'string' ? inner.path : '/',
|
|
27
|
+
method: inner?.method || 'GET',
|
|
28
|
+
headers: {
|
|
29
|
+
...(inner?.headers ?? {}),
|
|
30
|
+
host: `127.0.0.1:${localPort}`,
|
|
31
|
+
'x-dsh-via': 'p2p',
|
|
32
|
+
'content-length': Buffer.byteLength(text),
|
|
33
|
+
},
|
|
34
|
+
timeout: 10000,
|
|
35
|
+
}, (response) => {
|
|
36
|
+
let data = ''
|
|
37
|
+
response.on('data', (chunk) => { data += chunk })
|
|
38
|
+
response.on('end', () => resolve({ status: response.statusCode ?? 502, bodyText: data }))
|
|
39
|
+
})
|
|
40
|
+
request.on('timeout', () => request.destroy(new Error('timeout')))
|
|
41
|
+
request.on('error', () => resolve({ status: 502, bodyText: '' }))
|
|
42
|
+
request.end(text || undefined)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Forward one SSE stream to a peer as datagrams. */
|
|
47
|
+
function relayStream(remote, box, inner, streamId) {
|
|
48
|
+
const request = http.request({
|
|
49
|
+
host: '127.0.0.1',
|
|
50
|
+
port: localPort,
|
|
51
|
+
path: typeof inner?.path === 'string' ? inner.path : '/',
|
|
52
|
+
method: 'GET',
|
|
53
|
+
headers: {
|
|
54
|
+
...(inner?.headers ?? {}),
|
|
55
|
+
host: `127.0.0.1:${localPort}`,
|
|
56
|
+
'x-dsh-via': 'p2p',
|
|
57
|
+
accept: 'text/event-stream',
|
|
58
|
+
},
|
|
59
|
+
timeout: 15000,
|
|
60
|
+
}, (response) => {
|
|
61
|
+
response.setEncoding('utf8')
|
|
62
|
+
response.on('data', (chunk) => {
|
|
63
|
+
channel.notify(remote, { from: selfDeviceId, box: box.seal(JSON.stringify({ streamChunk: streamId, chunk })) })
|
|
64
|
+
})
|
|
65
|
+
response.on('end', () => {
|
|
66
|
+
channel.notify(remote, { from: selfDeviceId, box: box.seal(JSON.stringify({ streamEnd: streamId })) })
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
request.on('timeout', () => request.destroy(new Error('timeout')))
|
|
70
|
+
request.on('error', () => { /* the peer sees the stream stall and retries */ })
|
|
71
|
+
request.end()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleDatagram(message, rinfo) {
|
|
75
|
+
// The punch probe is authenticated too: an open responder would answer any
|
|
76
|
+
// datagram and hand out a reflection surface.
|
|
77
|
+
if (typeof message?.box !== 'string') return { error: 'bad-envelope' }
|
|
78
|
+
const deviceId = String(message.from || '')
|
|
79
|
+
const box = keyFor(deviceId)
|
|
80
|
+
if (!box || typeof box.open !== 'function') return { error: 'no-channel' }
|
|
81
|
+
let inner
|
|
82
|
+
try {
|
|
83
|
+
inner = JSON.parse(box.open(message.box).toString('utf8'))
|
|
84
|
+
} catch {
|
|
85
|
+
return { error: 'bad-envelope' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (inner?.punch === true) return { box: box.seal(JSON.stringify({ pong: true })) }
|
|
89
|
+
|
|
90
|
+
if (inner?.streamOpen) {
|
|
91
|
+
relayStream(rinfo, box, inner.streamOpen, inner.streamId)
|
|
92
|
+
return { ok: true }
|
|
93
|
+
}
|
|
94
|
+
if (typeof inner?.streamChunk === 'number') {
|
|
95
|
+
streams.get(inner.streamChunk)?.onChunk?.(String(inner.chunk ?? ''))
|
|
96
|
+
return { ok: true }
|
|
97
|
+
}
|
|
98
|
+
if (typeof inner?.streamEnd === 'number') {
|
|
99
|
+
const entry = streams.get(inner.streamEnd)
|
|
100
|
+
streams.delete(inner.streamEnd)
|
|
101
|
+
entry?.onEnd?.()
|
|
102
|
+
return { ok: true }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await forward(inner)
|
|
106
|
+
return { box: box.seal(JSON.stringify(result)) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const channel = await createUdpChannel({ bindPort, handler: handleDatagram })
|
|
110
|
+
|
|
111
|
+
async function request(remote, deviceId, inner) {
|
|
112
|
+
const box = keyFor(deviceId)
|
|
113
|
+
if (!box) return { ok: false, error: 'no-channel' }
|
|
114
|
+
const result = await channel.request(remote, { from: selfDeviceId, box: box.seal(JSON.stringify(inner)) })
|
|
115
|
+
if (!result.ok) return result
|
|
116
|
+
const reply = result.message
|
|
117
|
+
if (typeof reply?.box !== 'string') return { ok: false, error: reply?.error || 'bad-reply' }
|
|
118
|
+
try {
|
|
119
|
+
return { ok: true, message: JSON.parse(box.open(reply.box).toString('utf8')) }
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, error: 'bad-reply' }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function openStream(remote, deviceId, inner, handlers = {}) {
|
|
126
|
+
const box = keyFor(deviceId)
|
|
127
|
+
if (!box) return { ok: false, error: 'no-channel' }
|
|
128
|
+
const streamId = nextStreamId++
|
|
129
|
+
streams.set(streamId, handlers)
|
|
130
|
+
channel.notify(remote, {
|
|
131
|
+
from: selfDeviceId,
|
|
132
|
+
box: box.seal(JSON.stringify({ streamOpen: inner, streamId })),
|
|
133
|
+
})
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
streamId,
|
|
137
|
+
close: () => {
|
|
138
|
+
streams.delete(streamId)
|
|
139
|
+
const current = keyFor(deviceId)
|
|
140
|
+
if (current) {
|
|
141
|
+
channel.notify(remote, { from: selfDeviceId, box: current.seal(JSON.stringify({ streamEnd: streamId })) })
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
port: channel.port,
|
|
149
|
+
request,
|
|
150
|
+
openStream,
|
|
151
|
+
mapped: (options) => channel.stun(options),
|
|
152
|
+
async punch(remote, deviceId) {
|
|
153
|
+
const box = keyFor(deviceId)
|
|
154
|
+
if (!box) return { ok: false, error: 'no-channel' }
|
|
155
|
+
const result = await channel.request(remote, { from: selfDeviceId, box: box.seal(JSON.stringify({ punch: true })) })
|
|
156
|
+
if (!result.ok) return { ok: false, error: result.error || 'no-pong' }
|
|
157
|
+
if (typeof result.message?.box !== 'string') return { ok: false, error: result.message?.error || 'no-pong' }
|
|
158
|
+
try {
|
|
159
|
+
const reply = JSON.parse(box.open(result.message.box).toString('utf8'))
|
|
160
|
+
return reply?.pong === true ? { ok: true } : { ok: false, error: 'no-pong' }
|
|
161
|
+
} catch {
|
|
162
|
+
return { ok: false, error: 'bad-reply' }
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
close: () => channel.close(),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export { createBoxCipher }
|
package/host/punch.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import dgram from 'node:dgram'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UDP hole-punch exchange (Phase B).
|
|
6
|
+
*
|
|
7
|
+
* Both sides send a small HELLO packet to each of the peer's advertised
|
|
8
|
+
* candidates on an interval; the first packet to arrive proves the NAT mapping
|
|
9
|
+
* and the socket becomes the direct lane. Retries continue until the timeout
|
|
10
|
+
* because the two sides' first sends rarely cross.
|
|
11
|
+
*
|
|
12
|
+
* This is the exchange only. On a network without NAT the same code path runs
|
|
13
|
+
* between two bound sockets, which is exactly how scripts/punch-test.mjs
|
|
14
|
+
* verifies it; on two real NATs it still needs a live trial.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MAGIC = Buffer.from('DSHPUNCH1', 'utf8')
|
|
18
|
+
|
|
19
|
+
export async function createPuncher({ intervalMs = 200, bindPort = 0 } = {}) {
|
|
20
|
+
const socket = dgram.createSocket('udp4')
|
|
21
|
+
let settled
|
|
22
|
+
let timer
|
|
23
|
+
let resolveDone
|
|
24
|
+
const done = new Promise((resolve) => { resolveDone = resolve })
|
|
25
|
+
|
|
26
|
+
function finish(value) {
|
|
27
|
+
if (settled) return
|
|
28
|
+
settled = value
|
|
29
|
+
clearInterval(timer)
|
|
30
|
+
try { socket.close() } catch { /* already closed */ }
|
|
31
|
+
resolveDone(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
socket.on('message', (message, rinfo) => {
|
|
35
|
+
if (message.length >= MAGIC.length && message.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
36
|
+
finish({ ok: true, address: rinfo.address, port: rinfo.port })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
socket.on('error', (error) => finish({ ok: false, error: error?.message || 'socket-error' }))
|
|
40
|
+
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
socket.once('error', reject)
|
|
43
|
+
socket.bind(bindPort, resolve)
|
|
44
|
+
})
|
|
45
|
+
const port = socket.address().port
|
|
46
|
+
|
|
47
|
+
function start(candidates, timeoutMs = 3000) {
|
|
48
|
+
if (settled) return Promise.resolve(settled)
|
|
49
|
+
const targets = (candidates ?? []).filter((entry) => entry && entry.address && entry.port)
|
|
50
|
+
const packet = Buffer.concat([MAGIC, randomBytes(4)])
|
|
51
|
+
const send = () => {
|
|
52
|
+
for (const target of targets) {
|
|
53
|
+
try { socket.send(packet, 0, packet.length, target.port, target.address) } catch { /* candidate unreachable */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
send()
|
|
57
|
+
timer = setInterval(send, intervalMs)
|
|
58
|
+
timer.unref?.()
|
|
59
|
+
const deadline = setTimeout(() => finish({ ok: false, error: 'timeout' }), timeoutMs)
|
|
60
|
+
deadline.unref?.()
|
|
61
|
+
return done
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
port: () => port,
|
|
66
|
+
start,
|
|
67
|
+
close: () => finish({ ok: false, error: 'closed' }),
|
|
68
|
+
}
|
|
69
|
+
}
|