@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.3
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/CHANGELOG.md +74 -0
- package/CHANGELOG_ZH.md +59 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/README_ZH.md +185 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +9 -0
- package/lib/client.js +4413 -0
- package/lib/discovery.js +354 -0
- package/lib/index.js +1053 -0
- package/lib/lan.js +213 -0
- package/lib/logfile.js +80 -0
- package/lib/probe.js +100 -0
- package/lib/repair.js +150 -0
- package/lib/sessions.js +501 -0
- package/lib/state.js +236 -0
- package/package.json +64 -4
- package/screenshots.json +1 -0
- package/scripts/repair-sessions.mjs +31 -0
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAN discovery over UDP broadcast.
|
|
3
|
+
*
|
|
4
|
+
* Every instance beacons `{t:'dsh-session-sync/hello', ...}` to the broadcast
|
|
5
|
+
* address of each IPv4 interface plus 127.0.0.1 (so two instances on one host
|
|
6
|
+
* find each other during development). A receiver that has not seen an id
|
|
7
|
+
* before answers immediately with a unicast hello, which makes discovery feel
|
|
8
|
+
* instant instead of "within one beacon interval".
|
|
9
|
+
*
|
|
10
|
+
* The beacon carries only identity and a session count; session lists are
|
|
11
|
+
* fetched over the HTTP sync port named in the beacon.
|
|
12
|
+
*/
|
|
13
|
+
import dgram from 'node:dgram'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import { parseTarget } from './probe.js'
|
|
16
|
+
|
|
17
|
+
export const MAGIC = 'dsh-session-sync/hello'
|
|
18
|
+
export const PROTOCOL_VERSION = 1
|
|
19
|
+
|
|
20
|
+
const BEACON_INTERVAL_MS = 4000
|
|
21
|
+
/** How often the cross-subnet sweep rebuilds its host list and starts over. */
|
|
22
|
+
const SWEEP_INTERVAL_MS = 60_000
|
|
23
|
+
/** Pacing: 10 unicast hellos every 150 ms keeps the sweep invisible on the wire. */
|
|
24
|
+
const SWEEP_BATCH = 10
|
|
25
|
+
const SWEEP_TICK_MS = 150
|
|
26
|
+
/** Hard cap on expansion so a `/8` cannot turn into 16M packets. */
|
|
27
|
+
const SWEEP_MAX_HOSTS = 4096
|
|
28
|
+
|
|
29
|
+
function ipToInt(ip) {
|
|
30
|
+
return ip.split('.').reduce((acc, part) => (acc << 8 >>> 0) + (Number(part) & 0xff), 0) >>> 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function intToIp(int) {
|
|
34
|
+
return [24, 16, 8, 0].map((shift) => (int >>> shift) & 0xff).join('.')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Broadcast targets for the interfaces that exist right now. */
|
|
38
|
+
export function broadcastTargets() {
|
|
39
|
+
const out = new Set(['127.0.0.1'])
|
|
40
|
+
let interfaces = {}
|
|
41
|
+
try {
|
|
42
|
+
interfaces = os.networkInterfaces()
|
|
43
|
+
} catch {
|
|
44
|
+
/* ignore */
|
|
45
|
+
}
|
|
46
|
+
for (const entries of Object.values(interfaces)) {
|
|
47
|
+
for (const entry of entries ?? []) {
|
|
48
|
+
if (entry.family !== 'IPv4' || entry.internal) continue
|
|
49
|
+
try {
|
|
50
|
+
const ip = ipToInt(entry.address)
|
|
51
|
+
const mask = ipToInt(entry.netmask)
|
|
52
|
+
out.add(intToIp(((ip & mask) | (~mask >>> 0)) >>> 0))
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore malformed interface entry */
|
|
55
|
+
}
|
|
56
|
+
out.add('255.255.255.255')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...out]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Local /24s: broadcast is fine inside one segment, but some switches drop it. */
|
|
63
|
+
function localNets() {
|
|
64
|
+
const out = new Set()
|
|
65
|
+
let interfaces = {}
|
|
66
|
+
try {
|
|
67
|
+
interfaces = os.networkInterfaces()
|
|
68
|
+
} catch {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
for (const entries of Object.values(interfaces)) {
|
|
72
|
+
for (const entry of entries ?? []) {
|
|
73
|
+
if (entry.family !== 'IPv4' || entry.internal) continue
|
|
74
|
+
const parts = entry.address.split('.')
|
|
75
|
+
if (parts.length === 4) out.add(`${parts[0]}.${parts[1]}.${parts[2]}.0/24`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...out]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Expand one CIDR / bare IP into the host addresses it covers. */
|
|
82
|
+
export function expandTarget(spec) {
|
|
83
|
+
const trimmed = String(spec ?? '').trim()
|
|
84
|
+
if (trimmed.length === 0) return []
|
|
85
|
+
const [address, rawPrefix] = trimmed.split('/')
|
|
86
|
+
const parts = address.split('.').map(Number)
|
|
87
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return []
|
|
88
|
+
const prefix = rawPrefix === undefined ? 32 : Number(rawPrefix)
|
|
89
|
+
if (!Number.isInteger(prefix) || prefix < 16 || prefix > 32) return []
|
|
90
|
+
const base = ipToInt(address)
|
|
91
|
+
const size = 2 ** (32 - prefix)
|
|
92
|
+
const first = prefix >= 31 ? base : base + 1
|
|
93
|
+
const last = prefix >= 31 ? base + size - 1 : base + size - 2
|
|
94
|
+
const out = []
|
|
95
|
+
for (let value = first; value <= last && out.length < SWEEP_MAX_HOSTS; value += 1) out.push(intToIp(value))
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class Discovery {
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} options
|
|
102
|
+
* @param {number} options.port - UDP port shared by every device.
|
|
103
|
+
* @param {() => object} options.identity - current beacon payload fields.
|
|
104
|
+
* @param {(peer: object, rinfo: {address: string, port: number}) => void} options.onPeer
|
|
105
|
+
* @param {() => string[]} [options.seeds] - extra `host:port` targets.
|
|
106
|
+
* @param {() => string[]} [options.subnets] - CIDRs / IPs to unicast-sweep.
|
|
107
|
+
* @param {(message: string) => void} [options.log]
|
|
108
|
+
*/
|
|
109
|
+
constructor({ port, identity, onPeer, seeds, subnets, log }) {
|
|
110
|
+
this.port = port
|
|
111
|
+
this.identity = identity
|
|
112
|
+
this.onPeer = onPeer
|
|
113
|
+
this.seeds = seeds ?? (() => [])
|
|
114
|
+
this.subnets = subnets ?? (() => [])
|
|
115
|
+
this.log = log ?? (() => {})
|
|
116
|
+
this.socket = null
|
|
117
|
+
this.timer = null
|
|
118
|
+
this.sweepTimer = null
|
|
119
|
+
this.sweepRefresh = null
|
|
120
|
+
this._sweep = []
|
|
121
|
+
this.boundPort = null
|
|
122
|
+
this._seen = new Map()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async start() {
|
|
126
|
+
if (this.socket !== null) return
|
|
127
|
+
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
128
|
+
this.socket = socket
|
|
129
|
+
socket.on('error', (error) => {
|
|
130
|
+
this.log(`发现服务 UDP 错误: ${error?.message ?? error}`)
|
|
131
|
+
if (this.boundPort === null) {
|
|
132
|
+
// Port taken by an instance that does not share it: retry on an
|
|
133
|
+
// ephemeral port. Peers still answer to the source port of our beacons.
|
|
134
|
+
try {
|
|
135
|
+
socket.close()
|
|
136
|
+
} catch {
|
|
137
|
+
/* ignore */
|
|
138
|
+
}
|
|
139
|
+
this.socket = null
|
|
140
|
+
this._listen(0).catch((retry) => this.log(`发现服务启动失败: ${retry?.message ?? retry}`))
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
socket.on('message', (buffer, rinfo) => this._onMessage(buffer, rinfo))
|
|
144
|
+
await this._listen(this.port)
|
|
145
|
+
// A configured port of 0 means "pick any free port"; beacons must then go
|
|
146
|
+
// to the port the OS actually assigned, not to 0.
|
|
147
|
+
if (this.port === 0 && this.boundPort !== null) this.port = this.boundPort
|
|
148
|
+
this.timer = setInterval(() => this.beacon(), BEACON_INTERVAL_MS)
|
|
149
|
+
this.timer.unref?.()
|
|
150
|
+
this.beacon()
|
|
151
|
+
this.startSweep()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_listen(port) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const socket = this.socket
|
|
157
|
+
if (socket === null) {
|
|
158
|
+
reject(new Error('socket closed'))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const onListening = () => {
|
|
162
|
+
socket.off('error', onError)
|
|
163
|
+
try {
|
|
164
|
+
socket.setBroadcast(true)
|
|
165
|
+
socket.setMulticastTTL?.(1)
|
|
166
|
+
} catch {
|
|
167
|
+
/* optional socket tuning */
|
|
168
|
+
}
|
|
169
|
+
this.boundPort = socket.address().port
|
|
170
|
+
this.log(`发现服务已监听 UDP ${this.boundPort}`)
|
|
171
|
+
resolve()
|
|
172
|
+
}
|
|
173
|
+
const onError = (error) => {
|
|
174
|
+
socket.off('listening', onListening)
|
|
175
|
+
try {
|
|
176
|
+
socket.close()
|
|
177
|
+
} catch {
|
|
178
|
+
/* ignore */
|
|
179
|
+
}
|
|
180
|
+
this.socket = null
|
|
181
|
+
reject(error)
|
|
182
|
+
}
|
|
183
|
+
socket.once('listening', onListening)
|
|
184
|
+
socket.once('error', onError)
|
|
185
|
+
socket.bind(port, '0.0.0.0')
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
beacon() {
|
|
190
|
+
const socket = this.socket
|
|
191
|
+
if (socket === null || this.boundPort === null) return
|
|
192
|
+
const payload = Buffer.from(JSON.stringify({
|
|
193
|
+
t: MAGIC,
|
|
194
|
+
proto: PROTOCOL_VERSION,
|
|
195
|
+
...this.identity(),
|
|
196
|
+
at: Date.now(),
|
|
197
|
+
}))
|
|
198
|
+
const targets = new Set(broadcastTargets())
|
|
199
|
+
for (const seed of this.seeds()) {
|
|
200
|
+
const host = String(seed).split(':')[0]
|
|
201
|
+
if (host.length > 0) targets.add(host)
|
|
202
|
+
}
|
|
203
|
+
for (const target of targets) {
|
|
204
|
+
socket.send(payload, 0, payload.length, this.port, target, (error) => {
|
|
205
|
+
if (error !== undefined && error !== null && target !== '127.0.0.1') {
|
|
206
|
+
// Interfaces come and go; a failed broadcast is routine.
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
for (const seed of this.seeds()) {
|
|
211
|
+
const [host, rawPort] = String(seed).split(':')
|
|
212
|
+
const port = Number(rawPort)
|
|
213
|
+
if (host && Number.isInteger(port) && port > 0) {
|
|
214
|
+
socket.send(payload, 0, payload.length, port, host, () => {})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Silent cross-subnet scan: a hello is a normal discovery packet, so a peer
|
|
221
|
+
* that receives one answers with its own identity and shows up in the list
|
|
222
|
+
* exactly like a broadcast peer. No port scanning, no HTTP probe.
|
|
223
|
+
*/
|
|
224
|
+
startSweep() {
|
|
225
|
+
if (this.sweepTimer !== null) return
|
|
226
|
+
const refill = () => {
|
|
227
|
+
const targets = new Map()
|
|
228
|
+
for (const spec of [...localNets(), ...this.subnets()]) {
|
|
229
|
+
const parsed = parseTarget(spec)
|
|
230
|
+
// `10.0.0.7:47901` is allowed so a peer whose UDP port moved is still
|
|
231
|
+
// reachable; a bare CIDR/IP sweeps the shared discovery port.
|
|
232
|
+
const hosts = parsed !== null && parsed.port !== null ? [parsed.host] : expandTarget(spec)
|
|
233
|
+
for (const host of hosts) {
|
|
234
|
+
if (!targets.has(host)) targets.set(host, parsed?.port ?? this.port)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this._sweep = [...targets.entries()].map(([host, port]) => ({ host, port }))
|
|
238
|
+
this._sweepIndex = 0
|
|
239
|
+
}
|
|
240
|
+
refill()
|
|
241
|
+
this.sweepRefresh = setInterval(refill, SWEEP_INTERVAL_MS)
|
|
242
|
+
this.sweepRefresh.unref?.()
|
|
243
|
+
this.sweepTimer = setInterval(() => this._sendSweepBatch(), SWEEP_TICK_MS)
|
|
244
|
+
this.sweepTimer.unref?.()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_sendSweepBatch() {
|
|
248
|
+
const socket = this.socket
|
|
249
|
+
if (socket === null || this.boundPort === null) return
|
|
250
|
+
if (!Array.isArray(this._sweep) || this._sweep.length === 0) return
|
|
251
|
+
const identity = this.identity()
|
|
252
|
+
const payload = Buffer.from(JSON.stringify({
|
|
253
|
+
t: MAGIC,
|
|
254
|
+
proto: PROTOCOL_VERSION,
|
|
255
|
+
...identity,
|
|
256
|
+
at: Date.now(),
|
|
257
|
+
}))
|
|
258
|
+
const selfHost = typeof identity.host === 'string' ? identity.host : null
|
|
259
|
+
let sent = 0
|
|
260
|
+
while (sent < SWEEP_BATCH && this._sweepIndex < this._sweep.length) {
|
|
261
|
+
const entry = this._sweep[this._sweepIndex]
|
|
262
|
+
this._sweepIndex += 1
|
|
263
|
+
const host = entry.host
|
|
264
|
+
if (host === selfHost || host === '0.0.0.0' || host.endsWith('.255')) continue
|
|
265
|
+
socket.send(payload, 0, payload.length, entry.port, host, () => {})
|
|
266
|
+
sent += 1
|
|
267
|
+
}
|
|
268
|
+
// Wrap around so a short list keeps getting poked between refills.
|
|
269
|
+
if (this._sweepIndex >= this._sweep.length) this._sweepIndex = 0
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_onMessage(buffer, rinfo) {
|
|
273
|
+
let message
|
|
274
|
+
try {
|
|
275
|
+
message = JSON.parse(buffer.toString('utf8'))
|
|
276
|
+
} catch {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (message?.t !== MAGIC || message?.proto !== PROTOCOL_VERSION) return
|
|
280
|
+
if (typeof message.id !== 'string' || message.id.length === 0) return
|
|
281
|
+
const self = this.identity()
|
|
282
|
+
if (message.id === self.id) return
|
|
283
|
+
const peer = {
|
|
284
|
+
id: message.id,
|
|
285
|
+
name: typeof message.name === 'string' ? message.name : '',
|
|
286
|
+
host: typeof message.host === 'string' ? message.host : rinfo.address,
|
|
287
|
+
port: Number.isInteger(message.port) ? message.port : null,
|
|
288
|
+
platform: typeof message.platform === 'string' ? message.platform : '',
|
|
289
|
+
version: typeof message.version === 'string' ? message.version : '',
|
|
290
|
+
sessions: Number.isInteger(message.sessions) ? message.sessions : null,
|
|
291
|
+
at: typeof message.at === 'number' ? message.at : Date.now(),
|
|
292
|
+
address: rinfo.address,
|
|
293
|
+
}
|
|
294
|
+
const known = this._seen.has(peer.id)
|
|
295
|
+
this._seen.set(peer.id, Date.now())
|
|
296
|
+
this.onPeer(peer, rinfo)
|
|
297
|
+
if (!known) {
|
|
298
|
+
const socket = this.socket
|
|
299
|
+
if (socket !== null && this.boundPort !== null) {
|
|
300
|
+
const reply = Buffer.from(JSON.stringify({
|
|
301
|
+
t: MAGIC,
|
|
302
|
+
proto: PROTOCOL_VERSION,
|
|
303
|
+
...self,
|
|
304
|
+
at: Date.now(),
|
|
305
|
+
}))
|
|
306
|
+
socket.send(reply, 0, reply.length, rinfo.port, rinfo.address, () => {})
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Rebuild the sweep list right now (used after the settings change). */
|
|
312
|
+
refresh() {
|
|
313
|
+
if (this.sweepTimer === null) {
|
|
314
|
+
this.startSweep()
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const targets = new Map()
|
|
318
|
+
for (const spec of [...localNets(), ...this.subnets()]) {
|
|
319
|
+
const parsed = parseTarget(spec)
|
|
320
|
+
const hosts = parsed !== null && parsed.port !== null ? [parsed.host] : expandTarget(spec)
|
|
321
|
+
for (const host of hosts) {
|
|
322
|
+
if (!targets.has(host)) targets.set(host, parsed?.port ?? this.port)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
this._sweep = [...targets.entries()].map(([host, port]) => ({ host, port }))
|
|
326
|
+
this._sweepIndex = 0
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
close() {
|
|
330
|
+
if (this.timer !== null) {
|
|
331
|
+
clearInterval(this.timer)
|
|
332
|
+
this.timer = null
|
|
333
|
+
}
|
|
334
|
+
if (this.sweepTimer !== null) {
|
|
335
|
+
clearInterval(this.sweepTimer)
|
|
336
|
+
this.sweepTimer = null
|
|
337
|
+
}
|
|
338
|
+
if (this.sweepRefresh !== null) {
|
|
339
|
+
clearInterval(this.sweepRefresh)
|
|
340
|
+
this.sweepRefresh = null
|
|
341
|
+
}
|
|
342
|
+
this._sweep = []
|
|
343
|
+
const socket = this.socket
|
|
344
|
+
this.socket = null
|
|
345
|
+
this.boundPort = null
|
|
346
|
+
if (socket !== null) {
|
|
347
|
+
try {
|
|
348
|
+
socket.close()
|
|
349
|
+
} catch {
|
|
350
|
+
/* ignore */
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|