@dshn/agent 0.1.6 → 0.2.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/README.md CHANGED
@@ -22,6 +22,11 @@ the relay operator sees only ciphertext.
22
22
  - **Trust-on-first-use claim.** The first agent to present a free subdomain sets
23
23
  its password (scrypt-hashed on the relay). Later connects and every browser
24
24
  login must match it — squatting-protected.
25
+ - **Multi-device.** Several machines can bind ONE subdomain with the same
26
+ credential — each shows up as a named device. With ≥2 online, opening the URL
27
+ offers a device picker, and a switcher appears in the page's sidebar footer;
28
+ the choice sticks per browser (a routing cookie), and switching is a clean
29
+ reload against the other machine. One device online behaves exactly as before.
25
30
  - **Optional end-to-end encryption** (off by default). A *separate* e2e password,
26
31
  never sent to the relay, encrypts `/api` bodies and the event stream:
27
32
  PBKDF2-SHA256 (210k) → AES-256-GCM. Visitors enter it once in the browser; it
package/client.js CHANGED
@@ -153,7 +153,7 @@ window.__ModuleLoader__.load({
153
153
  if (!info || !info.enabled || !info.salt) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'off-restored'; resolveReady(); return }
154
154
  active = true
155
155
  window.__dshnE2E.stage = 'gating'
156
- await unlockGate(info.salt)
156
+ await unlockGate(info.salt, info.device)
157
157
  window.__dshnE2E.stage = 'unlocked'
158
158
  } catch (e) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
159
159
  resolveReady()
