dsh-plugin-mobile-gateway 0.7.7 → 0.8.1
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 +38 -12
- package/README.md +30 -22
- package/cordis.patch.yml +4 -0
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -0
- package/docs/multi-gateway-app-integration.md +2 -1
- package/lib/client.js +254 -18
- package/lib/cloudflare-tunnel.mjs +268 -0
- package/lib/cloudflared-binary.mjs +114 -0
- package/lib/dsh-host-adapter.mjs +20 -6
- package/lib/gateway-tools.mjs +36 -0
- package/lib/index.mjs +215 -44
- package/lib/web-restart.mjs +47 -0
- package/package.json +3 -3
package/lib/client.js
CHANGED
|
@@ -49,6 +49,7 @@ window.__ModuleLoader__.load({
|
|
|
49
49
|
input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 10px', marginBottom: 10, background: 'transparent', color: 'inherit', outline: 'none' },
|
|
50
50
|
pairingText: { width: '100%', minHeight: 86, boxSizing: 'border-box', resize: 'vertical', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '9px 10px', margin: '10px 0', background: 'transparent', color: 'inherit', outline: 'none', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, lineHeight: 1.45, overflowWrap: 'anywhere' },
|
|
51
51
|
button: { cursor: 'pointer', border: `1px solid ${colors.border}`, background: 'transparent', borderRadius: 8, padding: '7px 11px', color: 'inherit', font: 'inherit' },
|
|
52
|
+
headerButton: { width: 34, height: 34, boxSizing: 'border-box', padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
|
|
52
53
|
primary: { background: colors.accent, color: '#fff', borderColor: colors.accent, width: '100%' },
|
|
53
54
|
muted: { color: colors.muted, fontSize: 12, lineHeight: 1.55 },
|
|
54
55
|
row: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 12, alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${colors.border}` },
|
|
@@ -56,6 +57,10 @@ window.__ModuleLoader__.load({
|
|
|
56
57
|
dot: { width: 7, height: 7, borderRadius: 99, display: 'inline-block' },
|
|
57
58
|
error: { color: '#ff8092', background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
|
|
58
59
|
qr: { display: 'block', width: 220, height: 220, margin: '12px auto', background: '#fff', borderRadius: 10, padding: 8 },
|
|
60
|
+
helpButton: { width: 20, height: 20, boxSizing: 'border-box', padding: 0, borderRadius: '50%', border: `1px solid ${colors.muted}`, background: 'transparent', color: colors.muted, cursor: 'help', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 12, fontWeight: 600, lineHeight: 1 },
|
|
61
|
+
helpBubble: { position: 'absolute', top: '100%', left: 0, zIndex: 20, width: 'min(300px, calc(100vw - 88px))', maxHeight: 'min(440px, calc(100vh - 160px))', overflowY: 'auto', boxSizing: 'border-box', padding: 12, borderRadius: 10, border: '1px solid rgba(255,255,255,.18)', background: '#20242c', color: '#f4f4f5', boxShadow: '0 12px 28px rgba(0,0,0,.35)', fontSize: 12, lineHeight: 1.5 },
|
|
62
|
+
toolsMenu: { position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 30, width: 250, maxWidth: 'calc(100vw - 48px)', border: `1px solid ${colors.border}`, borderRadius: 10, padding: 5, background: '#20242c', boxShadow: '0 12px 28px rgba(0,0,0,.35)' },
|
|
63
|
+
toolsItem: { display: 'block', width: '100%', padding: '9px 10px', textAlign: 'left', border: 0, borderRadius: 7, background: 'transparent', color: colors.text, font: 'inherit', cursor: 'pointer' },
|
|
59
64
|
switch: { position: 'relative', display: 'inline-flex', width: 42, height: 24, flexShrink: 0 },
|
|
60
65
|
switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
|
|
61
66
|
switchTrack: { position: 'absolute', inset: 0, borderRadius: 99, transition: 'background .18s ease', cursor: 'pointer' },
|
|
@@ -75,6 +80,42 @@ window.__ModuleLoader__.load({
|
|
|
75
80
|
return `${scheme}//${window.location.host}${wsPath || '/ws/mobile'}`
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
function lanPairingUrl(status) {
|
|
84
|
+
const urls = status?.lan?.listening && Array.isArray(status.lan.urls) ? status.lan.urls : []
|
|
85
|
+
return urls.find((url) => !/^ws:\/\/169\.254\./.test(url)) || urls[0] || ''
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pairingUrlFor(route, status) {
|
|
89
|
+
const lanUrl = lanPairingUrl(status)
|
|
90
|
+
const tunnelUrl = status.cloudflare?.enabled ? status.cloudflare.publicUrl || '' : ''
|
|
91
|
+
if (route === 'lan') return lanUrl
|
|
92
|
+
if (route === 'cloudflare') return tunnelUrl
|
|
93
|
+
if (status.cloudflare?.enabled && status.cloudflare.mode === 'quick') return tunnelUrl
|
|
94
|
+
return tunnelUrl || status.publicUrl || lanUrl || inferredUrl(status.wsPath)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function connectionAdvice(status, publicSetup) {
|
|
98
|
+
if (!status) return { detected: '正在读取网关环境…', local: '', remote: '' }
|
|
99
|
+
const system = status.platform === 'darwin' ? 'macOS 电脑' : status.platform === 'win32' ? 'Windows 电脑' : status.platform === 'linux' ? 'Linux 服务器' : '当前设备'
|
|
100
|
+
const lanReady = !!lanPairingUrl(status)
|
|
101
|
+
const tunnel = status.cloudflare
|
|
102
|
+
const tunnelReady = !!(tunnel?.enabled && tunnel.state === 'online' && tunnel.publicUrl)
|
|
103
|
+
const serverReady = status.platform === 'linux' && !!(
|
|
104
|
+
(publicSetup?.configured && publicSetup.publicUrl) || /^wss:\/\//.test(status.publicUrl || ''))
|
|
105
|
+
const detected = `当前检测:${system};局域网入口${lanReady ? '已就绪' : '未就绪'};${serverReady ? '公网服务器入口已配置' : tunnelReady ? `${tunnel.mode === 'quick' ? 'Quick' : '命名'} Tunnel 已连接` : tunnel?.enabled ? 'Cloudflare Tunnel 连接中' : '外网入口未连接'}。`
|
|
106
|
+
const local = lanReady
|
|
107
|
+
? '手机与 DSH 处于可互访的同一局域网时,建议选“局域网直连”。'
|
|
108
|
+
: '如果手机和 DSH 在同一局域网,先确认局域网入口已开启并监听成功。'
|
|
109
|
+
let remote
|
|
110
|
+
if (serverReady) remote = '手机在外网时,建议使用已配置的公网服务器地址(“自动选择”)。'
|
|
111
|
+
else if (tunnelReady && tunnel.mode === 'named') remote = '手机在外网时,建议选“Cloudflare 外网”;当前命名 Tunnel 有固定域名。'
|
|
112
|
+
else if (tunnelReady) remote = '手机在外网临时使用时,建议选“Cloudflare 外网”;Quick 地址变化后需要重新确认。'
|
|
113
|
+
else if (status.platform === 'darwin' || status.platform === 'win32') remote = '手机在外网时,无自有域名可开启 Quick Tunnel;有域名且需要固定地址可配置命名 Tunnel。'
|
|
114
|
+
else if (status.platform === 'linux') remote = '手机在外网时,若使用受支持的 Ubuntu/Debian 服务器且有固定公网 IPv4,可先配置“公网接入”,再选“自动选择”。'
|
|
115
|
+
else remote = '手机在外网时,需要一个可访问的 wss:// 地址;可在“手动输入地址”中填写。'
|
|
116
|
+
return { detected, local, remote }
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
function DeviceRow({ device, onRevoke, revoking }) {
|
|
79
120
|
const triggerRevoke = (event) => {
|
|
80
121
|
event.preventDefault()
|
|
@@ -116,9 +157,19 @@ window.__ModuleLoader__.load({
|
|
|
116
157
|
const [devices, setDevices] = React.useState(null)
|
|
117
158
|
const [status, setStatus] = React.useState(null)
|
|
118
159
|
const [publicUrl, setPublicUrl] = React.useState('')
|
|
160
|
+
const [pairingRoute, setPairingRoute] = React.useState('auto')
|
|
161
|
+
const [connectionHelpOpen, setConnectionHelpOpen] = React.useState(false)
|
|
162
|
+
const [toolsOpen, setToolsOpen] = React.useState(false)
|
|
163
|
+
const [toolBusy, setToolBusy] = React.useState(false)
|
|
164
|
+
const [toolNotice, setToolNotice] = React.useState('')
|
|
165
|
+
const stoppedRef = React.useRef(false)
|
|
119
166
|
const [publicIp, setPublicIp] = React.useState('')
|
|
120
167
|
const [publicSetup, setPublicSetup] = React.useState(null)
|
|
121
168
|
const [publicSetupBusy, setPublicSetupBusy] = React.useState(false)
|
|
169
|
+
const [cloudflareHost, setCloudflareHost] = React.useState('')
|
|
170
|
+
const [cloudflareToken, setCloudflareToken] = React.useState('')
|
|
171
|
+
const [cloudflareMode, setCloudflareMode] = React.useState('quick')
|
|
172
|
+
const [cloudflareBusy, setCloudflareBusy] = React.useState(false)
|
|
122
173
|
const [deviceName, setDeviceName] = React.useState('iPhone')
|
|
123
174
|
const [qr, setQr] = React.useState(null)
|
|
124
175
|
const [error, setError] = React.useState(null)
|
|
@@ -131,6 +182,8 @@ window.__ModuleLoader__.load({
|
|
|
131
182
|
const [revokingIds, setRevokingIds] = React.useState(() => new Set())
|
|
132
183
|
const revokingIdsRef = React.useRef(new Set())
|
|
133
184
|
const automaticUrlRef = React.useRef('')
|
|
185
|
+
const pairingRouteRef = React.useRef('auto')
|
|
186
|
+
const cloudflareModeLoadedRef = React.useRef(false)
|
|
134
187
|
|
|
135
188
|
const refresh = React.useCallback(async () => {
|
|
136
189
|
try {
|
|
@@ -146,15 +199,23 @@ window.__ModuleLoader__.load({
|
|
|
146
199
|
setDevices((current) => JSON.stringify(current) === JSON.stringify(nextDevices) ? current : nextDevices)
|
|
147
200
|
}
|
|
148
201
|
setStatus((current) => JSON.stringify(current) === JSON.stringify(statusData) ? current : statusData)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
202
|
+
if (statusData.cloudflare && statusData.cloudflare.hostname) {
|
|
203
|
+
setCloudflareHost((current) => current || statusData.cloudflare.hostname)
|
|
204
|
+
}
|
|
205
|
+
if (statusData.cloudflare && !cloudflareModeLoadedRef.current) {
|
|
206
|
+
cloudflareModeLoadedRef.current = true
|
|
207
|
+
setCloudflareMode(statusData.cloudflare.mode || 'quick')
|
|
208
|
+
}
|
|
209
|
+
if (pairingRouteRef.current === 'cloudflare' && !statusData.cloudflare?.enabled) {
|
|
210
|
+
pairingRouteRef.current = 'auto'
|
|
211
|
+
setPairingRoute('auto')
|
|
212
|
+
}
|
|
213
|
+
if (pairingRouteRef.current !== 'custom') {
|
|
214
|
+
const preferredUrl = pairingUrlFor(pairingRouteRef.current, statusData)
|
|
215
|
+
if (automaticUrlRef.current && preferredUrl !== automaticUrlRef.current) setQr(null)
|
|
216
|
+
automaticUrlRef.current = preferredUrl
|
|
217
|
+
setPublicUrl(preferredUrl)
|
|
218
|
+
}
|
|
158
219
|
return true
|
|
159
220
|
} catch (cause) {
|
|
160
221
|
setError(cause.message)
|
|
@@ -175,6 +236,43 @@ window.__ModuleLoader__.load({
|
|
|
175
236
|
}
|
|
176
237
|
}
|
|
177
238
|
|
|
239
|
+
const runTool = async (tool) => {
|
|
240
|
+
if (toolBusy) return
|
|
241
|
+
setToolsOpen(false)
|
|
242
|
+
setToolBusy(true)
|
|
243
|
+
setToolNotice('')
|
|
244
|
+
setError(null)
|
|
245
|
+
try {
|
|
246
|
+
const oldPid = status?.webPid
|
|
247
|
+
await request(`/mgw/tools/${tool}`, { method: 'POST' })
|
|
248
|
+
if (tool === 'stop-web') {
|
|
249
|
+
stoppedRef.current = true
|
|
250
|
+
setToolNotice('DSH Web 已收到停止指令。需要时可在 Terminal 运行 dsh web 重新启动。')
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
if (tool === 'restart-web') {
|
|
254
|
+
setToolNotice('正在重启 DSH Web,当前连接会短暂中断…')
|
|
255
|
+
const deadline = Date.now() + 60_000
|
|
256
|
+
while (Date.now() < deadline) {
|
|
257
|
+
await new Promise((resolve) => window.setTimeout(resolve, 700))
|
|
258
|
+
try {
|
|
259
|
+
const next = await request('/mgw/status')
|
|
260
|
+
if (next.webPid && next.webPid !== oldPid) {
|
|
261
|
+
window.location.reload()
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
} catch { /* old process is shutting down */ }
|
|
265
|
+
}
|
|
266
|
+
throw new Error('DSH Web 重启超时,请检查 /tmp/mobile-gateway.log')
|
|
267
|
+
}
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
setError(cause.message)
|
|
270
|
+
setToolNotice('')
|
|
271
|
+
} finally {
|
|
272
|
+
setToolBusy(false)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
178
276
|
React.useEffect(() => {
|
|
179
277
|
let active = true
|
|
180
278
|
refresh()
|
|
@@ -186,10 +284,25 @@ window.__ModuleLoader__.load({
|
|
|
186
284
|
if (match) setPublicIp(match[1])
|
|
187
285
|
}
|
|
188
286
|
}).catch((cause) => active && setPublicSetup({ installed: false, configured: false, error: cause.message }))
|
|
189
|
-
const timer = window.setInterval(refresh, 3000)
|
|
287
|
+
const timer = window.setInterval(() => { if (!stoppedRef.current) refresh() }, 3000)
|
|
190
288
|
return () => { active = false; window.clearInterval(timer) }
|
|
191
289
|
}, [refresh])
|
|
192
290
|
|
|
291
|
+
const choosePairingRoute = (route) => {
|
|
292
|
+
pairingRouteRef.current = route
|
|
293
|
+
setPairingRoute(route)
|
|
294
|
+
setQr(null)
|
|
295
|
+
if (route === 'custom') {
|
|
296
|
+
automaticUrlRef.current = ''
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
if (status) {
|
|
300
|
+
const nextUrl = pairingUrlFor(route, status)
|
|
301
|
+
automaticUrlRef.current = nextUrl
|
|
302
|
+
setPublicUrl(nextUrl)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
193
306
|
const configurePublicAccess = async () => {
|
|
194
307
|
if (publicSetupBusy) return
|
|
195
308
|
setPublicSetupBusy(true)
|
|
@@ -201,7 +314,7 @@ window.__ModuleLoader__.load({
|
|
|
201
314
|
body: JSON.stringify({ publicIp }),
|
|
202
315
|
})
|
|
203
316
|
setPublicSetup(data)
|
|
204
|
-
if (data.publicUrl) {
|
|
317
|
+
if (data.publicUrl && pairingRouteRef.current === 'auto') {
|
|
205
318
|
automaticUrlRef.current = data.publicUrl
|
|
206
319
|
setPublicUrl(data.publicUrl)
|
|
207
320
|
}
|
|
@@ -212,6 +325,33 @@ window.__ModuleLoader__.load({
|
|
|
212
325
|
}
|
|
213
326
|
}
|
|
214
327
|
|
|
328
|
+
const configureCloudflare = async (enabled) => {
|
|
329
|
+
if (cloudflareBusy) return
|
|
330
|
+
setCloudflareBusy(true)
|
|
331
|
+
setError(null)
|
|
332
|
+
try {
|
|
333
|
+
const data = await request('/mgw/cloudflare', {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'Content-Type': 'application/json' },
|
|
336
|
+
body: JSON.stringify({ enabled, ...(enabled ? {
|
|
337
|
+
mode: cloudflareMode,
|
|
338
|
+
...(cloudflareMode === 'named' ? { hostname: cloudflareHost, token: cloudflareToken } : {}),
|
|
339
|
+
} : {}) }),
|
|
340
|
+
})
|
|
341
|
+
setCloudflareToken('')
|
|
342
|
+
setStatus((current) => ({ ...(current || {}), cloudflare: data, gatewayEnabled: data.gatewayEnabled, gatewayMode: data.gatewayMode, publicUrl: data.publicUrl || null }))
|
|
343
|
+
if (!enabled && pairingRouteRef.current === 'cloudflare') {
|
|
344
|
+
pairingRouteRef.current = 'auto'
|
|
345
|
+
setPairingRoute('auto')
|
|
346
|
+
}
|
|
347
|
+
await refresh()
|
|
348
|
+
} catch (cause) {
|
|
349
|
+
setError(cause.message)
|
|
350
|
+
} finally {
|
|
351
|
+
setCloudflareBusy(false)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
215
355
|
const createPairing = async (endpoint) => request('/mgw/pair', {
|
|
216
356
|
method: 'POST',
|
|
217
357
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -312,13 +452,41 @@ window.__ModuleLoader__.load({
|
|
|
312
452
|
}
|
|
313
453
|
}
|
|
314
454
|
|
|
455
|
+
const advice = connectionAdvice(status, publicSetup)
|
|
456
|
+
const helpLine = (name, description) => React.createElement('div', { style: { marginTop: 7 } },
|
|
457
|
+
React.createElement('strong', null, name), ':', description)
|
|
458
|
+
|
|
315
459
|
return React.createElement('div', { style: styles.backdrop, onMouseDown: () => setOpen(false) },
|
|
316
|
-
React.createElement('aside', { style: styles.panel, onMouseDown: (event) => event.stopPropagation(), role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
|
|
460
|
+
React.createElement('aside', { style: styles.panel, onMouseDown: (event) => { event.stopPropagation(); setToolsOpen(false) }, role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
|
|
317
461
|
React.createElement('div', { style: styles.header },
|
|
318
462
|
React.createElement('div', null,
|
|
319
463
|
React.createElement('h2', { style: styles.title }, '移动设备'),
|
|
320
464
|
React.createElement('div', { style: styles.muted }, status && status.gatewayEnabled ? '移动网关已开启 · 设备鉴权生效中' : '移动网关已关闭 · 普通 WebUI 模式')),
|
|
321
|
-
React.createElement('
|
|
465
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
466
|
+
React.createElement('div', { style: { position: 'relative' }, onMouseDown: (event) => event.stopPropagation() },
|
|
467
|
+
React.createElement('button', {
|
|
468
|
+
type: 'button', style: { ...styles.button, ...styles.headerButton, borderRadius: '50%' }, title: '工具', 'aria-label': '工具',
|
|
469
|
+
'aria-haspopup': 'menu', 'aria-expanded': toolsOpen,
|
|
470
|
+
onClick: () => setToolsOpen((value) => !value),
|
|
471
|
+
}, React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
472
|
+
React.createElement('path', { d: 'M10.3 2.8h3.4l.5 2.2a7.6 7.6 0 0 1 1.7.7l1.9-1.2 2.4 2.4L19 8.8c.3.5.5 1.1.7 1.7l2.2.5v3.4l-2.2.5a7.6 7.6 0 0 1-.7 1.7l1.2 1.9-2.4 2.4-1.9-1.2a7.6 7.6 0 0 1-1.7.7l-.5 2.2h-3.4l-.5-2.2a7.6 7.6 0 0 1-1.7-.7l-1.9 1.2-2.4-2.4L5 16.6a7.6 7.6 0 0 1-.7-1.7l-2.2-.5V11l2.2-.5A7.6 7.6 0 0 1 5 8.8L3.8 6.9l2.4-2.4 1.9 1.2a7.6 7.6 0 0 1 1.7-.7l.5-2.2Z' }),
|
|
473
|
+
React.createElement('circle', { cx: 12, cy: 12.7, r: 3.1 }))),
|
|
474
|
+
toolsOpen ? React.createElement('div', { style: styles.toolsMenu, role: 'menu', 'aria-label': '网关工具' },
|
|
475
|
+
React.createElement('button', {
|
|
476
|
+
type: 'button', role: 'menuitem', disabled: toolBusy || stoppedRef.current || !status?.tools?.restartWeb,
|
|
477
|
+
style: { ...styles.toolsItem, opacity: status?.tools?.restartWeb ? 1 : .5 },
|
|
478
|
+
onClick: () => runTool('restart-web'),
|
|
479
|
+
}, '重启当前网关',
|
|
480
|
+
React.createElement('span', { style: { ...styles.muted, display: 'block', marginTop: 2 } }, '结束当前进程并重新启动 DSH Web')),
|
|
481
|
+
React.createElement('button', {
|
|
482
|
+
type: 'button', role: 'menuitem', disabled: toolBusy || stoppedRef.current || !status?.tools?.stopWeb,
|
|
483
|
+
style: { ...styles.toolsItem, color: colors.danger, opacity: status?.tools?.stopWeb ? 1 : .5 },
|
|
484
|
+
onClick: () => runTool('stop-web'),
|
|
485
|
+
}, '停止 DSH Web',
|
|
486
|
+
React.createElement('span', { style: { ...styles.muted, display: 'block', marginTop: 2 } }, '结束后台进程,所有连接将断开')))
|
|
487
|
+
: null),
|
|
488
|
+
React.createElement('button', { style: { ...styles.button, ...styles.headerButton }, onClick: () => setOpen(false), 'aria-label': '关闭' }, '✕'))),
|
|
489
|
+
toolNotice ? React.createElement('div', { style: { ...styles.muted, marginBottom: 10 } }, toolNotice) : null,
|
|
322
490
|
error ? React.createElement('div', { style: styles.error }, error) : null,
|
|
323
491
|
React.createElement('section', { style: styles.card },
|
|
324
492
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
@@ -373,7 +541,35 @@ window.__ModuleLoader__.load({
|
|
|
373
541
|
status && !status.requireAuth
|
|
374
542
|
? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
|
|
375
543
|
: null),
|
|
376
|
-
|
|
544
|
+
status && status.cloudflare && status.cloudflare.supported
|
|
545
|
+
? React.createElement('section', { style: styles.card },
|
|
546
|
+
React.createElement('strong', null, 'Cloudflare Tunnel · PC 外网接入'),
|
|
547
|
+
React.createElement('p', { style: styles.muted }, '首次开启时自动下载并校验 cloudflared;公网入口只接受已鉴权的移动 WebSocket。'),
|
|
548
|
+
React.createElement('label', { style: styles.label }, '接入方式'),
|
|
549
|
+
React.createElement('select', { style: styles.input, value: cloudflareMode, onChange: (event) => setCloudflareMode(event.target.value), disabled: cloudflareBusy },
|
|
550
|
+
React.createElement('option', { value: 'quick' }, 'Quick Tunnel · 无需域名'),
|
|
551
|
+
React.createElement('option', { value: 'named' }, '命名 Tunnel · 固定域名')),
|
|
552
|
+
cloudflareMode === 'quick'
|
|
553
|
+
? React.createElement('p', { style: styles.muted }, 'Cloudflare 会分配随机地址;DSH 重启或隧道重连后地址可能改变,手机需重新扫码确认新地址。适合临时使用。')
|
|
554
|
+
: React.createElement(React.Fragment, null,
|
|
555
|
+
React.createElement('p', { style: styles.muted }, '先在 Cloudflare 创建命名 Tunnel,把公开域名的 Service URL 设为 ', React.createElement('code', null, `http://127.0.0.1:${status.cloudflare.port}`), '。'),
|
|
556
|
+
React.createElement('label', { style: styles.label }, '公开域名'),
|
|
557
|
+
React.createElement('input', { style: styles.input, value: cloudflareHost, onChange: (event) => setCloudflareHost(event.target.value.trim()), placeholder: 'gateway.example.com', spellCheck: false }),
|
|
558
|
+
React.createElement('label', { style: styles.label }, 'Tunnel Token'),
|
|
559
|
+
React.createElement('input', { style: styles.input, type: 'password', value: cloudflareToken, onChange: (event) => setCloudflareToken(event.target.value.trim()), placeholder: status.cloudflare.configured ? '已保存;留空则保持原 Token' : '粘贴 eyJ… Token', autoComplete: 'off', spellCheck: false })),
|
|
560
|
+
React.createElement('button', {
|
|
561
|
+
style: { ...styles.button, ...styles.primary, opacity: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))) ? .65 : 1 },
|
|
562
|
+
disabled: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))),
|
|
563
|
+
onClick: () => configureCloudflare(true),
|
|
564
|
+
}, cloudflareBusy ? '正在处理…' : status.cloudflare.enabled && cloudflareMode === status.cloudflare.mode ? '更新 Tunnel 配置' : cloudflareMode === 'quick' ? '一键开启 Quick Tunnel' : '开启命名 Tunnel'),
|
|
565
|
+
status.cloudflare.enabled
|
|
566
|
+
? React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy, onClick: () => configureCloudflare(false) }, '关闭 Cloudflare Tunnel')
|
|
567
|
+
: null,
|
|
568
|
+
React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere', color: status.cloudflare.error ? colors.danger : colors.muted } },
|
|
569
|
+
status.cloudflare.error || ({ disabled: '未开启', starting: '正在启动本机入口…', preparing: '正在准备 cloudflared(首次需下载)…', connecting: '正在连接 Cloudflare…', online: '已连接 Cloudflare', 'waiting-for-gateway': '等待移动网关开启' }[status.cloudflare.state] || status.cloudflare.state),
|
|
570
|
+
status.cloudflare.publicUrl ? ` · ${status.cloudflare.publicUrl}` : ''))
|
|
571
|
+
: null,
|
|
572
|
+
status && status.platform === 'linux' ? React.createElement('section', { style: styles.card },
|
|
377
573
|
React.createElement('strong', null, '公网接入'),
|
|
378
574
|
publicSetup === null
|
|
379
575
|
? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
|
|
@@ -403,10 +599,46 @@ window.__ModuleLoader__.load({
|
|
|
403
599
|
: null)
|
|
404
600
|
: React.createElement(React.Fragment, null,
|
|
405
601
|
React.createElement('p', { style: { ...styles.muted, marginBottom: 8 } }, '尚未安装系统 Helper。请在服务器执行一次初始化:'),
|
|
406
|
-
React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))),
|
|
602
|
+
React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))) : null,
|
|
407
603
|
React.createElement('section', { style: styles.card },
|
|
604
|
+
React.createElement('div', { style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }, onMouseLeave: () => setConnectionHelpOpen(false) },
|
|
605
|
+
React.createElement('span', { style: { ...styles.label, marginBottom: 0 } }, '配对连接方式'),
|
|
606
|
+
React.createElement('button', {
|
|
607
|
+
type: 'button',
|
|
608
|
+
style: styles.helpButton,
|
|
609
|
+
'aria-label': '连接方式说明与场景建议',
|
|
610
|
+
'aria-expanded': connectionHelpOpen,
|
|
611
|
+
'aria-describedby': connectionHelpOpen ? 'mgw-route-help' : undefined,
|
|
612
|
+
onMouseEnter: () => setConnectionHelpOpen(true),
|
|
613
|
+
onFocus: () => setConnectionHelpOpen(true),
|
|
614
|
+
onBlur: () => setConnectionHelpOpen(false),
|
|
615
|
+
onClick: () => setConnectionHelpOpen(true),
|
|
616
|
+
onKeyDown: (event) => { if (event.key === 'Escape') setConnectionHelpOpen(false) },
|
|
617
|
+
}, '?'),
|
|
618
|
+
connectionHelpOpen ? React.createElement('div', { id: 'mgw-route-help', role: 'tooltip', style: styles.helpBubble },
|
|
619
|
+
React.createElement('strong', null, '怎么选连接方式'),
|
|
620
|
+
helpLine('局域网直连', '手机与 DSH 在同一个可互访的局域网,使用 ws:// 私有地址;离开该网络就无法直连。'),
|
|
621
|
+
helpLine('Cloudflare Channel(命名 Tunnel)', 'PC 主动连接 Cloudflare,外网用固定域名的 wss://;需要 Cloudflare 账户和域名。'),
|
|
622
|
+
helpLine('Quick Channel(Quick Tunnel)', '无需账户或域名,Cloudflare 分配临时 wss:// 地址;隧道重建后地址可能变化。'),
|
|
623
|
+
helpLine('公网服务器连接', 'DSH 运行在有固定公网 IPv4 的 Linux 服务器上,配置公网入口后用 wss:// 公网 IP 连接。'),
|
|
624
|
+
React.createElement('div', { style: { borderTop: '1px solid rgba(255,255,255,.18)', marginTop: 10, paddingTop: 8 } },
|
|
625
|
+
React.createElement('strong', null, '结合当前环境'),
|
|
626
|
+
React.createElement('div', { style: { color: '#c6cbd4', marginTop: 4 } }, advice.detected),
|
|
627
|
+
React.createElement('div', { style: { marginTop: 6 } }, advice.local),
|
|
628
|
+
React.createElement('div', { style: { marginTop: 6 } }, advice.remote),
|
|
629
|
+
React.createElement('div', { style: { color: '#aeb6c3', marginTop: 6 } }, '面板无法判断手机当前网络;请按手机所在位置选择,提示不会替你修改选择。')))
|
|
630
|
+
: null),
|
|
631
|
+
React.createElement('select', { style: styles.input, value: pairingRoute, onChange: (event) => choosePairingRoute(event.target.value), disabled: !status, 'aria-label': '配对连接方式' },
|
|
632
|
+
React.createElement('option', { value: 'auto' }, '自动选择 · 优先外网'),
|
|
633
|
+
lanPairingUrl(status) || pairingRoute === 'lan'
|
|
634
|
+
? React.createElement('option', { value: 'lan', disabled: !lanPairingUrl(status) }, lanPairingUrl(status)
|
|
635
|
+
? '局域网直连 · ' + lanPairingUrl(status).replace(/^wss?:\/\//, '').split('/')[0]
|
|
636
|
+
: '局域网直连 · 当前不可用')
|
|
637
|
+
: null,
|
|
638
|
+
status?.cloudflare?.enabled ? React.createElement('option', { value: 'cloudflare' }, 'Cloudflare 外网') : null,
|
|
639
|
+
React.createElement('option', { value: 'custom' }, '手动输入地址')),
|
|
408
640
|
React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
|
|
409
|
-
React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => {
|
|
641
|
+
React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => { choosePairingRoute('custom'); setPublicUrl(event.target.value) }, placeholder: 'ws://192.168.1.10:3081/ws/mobile', spellCheck: false }),
|
|
410
642
|
status && status.lan && status.lan.enabled
|
|
411
643
|
? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', color: status.lan.error ? colors.danger : colors.muted } },
|
|
412
644
|
status.lan.error
|
|
@@ -417,8 +649,12 @@ window.__ModuleLoader__.load({
|
|
|
417
649
|
: null,
|
|
418
650
|
React.createElement('label', { style: styles.label }, '设备名称'),
|
|
419
651
|
React.createElement('input', { style: styles.input, value: deviceName, onChange: (event) => setDeviceName(event.target.value), maxLength: 80, placeholder: 'iPhone' }),
|
|
420
|
-
React.createElement('button', {
|
|
421
|
-
|
|
652
|
+
React.createElement('button', {
|
|
653
|
+
style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) || !publicUrl.trim() ? .65 : 1 },
|
|
654
|
+
disabled: busy || !(status && status.gatewayEnabled) || !publicUrl.trim(),
|
|
655
|
+
onClick: pair,
|
|
656
|
+
}, busy ? '正在生成…' : !(status && status.gatewayEnabled) ? '请先开启移动网关' : !publicUrl.trim() ? '等待连接地址' : '生成配对二维码'),
|
|
657
|
+
React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '同一局域网选择“局域网直连”,无需关闭 Cloudflare Tunnel。局域网使用私有地址的 ws://;公网地址必须使用 wss://。配对码只能使用一次,并在 5 分钟内过期。')),
|
|
422
658
|
qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
|
|
423
659
|
React.createElement('strong', null, '使用 iOS 客户端扫码'),
|
|
424
660
|
React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
|
|
@@ -0,0 +1,268 @@
|
|
|
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 dispose = () => { disposed = true; void stop() }
|
|
267
|
+
return { snapshot, configure, reconcile, dispose }
|
|
268
|
+
}
|