dsh-plugin-mobile-gateway 0.7.7 → 0.9.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/PROTOCOL.md +68 -12
- package/README.md +32 -22
- package/cordis.patch.yml +4 -0
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -0
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -0
- package/docs/multi-gateway-app-integration.md +2 -1
- package/lib/client.js +273 -18
- package/lib/cloudflare-tunnel.mjs +282 -0
- package/lib/cloudflared-binary.mjs +114 -0
- package/lib/dsh-host-adapter.mjs +47 -6
- package/lib/gateway-tools.mjs +36 -0
- package/lib/index.mjs +267 -47
- package/lib/plugin-management.mjs +194 -0
- package/lib/web-restart.mjs +47 -0
- package/package.json +3 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { ensureCloudflared } from './cloudflared-binary.mjs'
|
|
7
|
+
|
|
8
|
+
function hostnameOf(value) {
|
|
9
|
+
if (typeof value !== 'string') throw new TypeError('Cloudflare hostname is required')
|
|
10
|
+
const hostname = value.trim().toLowerCase()
|
|
11
|
+
if (hostname.length > 253 || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(hostname) ||
|
|
12
|
+
!hostname.includes('.') || hostname.includes('..') ||
|
|
13
|
+
hostname.split('.').some((label) => label.length > 63 || label.startsWith('-') || label.endsWith('-'))) {
|
|
14
|
+
throw new TypeError('Cloudflare hostname must be a domain name, for example gateway.example.com')
|
|
15
|
+
}
|
|
16
|
+
return hostname
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function tokenOf(value) {
|
|
20
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 8192 || /\s/.test(value)) {
|
|
21
|
+
throw new TypeError('Cloudflare Tunnel Token must be a nonempty token without whitespace')
|
|
22
|
+
}
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readState(file) {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
29
|
+
if (value?.version !== 1 || typeof value.enabled !== 'boolean' ||
|
|
30
|
+
typeof value.hostname !== 'string' || typeof value.token !== 'string') {
|
|
31
|
+
throw new TypeError('invalid Cloudflare Tunnel settings')
|
|
32
|
+
}
|
|
33
|
+
if (value.hostname) hostnameOf(value.hostname)
|
|
34
|
+
if (value.token) tokenOf(value.token)
|
|
35
|
+
value.mode ??= 'named'
|
|
36
|
+
if (!['named', 'quick'].includes(value.mode)) throw new TypeError('invalid Cloudflare Tunnel mode')
|
|
37
|
+
if (value.enabled && value.mode === 'named' && (!value.hostname || !value.token)) {
|
|
38
|
+
throw new TypeError('enabled named tunnel has no hostname or token')
|
|
39
|
+
}
|
|
40
|
+
fs.chmodSync(file, 0o600)
|
|
41
|
+
return value
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') return { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
|
|
44
|
+
throw new Error(`failed to load Cloudflare Tunnel settings ${file}: ${error.message}`, { cause: error })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeState(file, value) {
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
50
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' })
|
|
53
|
+
fs.renameSync(temporary, file)
|
|
54
|
+
fs.chmodSync(file, 0o600)
|
|
55
|
+
} finally {
|
|
56
|
+
fs.rmSync(temporary, { force: true })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A separate loopback listener exposes only the mobile WebSocket. In particular,
|
|
61
|
+
// Cloudflare can never reach the DSH WebUI or the /mgw management API through it.
|
|
62
|
+
export function createCloudflareTunnel({ file, port = 3082, wsPath, executable = 'cloudflared',
|
|
63
|
+
supported = true, isGatewayEnabled, onUpgrade, log = () => {}, spawnProcess = spawn,
|
|
64
|
+
prepareExecutable = ensureCloudflared }) {
|
|
65
|
+
let settings = supported ? readState(file) : { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
|
|
66
|
+
let server = null
|
|
67
|
+
let child = null
|
|
68
|
+
let preparing = null
|
|
69
|
+
let retryTimer = null
|
|
70
|
+
let disposed = false
|
|
71
|
+
let phase = 'disabled'
|
|
72
|
+
let error = null
|
|
73
|
+
let quickUrl = null
|
|
74
|
+
let queue = Promise.resolve()
|
|
75
|
+
const sockets = new Set()
|
|
76
|
+
|
|
77
|
+
const shouldRun = () => supported && !disposed && settings.enabled && isGatewayEnabled()
|
|
78
|
+
const publicUrl = () => settings.mode === 'quick'
|
|
79
|
+
? quickUrl
|
|
80
|
+
: settings.hostname ? `wss://${settings.hostname}${wsPath}` : null
|
|
81
|
+
const snapshot = () => ({
|
|
82
|
+
supported,
|
|
83
|
+
enabled: settings.enabled,
|
|
84
|
+
mode: settings.mode,
|
|
85
|
+
configured: !!(settings.hostname && settings.token),
|
|
86
|
+
hostname: settings.hostname || null,
|
|
87
|
+
publicUrl: settings.enabled ? publicUrl() : null,
|
|
88
|
+
port,
|
|
89
|
+
state: !settings.enabled ? 'disabled' : !isGatewayEnabled() ? 'waiting-for-gateway' : phase,
|
|
90
|
+
error,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const stop = async () => {
|
|
94
|
+
if (retryTimer) clearTimeout(retryTimer)
|
|
95
|
+
retryTimer = null
|
|
96
|
+
const previous = child
|
|
97
|
+
child = null
|
|
98
|
+
if (previous && !previous.killed) previous.kill()
|
|
99
|
+
quickUrl = null
|
|
100
|
+
for (const socket of sockets) socket.destroy()
|
|
101
|
+
sockets.clear()
|
|
102
|
+
const listener = server
|
|
103
|
+
server = null
|
|
104
|
+
phase = 'disabled'
|
|
105
|
+
error = null
|
|
106
|
+
if (listener) await new Promise((resolve) => listener.close(resolve))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const startChild = () => {
|
|
110
|
+
if (!shouldRun() || !server || child || preparing) return
|
|
111
|
+
phase = 'preparing'
|
|
112
|
+
error = null
|
|
113
|
+
preparing = prepareExecutable({ cacheDir: path.join(path.dirname(file), 'cloudflared-bin'), executable })
|
|
114
|
+
.then((binary) => {
|
|
115
|
+
preparing = null
|
|
116
|
+
if (!shouldRun() || !server || child) return
|
|
117
|
+
phase = 'connecting'
|
|
118
|
+
const quick = settings.mode === 'quick'
|
|
119
|
+
const args = quick
|
|
120
|
+
? ['tunnel', '--no-autoupdate', '--loglevel', 'info', '--url', `http://127.0.0.1:${port}`]
|
|
121
|
+
: ['tunnel', '--no-autoupdate', '--loglevel', 'info', 'run']
|
|
122
|
+
const childEnv = { ...process.env }
|
|
123
|
+
delete childEnv.TUNNEL_TOKEN
|
|
124
|
+
if (!quick) childEnv.TUNNEL_TOKEN = settings.token
|
|
125
|
+
const running = spawnProcess(binary, args, {
|
|
126
|
+
env: childEnv,
|
|
127
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
windowsHide: true,
|
|
129
|
+
})
|
|
130
|
+
child = running
|
|
131
|
+
let diagnostics = ''
|
|
132
|
+
let outputTail = ''
|
|
133
|
+
let registered = false
|
|
134
|
+
const onOutput = (chunk) => {
|
|
135
|
+
if (child !== running) return
|
|
136
|
+
const lines = chunk.toString().slice(0, 4096)
|
|
137
|
+
outputTail = (outputTail + lines).slice(-4096)
|
|
138
|
+
if (quick) {
|
|
139
|
+
const match = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com\b/i.exec(outputTail)
|
|
140
|
+
if (match) quickUrl = `wss://${match[1].toLowerCase()}.trycloudflare.com${wsPath}`
|
|
141
|
+
}
|
|
142
|
+
if (/Registered tunnel connection/i.test(outputTail)) {
|
|
143
|
+
registered = true
|
|
144
|
+
outputTail = ''
|
|
145
|
+
} else if (/ERR|error/i.test(lines)) {
|
|
146
|
+
diagnostics = (settings.token ? outputTail.replaceAll(settings.token, '[redacted]') : outputTail)
|
|
147
|
+
.replace(/eyJ[A-Za-z0-9._=-]+/g, '[redacted]').trim().slice(-500)
|
|
148
|
+
}
|
|
149
|
+
if (registered && (!quick || quickUrl)) {
|
|
150
|
+
phase = 'online'
|
|
151
|
+
error = null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
running.stderr?.on('data', onOutput)
|
|
155
|
+
running.stdout?.on('data', onOutput)
|
|
156
|
+
const exited = (reason) => {
|
|
157
|
+
if (child !== running) return
|
|
158
|
+
child = null
|
|
159
|
+
quickUrl = null
|
|
160
|
+
phase = 'error'
|
|
161
|
+
error = reason || diagnostics || 'cloudflared exited unexpectedly'
|
|
162
|
+
log(`Cloudflare Tunnel stopped: ${error}`)
|
|
163
|
+
if (shouldRun()) retryTimer = setTimeout(() => { retryTimer = null; startChild() }, 10_000)
|
|
164
|
+
}
|
|
165
|
+
running.once('error', (cause) => exited(cause.code === 'ENOENT'
|
|
166
|
+
? 'cloudflared is not installed or is not on PATH'
|
|
167
|
+
: `cloudflared failed: ${cause.message}`))
|
|
168
|
+
running.once('exit', (code, signal) => exited(diagnostics || `cloudflared exited (${signal || code})`))
|
|
169
|
+
})
|
|
170
|
+
.catch((cause) => {
|
|
171
|
+
preparing = null
|
|
172
|
+
if (!shouldRun()) return
|
|
173
|
+
phase = 'error'
|
|
174
|
+
error = `Cloudflare binary preparation failed: ${cause.message}`
|
|
175
|
+
log(error)
|
|
176
|
+
retryTimer = setTimeout(() => { retryTimer = null; startChild() }, 30_000)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const start = async () => {
|
|
181
|
+
if (server || !shouldRun()) return
|
|
182
|
+
phase = 'starting'
|
|
183
|
+
error = null
|
|
184
|
+
const listener = http.createServer((_req, res) => {
|
|
185
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' })
|
|
186
|
+
res.end('Not found')
|
|
187
|
+
})
|
|
188
|
+
listener.on('connection', (socket) => {
|
|
189
|
+
sockets.add(socket)
|
|
190
|
+
socket.on('close', () => sockets.delete(socket))
|
|
191
|
+
})
|
|
192
|
+
listener.on('upgrade', (req, socket, head) => {
|
|
193
|
+
let pathname
|
|
194
|
+
try { pathname = new URL(req.url || '/', 'http://localhost').pathname } catch { pathname = '' }
|
|
195
|
+
if (pathname !== wsPath) {
|
|
196
|
+
socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n')
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
onUpgrade(req, socket, head, { lan: true, port })
|
|
200
|
+
})
|
|
201
|
+
try {
|
|
202
|
+
await new Promise((resolve, reject) => {
|
|
203
|
+
listener.once('error', reject)
|
|
204
|
+
listener.listen(port, '127.0.0.1', () => {
|
|
205
|
+
listener.off('error', reject)
|
|
206
|
+
resolve()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
if (!shouldRun()) {
|
|
210
|
+
listener.close()
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
server = listener
|
|
214
|
+
listener.on('error', (cause) => {
|
|
215
|
+
const message = `Cloudflare origin listener failed: ${cause.message}`
|
|
216
|
+
void stop().then(() => {
|
|
217
|
+
if (!shouldRun()) return
|
|
218
|
+
phase = 'error'
|
|
219
|
+
error = message
|
|
220
|
+
log(message)
|
|
221
|
+
retryTimer = setTimeout(() => { retryTimer = null; void reconcile() }, 10_000)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
startChild()
|
|
225
|
+
} catch (cause) {
|
|
226
|
+
try { listener.close() } catch { /* The listener never opened. */ }
|
|
227
|
+
if (shouldRun()) {
|
|
228
|
+
phase = 'error'
|
|
229
|
+
error = `Cloudflare origin listener failed: ${cause.message}`
|
|
230
|
+
log(error)
|
|
231
|
+
retryTimer = setTimeout(() => { retryTimer = null; void reconcile() }, 10_000)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const reconcile = () => {
|
|
237
|
+
queue = queue.catch(() => {}).then(async () => {
|
|
238
|
+
if (shouldRun()) await start()
|
|
239
|
+
else await stop()
|
|
240
|
+
})
|
|
241
|
+
return queue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const configure = async ({ enabled, mode, hostname, token }) => {
|
|
245
|
+
if (!supported) throw new TypeError('Cloudflare Tunnel is available on PC only')
|
|
246
|
+
if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean')
|
|
247
|
+
const next = {
|
|
248
|
+
version: 1,
|
|
249
|
+
enabled,
|
|
250
|
+
mode: mode === undefined ? settings.mode : mode,
|
|
251
|
+
hostname: hostname === undefined ? settings.hostname : hostnameOf(hostname),
|
|
252
|
+
token: token === undefined || token === '' ? settings.token : tokenOf(token),
|
|
253
|
+
}
|
|
254
|
+
if (!['named', 'quick'].includes(next.mode)) throw new TypeError('mode must be named or quick')
|
|
255
|
+
if (enabled && next.mode === 'named' && (!next.hostname || !next.token)) {
|
|
256
|
+
throw new TypeError('hostname and Tunnel Token are required')
|
|
257
|
+
}
|
|
258
|
+
const changed = next.mode !== settings.mode || next.hostname !== settings.hostname || next.token !== settings.token
|
|
259
|
+
writeState(file, next)
|
|
260
|
+
settings = next
|
|
261
|
+
if (changed) await stop()
|
|
262
|
+
await reconcile()
|
|
263
|
+
return snapshot()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const restart = () => {
|
|
267
|
+
queue = queue.catch(() => {}).then(async () => {
|
|
268
|
+
if (!shouldRun()) {
|
|
269
|
+
const cause = new Error('请先开启移动网关和 Cloudflare Tunnel')
|
|
270
|
+
cause.status = 409
|
|
271
|
+
throw cause
|
|
272
|
+
}
|
|
273
|
+
await stop()
|
|
274
|
+
await start()
|
|
275
|
+
return snapshot()
|
|
276
|
+
})
|
|
277
|
+
return queue
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const dispose = () => { disposed = true; void stop() }
|
|
281
|
+
return { snapshot, configure, reconcile, restart, dispose }
|
|
282
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { createReadStream } from 'node:fs'
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile)
|
|
9
|
+
const CLOUDFLARED_VERSION = '2026.9.3'
|
|
10
|
+
const MAX_RELEASE_BYTES = 80 * 1024 * 1024
|
|
11
|
+
// GitHub's asset digests for the pinned Cloudflare release. The release API may
|
|
12
|
+
// return 403 even when the public release assets remain available.
|
|
13
|
+
const RELEASE_DIGESTS = Object.freeze({
|
|
14
|
+
'cloudflared-darwin-amd64.tgz': 'd1155d0837487f261183b15c1eab6c4ebcad9dc49b94675f1524c3564cea3977',
|
|
15
|
+
'cloudflared-darwin-arm64.tgz': '587c2cfb1c230fe36c7fa7727da78be459dae028cabe8c001291999350f07095',
|
|
16
|
+
'cloudflared-windows-386.exe': '9b95ddc2eba67b86ed3dc4cc2a15881960563031b52ce564376af41fb91ad402',
|
|
17
|
+
'cloudflared-windows-amd64.exe': 'f096265ec2fcbe9bb6e2d64268db167ced3fcbb83d894bdb9e2fcdb26f2ea7e2',
|
|
18
|
+
})
|
|
19
|
+
const BINARY_DIGESTS = Object.freeze({
|
|
20
|
+
'cloudflared-darwin-amd64.tgz': 'ab588b3b4db9cdb4476c30a3db2a72635b1d8327d44741fee6799a0f37b0ec07',
|
|
21
|
+
'cloudflared-darwin-arm64.tgz': '5472c1a01c84bc31b3021056a73b4e5774ddddefc572124ea8fdf6c340639f32',
|
|
22
|
+
'cloudflared-windows-386.exe': RELEASE_DIGESTS['cloudflared-windows-386.exe'],
|
|
23
|
+
'cloudflared-windows-amd64.exe': RELEASE_DIGESTS['cloudflared-windows-amd64.exe'],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export function cloudflaredAsset(platform = process.platform, arch = process.arch) {
|
|
27
|
+
if (platform === 'darwin' && (arch === 'arm64' || arch === 'x64')) {
|
|
28
|
+
return `cloudflared-darwin-${arch === 'x64' ? 'amd64' : 'arm64'}.tgz`
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'win32' && arch === 'x64') return 'cloudflared-windows-amd64.exe'
|
|
31
|
+
if (platform === 'win32' && arch === 'ia32') return 'cloudflared-windows-386.exe'
|
|
32
|
+
throw new Error(`unsupported Cloudflare PC architecture: ${platform}/${arch}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readBounded(response, limit) {
|
|
36
|
+
if (!response.ok) throw new Error(`Cloudflare binary download returned HTTP ${response.status} from GitHub Releases`)
|
|
37
|
+
const length = Number(response.headers.get('content-length'))
|
|
38
|
+
if (length > limit) throw new Error('Cloudflare binary download exceeds the size limit')
|
|
39
|
+
const chunks = []
|
|
40
|
+
let size = 0
|
|
41
|
+
for await (const chunk of response.body) {
|
|
42
|
+
size += chunk.length
|
|
43
|
+
if (size > limit) throw new Error('Cloudflare binary download exceeds the size limit')
|
|
44
|
+
chunks.push(Buffer.from(chunk))
|
|
45
|
+
}
|
|
46
|
+
return Buffer.concat(chunks, size)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function regularExecutable(file) {
|
|
50
|
+
try { return (await fs.lstat(file)).isFile() } catch { return false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function sha256File(file) {
|
|
54
|
+
const hash = crypto.createHash('sha256')
|
|
55
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk)
|
|
56
|
+
return hash.digest('hex')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The default executable stays in DSH's private cache. External binaries are
|
|
60
|
+
// used only when an explicit path is configured.
|
|
61
|
+
export async function ensureCloudflared({ cacheDir, executable = 'cloudflared',
|
|
62
|
+
platform = process.platform, arch = process.arch, fetchImpl = fetch,
|
|
63
|
+
releaseDigests = RELEASE_DIGESTS, binaryDigests = BINARY_DIGESTS } = {}) {
|
|
64
|
+
if (executable !== 'cloudflared') return executable
|
|
65
|
+
const assetName = cloudflaredAsset(platform, arch)
|
|
66
|
+
const destination = path.join(cacheDir, `${CLOUDFLARED_VERSION}-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`)
|
|
67
|
+
await fs.mkdir(cacheDir, { recursive: true, mode: 0o700 })
|
|
68
|
+
await fs.chmod(cacheDir, 0o700)
|
|
69
|
+
const expectedDigest = releaseDigests[assetName]
|
|
70
|
+
const expectedBinaryDigest = binaryDigests[assetName]
|
|
71
|
+
if (!/^[0-9a-f]{64}$/.test(expectedDigest || '') ||
|
|
72
|
+
!/^[0-9a-f]{64}$/.test(expectedBinaryDigest || '')) {
|
|
73
|
+
throw new Error('Cloudflare release has no verifiable binary for this PC')
|
|
74
|
+
}
|
|
75
|
+
if (await regularExecutable(destination)) {
|
|
76
|
+
if (await sha256File(destination) === expectedBinaryDigest) {
|
|
77
|
+
await fs.chmod(destination, 0o700)
|
|
78
|
+
return destination
|
|
79
|
+
}
|
|
80
|
+
await fs.rm(destination)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const expectedUrl = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${assetName}`
|
|
84
|
+
const response = await fetchImpl(expectedUrl, {
|
|
85
|
+
headers: { 'User-Agent': 'dsh-mobile-gateway' },
|
|
86
|
+
signal: AbortSignal.timeout(120_000),
|
|
87
|
+
})
|
|
88
|
+
const archive = await readBounded(response, MAX_RELEASE_BYTES)
|
|
89
|
+
const actual = crypto.createHash('sha256').update(archive).digest('hex')
|
|
90
|
+
if (actual !== expectedDigest) {
|
|
91
|
+
throw new Error('Cloudflare binary checksum mismatch')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stage = await fs.mkdtemp(path.join(cacheDir, '.cloudflared-'))
|
|
95
|
+
try {
|
|
96
|
+
let source
|
|
97
|
+
if (assetName.endsWith('.tgz')) {
|
|
98
|
+
const archiveFile = path.join(stage, assetName)
|
|
99
|
+
await fs.writeFile(archiveFile, archive, { mode: 0o600 })
|
|
100
|
+
await execFileAsync('tar', ['-xzf', archiveFile, '-C', stage], { timeout: 30_000 })
|
|
101
|
+
source = path.join(stage, 'cloudflared')
|
|
102
|
+
if (!await regularExecutable(source)) throw new Error('Cloudflare archive contains no cloudflared executable')
|
|
103
|
+
} else {
|
|
104
|
+
source = path.join(stage, 'cloudflared.exe')
|
|
105
|
+
await fs.writeFile(source, archive, { mode: 0o700 })
|
|
106
|
+
}
|
|
107
|
+
if (await sha256File(source) !== expectedBinaryDigest) throw new Error('Cloudflare executable checksum mismatch')
|
|
108
|
+
await fs.chmod(source, 0o700)
|
|
109
|
+
if (!await regularExecutable(destination)) await fs.rename(source, destination)
|
|
110
|
+
return destination
|
|
111
|
+
} finally {
|
|
112
|
+
await fs.rm(stage, { recursive: true, force: true })
|
|
113
|
+
}
|
|
114
|
+
}
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -13,13 +13,13 @@ function requireArray(value, endpoint) {
|
|
|
13
13
|
return value
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const DSH_VERSION = '0.
|
|
17
|
-
export const SESSION_FORMAT_VERSION =
|
|
16
|
+
export const DSH_VERSION = '0.2.0-rc.2'
|
|
17
|
+
export const SESSION_FORMAT_VERSION = 4
|
|
18
18
|
|
|
19
19
|
export function readHistoryRecords(records) {
|
|
20
20
|
return requireArray(records, 'session history').map(record => {
|
|
21
21
|
const value = requireRecord(record, 'session history record')
|
|
22
|
-
if (value.type !== 'event') throw new Error('DSH
|
|
22
|
+
if (value.type !== 'event') throw new Error('DSH Session V4 history requires event records')
|
|
23
23
|
const event = requireRecord(value.event, 'session history event')
|
|
24
24
|
if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
|
|
25
25
|
return event
|
|
@@ -31,7 +31,7 @@ export function readSessionSnapshot(frame, sessionId) {
|
|
|
31
31
|
if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
|
|
32
32
|
|| frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
|
|
33
33
|
if (frame.header.version !== SESSION_FORMAT_VERSION) {
|
|
34
|
-
throw Object.assign(new Error(
|
|
34
|
+
throw Object.assign(new Error(`mobile-gateway requires Session format ${SESSION_FORMAT_VERSION}; received ${frame.header.version}`), {
|
|
35
35
|
code: 'unsupported-session-format',
|
|
36
36
|
})
|
|
37
37
|
}
|
|
@@ -66,7 +66,7 @@ function requestArgs(request) {
|
|
|
66
66
|
return { request }
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// The
|
|
69
|
+
// The SessionController names its reserved list argument
|
|
70
70
|
// `_request`. Typert descriptors preserve that source parameter name exactly,
|
|
71
71
|
// so this endpoint cannot share the normal `{ request }` wrapper.
|
|
72
72
|
function sessionListArgs(request) {
|
|
@@ -160,7 +160,7 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
160
160
|
|
|
161
161
|
const commands = {
|
|
162
162
|
list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
|
|
163
|
-
// DSH
|
|
163
|
+
// DSH accepts `submittedAttachments`. The parsed
|
|
164
164
|
// wire images already carry the { type: 'image', ... } shape it expects, so
|
|
165
165
|
// only the field name changes.
|
|
166
166
|
execute: (sessionId, line, attachments, signal) => invoke(
|
|
@@ -247,12 +247,53 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
247
247
|
},
|
|
248
248
|
settings: {
|
|
249
249
|
describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
|
|
250
|
+
mutate: (payload, signal) => invoke('settings', 'mutate', {
|
|
251
|
+
ns: payload.ns,
|
|
252
|
+
ops: payload.ops,
|
|
253
|
+
expectedRevision: payload.expectedRevision,
|
|
254
|
+
}, signal),
|
|
250
255
|
update: (payload, signal) => invoke('settings', 'update', {
|
|
251
256
|
ns: payload.ns,
|
|
252
257
|
patch: payload.patch,
|
|
253
258
|
...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
|
|
254
259
|
}, signal),
|
|
255
260
|
},
|
|
261
|
+
plugins: {
|
|
262
|
+
inventory: (_payload = {}, signal) => invoke('pluginInventory', 'list', {}, signal),
|
|
263
|
+
listBundles: (signal) => invoke('pluginManager', 'listBundles', {}, signal),
|
|
264
|
+
listPlugins: (signal) => invoke('pluginManager', 'listPlugins', {}, signal),
|
|
265
|
+
registries: (signal) => invoke('pluginManager', 'registries', {}, signal),
|
|
266
|
+
inspect: (spec, options, signal) => invoke('pluginManager', 'inspect', {
|
|
267
|
+
spec, ...(options === undefined ? {} : { options }),
|
|
268
|
+
}, signal),
|
|
269
|
+
installBundle: (spec, options) => invoke('pluginManager', 'installBundle', { spec, options }),
|
|
270
|
+
waitForInstall: (requestId) => invoke('pluginManager', 'waitForInstall', { requestId }),
|
|
271
|
+
cancelInstall: (requestId) => invoke('pluginManager', 'cancelInstall', { requestId }),
|
|
272
|
+
setBundleEnabled: (name, enabled) => invoke('pluginManager', 'setBundleEnabled', { name, enabled }),
|
|
273
|
+
setPluginEnabled: (id, enabled) => invoke('pluginManager', 'setPluginEnabled', { id, enabled }),
|
|
274
|
+
removeBundle: (name) => invoke('pluginManager', 'removeBundle', { name }),
|
|
275
|
+
listVersionExemptions: () => invoke('pluginManager', 'listVersionExemptions', {}),
|
|
276
|
+
setVersionExemption: (packageVersion, runtimeVersion, enabled, acceptRisk) => invoke(
|
|
277
|
+
'pluginManager', 'setVersionExemption', {
|
|
278
|
+
packageVersion, runtimeVersion, enabled,
|
|
279
|
+
...(acceptRisk === undefined ? {} : { acceptRisk }),
|
|
280
|
+
},
|
|
281
|
+
),
|
|
282
|
+
},
|
|
283
|
+
permissionPresets: {
|
|
284
|
+
catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
|
|
285
|
+
},
|
|
286
|
+
schedule: {
|
|
287
|
+
async catalog(_payload = {}, signal) {
|
|
288
|
+
return { items: requireArray(await invoke('schedule', 'catalog', {}, signal), 'schedule/catalog') }
|
|
289
|
+
},
|
|
290
|
+
async list(payload, signal) {
|
|
291
|
+
return { items: requireArray(await invoke('schedule', 'list', requestArgs(payload), signal), 'schedule/list') }
|
|
292
|
+
},
|
|
293
|
+
history: (payload, signal) => invoke('schedule', 'history', requestArgs(payload), signal),
|
|
294
|
+
update: (payload, signal) => invoke('schedule', 'update', requestArgs(payload), signal),
|
|
295
|
+
delete: (payload, signal) => invoke('schedule', 'delete', requestArgs(payload), signal),
|
|
296
|
+
},
|
|
256
297
|
skills: {
|
|
257
298
|
list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
|
|
258
299
|
},
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
export function webRestartAvailable(profile, runtime = process) {
|
|
6
|
+
if (profile?.name !== 'web' || !runtime.argv?.[1]) return false
|
|
7
|
+
try {
|
|
8
|
+
return /[/\\]@deepseek-ai[/\\]dsh[/\\]lib[/\\]bin\.js$/.test(realpathSync(runtime.argv[1]))
|
|
9
|
+
} catch {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function prepareWebRestart(runtime = process, spawnChild = spawn) {
|
|
15
|
+
const argv = runtime.argv.slice(1)
|
|
16
|
+
// DSH Web opens a browser on every boot by default. This restart was
|
|
17
|
+
// initiated from an existing tab, so keep that tab and suppress the handoff.
|
|
18
|
+
const terminator = argv.indexOf('--')
|
|
19
|
+
const flagEnd = terminator < 0 ? argv.length : terminator
|
|
20
|
+
if (!argv.slice(0, flagEnd).includes('--no-open')) argv.splice(flagEnd, 0, '--no-open')
|
|
21
|
+
const payload = Buffer.from(JSON.stringify({
|
|
22
|
+
pid: runtime.pid,
|
|
23
|
+
execPath: runtime.execPath,
|
|
24
|
+
execArgv: runtime.execArgv,
|
|
25
|
+
argv,
|
|
26
|
+
cwd: runtime.cwd(),
|
|
27
|
+
})).toString('base64url')
|
|
28
|
+
const helper = spawnChild(runtime.execPath, [fileURLToPath(new URL('./web-restart.mjs', import.meta.url)), payload], {
|
|
29
|
+
cwd: runtime.cwd(), env: runtime.env, detached: true, stdio: 'ignore',
|
|
30
|
+
})
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
helper.once('spawn', resolve)
|
|
33
|
+
helper.once('error', reject)
|
|
34
|
+
})
|
|
35
|
+
helper.unref()
|
|
36
|
+
}
|