@@ -161,7 +161,7 @@ window.__ModuleLoader__.load({
161
161
 
162
162
  // A blocking DOM overlay (not React — must appear before the app mounts)
163
163
  // asking for the e2e password; verified by a sealed probe to /api.
164
- function unlockGate(salt) {
164
+ function unlockGate(salt, deviceKey) {
165
165
  return new Promise((resolve) => {
166
166
  const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
167
167
  const L = zh
@@ -169,12 +169,24 @@ window.__ModuleLoader__.load({
169
169
  save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
170
170
  : { t: 'End-to-end encrypted', s: 'This session is end-to-end encrypted. Enter the e2e password to unlock — it is never sent to the cloud.', p: 'E2E password', u: 'Unlock', bad: 'Wrong password — cannot decrypt.',
171
171
  save: 'Remember on this device', stale: 'The saved password no longer works (it may have been changed). Enter it again.' }
172
- // Remembered password lives in localStorage, per public host, on THIS
173
- // device only — never transmitted (E2E is intact). Keyed by host (not
174
- // salt) so a changed e2e password is detected and re-prompted.
175
- const STORE_KEY = 'dshn:e2e:' + location.hostname
176
- const readSaved = () => { try { return localStorage.getItem(STORE_KEY) } catch { return null } }
177
- const writeSaved = (v) => { try { if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v) } catch { /* storage may be blocked */ } }
172
+ // Remembered password lives in localStorage, per public host AND per
173
+ // device, on THIS browser only — never transmitted (E2E is intact).
174
+ // The device part matters on a multi-device subdomain: each machine
175
+ // has its own e2e password, and one saved copy must not clobber (or be
176
+ // probed against) another device's. Keyed by host+device (not salt) so
177
+ // a changed e2e password is detected and re-prompted. The old
178
+ // host-only key is read once as a fallback and migrated on success.
179
+ const LEGACY_KEY = 'dshn:e2e:' + location.hostname
180
+ const STORE_KEY = LEGACY_KEY + (deviceKey ? ':' + deviceKey : '')
181
+ const readSaved = () => {
182
+ try { return localStorage.getItem(STORE_KEY) || (STORE_KEY !== LEGACY_KEY ? localStorage.getItem(LEGACY_KEY) : null) } catch { return null }
183
+ }
184
+ const writeSaved = (v) => {
185
+ try {
186
+ if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v)
187
+ if (STORE_KEY !== LEGACY_KEY) localStorage.removeItem(LEGACY_KEY)
188
+ } catch { /* storage may be blocked */ }
189
+ }
178
190
 
179
191
  // Derive from a password string and probe /api with a sealed body; on a
180
192
  // correct key set the live key and return true. A wrong key → agent 400
@@ -198,7 +210,10 @@ window.__ModuleLoader__.load({
198
210
  let stale = false
199
211
  const saved = readSaved()
200
212
  if (saved) {
201
- if (await attempt(saved)) { window.__dshnE2E.autounlock = true; resolve(); return }
213
+ if (await attempt(saved)) {
214
+ writeSaved(saved) // re-write so a legacy host-only entry migrates to the per-device key
215
+ window.__dshnE2E.autounlock = true; resolve(); return
216
+ }
202
217
  writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
203
218
  }
204
219
 
@@ -279,7 +294,7 @@ window.__ModuleLoader__.load({
279
294
  border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.25)); }
280
295
  .dshn-panel[data-mode="modal"] { width: min(400px, 92vw); padding: 20px 22px 18px; box-shadow: 0 24px 64px rgba(0,0,0,.34); }
281
296
  .dshn-panel[data-mode="card"] { width: 308px; margin-top: 8px; padding: 14px 15px 13px; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
282
- .dshn-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
297
+ .dshn-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 9px; }
283
298
  .dshn-htitle { font-size: 14.5px; font-weight: 650; }
284
299
  .dshn-hsub { color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 11.5px; margin-top: 2px; }
285
300
  .dshn-x { border: 0; background: transparent; cursor: pointer; font-size: 17px; line-height: 1; padding: 2px 4px;
@@ -289,7 +304,7 @@ window.__ModuleLoader__.load({
289
304
  .dshn-status { display: flex; align-items: center; gap: 7px; margin-bottom: 11px; font-size: 12px; }
290
305
  .dshn-url { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
291
306
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
292
- .dshn-addr { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding: 8px 8px 8px 11px;
307
+ .dshn-addr { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; padding: 6px 8px 6px 11px;
293
308
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.25)); border-radius: 10px;
294
309
  background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
295
310
  .dshn-addr-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-tertiary, #8b9099); }
@@ -303,10 +318,10 @@ window.__ModuleLoader__.load({
303
318
  color: var(--dsw-alias-label-tertiary, #8b9099); text-decoration: none; }
304
319
  .dshn-addr-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,134,142,.16)); color: var(--dsw-alias-label-primary, #1c1e21); }
305
320
 
306
- .dshn-field { display: block; margin-bottom: 11px; }
307
- .dshn-field > span { display: block; color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 11px; margin-bottom: 4px; }
321
+ .dshn-field { display: block; margin-bottom: 7px; }
322
+ .dshn-field > span { display: block; color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 11px; margin-bottom: 3px; }
308
323
  .dshn-prefixwrap { display: flex; align-items: stretch; }
309
- .dshn-input { width: 100%; box-sizing: border-box; padding: 8px 10px; font-size: 13.5px;
324
+ .dshn-input { width: 100%; box-sizing: border-box; padding: 6px 10px; font-size: 13.5px;
310
325
  border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.35)); border-radius: 8px; background: transparent; color: inherit; }
311
326
  .dshn-input:focus { outline: 2px solid var(--dsw-alias-label-primary-bluish, #4176e6); outline-offset: -1px; }
312
327
  .dshn-input[data-bad="1"] { border-color: var(--dsw-alias-state-error-primary, #e5484d); }
@@ -325,27 +340,27 @@ window.__ModuleLoader__.load({
325
340
  .dshn-strength { font-size: 10.5px; margin-top: 3px; }
326
341
 
327
342
  .dshn-actions { display: flex; gap: 8px; align-items: center; margin-top: 3px; }
328
- .dshn-primary { flex: 1; padding: 9px; border: 0; border-radius: 8px; cursor: pointer; background: #4176e6; color: #fff; font-size: 13.5px; }
343
+ .dshn-primary { flex: 1; padding: 7px; border: 0; border-radius: 8px; cursor: pointer; background: #4176e6; color: #fff; font-size: 13.5px; }
329
344
  .dshn-primary:disabled { opacity: .5; cursor: default; }
330
- .dshn-ghost { border: 0; background: transparent; cursor: pointer; color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 12.5px; padding: 9px 8px; }
345
+ .dshn-ghost { border: 0; background: transparent; cursor: pointer; color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 12.5px; padding: 7px 8px; }
331
346
  .dshn-err { color: var(--dsw-alias-state-error-primary, #e5484d); font-size: 11.5px; margin-bottom: 9px; }
332
347
  .dshn-hint { color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 10.5px; margin-top: 3px; }
333
- .dshn-note { color: var(--dsw-alias-state-warn-primary, #d98324); font-size: 10.5px; margin: 2px 0 10px; }
334
- .dshn-modeseg { display: flex; gap: 4px; padding: 3px; border-radius: 9px; background: var(--dsw-alias-bg-layer-2, #f4f5f7);
348
+ .dshn-note { color: var(--dsw-alias-state-warn-primary, #d98324); font-size: 10.5px; margin: 2px 0 8px; }
349
+ .dshn-modeseg { display: flex; gap: 4px; padding: 2px; border-radius: 9px; background: var(--dsw-alias-bg-layer-2, #f4f5f7);
335
350
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.22)); }
336
- .dshn-modebtn { flex: 1; padding: 6px 10px; border: 0; border-radius: 6px; cursor: pointer; font-size: 12.5px; font-family: inherit;
351
+ .dshn-modebtn { flex: 1; padding: 5px 10px; border: 0; border-radius: 6px; cursor: pointer; font-size: 12.5px; font-family: inherit;
337
352
  background: transparent; color: var(--dsw-alias-label-secondary, #4a4f57); }
338
353
  .dshn-modebtn.dshn-on { background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #1c1e21);
339
354
  box-shadow: 0 1px 2px rgba(0,0,0,.08); }
340
- .dshn-ca { min-height: 54px; resize: vertical; font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.4; }
355
+ .dshn-ca { min-height: 46px; resize: vertical; font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.4; }
341
356
  .dshn-catoggle { border: 0; background: transparent; cursor: pointer; padding: 2px 0; font-size: 11px; font-family: inherit;
342
357
  color: var(--dsw-alias-label-tertiary, #8b9099); }
343
358
  .dshn-catoggle:hover { color: var(--dsw-alias-label-primary, #1c1e21); }
344
- .dshn-e2e-box { margin: 4px 0 12px; padding: 11px 12px; border-radius: 10px;
359
+ .dshn-e2e-box { margin: 2px 0 8px; padding: 9px 11px; border-radius: 10px;
345
360
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.25)); background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
346
- .dshn-e2e-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px;
361
+ .dshn-e2e-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;
347
362
  color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 11px; }
348
- .dshn-e2e-actions { display: flex; gap: 8px; align-items: center; margin-top: 9px; }
363
+ .dshn-e2e-actions { display: flex; gap: 8px; align-items: center; margin-top: 7px; }
349
364
  .dshn-btn-sm { padding: 6px 12px; border: 0; border-radius: 7px; cursor: pointer; font-size: 12px;
350
365
  background: #4176e6; color: #fff; }
351
366
  .dshn-btn-sm:disabled { opacity: .5; cursor: default; }
@@ -353,17 +368,33 @@ window.__ModuleLoader__.load({
353
368
  border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.3)); }
354
369
  .dshn-btn-warn:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e5484d) 10%, transparent); }
355
370
  .dshn-info { border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.22)); border-radius: 9px;
356
- padding: 8px 10px; margin-bottom: 12px; }
357
- .dshn-info-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 11.5px; padding: 3px 0; }
371
+ padding: 6px 10px; margin-bottom: 8px; }
372
+ .dshn-info-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 11.5px; padding: 1.5px 0; }
358
373
  .dshn-info-k { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-tertiary, #8b9099); }
359
374
  .dshn-info-k svg { flex: none; opacity: .85; }
360
375
  .dshn-info-v { font-family: ui-monospace, Menlo, monospace; text-align: right; font-variant-numeric: tabular-nums; }
361
376
  .dshn-dcwarn { border: 1px solid var(--dsw-alias-state-error-primary, #e5484d); background: rgba(229,72,77,.08);
362
- border-radius: 9px; padding: 10px 11px; margin-bottom: 11px; }
363
- .dshn-dcwarn-title { font-weight: 640; font-size: 12.5px; margin-bottom: 5px; }
377
+ border-radius: 9px; padding: 8px 10px; margin-bottom: 8px; }
378
+ .dshn-dcwarn-title { font-weight: 640; font-size: 12.5px; margin-bottom: 3px; }
364
379
  .dshn-dcwarn-body { font-size: 11.5px; color: var(--dsw-alias-label-secondary, #4a4f57); }
365
- .dshn-danger { flex: 1; padding: 9px; border: 0; border-radius: 8px; cursor: pointer;
380
+ .dshn-danger { flex: 1; padding: 7px; border: 0; border-radius: 8px; cursor: pointer;
366
381
  background: var(--dsw-alias-state-error-primary, #e5484d); color: #fff; font-size: 13px; }
382
+
383
+ /* Multi-device switcher (remote pages only): a footer row like the local one,
384
+ opening a small fixed popover above it listing this subdomain's devices. */
385
+ .dshn-devpop { position: fixed; left: 12px; bottom: 56px; z-index: 70; width: 244px;
386
+ box-sizing: border-box; padding: 10px 10px 8px; border-radius: 12px;
387
+ background: var(--dsw-alias-bg-layer-3, #fff);
388
+ border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.25));
389
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.24)); }
390
+ .dshn-devpop-title { font-size: 11px; color: var(--dsw-alias-label-tertiary, #8b9099); margin: 0 4px 6px; }
391
+ .dshn-devrow { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
392
+ padding: 8px 9px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; text-align: left;
393
+ color: var(--dsw-alias-label-primary, #1c1e21); font-size: 13px; font-family: inherit; }
394
+ .dshn-devrow:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,134,142,.12)); }
395
+ .dshn-devrow:disabled { cursor: default; opacity: .6; }
396
+ .dshn-devrow-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
397
+ .dshn-devrow-tag { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, #8b9099); }
367
398
  `
368
399
  const cssId = ID + '/widget.css'
369
400
  if (typeof document !== 'undefined'
@@ -386,7 +417,7 @@ window.__ModuleLoader__.load({
386
417
  savedHint: '手机访问用这个密码登录。忘记时点“复制/显示”取回。',
387
418
  weak: '弱', fair: '一般', good: '较强', strong: '强',
388
419
  infoRelay: '线路', infoMode: { direct: '直连源站', cloudflare: '经 Cloudflare' },
389
- infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟',
420
+ infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟', infoDevice: '设备名',
390
421
  e2eLabel: '端到端密码(可选)', e2eHint: '设置后,会话内容用它加密,云端也看不到;密码不出本机。访问时需在网页再输一次。',
391
422
  e2eApply: '设置端到端密码', e2eUpdate: '更新端到端密码', e2eDisable: '关闭加密', e2eApplied: '✓ 端到端加密已开启', e2eOff2: '✓ 端到端加密已关闭', e2eIndep: '独立设置,不影响上面的连接。',
392
423
  infoE2E: '端到端加密', e2eOn: '已开启', e2eOff: '未开启',
@@ -398,7 +429,8 @@ window.__ModuleLoader__.load({
398
429
  mode: '模式', modeOfficial: '官方 ds.hn', modeSelf: '自托管', yourDomain: '你的域名', relayHost: '中继地址',
399
430
  relayHostHint: '填你自己的 @dshn/relay,如 wss://tunnel.example.com。子域会挂在它的域名下。',
400
431
  relayCa: '中继证书(自签名,可选)',
401
- relayCaHint: '仅当你的中继用自签名证书时:粘贴其 PEM 证书以固定信任(公有证书/套 Cloudflare 时留空)。' }
432
+ relayCaHint: '仅当你的中继用自签名证书时:粘贴其 PEM 证书以固定信任(公有证书/套 Cloudflare 时留空)。',
433
+ devLabel: '设备', devSwitch: '切换设备', devOffline: '离线', devCurrent: '当前' }
402
434
  : { brand: 'Public forwarding · ds.hn', connecting: 'connecting…', live: 'live', off: 'off', notset: 'not set up',
403
435
  setupTitle: 'Set up public forwarding', setupSub: 'Pick a subdomain prefix and an access password — the two are your credential.',
404
436
  connTitle: 'Public forwarding', prefix: 'Subdomain prefix', password: 'Access password', confirm: 'Confirm password',
@@ -410,7 +442,7 @@ window.__ModuleLoader__.load({
410
442
  savedHint: 'Log in from a phone with this password. Copy/show it here if you forget.',
411
443
  weak: 'weak', fair: 'fair', good: 'good', strong: 'strong',
412
444
  infoRelay: 'Link', infoMode: { direct: 'direct to origin', cloudflare: 'via Cloudflare' },
413
- infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency',
445
+ infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency', infoDevice: 'Device name',
414
446
  e2eLabel: 'End-to-end password (optional)', e2eHint: 'If set, session content is encrypted with it — even the cloud cannot read it, and it never leaves this machine. Visitors enter it again in the browser.',
415
447
  e2eApply: 'Set e2e password', e2eUpdate: 'Update e2e password', e2eDisable: 'Turn off', e2eApplied: '✓ End-to-end encryption on', e2eOff2: '✓ End-to-end encryption off', e2eIndep: 'Applied on its own — does not affect the connection above.',
416
448
  infoE2E: 'End-to-end encryption', e2eOn: 'on', e2eOff: 'off',
@@ -422,7 +454,8 @@ window.__ModuleLoader__.load({
422
454
  mode: 'Mode', modeOfficial: 'Official ds.hn', modeSelf: 'Self-hosted', yourDomain: 'your-domain', relayHost: 'Relay host',
423
455
  relayHostHint: 'Your own @dshn/relay, e.g. wss://tunnel.example.com. Your subdomain lives under its domain.',
424
456
  relayCa: 'Relay CA (self-signed, optional)',
425
- relayCaHint: 'Only when your relay uses a self-signed cert: paste its PEM to pin trust (leave blank for a public cert / behind Cloudflare).' }
457
+ relayCaHint: 'Only when your relay uses a self-signed cert: paste its PEM to pin trust (leave blank for a public cert / behind Cloudflare).',
458
+ devLabel: 'Device', devSwitch: 'Switch device', devOffline: 'offline', devCurrent: 'current' }
426
459
 
427
460
  function strength(pw) {
428
461
  if (pw.length < MIN_PW) return { score: 0, ok: false }
@@ -623,12 +656,22 @@ window.__ModuleLoader__.load({
623
656
  s.localPort ? h('div', { className: 'dshn-info-row' },
624
657
  h('span', { className: 'dshn-info-k' }, Icon('server'), T.infoPort),
625
658
  h('span', { className: 'dshn-info-v' }, String(s.localPort))) : null,
659
+ // How this machine shows up in the multi-device switcher when several
660
+ // devices bind one subdomain.
661
+ s.deviceName ? h('div', { className: 'dshn-info-row' },
662
+ h('span', { className: 'dshn-info-k' }, Icon('server'), T.infoDevice),
663
+ h('span', { className: 'dshn-info-v' }, s.deviceName)) : null,
626
664
  h('div', { className: 'dshn-info-row' },
627
665
  h('span', { className: 'dshn-info-k' }, Icon(s.e2eEnabled ? 'lock' : 'unlock'), T.infoE2E),
628
666
  h('span', { className: 'dshn-info-v', style: { color: s.e2eEnabled ? '#3aa675' : undefined } }, s.e2eEnabled ? T.e2eOn : T.e2eOff))) : null,
629
667
 
630
668
  liveErr ? h('div', { className: 'dshn-err' }, liveErr) : null,
631
669
 
670
+ // While confirming a disconnect, hide the whole editable form (you're
671
+ // leaving it) so the confirmation stays short and fully visible instead of
672
+ // growing the panel past its container.
673
+ confirmDc ? null : h(react.Fragment, null,
674
+
632
675
  // Mode: official ds.hn vs self-hosted. The subdomain + password + e2e
633
676
  // below are shared; self-hosted just adds where your own relay lives.
634
677
  h('div', { className: 'dshn-field' },
@@ -725,7 +768,7 @@ window.__ModuleLoader__.load({
725
768
  e2eBusy ? T.connecting2 : (s.e2eEnabled ? T.e2eUpdate : T.e2eApply)),
726
769
  s.e2eEnabled ? h('button', { className: 'dshn-btn-sm dshn-btn-warn', disabled: e2eBusy, onClick: () => { setE2e(''); applyE2E('') } }, T.e2eDisable) : null),
727
770
  h('div', { className: 'dshn-hint', style: { marginTop: '7px' } }, T.e2eHint + ' ' + T.e2eIndep))
728
- })() : null,
771
+ })() : null),
729
772
 
730
773
  // Disconnecting severs public access and the password is unrecoverable
731
774
  // from the cloud — so it takes an explicit, spelled-out confirmation.
@@ -774,6 +817,69 @@ window.__ModuleLoader__.load({
774
817
  return store
775
818
  }
776
819
 
820
+ // ── multi-device switcher (remote pages only) ─────────────────────────────
821
+ // On a public host, `/__dshn/devices` is answered by the RELAY (same host,
822
+ // behind the same login cookie): the list of devices bound to this subdomain.
823
+ // `multi` goes true when ≥2 are live — only then does the switcher appear.
824
+ // `/dshn-e2e` (answered by the SERVING device through the tunnel) tells us
825
+ // which device this page is actually on, for when no selection cookie is set.
826
+ // An old relay answers neither with JSON — the switcher just stays hidden.
827
+ const DEV_POLL_MS = 10000
828
+ const devStore = {
829
+ info: null, self: null, started: false, subs: new Set(),
830
+ set(patch) { Object.assign(this, patch); this.subs.forEach((f) => f()) },
831
+ sub(f) { this.subs.add(f); return () => this.subs.delete(f) },
832
+ start() {
833
+ if (this.started || pageLoopback) return
834
+ this.started = true
835
+ const tick = () => fetch('/__dshn/devices', { cache: 'no-store', credentials: 'include', headers: { accept: 'application/json' } })
836
+ .then((r) => (r.ok && String(r.headers.get('content-type') || '').includes('json') ? r.json() : null))
837
+ .then((j) => { if (j && Array.isArray(j.devices)) this.set({ info: j }) })
838
+ .catch(() => {})
839
+ tick(); setInterval(tick, DEV_POLL_MS)
840
+ fetch(E2E_PUB_PATH, { cache: 'no-store', credentials: 'include' })
841
+ .then((r) => (r.ok ? r.json() : null))
842
+ .then((j) => { if (j && j.device) this.set({ self: j.device }) })
843
+ .catch(() => {})
844
+ },
845
+ }
846
+ function DeviceSwitcher() {
847
+ const [, force] = react.useReducer((x) => x + 1, 0)
848
+ react.useEffect(() => devStore.sub(force), [])
849
+ const [open, setOpen] = react.useState(false)
850
+ const [busy, setBusy] = react.useState(false)
851
+ const info = devStore.info
852
+ if (!info || !info.multi) return null
853
+ const devices = info.devices || []
854
+ const currentId = info.current || devStore.self
855
+ const current = devices.find((d) => d.id === currentId) || null
856
+ const pick = (d) => {
857
+ if (busy || !d.online || d.id === currentId) return
858
+ setBusy(true)
859
+ // Set the selection cookie, then a full reload boots the app cleanly
860
+ // against the chosen device (no cross-device state survives).
861
+ fetch('/__dshn/select', { method: 'POST', credentials: 'include',
862
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
863
+ body: JSON.stringify({ device: d.id }) })
864
+ .then((r) => { if (r.ok) location.reload(); else setBusy(false) })
865
+ .catch(() => setBusy(false))
866
+ }
867
+ return h(react.Fragment, null,
868
+ h('button', { className: 'dshn-frow', title: T.devSwitch, 'aria-label': T.devSwitch, onClick: () => setOpen(!open) },
869
+ h('span', { className: 'dshn-frow-ic' }, Icon('server', { width: 16, height: 16 })),
870
+ h('span', { className: 'dshn-frow-label' }, current ? current.name : T.devLabel),
871
+ h('span', { className: 'dshn-frow-trail' }, (info.live || 0) + '/' + devices.length)),
872
+ open ? h('div', { className: 'dshn-devpop' },
873
+ h('div', { className: 'dshn-devpop-title' }, T.devSwitch),
874
+ devices.map((d) => h('button', {
875
+ key: d.id, className: 'dshn-devrow', disabled: busy || !d.online || d.id === currentId,
876
+ onClick: () => pick(d) },
877
+ h('span', { className: 'dshn-dot', 'data-on': d.online ? '1' : '0' }),
878
+ h('span', { className: 'dshn-devrow-name' }, d.name),
879
+ d.id === currentId ? h('span', { className: 'dshn-devrow-tag' }, T.devCurrent)
880
+ : (!d.online ? h('span', { className: 'dshn-devrow-tag' }, T.devOffline) : null)))) : null)
881
+ }
882
+
777
883
  // Open dsh's Settings and land on our section. The settings trigger is a
778
884
  // stable `button[aria-haspopup="dialog"]` (class names are hashed); once it
779
885
  // is open, click our section's nav entry by its label.
@@ -836,7 +942,10 @@ window.__ModuleLoader__.load({
836
942
  // Configuration itself lives in the Settings page, not here.
837
943
  function FooterButton() {
838
944
  useStore()
839
- if (!pageLoopback) return null
945
+ // Remote pages get the device switcher in this slot instead of the local
946
+ // status row (configuration is local-only; switching devices is the one
947
+ // thing a remote visitor can do here).
948
+ if (!pageLoopback) return h(DeviceSwitcher)
840
949
  const s = store.status
841
950
  const connected = s && s.connected
842
951
  const configured = s && s.configured
@@ -866,7 +975,8 @@ window.__ModuleLoader__.load({
866
975
 
867
976
  const inject = ['slots']
868
977
  function apply(ctx) {
869
- store.start()
978
+ if (pageLoopback) store.start()
979
+ else devStore.start()
870
980
  ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dshn-footer', order: 50 }, FooterButton))
871
981
  ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dshn', order: 40, label: () => T.navLabel }, DshnSection))
872
982
  // Keep our settings-nav globe applied however the panel is opened (dsh's own
package/lib/index.js CHANGED
@@ -2270,7 +2270,7 @@ var require_websocket = __commonJS({
2270
2270
  var http2 = __require("http");
2271
2271
  var net = __require("net");
2272
2272
  var tls = __require("tls");
2273
- var { randomBytes: randomBytes2, createHash } = __require("crypto");
2273
+ var { randomBytes: randomBytes2, createHash: createHash2 } = __require("crypto");
2274
2274
  var { Duplex, Readable } = __require("stream");
2275
2275
  var { URL } = __require("url");
2276
2276
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -2938,7 +2938,7 @@ var require_websocket = __commonJS({
2938
2938
  abortHandshake(websocket, socket, "Invalid Upgrade header");
2939
2939
  return;
2940
2940
  }
2941
- const digest = createHash("sha1").update(key + GUID).digest("base64");
2941
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
2942
2942
  if (res.headers["sec-websocket-accept"] !== digest) {
2943
2943
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
2944
2944
  return;
@@ -3307,7 +3307,7 @@ var require_websocket_server = __commonJS({
3307
3307
  var EventEmitter = __require("events");
3308
3308
  var http2 = __require("http");
3309
3309
  var { Duplex } = __require("stream");
3310
- var { createHash } = __require("crypto");
3310
+ var { createHash: createHash2 } = __require("crypto");
3311
3311
  var extension2 = require_extension();
3312
3312
  var PerMessageDeflate2 = require_permessage_deflate();
3313
3313
  var subprotocol2 = require_subprotocol();
@@ -3614,7 +3614,7 @@ var require_websocket_server = __commonJS({
3614
3614
  );
3615
3615
  }
3616
3616
  if (this._state > RUNNING) return abortHandshake(socket, 503);
3617
- const digest = createHash("sha1").update(key + GUID).digest("base64");
3617
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
3618
3618
  const headers = [
3619
3619
  "HTTP/1.1 101 Switching Protocols",
3620
3620
  "Upgrade: websocket",
@@ -3703,8 +3703,9 @@ var require_websocket_server = __commonJS({
3703
3703
 
3704
3704
  // packages/agent/lib/index.js
3705
3705
  import http from "node:http";
3706
+ import { createHash } from "node:crypto";
3706
3707
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3707
- import { homedir } from "node:os";
3708
+ import { homedir, hostname } from "node:os";
3708
3709
  import { dirname, join } from "node:path";
3709
3710
 
3710
3711
  // node_modules/.pnpm/@deepseek-ai+cosmokit@1.8.2/node_modules/@deepseek-ai/cosmokit/lib/index.js
@@ -4746,6 +4747,18 @@ var AgentTunnel = class {
4746
4747
  e2eKey = null;
4747
4748
  /** Public salt for the current e2e key. */
4748
4749
  e2eSalt = "";
4750
+ /**
4751
+ * Stable device identity for multi-device: several machines may bind the same
4752
+ * subdomain, and the relay tells them apart by this id. Derived, not stored:
4753
+ * hashing the hostname with the state path gives an id that survives restarts
4754
+ * and disconnects, distinguishes two profiles on one machine (different
4755
+ * DSH_HOME/DSHN_STATE), and needs no schema or migration. Two agents sharing
4756
+ * one profile dir collide — deliberately, since sharing a profile already
4757
+ * means fighting over the same credentials.
4758
+ */
4759
+ deviceId;
4760
+ /** Human-readable device name shown in the relay's switcher (env override, else hostname). */
4761
+ deviceName;
4749
4762
  status;
4750
4763
  creds = null;
4751
4764
  /** ms epoch the current tunnel became live (READY), or null when down. */
@@ -4760,6 +4773,8 @@ var AgentTunnel = class {
4760
4773
  this.config = config;
4761
4774
  this.localPort = localPort;
4762
4775
  this.store = store;
4776
+ this.deviceId = createHash("sha256").update(`${hostname()}|${config.statePath}`).digest("hex").slice(0, 12);
4777
+ this.deviceName = (process.env.DSHN_DEVICE_NAME ?? hostname()).trim().slice(0, 40) || this.deviceId;
4763
4778
  this.creds = this.store.load();
4764
4779
  this.refreshE2E();
4765
4780
  this.status = {
@@ -4958,7 +4973,9 @@ var AgentTunnel = class {
4958
4973
  subdomain: this.creds.subdomain,
4959
4974
  password: this.creds.password,
4960
4975
  agent: `dshn-agent/${DSHN_PROTOCOL_VERSION}`,
4961
- protocol: DSHN_PROTOCOL_VERSION
4976
+ protocol: DSHN_PROTOCOL_VERSION,
4977
+ deviceId: this.deviceId,
4978
+ device: this.deviceName
4962
4979
  });
4963
4980
  }
4964
4981
  this.lastPong = Date.now();
@@ -5278,8 +5295,8 @@ function isLoopbackRequest(req) {
5278
5295
  if (req.headers[TUNNEL_MARKER] !== void 0)
5279
5296
  return false;
5280
5297
  const host = String(req.headers.host ?? "");
5281
- const hostname = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
5282
- return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
5298
+ const hostname2 = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
5299
+ return hostname2 === "localhost" || hostname2 === "::1" || hostname2.startsWith("127.");
5283
5300
  }
5284
5301
  function publicApex(relayHost) {
5285
5302
  const bare = relayHost.replace(/^wss?:\/\//, "").replace(/:\d+$/, "");
@@ -5342,6 +5359,9 @@ function apply(ctx, rawConfig) {
5342
5359
  lastError: tunnel.status.lastError,
5343
5360
  configurable: loopback,
5344
5361
  apex: publicApex(info.relayHost),
5362
+ // This machine's identity in the multi-device switcher.
5363
+ deviceId: tunnel.deviceId,
5364
+ deviceName: tunnel.deviceName,
5345
5365
  // The saved password is the only recoverable copy (cloud stores a hash).
5346
5366
  // Only ever handed to a loopback caller — the local machine's owner.
5347
5367
  password: loopback ? tunnel.revealPassword() : null,
@@ -5367,7 +5387,7 @@ function apply(ctx, rawConfig) {
5367
5387
  path: E2E_PUB_PATH,
5368
5388
  handler: (_req, res) => {
5369
5389
  const e = tunnel.e2eInfo();
5370
- json(res, 200, { enabled: e.enabled, salt: e.enabled ? e.salt : null });
5390
+ json(res, 200, { enabled: e.enabled, salt: e.enabled ? e.salt : null, device: tunnel.deviceId });
5371
5391
  }
5372
5392
  });
5373
5393
  const disposeConfigure = ctx.webServer.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dshn/agent",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Forward a local dsh web service to the public internet over ds.hn (bundled).",
5
5
  "keywords": [
6
6
  "dsh",