dsh-plugin-mobile-link 0.7.0 → 0.8.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/lib/dlp.js CHANGED
@@ -193,6 +193,21 @@ export function pairCodeRegenerateEndpoint(relayUrl) {
193
193
  return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code/regenerate')
194
194
  }
195
195
 
196
+ /** List every paired device for this agent (`GET /devices`, agent secret). */
197
+ export function deviceListEndpoint(relayUrl) {
198
+ return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'devices')
199
+ }
200
+
201
+ /** Revoke one paired device for this agent (`POST /devices/revoke`, agent secret). */
202
+ export function deviceRevokeEndpoint(relayUrl) {
203
+ return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'devices/revoke')
204
+ }
205
+
206
+ /** Backfill the machine MAC for an already enrolled agent (`POST /agents/machine`). */
207
+ export function agentMachineEndpoint(relayUrl) {
208
+ return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/machine')
209
+ }
210
+
196
211
  /** The relay's self-service enrollment endpoint (`POST /agents/enroll`). */
197
212
  export function enrollEndpoint(relayUrl) {
198
213
  return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/enroll')
package/lib/enroll.js CHANGED
@@ -1,24 +1,136 @@
1
1
  /**
2
- * First-run enrollment: turn an invite code into a durable agent identity.
2
+ * First-run enrollment: turn a relay address into a durable agent identity.
3
3
  *
4
- * Until now an identity could only be provisioned by whoever owns the relay
5
- * (`dsh-relay admin agent-register --write-config`). That does not distribute:
6
- * somebody else installs DSH plus this connector on their own computer and has
7
- * to be handed a credential by the operator, out of band.
8
- *
9
- * So the relay grew `POST /agents/enroll` (relay-go/internal/httpapi), which redeems a
10
- * one-time invite code for a fresh `agentId` / `agentSecret`. This module is the
11
- * connector half: one call, then the identity is written to the same
4
+ * An identity used to require an invite code from the relay operator. Enrollment
5
+ * is now open: `POST /agents/enroll` (relay-go/internal/httpapi) creates the
6
+ * account and computer, rate-limited by source. This module is the connector
7
+ * half: one call, then the identity is written to the same
12
8
  * `~/.dsh/mobile-link/agent.json` (mode 0600) that `agent-register` writes, so
13
9
  * nothing downstream can tell the two apart.
14
10
  *
15
- * Enrollment is deliberately *not* automatic against an open endpoint: an
16
- * invite code must be present, because the relay is on the public internet.
11
+ * The optional `inviteCode` path remains only as a compatibility shim for old
12
+ * deployments and old callers; new installs do not use it.
17
13
  */
18
14
 
19
- import { enrollEndpoint } from './dlp.js'
15
+ import { execFileSync } from 'node:child_process'
16
+ import { hostname, networkInterfaces } from 'node:os'
17
+
18
+ import { agentMachineEndpoint, enrollEndpoint, isPlaceholderRelayUrl, normalizeRelayUrl } from './dlp.js'
20
19
  import { DEFAULT_AGENT_NAME, INVITE_ENV, defaultStatePath, readState, writeState } from './state.js'
21
20
 
21
+ /**
22
+ * Normalize a MAC-48 address to uppercase colon form.
23
+ * Returns '' for anything that is not a MAC.
24
+ */
25
+ export function normalizeMacAddress(value) {
26
+ const compact = String(value ?? '').trim().replace(/[-.:\s]/g, '').toUpperCase()
27
+ if (!/^[0-9A-F]{12}$/.test(compact)) return ''
28
+ return compact.match(/.{2}/g).join(':')
29
+ }
30
+
31
+ /**
32
+ * Pick the computer's primary physical interface MAC.
33
+ *
34
+ * This is an *identifier*, not an authenticator: a MAC can be spoofed. It is
35
+ * used only so the relay can associate repeat enrollments of the same machine.
36
+ * Virtual/VPN interfaces are skipped (they change or reflect tunnels), and
37
+ * common primary interface names win over the rest. If nothing is found the
38
+ * field is omitted rather than inventing a value.
39
+ */
40
+ export function primaryMacAddress(interfaces) {
41
+ // macOS 的 Node 经常只拿到 02:00:00:00:00:00(系统隐私/沙箱占位),
42
+ // 而 networksetup 仍能读到真实硬件地址;优先向它要,读不到再走接口枚举。
43
+ if (interfaces === undefined && process.platform === 'darwin') {
44
+ const mac = darwinMacAddress()
45
+ if (mac) return mac
46
+ }
47
+ const virtual = /^(lo|docker|veth|br-|virbr|vmnet|vboxnet|utun|tun|tap|wg|tailscale|zt|bridge|awdl|llw)/i
48
+ const preferred = /^(en0|eth0|wlan0|wi-?fi|ethernet)$/i
49
+ const seen = new Set()
50
+ const candidates = []
51
+ for (const [name, entries] of Object.entries(interfaces ?? networkInterfaces())) {
52
+ if (!entries || virtual.test(name)) continue
53
+ for (const entry of entries) {
54
+ if (!entry || entry.internal) continue
55
+ const mac = normalizeMacAddress(entry.mac)
56
+ if (!mac || mac === '00:00:00:00:00:00' || mac === '02:00:00:00:00:00' || seen.has(mac)) continue
57
+ seen.add(mac)
58
+ candidates.push({ name, mac, preferred: preferred.test(name) ? 0 : 1 })
59
+ }
60
+ }
61
+ candidates.sort((a, b) => a.preferred - b.preferred || a.name.localeCompare(b.name))
62
+ return candidates[0]?.mac ?? ''
63
+ }
64
+
65
+ /// macOS 的硬件端口表是唯一能稳定拿到真实 MAC 的入口:Node 的
66
+ /// `os.networkInterfaces()` 在部分系统上被隐私层替换成 02:00:00:00:00:00。
67
+ function darwinMacAddress() {
68
+ const activeDevice = darwinDefaultInterface()
69
+ let output
70
+ try {
71
+ output = execFileSync('/usr/sbin/networksetup', ['-listallhardwareports'], {
72
+ encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
73
+ })
74
+ } catch {
75
+ return ''
76
+ }
77
+ const blocks = []
78
+ let current = {}
79
+ const flush = () => {
80
+ if (current.mac) blocks.push(current)
81
+ current = {}
82
+ }
83
+ for (const line of output.split('\n')) {
84
+ const match = line.match(/^\s*(Hardware Port|Device|Ethernet Address):\s*(.+?)\s*$/)
85
+ if (!match) continue
86
+ if (match[1] === 'Hardware Port') {
87
+ flush()
88
+ current.port = match[2]
89
+ }
90
+ if (match[1] === 'Device') current.device = match[2]
91
+ if (match[1] === 'Ethernet Address') current.mac = normalizeMacAddress(match[2])
92
+ }
93
+ flush()
94
+ const active = blocks.find((block) => block.device === activeDevice)
95
+ if (active?.mac) return active.mac
96
+ const preferred = /(wi-?fi|ethernet|lan)/i
97
+ const chosen = blocks.find((block) => preferred.test(block.port ?? block.device ?? ''))
98
+ return chosen?.mac ?? blocks[0]?.mac ?? ''
99
+ }
100
+
101
+ /// 默认路由对应的接口:优先用它,避免机器同时插网线和 Wi-Fi 时选到未使用的网口。
102
+ function darwinDefaultInterface() {
103
+ try {
104
+ const output = execFileSync('/sbin/route', ['-n', 'get', 'default'], {
105
+ encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
106
+ })
107
+ return output.match(/^\s*interface:\s*(\S+)/m)?.[1] ?? ''
108
+ } catch {
109
+ return ''
110
+ }
111
+ }
112
+
113
+ /**
114
+ * A failed enrollment, tagged with a machine-readable code.
115
+ *
116
+ * The CLI prints `.message`; the panel maps `.code` to Chinese copy. Keeping both
117
+ * on one error is deliberate: the relay's three invite failures need different
118
+ * advice, and the UI must not re-parse English text to find out which one it got.
119
+ */
120
+ export class EnrollError extends Error {
121
+ constructor(code, message, { status, retryable = false, stateFile } = {}) {
122
+ super(message)
123
+ this.name = 'EnrollError'
124
+ this.code = code
125
+ this.status = status
126
+ this.retryable = Boolean(retryable)
127
+ if (stateFile) this.stateFile = stateFile
128
+ }
129
+ }
130
+
131
+ /** How long a relay may take to answer an enrollment before it is called stuck. */
132
+ export const ENROLL_TIMEOUT_MS = 15000
133
+
22
134
  /** Where the invite code may come from: explicit argument, then environment. */
23
135
  export function inviteFrom({ inviteCode, env = process.env } = {}) {
24
136
  const value = typeof inviteCode === 'string' && inviteCode.trim() ? inviteCode : env?.[INVITE_ENV]
@@ -40,7 +152,7 @@ export async function enrollPlan({ stateFile, inviteCode, relayUrl, env = proces
40
152
  }
41
153
  const invite = inviteFrom({ inviteCode, env })
42
154
  const relay = relayUrl || env?.DSH_MOBILE_LINK_RELAY || ''
43
- return { needed: true, stateFile: path, invite, relayUrl: relay, canEnroll: Boolean(invite && relay) }
155
+ return { needed: true, stateFile: path, invite, relayUrl: relay, canEnroll: Boolean(relay) }
44
156
  }
45
157
 
46
158
  /**
@@ -58,55 +170,156 @@ export async function enrollPlan({ stateFile, inviteCode, relayUrl, env = proces
58
170
  * @returns {Promise<{agentId: string, agentSecret: string, agentName: string, relayUrl: string, stateFile: string}>}
59
171
  */
60
172
  export async function enrollAgent({
61
- relayUrl, inviteCode, name, stateFile, deviceName,
62
- fetchImpl = fetch, logger = undefined,
173
+ relayUrl, inviteCode, name, stateFile, deviceName, macAddress,
174
+ fetchImpl = fetch, logger = undefined, timeoutMs = ENROLL_TIMEOUT_MS,
63
175
  } = {}) {
64
- if (!inviteCode || !String(inviteCode).trim()) {
65
- throw new Error('mobile-link: an invite code is required to enroll (--invite <code>)')
66
- }
176
+ const invite = typeof inviteCode === 'string' ? inviteCode.trim() : ''
67
177
  if (!relayUrl || !String(relayUrl).trim()) {
68
- throw new Error('mobile-link: a relay URL is required to enroll (--relay <url>)')
178
+ throw new EnrollError('enroll/bad-request', 'mobile-link: a relay URL is required to enroll (--relay <url>)')
179
+ }
180
+ // Validate before the network call: a typo in the relay address should not
181
+ // look like a rejected invite, and the placeholder shipped in the repository
182
+ // must never be dialed as if it were a deployment.
183
+ try {
184
+ normalizeRelayUrl(relayUrl)
185
+ } catch (error) {
186
+ throw new EnrollError('enroll/invalid-relay',
187
+ `mobile-link: invalid relay URL ${JSON.stringify(relayUrl)}: ${messageOf(error)}`)
69
188
  }
189
+ if (isPlaceholderRelayUrl(relayUrl)) {
190
+ throw new EnrollError('enroll/placeholder-relay',
191
+ 'mobile-link: the relay address is the repository placeholder; use the address your provider gave you')
192
+ }
193
+
70
194
  const path = stateFile || defaultStatePath()
71
- const agentName = (name || deviceName || '').trim() || DEFAULT_AGENT_NAME
195
+ const agentName = (name || deviceName || '').trim() || hostname().trim() || DEFAULT_AGENT_NAME
196
+ const machineMac = normalizeMacAddress(macAddress ?? primaryMacAddress())
197
+ const waitMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
198
+ ? Number(timeoutMs)
199
+ : ENROLL_TIMEOUT_MS
200
+
201
+ // `Promise.race` rather than relying on the signal alone: an injected fetch
202
+ // (or a runtime that does not honour AbortSignal) must still surface a
203
+ // timeout instead of leaving the panel on "登记中" forever.
204
+ const controller = new AbortController()
205
+ let timer
206
+ const timeout = new Promise((_, reject) => {
207
+ timer = setTimeout(() => {
208
+ controller.abort()
209
+ reject(new EnrollError('enroll/timeout',
210
+ `mobile-link: the relay did not answer within ${waitMs} ms`, { retryable: true }))
211
+ }, waitMs)
212
+ // Deliberately NOT unref'd: if an injected fetch holds no handle, an unref'd
213
+ // timer lets Node decide the event loop is empty and exit with the promise
214
+ // still pending (caught by the Linux L2 run). The timer is cleared on every
215
+ // settled fetch, so it only keeps the process alive while a request is
216
+ // genuinely outstanding.
217
+ })
72
218
 
73
219
  let response
74
220
  try {
75
- response = await fetchImpl(enrollEndpoint(relayUrl), {
76
- method: 'POST',
77
- headers: { 'content-type': 'application/json' },
78
- body: JSON.stringify({ inviteCode: String(inviteCode).trim(), name: agentName }),
79
- })
221
+ response = await Promise.race([
222
+ fetchImpl(enrollEndpoint(relayUrl), {
223
+ method: 'POST',
224
+ headers: { 'content-type': 'application/json' },
225
+ // 有邀请码就带上(老的邀请码发放方式仍可用);没有就是开放登记。
226
+ body: JSON.stringify({
227
+ ...(invite ? { inviteCode: invite } : {}),
228
+ name: agentName,
229
+ ...(machineMac ? { macAddress: machineMac } : {}),
230
+ }),
231
+ signal: controller.signal,
232
+ }),
233
+ timeout,
234
+ ])
80
235
  } catch (error) {
81
- throw new Error(`mobile-link: cannot reach the relay at ${relayUrl}: ${messageOf(error)}`)
236
+ if (error instanceof EnrollError) throw error
237
+ throw new EnrollError('enroll/unreachable',
238
+ `mobile-link: cannot reach the relay at ${relayUrl}: ${messageOf(error)}`, { retryable: true })
239
+ } finally {
240
+ clearTimeout(timer)
82
241
  }
83
242
 
84
243
  let body
85
244
  try {
86
245
  body = await response.json()
87
246
  } catch {
88
- throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /agents/enroll`)
247
+ throw new EnrollError('enroll/bad-response',
248
+ `mobile-link: the relay answered HTTP ${response.status} to /agents/enroll`,
249
+ { status: response.status })
89
250
  }
90
251
  if (!response.ok || body?.ok !== true || !body.agentId || !body.agentSecret) {
91
- throw new Error(describeEnrollFailure(body, response.status))
252
+ const code = body?.error?.code
253
+ throw new EnrollError(typeof code === 'string' && code ? code : 'enroll/relay-error',
254
+ describeEnrollFailure(body, response.status), {
255
+ status: response.status,
256
+ retryable: code === 'enroll/rate-limited' || code === 'enroll/store-error' || code === 'internal/error',
257
+ })
92
258
  }
93
259
 
94
- await writeState(path, {
95
- relayUrl,
96
- agentId: body.agentId,
97
- agentSecret: body.agentSecret,
98
- agentName: body.agentName || agentName,
99
- })
260
+ try {
261
+ await writeState(path, {
262
+ relayUrl,
263
+ agentId: body.agentId,
264
+ agentSecret: body.agentSecret,
265
+ agentName: body.agentName || agentName,
266
+ })
267
+ } catch (error) {
268
+ // The code has already been redeemed at this point. Say so explicitly:
269
+ // retrying with the same code will fail with "already used", and the person
270
+ // needs to know the identity could not be saved (and where).
271
+ throw new EnrollError('enroll/state-write',
272
+ `mobile-link: the identity was redeemed but could not be saved to ${path}: ${messageOf(error)}`,
273
+ { stateFile: path })
274
+ }
100
275
  logger?.info?.(`mobile-link: enrolled as ${body.agentId} (${body.agentName || agentName})`)
101
276
  return {
102
277
  agentId: body.agentId,
103
278
  agentSecret: body.agentSecret,
104
279
  agentName: body.agentName || agentName,
280
+ macAddress: body.macAddress || machineMac,
105
281
  relayUrl,
106
282
  stateFile: path,
107
283
  }
108
284
  }
109
285
 
286
+ /**
287
+ * Backfill the MAC for an identity enrolled before the relay stored one.
288
+ *
289
+ * Runs on every connector start, authenticated with the agent secret. The relay
290
+ * only fills an empty slot, so a later network/interface change cannot rewrite
291
+ * the machine index. An old relay answers 404: that is "not supported", not a
292
+ * connection failure, and startup must continue.
293
+ */
294
+ export async function reportMachineAddress({
295
+ relayUrl, agentSecret, macAddress, fetchImpl = fetch, timeoutMs = 8000,
296
+ } = {}) {
297
+ const mac = normalizeMacAddress(macAddress ?? primaryMacAddress())
298
+ if (!mac || !relayUrl || !agentSecret) return { ok: false, skipped: 'missing-mac-or-identity' }
299
+ const controller = new AbortController()
300
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
301
+ try {
302
+ const response = await fetchImpl(agentMachineEndpoint(relayUrl), {
303
+ method: 'POST',
304
+ headers: {
305
+ 'content-type': 'application/json',
306
+ authorization: `Bearer ${agentSecret}`,
307
+ },
308
+ body: JSON.stringify({ macAddress: mac }),
309
+ signal: controller.signal,
310
+ })
311
+ let body
312
+ try { body = await response.json() } catch { body = undefined }
313
+ if (response.status === 404) return { ok: false, unsupported: true, macAddress: mac }
314
+ if (!response.ok || body?.ok !== true) {
315
+ throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the machine MAC (HTTP ${response.status})`)
316
+ }
317
+ return { ok: true, macAddress: body.macAddress || mac, updated: body.updated === true }
318
+ } finally {
319
+ clearTimeout(timer)
320
+ }
321
+ }
322
+
110
323
  /**
111
324
  * Turn the relay's failure into something a person can act on.
112
325
  *
@@ -132,7 +345,8 @@ export function describeEnrollFailure(body, status) {
132
345
 
133
346
  /** The one-liner a fresh install runs, quoted for copy/paste. */
