dsh-relay-phone 0.11.0 → 0.11.2
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/package.json +1 -1
- package/src/mobile/assets/app.js +16 -5
- package/src/mobile/bridge.js +29 -3
- package/src/panel.js +18 -3
- package/src/pump.js +19 -0
- package/src/relay-client.js +36 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-relay-phone",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"description": "Remote-control your local DeepSeek Harness from a phone through your own zero-knowledge relay: phone-first page, QR pairing, end-to-end encryption, approvals, full desktop UI fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/mobile/assets/app.js
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
polling: false,
|
|
73
73
|
timer: null,
|
|
74
74
|
busy: false,
|
|
75
|
+
text: '', // everything received so far; the host only sends new lines
|
|
75
76
|
},
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -2597,7 +2598,8 @@
|
|
|
2597
2598
|
$('terminalTitle').textContent = '终端'
|
|
2598
2599
|
try {
|
|
2599
2600
|
await ensureTerminal()
|
|
2600
|
-
|
|
2601
|
+
state.terminal.text = ''
|
|
2602
|
+
await refreshTerminal(true)
|
|
2601
2603
|
startTerminalPoll()
|
|
2602
2604
|
} catch (error) {
|
|
2603
2605
|
$('terminalOut').textContent = '终端不可用:' + (error.message || error)
|
|
@@ -2607,6 +2609,7 @@
|
|
|
2607
2609
|
function closeTerminal() {
|
|
2608
2610
|
$('terminalPanel').hidden = true
|
|
2609
2611
|
state.terminal.open = false
|
|
2612
|
+
state.terminal.text = ''
|
|
2610
2613
|
stopTerminalPoll()
|
|
2611
2614
|
}
|
|
2612
2615
|
|
|
@@ -2660,15 +2663,22 @@
|
|
|
2660
2663
|
.replace(/\u001b(\[[0-9;?]*)?$/g, '')
|
|
2661
2664
|
}
|
|
2662
2665
|
|
|
2663
|
-
async function refreshTerminal() {
|
|
2666
|
+
async function refreshTerminal(reset) {
|
|
2664
2667
|
if (!state.terminal.id || !state.terminal.open) return
|
|
2665
2668
|
var result = await rpc('terminal.read', {
|
|
2666
2669
|
sessionId: state.currentId,
|
|
2667
2670
|
terminalId: state.terminal.id,
|
|
2668
2671
|
count: 300,
|
|
2672
|
+
reset: reset === true,
|
|
2669
2673
|
})
|
|
2670
2674
|
var out = $('terminalOut')
|
|
2671
|
-
|
|
2675
|
+
// The bridge sends only the lines this phone has not seen; append them,
|
|
2676
|
+
// and replace the buffer whenever it says the gap cannot be bridged.
|
|
2677
|
+
var chunk = plainTerminalText(result.text)
|
|
2678
|
+
if (result.appended !== true) state.terminal.text = chunk
|
|
2679
|
+
else if (chunk) state.terminal.text = (state.terminal.text || '') + (state.terminal.text ? '\n' : '') + chunk
|
|
2680
|
+
if (state.terminal.text.length > 200000) state.terminal.text = state.terminal.text.slice(-200000)
|
|
2681
|
+
var text = state.terminal.text || '(无输出)'
|
|
2672
2682
|
if (out.textContent !== text) {
|
|
2673
2683
|
out.textContent = text
|
|
2674
2684
|
out.scrollTop = out.scrollHeight
|
|
@@ -2714,12 +2724,13 @@
|
|
|
2714
2724
|
var created = await rpc('terminal.create', { sessionId: state.currentId })
|
|
2715
2725
|
state.terminal.id = created.id
|
|
2716
2726
|
state.terminal.name = created.name || created.type
|
|
2717
|
-
|
|
2727
|
+
state.terminal.text = plainTerminalText(created.motd)
|
|
2728
|
+
$('terminalOut').textContent = state.terminal.text || ''
|
|
2718
2729
|
toast('已新建终端')
|
|
2719
2730
|
} catch (error) {
|
|
2720
2731
|
toast('新建终端失败:' + (error.message || error))
|
|
2721
2732
|
}
|
|
2722
|
-
refreshTerminal().catch(function () {})
|
|
2733
|
+
refreshTerminal(true).catch(function () {})
|
|
2723
2734
|
}
|
|
2724
2735
|
|
|
2725
2736
|
async function terminalKill() {
|
package/src/mobile/bridge.js
CHANGED
|
@@ -85,6 +85,7 @@ export class MobileBridge {
|
|
|
85
85
|
this.continued = new Map() // key (`q:<callId>`) -> {sessionId, callId, questions}
|
|
86
86
|
this.titles = new Map() // sessionId -> title from list rows
|
|
87
87
|
this.ownTerminals = new Map() // `${sessionId}:${id}` -> plugin-owned fallback PTY
|
|
88
|
+
this.terminalCursors = new Map() // `${sessionId}:${id}` -> last line index sent to the phone
|
|
88
89
|
this.disposers = []
|
|
89
90
|
this.started = false
|
|
90
91
|
this.phoneCount = 0
|
|
@@ -989,8 +990,10 @@ export class MobileBridge {
|
|
|
989
990
|
const sessionId = params?.sessionId
|
|
990
991
|
const id = params?.terminalId
|
|
991
992
|
if (typeof sessionId !== 'string' || typeof id !== 'string') throw fail('bad-request', 'sessionId and terminalId required')
|
|
993
|
+
const key = `${sessionId}:${id}`
|
|
994
|
+
if (params?.reset === true) this.terminalCursors.delete(key)
|
|
992
995
|
const owned = this.ownTerminal(sessionId, id)
|
|
993
|
-
if (owned) return this.readOwnTerminal(owned, Number.isFinite(params?.offset) ? params.offset : 0, clampInt(params?.count, 20, 400, 120))
|
|
996
|
+
if (owned) return this.slimTerminal(key, this.readOwnTerminal(owned, Number.isFinite(params?.offset) ? params.offset : 0, clampInt(params?.count, 20, 400, 120)))
|
|
994
997
|
const agent = await this.resolveLiveAgent(sessionId)
|
|
995
998
|
const registry = this.presetTerminals(agent)
|
|
996
999
|
if (!registry) throw fail('unavailable', 'terminal service unavailable for this agent')
|
|
@@ -998,13 +1001,36 @@ export class MobileBridge {
|
|
|
998
1001
|
offset: Number.isFinite(params?.offset) ? params.offset : 0,
|
|
999
1002
|
count: clampInt(params?.count, 20, 400, 120),
|
|
1000
1003
|
})
|
|
1001
|
-
return {
|
|
1004
|
+
return this.slimTerminal(key, {
|
|
1002
1005
|
text: result?.text ?? '',
|
|
1003
1006
|
totalLines: result?.totalLines ?? 0,
|
|
1004
1007
|
lineBegin: result?.lineBegin ?? 0,
|
|
1005
1008
|
lineEnd: result?.lineEnd ?? 0,
|
|
1006
1009
|
truncated: result?.truncated === true,
|
|
1007
|
-
}
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Send only what the phone has not seen yet.
|
|
1015
|
+
*
|
|
1016
|
+
* The panel polls once a second, and re-sending the last 300 lines every time
|
|
1017
|
+
* is what made an open terminal the most expensive thing on the relay. The
|
|
1018
|
+
* read above stays as wide as before (it is a local read), but the wire now
|
|
1019
|
+
* carries just the new lines; when nothing changed the reply is an empty
|
|
1020
|
+
* string. `appended` tells the page whether to append or replace, and a jumped
|
|
1021
|
+
* `lineBegin` (ring buffer overflow) forces a replace so no gap is hidden.
|
|
1022
|
+
*/
|
|
1023
|
+
slimTerminal(key, fresh) {
|
|
1024
|
+
const previous = this.terminalCursors.get(key)
|
|
1025
|
+
this.terminalCursors.set(key, fresh.lineEnd)
|
|
1026
|
+
if (!previous || fresh.lineBegin > previous) {
|
|
1027
|
+
return { ...fresh, appended: false }
|
|
1028
|
+
}
|
|
1029
|
+
const skip = Math.max(0, previous - fresh.lineBegin)
|
|
1030
|
+
if (!fresh.text) return { ...fresh, text: '', appended: true }
|
|
1031
|
+
const lines = fresh.text.split('\n')
|
|
1032
|
+
if (skip >= lines.length) return { ...fresh, text: '', appended: true }
|
|
1033
|
+
return { ...fresh, text: lines.slice(skip).join('\n'), appended: true }
|
|
1008
1034
|
}
|
|
1009
1035
|
|
|
1010
1036
|
async terminalSend(params) {
|
package/src/panel.js
CHANGED
|
@@ -89,9 +89,17 @@ export async function registerPanel(ctx, service) {
|
|
|
89
89
|
kind: 'exact',
|
|
90
90
|
path: '/relay-bridge/panel',
|
|
91
91
|
handler: async (req, res) => {
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
92
|
+
// Desktop-only. This page hands out the pairing entry URL, which embeds
|
|
93
|
+
// the device PSK (`#k=`); a relayed request is a phone, and a phone that
|
|
94
|
+
// could read it would be able to pair unlimited extra devices. The pump
|
|
95
|
+
// marks relayed requests, so this cannot be forged by the client.
|
|
96
|
+
if (req.headers['x-dsh-relay']) {
|
|
97
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
98
|
+
res.end('dsh-relay: the management panel is desktop-only. Open it on 127.0.0.1.\n')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
// Same ticket policy as /relay-bridge/api: a local page load mints one so
|
|
102
|
+
// its write actions authenticate.
|
|
95
103
|
if (!service.pairing.sessionForHeaders(req.headers)) {
|
|
96
104
|
if (!isLoopback(req.socket?.remoteAddress ?? '')) {
|
|
97
105
|
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
|
|
@@ -117,6 +125,13 @@ export async function registerPanel(ctx, service) {
|
|
|
117
125
|
res.end(JSON.stringify(value))
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
// Desktop-only, for the same reason as the page: the snapshot contains
|
|
129
|
+
// the pairing URL with the device PSK, and the relay key can be changed
|
|
130
|
+
// from here.
|
|
131
|
+
if (req.headers['x-dsh-relay']) {
|
|
132
|
+
return reply(403, { ok: false, error: 'desktop-only' })
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
// Pairing-ticket gate. Requests arriving through the relay already
|
|
121
136
|
// carry a valid ticket (the pump refuses ticketless traffic), so this
|
|
122
137
|
// check only ever fires for DIRECT local access — the local user's own
|
package/src/pump.js
CHANGED
|
@@ -365,6 +365,17 @@ export class PumpManager {
|
|
|
365
365
|
return
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
// The plugin's own console is a desktop tool: it prints the pairing URL
|
|
369
|
+
// (which carries the device PSK) and can change the relay key. A phone that
|
|
370
|
+
// reached it would be able to pair unlimited extra devices, so relayed
|
|
371
|
+
// requests never see it, ticketed or not.
|
|
372
|
+
if (stream.relay && info.path.startsWith('/relay-bridge/')) {
|
|
373
|
+
auditLog(`relayed request to the management panel blocked: ${info.path}`)
|
|
374
|
+
this.respondInline(sid, 403, 'Forbidden', 'dsh-relay-phone: the management panel is desktop-only\n')
|
|
375
|
+
this.closeStream(sid)
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
368
379
|
if (!session) {
|
|
369
380
|
auditLog(`unauthenticated ${info.method} ${info.path} -> pairing page`)
|
|
370
381
|
this.respond(sid, 200, 'text/html; charset=utf-8',
|
|
@@ -530,10 +541,12 @@ export class PumpManager {
|
|
|
530
541
|
if (lower.startsWith('connection:')) continue
|
|
531
542
|
if (lower.startsWith('accept-encoding:')) continue
|
|
532
543
|
if (lower.startsWith('x-relay-enc:') || lower.startsWith('x-relay-ct:')) continue
|
|
544
|
+
if (lower.startsWith('x-dsh-relay:')) continue // the pump sets its own
|
|
533
545
|
headers.push(line)
|
|
534
546
|
}
|
|
535
547
|
headers.unshift(`Host: ${target.authority}`)
|
|
536
548
|
headers.unshift(`Origin: ${target.origin}`)
|
|
549
|
+
headers.push('x-dsh-relay: 1')
|
|
537
550
|
if (authCookie) headers.push(`Cookie: ${authCookie}`)
|
|
538
551
|
headers.push(`Content-Type: ${info.headers.get('x-relay-ct') ?? 'text/plain;charset=UTF-8'}`)
|
|
539
552
|
headers.push(`Content-Length: ${plain.length}`)
|
|
@@ -603,6 +616,8 @@ export class PumpManager {
|
|
|
603
616
|
hasHost = true
|
|
604
617
|
} else if (/^origin:/i.test(line)) {
|
|
605
618
|
rewritten.push(`Origin: ${target.origin}`)
|
|
619
|
+
} else if (/^x-dsh-relay:/i.test(line)) {
|
|
620
|
+
// Dropped: the pump adds its own marker below.
|
|
606
621
|
} else if (/^cookie:/i.test(line)) {
|
|
607
622
|
// The phone's relay ticket authenticates it to the pump, not to DSH;
|
|
608
623
|
// upstream speaks the browser-session credential instead.
|
|
@@ -621,6 +636,10 @@ export class PumpManager {
|
|
|
621
636
|
}
|
|
622
637
|
if (!hasHost) rewritten.unshift(`Host: ${target.authority}`)
|
|
623
638
|
if (!isUpgrade && !hasConnection) rewritten.push('Connection: close')
|
|
639
|
+
// Mark the request as relayed. The plugin's own console uses this to refuse
|
|
640
|
+
// phone-side access: it hands out pairing links, which embed the device PSK.
|
|
641
|
+
// Any inbound copy was dropped in the loop above, so a client cannot forge it.
|
|
642
|
+
rewritten.push('x-dsh-relay: 1')
|
|
624
643
|
if (authCookie) rewritten.push(`Cookie: ${authCookie}`)
|
|
625
644
|
const out = `${lines[0]}\r\n${rewritten.join('\r\n')}\r\n\r\n`
|
|
626
645
|
|
package/src/relay-client.js
CHANGED
|
@@ -12,6 +12,16 @@ import { auditLog } from './identity.js'
|
|
|
12
12
|
const CONNECT_TIMEOUT = 15000
|
|
13
13
|
const HEARTBEAT_INTERVAL = 20000
|
|
14
14
|
|
|
15
|
+
// Reasons a multi-tenant relay can reject a registration, in words the panel
|
|
16
|
+
// and the phone can show without translation.
|
|
17
|
+
const REJECT_TEXT = {
|
|
18
|
+
'quota-exceeded': '今日流量已用完,明天 0 点恢复',
|
|
19
|
+
'key-disabled': '这个 relay 密钥已被停用',
|
|
20
|
+
'unknown-key': 'relay 不认识这个密钥(可能已失效)',
|
|
21
|
+
'replaced': '同样的密钥在另一处接入,这一条被中继断开',
|
|
22
|
+
'bad key proof': '密钥不正确',
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export class RelayClient extends EventTarget {
|
|
16
26
|
constructor(options) {
|
|
17
27
|
super()
|
|
@@ -140,7 +150,15 @@ export class RelayClient extends EventTarget {
|
|
|
140
150
|
}, HEARTBEAT_INTERVAL)
|
|
141
151
|
ws.on('pong', () => { this.pongMisses = 0 })
|
|
142
152
|
|
|
143
|
-
|
|
153
|
+
// keyId lets a multi-tenant relay find the tenant without being told the
|
|
154
|
+
// key: it is the same hash both sides derive, so nothing new to configure.
|
|
155
|
+
ws.send(JSON.stringify({
|
|
156
|
+
t: 'hello',
|
|
157
|
+
v: 1,
|
|
158
|
+
id: this.identity.id,
|
|
159
|
+
gate: this.identity.gate,
|
|
160
|
+
keyId: crypto.createHash('sha256').update(String(this.relayKey)).digest('hex').slice(0, 12),
|
|
161
|
+
}))
|
|
144
162
|
})
|
|
145
163
|
|
|
146
164
|
ws.on('message', (data, isBinary) => {
|
|
@@ -155,11 +173,18 @@ export class RelayClient extends EventTarget {
|
|
|
155
173
|
}
|
|
156
174
|
if (msg.t === 'registered') {
|
|
157
175
|
this.backoff = 1000
|
|
176
|
+
this.backoffCap = 30000
|
|
158
177
|
this.emitStatus('connected', url)
|
|
159
178
|
return
|
|
160
179
|
}
|
|
161
180
|
if (msg.t === 'rejected') {
|
|
162
|
-
|
|
181
|
+
const reason = String(msg.reason ?? 'rejected by relay')
|
|
182
|
+
this.emitStatus('rejected', REJECT_TEXT[reason] ?? reason)
|
|
183
|
+
// Retrying fast cannot fix these: back off hard so a suspended or
|
|
184
|
+
// exhausted key does not hammer the relay all day.
|
|
185
|
+
if (reason === 'quota-exceeded') this.slowDown(5 * 60 * 1000)
|
|
186
|
+
else if (reason === 'key-disabled' || reason === 'unknown-key') this.slowDown(15 * 60 * 1000)
|
|
187
|
+
else if (reason === 'replaced') this.slowDown(60 * 1000)
|
|
163
188
|
return
|
|
164
189
|
}
|
|
165
190
|
if (msg.t === 'close' && typeof msg.sid === 'number') {
|
|
@@ -184,11 +209,18 @@ export class RelayClient extends EventTarget {
|
|
|
184
209
|
scheduleReconnect(detail) {
|
|
185
210
|
if (this.stopped) return
|
|
186
211
|
this.emitStatus('reconnecting', detail)
|
|
187
|
-
const
|
|
188
|
-
|
|
212
|
+
const cap = this.backoffCap ?? 30000
|
|
213
|
+
const delay = Math.min(cap, this.backoff) * (0.75 + Math.random() * 0.5)
|
|
214
|
+
this.backoff = Math.min(cap, this.backoff * 2)
|
|
189
215
|
this.reconnectTimer = setTimeout(() => this.connect(), delay)
|
|
190
216
|
}
|
|
191
217
|
|
|
218
|
+
/** Widen the retry window after a rejection that retrying cannot fix. */
|
|
219
|
+
slowDown(delayMs) {
|
|
220
|
+
this.backoffCap = delayMs
|
|
221
|
+
this.backoff = Math.max(this.backoff, delayMs)
|
|
222
|
+
}
|
|
223
|
+
|
|
192
224
|
// -- data -----------------------------------------------------------------
|
|
193
225
|
sendStream(sid, chunk) {
|
|
194
226
|
if (!this.connected()) return false
|