@dshn/agent 0.1.7 → 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 +5 -0
- package/client.js +120 -15
- package/lib/index.js +29 -9
- package/package.json +1 -1
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
|
|
173
|
-
// device only — never transmitted (E2E is intact).
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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)) {
|
|
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
|
|
|
@@ -364,6 +379,22 @@ window.__ModuleLoader__.load({
|
|
|
364
379
|
.dshn-dcwarn-body { font-size: 11.5px; color: var(--dsw-alias-label-secondary, #4a4f57); }
|
|
365
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,6 +656,11 @@ 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,
|
|
@@ -779,6 +817,69 @@ window.__ModuleLoader__.load({
|
|
|
779
817
|
return store
|
|
780
818
|
}
|
|
781
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
|
+
|
|
782
883
|
// Open dsh's Settings and land on our section. The settings trigger is a
|
|
783
884
|
// stable `button[aria-haspopup="dialog"]` (class names are hashed); once it
|
|
784
885
|
// is open, click our section's nav entry by its label.
|
|
@@ -841,7 +942,10 @@ window.__ModuleLoader__.load({
|
|
|
841
942
|
// Configuration itself lives in the Settings page, not here.
|
|
842
943
|
function FooterButton() {
|
|
843
944
|
useStore()
|
|
844
|
-
|
|
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)
|
|
845
949
|
const s = store.status
|
|
846
950
|
const connected = s && s.connected
|
|
847
951
|
const configured = s && s.configured
|
|
@@ -871,7 +975,8 @@ window.__ModuleLoader__.load({
|
|
|
871
975
|
|
|
872
976
|
const inject = ['slots']
|
|
873
977
|
function apply(ctx) {
|
|
874
|
-
store.start()
|
|
978
|
+
if (pageLoopback) store.start()
|
|
979
|
+
else devStore.start()
|
|
875
980
|
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dshn-footer', order: 50 }, FooterButton))
|
|
876
981
|
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dshn', order: 40, label: () => T.navLabel }, DshnSection))
|
|
877
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 =
|
|
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 =
|
|
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
|
|
5282
|
-
return
|
|
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({
|