134
347
  export function enrollCommand(relayUrl, name = undefined) {
135
- const parts = ['dsh-mobile-link enroll', `--relay ${relayUrl || '<relay-url>'}`, '--invite <code>']
348
+ // 开放登记后命令不再必须带激活码;老的邀请码仍可用 `--invite` 传。
349
+ const parts = ['dsh-mobile-link enroll', `--relay ${relayUrl || '<relay-url>'}`]
136
350
  if (name) parts.push(`--name ${JSON.stringify(name)}`)
137
351
  return parts.join(' ')
138
352
  }
package/lib/hello.js CHANGED
@@ -65,7 +65,7 @@ export function hostVersion() {
65
65
  * - `qr-pairing` this connector renders the pairing QR itself (the `svg`
66
66
  * field of `POST /mobile-link/pair-code`), so the DSH client
67
67
  * needs no QR library of its own
68
- * - `self-enroll` this connector can register itself with an invite code
68
+ * - `self-enroll` this connector can register itself without an invite code
69
69
  * - `git` git status / diff / log / show can be read from the work
70
70
  * tree on this computer (see git.js)
71
71
  * - `background-transfer`
package/lib/index.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * Scope (deliberately tiny so a DSH upgrade can never make it fatal):
5
5
  * 1. Run the DLP agent: dial the relay over WSS, serve the iOS client's
6
6
  * session/event traffic from the local DSH instance. On a computer that has
7
- * never been registered, redeem the invite code from the environment (or
8
- * the config) first, so a fresh install becomes usable without the relay
7
+ * never been registered, enroll with the relay address first (open
8
+ * enrollment), so a fresh install becomes usable without the relay
9
9
  * operator provisioning anything by hand.
10
10
  * 2. Two routes under `/mobile-link` behind DSH's own connection fence:
11
11
  * GET /status — is the link up, which devices are attached, and is
@@ -43,6 +43,10 @@ const DEFAULTS = {
43
43
  relayUrl: process.env.DSH_RELAY_URL
44
44
  || process.env.DSH_MOBILE_LINK_RELAY
45
45
  || 'wss://relay.example.com/dsh-link',
46
+ // 面板的默认中转:只用于预填,不参与拨号优先级。它和 `relayUrl` 分开,是因为
47
+ // 把地址钉进 `relayUrl` 会覆盖身份文件里真正登记过的地址;这里只给表单一个
48
+ // 可修改的初值。
49
+ defaultRelayUrl: process.env.DSH_DEFAULT_RELAY_URL || '',
46
50
  agentId: '',
47
51
  agentSecret: '',
48
52
  stateFile: '',
@@ -101,6 +105,9 @@ function buildAgent(config, logger) {
101
105
  return new MobileLinkAgent({
102
106
  enabled: config.enabled !== false,
103
107
  relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : DEFAULTS.relayUrl,
108
+ defaultRelayUrl: typeof config.defaultRelayUrl === 'string'
109
+ ? config.defaultRelayUrl
110
+ : DEFAULTS.defaultRelayUrl,
104
111
  agentId: typeof config.agentId === 'string' ? config.agentId : '',
105
112
  agentSecret: typeof config.agentSecret === 'string' ? config.agentSecret : '',
106
113
  stateFile: typeof config.stateFile === 'string' && config.stateFile ? config.stateFile : undefined,