dsh-remote-plugin 0.6.16 → 0.6.18
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/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +3 -2
- package/gateway.cjs +87 -8
- package/index.mjs +21 -3
- package/package.json +1 -1
- package/public/admin.html +11 -6
- package/public/admin.js +88 -14
- package/public/app.js +430 -86
- package/public/desktop/desktop.css +5 -0
- package/public/desktop/desktop.html +20 -15
- package/public/desktop/desktop.js +281 -52
- package/public/index.html +58 -19
- package/public/styles.css +87 -19
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway-stats.cjs
CHANGED
|
@@ -186,10 +186,11 @@ function tokenKeyName(key) {
|
|
|
186
186
|
|
|
187
187
|
/** 统计存储: 单文件按天 + 游标。写操作由网关单进程调用, 内部用同步队列串行化。 */
|
|
188
188
|
class StatsStore {
|
|
189
|
-
constructor(dir) {
|
|
189
|
+
constructor(dir, options = {}) {
|
|
190
190
|
this.dir = dir || path.join(os.homedir(), '.dsh-remote', 'stats')
|
|
191
191
|
this.daysDir = path.join(this.dir, DAYS_DIR)
|
|
192
192
|
this.cursorsFile = path.join(this.dir, CURSORS_FILE)
|
|
193
|
+
this.spawn = typeof options.spawn === 'function' ? options.spawn : spawn
|
|
193
194
|
this.cursors = null
|
|
194
195
|
this.queue = Promise.resolve()
|
|
195
196
|
fs.mkdirSync(this.daysDir, { recursive: true })
|
|
@@ -313,7 +314,7 @@ class StatsStore {
|
|
|
313
314
|
if (lastSeq >= 0) this._setCursor(sessionId, lastSeq)
|
|
314
315
|
}
|
|
315
316
|
|
|
316
|
-
const zstd = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
317
|
+
const zstd = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
317
318
|
const rl = readline.createInterface({ input: zstd.stdout })
|
|
318
319
|
|
|
319
320
|
zstd.on('error', (err) => {
|
package/gateway.cjs
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* DSH_REMOTE_FS_ROOT 文件传输额外允许根, 默认 ~, 使用系统路径分隔符配置多根
|
|
23
23
|
* DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
|
|
24
24
|
* DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
|
|
25
|
+
* DSH_REMOTE_ADVERTISE_HOSTS 额外写入配对二维码的宿主 IP/主机名, 逗号或空白分隔
|
|
26
|
+
* DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/disabled
|
|
25
27
|
*/
|
|
26
28
|
'use strict'
|
|
27
29
|
|
|
@@ -84,6 +86,8 @@ const STARTED_AT = Date.now()
|
|
|
84
86
|
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
85
87
|
const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
|
|
86
88
|
const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
|
|
89
|
+
const DSH_CONTROL_MODE_RAW = String(process.env.DSH_REMOTE_DSH_CONTROL_MODE || 'auto').trim().toLowerCase()
|
|
90
|
+
const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
|
|
87
91
|
const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
|
|
88
92
|
const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
|
|
89
93
|
const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
|
|
@@ -104,6 +108,52 @@ function gatewayVersion() {
|
|
|
104
108
|
return '0.0.0'
|
|
105
109
|
}
|
|
106
110
|
}
|
|
111
|
+
|
|
112
|
+
function validAdvertisedHost(value) {
|
|
113
|
+
const host = String(value || '').trim()
|
|
114
|
+
if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
|
|
115
|
+
if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
|
|
116
|
+
if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
|
|
117
|
+
const labels = host.split('.')
|
|
118
|
+
if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
|
|
119
|
+
return host
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function configuredAdvertisedHosts() {
|
|
123
|
+
return [...new Set(String(process.env.DSH_REMOTE_ADVERTISE_HOSTS || '')
|
|
124
|
+
.split(/[\s,]+/)
|
|
125
|
+
.map(validAdvertisedHost)
|
|
126
|
+
.filter(Boolean))]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function containerRuntimeDetected() {
|
|
130
|
+
if (fs.existsSync('/.dockerenv')) return true
|
|
131
|
+
try { return /(?:docker|containerd|kubepods|podman|lxc)/i.test(fs.readFileSync('/proc/1/cgroup', 'utf8')) } catch { return false }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dshControlSupport() {
|
|
135
|
+
if (DSH_CONTROL_MODE === 'disabled') {
|
|
136
|
+
return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前 DSH 由 Docker、面板或其他外部平台管理' }
|
|
137
|
+
}
|
|
138
|
+
if (process.platform === 'win32') {
|
|
139
|
+
if (DSH_CONTROL_MODE === 'systemd') return { supported: false, code: 'CONTROL_MODE_MISMATCH', message: 'Windows 环境不能使用 systemd 控制 DSH' }
|
|
140
|
+
return { supported: true, manager: 'windows' }
|
|
141
|
+
}
|
|
142
|
+
if (DSH_CONTROL_MODE === 'windows') return { supported: false, code: 'CONTROL_MODE_MISMATCH', message: '当前系统不能使用 Windows Service 控制 DSH' }
|
|
143
|
+
if (DSH_CONTROL_MODE === 'systemd' || (process.env.DSH_REMOTE_SYSTEMCTL && SYSTEMCTL !== 'systemctl')) {
|
|
144
|
+
return { supported: true, manager: 'systemd' }
|
|
145
|
+
}
|
|
146
|
+
if (containerRuntimeDetected()) {
|
|
147
|
+
return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前网关运行在容器中,DSH 生命周期应由 Docker、面板或其他外部平台管理' }
|
|
148
|
+
}
|
|
149
|
+
if (!fs.existsSync('/run/systemd/system')) {
|
|
150
|
+
return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前环境没有 systemd,DSH 可能由 Docker、面板或其他外部平台管理' }
|
|
151
|
+
}
|
|
152
|
+
return { supported: true, manager: 'systemd' }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ADVERTISED_HOSTS = configuredAdvertisedHosts()
|
|
156
|
+
const DSH_CONTROL_SUPPORT = dshControlSupport()
|
|
107
157
|
const VERSION = gatewayVersion()
|
|
108
158
|
const PROTOCOL_VERSION = 1
|
|
109
159
|
const CAPABILITIES = Object.freeze({
|
|
@@ -111,7 +161,7 @@ const CAPABILITIES = Object.freeze({
|
|
|
111
161
|
eventPolling: 1,
|
|
112
162
|
workspaceFiles: 2,
|
|
113
163
|
imagePromptTransport: 1,
|
|
114
|
-
dshLifecycle: 2,
|
|
164
|
+
dshLifecycle: DSH_CONTROL_SUPPORT.supported ? 2 : 0,
|
|
115
165
|
centralAnnouncements: 2,
|
|
116
166
|
feedback: 1,
|
|
117
167
|
deviceKeys: 1,
|
|
@@ -141,10 +191,16 @@ const MIME = {
|
|
|
141
191
|
// 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
|
|
142
192
|
// 复核一次, 防止 ../ 穿越与符号链接逃逸。
|
|
143
193
|
const FS_DEFAULT_ROOT = path.resolve(os.homedir())
|
|
194
|
+
function fsConfiguredRoot(value) {
|
|
195
|
+
const raw = String(value || '').trim()
|
|
196
|
+
if (!raw || raw === '~') return raw === '~' ? FS_DEFAULT_ROOT : ''
|
|
197
|
+
if (/^~[\\/]/.test(raw)) return path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
|
|
198
|
+
return path.resolve(raw)
|
|
199
|
+
}
|
|
144
200
|
const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
145
201
|
.split(path.delimiter)
|
|
202
|
+
.map(fsConfiguredRoot)
|
|
146
203
|
.filter(Boolean)
|
|
147
|
-
.map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
|
|
148
204
|
const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
|
|
149
205
|
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
150
206
|
const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
|
|
@@ -159,7 +215,7 @@ function fsRootReals() {
|
|
|
159
215
|
}
|
|
160
216
|
function fsInsideReal(real) {
|
|
161
217
|
for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
|
|
162
|
-
if (real
|
|
218
|
+
if (fsInsideRoot(real, root)) return true
|
|
163
219
|
}
|
|
164
220
|
return false
|
|
165
221
|
}
|
|
@@ -925,6 +981,9 @@ async function dshServiceStatus() {
|
|
|
925
981
|
if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
926
982
|
return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
|
|
927
983
|
}
|
|
984
|
+
if (!DSH_CONTROL_SUPPORT.supported) {
|
|
985
|
+
return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...DSH_CONTROL_SUPPORT }
|
|
986
|
+
}
|
|
928
987
|
if (process.platform === 'win32') return windowsServiceStatus()
|
|
929
988
|
const r = await execFileResult(SYSTEMCTL, [
|
|
930
989
|
'--user', 'show', DSH_SERVICE,
|
|
@@ -2026,6 +2085,7 @@ function serveAdminApi(req, res, url) {
|
|
|
2026
2085
|
port: PORT,
|
|
2027
2086
|
protocol: { version: PROTOCOL_VERSION },
|
|
2028
2087
|
capabilities: CAPABILITIES,
|
|
2088
|
+
dshControl: DSH_CONTROL_SUPPORT,
|
|
2029
2089
|
upstream: { url: UPSTREAM.origin, reachable },
|
|
2030
2090
|
latest: {
|
|
2031
2091
|
version: latestState.version,
|
|
@@ -2243,8 +2303,19 @@ function fsAuthorized(req, url, res) {
|
|
|
2243
2303
|
return true
|
|
2244
2304
|
}
|
|
2245
2305
|
|
|
2306
|
+
function fsInsideRootFor(pathApi, abs, root, caseInsensitive = false) {
|
|
2307
|
+
let candidate = pathApi.resolve(String(abs || ''))
|
|
2308
|
+
let boundary = pathApi.resolve(String(root || ''))
|
|
2309
|
+
if (caseInsensitive) {
|
|
2310
|
+
candidate = candidate.toLowerCase()
|
|
2311
|
+
boundary = boundary.toLowerCase()
|
|
2312
|
+
}
|
|
2313
|
+
const relative = pathApi.relative(boundary, candidate)
|
|
2314
|
+
return relative === '' || (relative !== '..' && !relative.startsWith('..' + pathApi.sep) && !pathApi.isAbsolute(relative))
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2246
2317
|
function fsInsideRoot(abs, root) {
|
|
2247
|
-
return abs
|
|
2318
|
+
return fsInsideRootFor(path, abs, root, process.platform === 'win32')
|
|
2248
2319
|
}
|
|
2249
2320
|
|
|
2250
2321
|
function fsWorkspacePath(value) {
|
|
@@ -2289,7 +2360,7 @@ async function fsResolve(input) {
|
|
|
2289
2360
|
const raw = String(input ?? '').trim()
|
|
2290
2361
|
let abs
|
|
2291
2362
|
if (!raw || raw === '~') abs = FS_ROOTS[0]
|
|
2292
|
-
else if (raw
|
|
2363
|
+
else if (/^~[\\/]/.test(raw)) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
|
|
2293
2364
|
else if (path.isAbsolute(raw)) abs = path.resolve(raw)
|
|
2294
2365
|
else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
|
|
2295
2366
|
if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
|
|
@@ -2386,6 +2457,7 @@ async function fsList(req, res, url) {
|
|
|
2386
2457
|
if (!info.isFile() && !info.isDirectory()) continue
|
|
2387
2458
|
entries.push({
|
|
2388
2459
|
name: d.name,
|
|
2460
|
+
path: full,
|
|
2389
2461
|
type: info.isDirectory() ? 'dir' : 'file',
|
|
2390
2462
|
size: info.isDirectory() ? 0 : info.size,
|
|
2391
2463
|
mtimeMs: Math.round(info.mtimeMs)
|
|
@@ -2398,7 +2470,13 @@ async function fsList(req, res, url) {
|
|
|
2398
2470
|
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
2399
2471
|
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true })
|
|
2400
2472
|
})
|
|
2401
|
-
fsJson(res, 200, {
|
|
2473
|
+
fsJson(res, 200, {
|
|
2474
|
+
path: resolved.abs,
|
|
2475
|
+
entries,
|
|
2476
|
+
roots: FS_ROOTS,
|
|
2477
|
+
platform: process.platform,
|
|
2478
|
+
separator: path.sep,
|
|
2479
|
+
})
|
|
2402
2480
|
}
|
|
2403
2481
|
|
|
2404
2482
|
async function fsFile(req, res, url) {
|
|
@@ -3309,6 +3387,7 @@ async function serveHealth(req, res, url) {
|
|
|
3309
3387
|
version: VERSION,
|
|
3310
3388
|
protocol: { version: PROTOCOL_VERSION },
|
|
3311
3389
|
capabilities: CAPABILITIES,
|
|
3390
|
+
dshControl: DSH_CONTROL_SUPPORT,
|
|
3312
3391
|
pid: process.pid,
|
|
3313
3392
|
upstream: UPSTREAM.origin,
|
|
3314
3393
|
upstreamProbe: DSH_HEALTH_PATH,
|
|
@@ -3322,12 +3401,12 @@ async function serveHealth(req, res, url) {
|
|
|
3322
3401
|
}
|
|
3323
3402
|
|
|
3324
3403
|
function lanAddresses() {
|
|
3325
|
-
const out = []
|
|
3404
|
+
const out = [...ADVERTISED_HOSTS]
|
|
3326
3405
|
let groups
|
|
3327
3406
|
try { groups = Object.values(os.networkInterfaces()) } catch { return out }
|
|
3328
3407
|
for (const infos of groups) {
|
|
3329
3408
|
for (const info of infos || []) {
|
|
3330
|
-
if (info.family === 'IPv4' && !info.internal) out.push(info.address)
|
|
3409
|
+
if (info.family === 'IPv4' && !info.internal && !out.includes(info.address)) out.push(info.address)
|
|
3331
3410
|
}
|
|
3332
3411
|
}
|
|
3333
3412
|
return out
|
package/index.mjs
CHANGED
|
@@ -70,17 +70,34 @@ try {
|
|
|
70
70
|
let dshListen = { host: '127.0.0.1', port: 3080 }
|
|
71
71
|
|
|
72
72
|
function lanIPs() {
|
|
73
|
-
const out = []
|
|
73
|
+
const out = [...configuredAdvertisedHosts()]
|
|
74
74
|
let groups
|
|
75
75
|
try { groups = Object.values(networkInterfaces()) } catch { return out }
|
|
76
76
|
for (const list of groups) {
|
|
77
77
|
for (const it of list ?? []) {
|
|
78
|
-
if (it.family === 'IPv4' && !it.internal) out.push(it.address)
|
|
78
|
+
if (it.family === 'IPv4' && !it.internal && !out.includes(it.address)) out.push(it.address)
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
return out
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
function validAdvertisedHost(value) {
|
|
85
|
+
const host = String(value || '').trim()
|
|
86
|
+
if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
|
|
87
|
+
if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
|
|
88
|
+
if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
|
|
89
|
+
const labels = host.split('.')
|
|
90
|
+
if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
|
|
91
|
+
return host
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function configuredAdvertisedHosts() {
|
|
95
|
+
return [...new Set(String(process.env.DSH_REMOTE_ADVERTISE_HOSTS || '')
|
|
96
|
+
.split(/[\s,]+/)
|
|
97
|
+
.map(validAdvertisedHost)
|
|
98
|
+
.filter(Boolean))]
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
function targetPath(pathname) {
|
|
85
102
|
let rel
|
|
86
103
|
try {
|
|
@@ -152,7 +169,7 @@ function runExit(cmd, args) {
|
|
|
152
169
|
|
|
153
170
|
const GATEWAY_ENV_KEYS = [
|
|
154
171
|
'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
|
|
155
|
-
'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL',
|
|
172
|
+
'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
|
|
156
173
|
'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
|
|
157
174
|
'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
|
|
158
175
|
'GATEWAY_WS_IDLE_MS', 'GATEWAY_WS_PING_MS', 'GATEWAY_WS_PONG_TIMEOUT_MS',
|
|
@@ -552,6 +569,7 @@ async function serveStatic(req, res, ctx) {
|
|
|
552
569
|
feedback: 0,
|
|
553
570
|
deviceKeys: 0,
|
|
554
571
|
},
|
|
572
|
+
dshControl: { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '插件内嵌模式不负责管理 DSH 自身进程' },
|
|
555
573
|
deviceKeys: { supported: false, enabled: false, entries: [] },
|
|
556
574
|
upstream: { url: 'DSH 内嵌(同进程, 无需网关)', reachable: true },
|
|
557
575
|
latest: { version, newer: false },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/admin.html
CHANGED
|
@@ -113,6 +113,8 @@
|
|
|
113
113
|
.host-ip-title { font-size: 16px; font-weight: 750; }
|
|
114
114
|
.host-ip-head .muted { margin-top: 4px; font-size: 12px; }
|
|
115
115
|
.host-ip-badge { flex: none; padding: 5px 9px; border: 1px solid var(--dsr-accent-line); border-radius: 999px; color: var(--dsr-accent-strong); background: var(--dsr-accent-soft); font-size: 11px; font-weight: 700; }
|
|
116
|
+
.host-ip-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
|
117
|
+
.host-ip-remove { margin-left: 8px; padding: 3px 7px; }
|
|
116
118
|
.host-ip-desc { margin: 12px 0; color: var(--dsr-muted); font-size: 12px; line-height: 1.55; }
|
|
117
119
|
.host-ip-table-wrap { overflow: hidden; border: 1px solid var(--dsr-line); border-radius: 13px; }
|
|
118
120
|
.host-ip-table { width: 100%; border-collapse: collapse; }
|
|
@@ -465,7 +467,10 @@
|
|
|
465
467
|
<div class="host-ip-title" data-i18n="hostIPs.title">主机 IP 地址</div>
|
|
466
468
|
<div id="host-ip-summary" class="muted" data-i18n="hostIPs.loading">正在读取主机地址…</div>
|
|
467
469
|
</div>
|
|
468
|
-
<div class="host-ip-
|
|
470
|
+
<div class="host-ip-head-actions">
|
|
471
|
+
<button id="btn-host-ip-add" class="mini-btn" type="button" data-i18n="hostIPs.add">添加宿主地址</button>
|
|
472
|
+
<div class="host-ip-badge" data-i18n="hostIPs.badge">手机连接候选</div>
|
|
473
|
+
</div>
|
|
469
474
|
</div>
|
|
470
475
|
<div class="host-ip-desc" data-i18n="hostIPs.desc">关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。</div>
|
|
471
476
|
<div class="host-ip-table-wrap">
|
|
@@ -473,7 +478,7 @@
|
|
|
473
478
|
<thead><tr><th data-i18n="hostIPs.enabledColumn">启用</th><th data-i18n="hostIPs.addressColumn">主机 IP</th><th data-i18n="hostIPs.statusColumn">状态</th></tr></thead>
|
|
474
479
|
<tbody id="host-ip-rows"></tbody>
|
|
475
480
|
</table>
|
|
476
|
-
<div id="host-ip-empty" class="host-ip-empty hidden" data-i18n="hostIPs.empty"
|
|
481
|
+
<div id="host-ip-empty" class="host-ip-empty hidden" data-i18n="hostIPs.empty">暂无可用的非回环地址</div>
|
|
477
482
|
</div>
|
|
478
483
|
</section>
|
|
479
484
|
|
|
@@ -601,13 +606,13 @@
|
|
|
601
606
|
'badge.embedded': '内嵌', 'badge.gateway': '网关', 'badge.connected': '已连接',
|
|
602
607
|
'badge.gateway.title': '新标签页打开网关管理面板', 'badge.gatewayDown': '网关未运行',
|
|
603
608
|
'token.pluginNoGateway': '插件模式 · 未接网关, 无需令牌', 'token.unavailable': '未获取到令牌',
|
|
604
|
-
'toast.tokenInvalid': '令牌无效', 'toast.connFailed': '连接失败',
|
|
609
|
+
'toast.tokenInvalid': '令牌无效', 'toast.connFailed': '连接失败', 'toast.authLayer': '登录代理拦截了管理 API,请检查 /remote/* 的认证规则',
|
|
605
610
|
'stat.pluginVersion': '插件版本', 'stat.gatewayVersion': '网关版本',
|
|
606
611
|
'stat.updateAvailable': '{version} 可用', 'stat.currentV': '当前 v{version}',
|
|
607
612
|
'stat.download': '去下载', 'stat.embedded': 'DSH 内嵌 · 免网关',
|
|
608
613
|
'stat.updateCheck': '更新检查: {error}', 'stat.latest': '已是最新(来源检查)', 'stat.notChecked': '未检查更新',
|
|
609
614
|
'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连 {port} 网关)', 'stat.phoneThis': ' (手机连这个地址)',
|
|
610
|
-
'hostIPs.title': '主机 IP 地址', 'hostIPs.badge': '手机连接候选', 'hostIPs.loading': '正在读取主机地址…', 'hostIPs.summary': '已启用 {enabled}/{total} 个地址', 'hostIPs.desc': '关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。', 'hostIPs.enabledColumn': '启用', 'hostIPs.addressColumn': '主机 IP', 'hostIPs.statusColumn': '状态', 'hostIPs.enabled': '已启用 · 会用于配对', 'hostIPs.disabled': '已关闭 · 不用于配对', 'hostIPs.enable': '切换此地址', 'hostIPs.empty': '
|
|
615
|
+
'hostIPs.title': '主机 IP 地址', 'hostIPs.badge': '手机连接候选', 'hostIPs.loading': '正在读取主机地址…', 'hostIPs.summary': '已启用 {enabled}/{total} 个地址', 'hostIPs.desc': '关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。Docker 中可手动添加宿主地址。', 'hostIPs.enabledColumn': '启用', 'hostIPs.addressColumn': '主机 IP', 'hostIPs.statusColumn': '状态', 'hostIPs.enabled': '已启用 · 会用于配对', 'hostIPs.disabled': '已关闭 · 不用于配对', 'hostIPs.enable': '切换此地址', 'hostIPs.empty': '暂无可用的非回环地址', 'hostIPs.keepOne': '至少保留一个可用地址', 'hostIPs.add': '添加宿主地址', 'hostIPs.addPrompt': '输入手机可访问的宿主 IP 或主机名:', 'hostIPs.invalid': '地址格式无效', 'hostIPs.exists': '该地址已在列表中', 'hostIPs.remove': '删除',
|
|
611
616
|
'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
|
|
612
617
|
'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
|
|
613
618
|
'stat.uptime': '运行时长 · {host}:{port}',
|
|
@@ -711,13 +716,13 @@
|
|
|
711
716
|
'badge.embedded': 'Embedded', 'badge.gateway': 'Gateway', 'badge.connected': 'Connected',
|
|
712
717
|
'badge.gateway.title': 'Open gateway admin in a new tab', 'badge.gatewayDown': 'Gateway not running',
|
|
713
718
|
'token.pluginNoGateway': 'Plugin mode · no gateway, no token needed', 'token.unavailable': 'Token unavailable',
|
|
714
|
-
'toast.tokenInvalid': 'Invalid token', 'toast.connFailed': 'Connection failed',
|
|
719
|
+
'toast.tokenInvalid': 'Invalid token', 'toast.connFailed': 'Connection failed', 'toast.authLayer': 'The login proxy blocked the admin API; check authentication rules for /remote/*',
|
|
715
720
|
'stat.pluginVersion': 'Plugin version', 'stat.gatewayVersion': 'Gateway version',
|
|
716
721
|
'stat.updateAvailable': 'v{version} available', 'stat.currentV': 'Current v{version}',
|
|
717
722
|
'stat.download': 'Download', 'stat.embedded': 'Embedded in DSH · no gateway',
|
|
718
723
|
'stat.updateCheck': 'Update check: {error}', 'stat.latest': 'Up to date (source check)', 'stat.notChecked': 'Not checked',
|
|
719
724
|
'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway {port})', 'stat.phoneThis': ' (phone connects to this address)',
|
|
720
|
-
'hostIPs.title': 'Host IP addresses', 'hostIPs.badge': 'Phone connection candidates', 'hostIPs.loading': 'Reading host addresses…', 'hostIPs.summary': '{enabled}/{total} addresses enabled', 'hostIPs.desc': 'Disabled virtual-machine, container, or other invalid addresses are excluded from pairing QR codes, diagnostics, and firewall suggestions.', 'hostIPs.enabledColumn': 'Enabled', 'hostIPs.addressColumn': 'Host IP', 'hostIPs.statusColumn': 'Status', 'hostIPs.enabled': 'Enabled · used for pairing', 'hostIPs.disabled': 'Disabled · excluded from pairing', 'hostIPs.enable': 'Toggle this address', 'hostIPs.empty': 'No usable non-loopback
|
|
725
|
+
'hostIPs.title': 'Host IP addresses', 'hostIPs.badge': 'Phone connection candidates', 'hostIPs.loading': 'Reading host addresses…', 'hostIPs.summary': '{enabled}/{total} addresses enabled', 'hostIPs.desc': 'Disabled virtual-machine, container, or other invalid addresses are excluded from pairing QR codes, diagnostics, and firewall suggestions. In Docker, add a host address manually.', 'hostIPs.enabledColumn': 'Enabled', 'hostIPs.addressColumn': 'Host IP', 'hostIPs.statusColumn': 'Status', 'hostIPs.enabled': 'Enabled · used for pairing', 'hostIPs.disabled': 'Disabled · excluded from pairing', 'hostIPs.enable': 'Toggle this address', 'hostIPs.empty': 'No usable non-loopback address', 'hostIPs.keepOne': 'Keep at least one usable address enabled', 'hostIPs.add': 'Add host address', 'hostIPs.addPrompt': 'Enter a host IP or hostname reachable by the phone:', 'hostIPs.invalid': 'Invalid address format', 'hostIPs.exists': 'This address is already listed', 'hostIPs.remove': 'Remove',
|
|
721
726
|
'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
|
|
722
727
|
'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
|
|
723
728
|
'stat.uptime': 'Uptime · {host}:{port}',
|
package/public/admin.js
CHANGED
|
@@ -18,6 +18,13 @@ const store = {
|
|
|
18
18
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
19
19
|
}
|
|
20
20
|
let token = store.get('dshAdminToken') || new URLSearchParams(location.search).get('token') || ''
|
|
21
|
+
function adminHeaders(extra = {}, accessToken = token) {
|
|
22
|
+
const headers = { 'x-dsh-remote-client': 'admin', ...extra }
|
|
23
|
+
// /remote/* 可能由 Caddy Basic Auth 保护。插件路由自身已在 DSH 登录态内,
|
|
24
|
+
// 这里不能再写 Authorization: Bearer,否则会覆盖浏览器的 Basic 凭据并形成 401 循环。
|
|
25
|
+
if (!pluginMode && accessToken) headers.authorization = 'Bearer ' + accessToken
|
|
26
|
+
return headers
|
|
27
|
+
}
|
|
21
28
|
let timer = null
|
|
22
29
|
let gatewayRunning = false
|
|
23
30
|
let gatewayBusy = false
|
|
@@ -32,17 +39,31 @@ let gatewayPortLoaded = false
|
|
|
32
39
|
let doctorExpanded = store.get('dshAdminDoctorCollapsed') !== '1'
|
|
33
40
|
let doctorChecks = []
|
|
34
41
|
const HOST_IP_SELECTION_KEY = 'dshAdminEnabledHostIPsV1'
|
|
42
|
+
const MANUAL_HOST_IP_KEY = 'dshAdminManualHostIPsV1'
|
|
35
43
|
|
|
36
44
|
function normalizedHostIPs(st) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
const detected = Array.isArray(st?.lanIPs) ? st.lanIPs : []
|
|
46
|
+
return [...new Set([...detected, ...manualHostIPs(st)].map(validManualHost).filter(Boolean))]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validManualHost(value) {
|
|
50
|
+
const host = String(value || '').trim()
|
|
51
|
+
if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
|
|
52
|
+
if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
|
|
53
|
+
if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
|
|
54
|
+
const labels = host.split('.')
|
|
55
|
+
if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
|
|
56
|
+
return host
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
function hostIPScope(st) {
|
|
43
60
|
return [st?.hostname || location.hostname || 'host', st?.host || ''].join('|')
|
|
44
61
|
}
|
|
45
62
|
|
|
63
|
+
function manualHostIPScope(st) {
|
|
64
|
+
return st?.hostname || location.hostname || 'host'
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
function hostIPPreferences() {
|
|
47
68
|
try {
|
|
48
69
|
const value = JSON.parse(store.get(HOST_IP_SELECTION_KEY) || '{}')
|
|
@@ -52,6 +73,26 @@ function hostIPPreferences() {
|
|
|
52
73
|
}
|
|
53
74
|
}
|
|
54
75
|
|
|
76
|
+
function manualHostPreferences() {
|
|
77
|
+
try {
|
|
78
|
+
const value = JSON.parse(store.get(MANUAL_HOST_IP_KEY) || '{}')
|
|
79
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
80
|
+
} catch {
|
|
81
|
+
return {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function manualHostIPs(st) {
|
|
86
|
+
const values = manualHostPreferences()[manualHostIPScope(st)]
|
|
87
|
+
return Array.isArray(values) ? [...new Set(values.map(validManualHost).filter(Boolean))] : []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function saveManualHostIPs(st, values) {
|
|
91
|
+
const prefs = manualHostPreferences()
|
|
92
|
+
prefs[manualHostIPScope(st)] = [...new Set(values.map(validManualHost).filter(Boolean))]
|
|
93
|
+
store.set(MANUAL_HOST_IP_KEY, JSON.stringify(prefs))
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
function enabledHostIPs(st) {
|
|
56
97
|
const all = normalizedHostIPs(st)
|
|
57
98
|
if (!all.length) return []
|
|
@@ -67,9 +108,33 @@ function saveEnabledHostIPs(st, selected) {
|
|
|
67
108
|
store.set(HOST_IP_SELECTION_KEY, JSON.stringify(prefs))
|
|
68
109
|
}
|
|
69
110
|
|
|
111
|
+
function addManualHostIP() {
|
|
112
|
+
if (!lastState) return
|
|
113
|
+
const input = prompt(t('hostIPs.addPrompt'), '')
|
|
114
|
+
if (input === null) return
|
|
115
|
+
const host = validManualHost(input)
|
|
116
|
+
if (!host) {
|
|
117
|
+
toast(t('hostIPs.invalid'), 'err')
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (normalizedHostIPs(lastState).includes(host)) {
|
|
121
|
+
toast(t('hostIPs.exists'), 'err')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
saveManualHostIPs(lastState, [...manualHostIPs(lastState), host])
|
|
125
|
+
saveEnabledHostIPs(lastState, [...enabledHostIPs(lastState), host])
|
|
126
|
+
render(lastState)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function removeManualHostIP(st, host) {
|
|
130
|
+
saveManualHostIPs(st, manualHostIPs(st).filter(value => value !== host))
|
|
131
|
+
render(st)
|
|
132
|
+
}
|
|
133
|
+
|
|
70
134
|
function renderHostIPs(st) {
|
|
71
135
|
const all = normalizedHostIPs(st)
|
|
72
136
|
const selected = enabledHostIPs(st)
|
|
137
|
+
const manual = new Set(manualHostIPs(st))
|
|
73
138
|
const rows = $('host-ip-rows')
|
|
74
139
|
const empty = $('host-ip-empty')
|
|
75
140
|
const summary = $('host-ip-summary')
|
|
@@ -80,7 +145,7 @@ function renderHostIPs(st) {
|
|
|
80
145
|
rows.innerHTML = all.map(ip => `<tr>
|
|
81
146
|
<td class="host-ip-toggle"><label class="host-ip-switch" title="${esc(t('hostIPs.enable'))}"><input type="checkbox" data-host-ip-toggle="${esc(ip)}" ${selected.includes(ip) ? 'checked' : ''}><span aria-hidden="true"></span></label></td>
|
|
82
147
|
<td class="mono host-ip-value">${esc(ip)}</td>
|
|
83
|
-
<td class="host-ip-use">${esc(t(selected.includes(ip) ? 'hostIPs.enabled' : 'hostIPs.disabled'))}</td>
|
|
148
|
+
<td class="host-ip-use">${esc(t(selected.includes(ip) ? 'hostIPs.enabled' : 'hostIPs.disabled'))}${manual.has(ip) ? ` <button class="mini-btn host-ip-remove" type="button" data-host-ip-remove="${esc(ip)}">${esc(t('hostIPs.remove'))}</button>` : ''}</td>
|
|
84
149
|
</tr>`).join('')
|
|
85
150
|
empty.classList.toggle('hidden', all.length > 0)
|
|
86
151
|
rows.querySelectorAll('[data-host-ip-toggle]').forEach(input => input.addEventListener('change', () => {
|
|
@@ -95,6 +160,9 @@ function renderHostIPs(st) {
|
|
|
95
160
|
saveEnabledHostIPs(st, next)
|
|
96
161
|
render(st)
|
|
97
162
|
}))
|
|
163
|
+
rows.querySelectorAll('[data-host-ip-remove]').forEach(button => button.addEventListener('click', () => {
|
|
164
|
+
removeManualHostIP(st, button.dataset.hostIpRemove)
|
|
165
|
+
}))
|
|
98
166
|
}
|
|
99
167
|
|
|
100
168
|
function onlineClientDevices(st) {
|
|
@@ -183,7 +251,7 @@ async function loadStats() {
|
|
|
183
251
|
if (!token && !pluginMode) return
|
|
184
252
|
try {
|
|
185
253
|
const res = await fetch(`${STATS_API}/summary?days=7`, {
|
|
186
|
-
headers:
|
|
254
|
+
headers: adminHeaders(), credentials: 'same-origin'
|
|
187
255
|
})
|
|
188
256
|
if (!res.ok) {
|
|
189
257
|
if (res.status === 401) return
|
|
@@ -204,7 +272,7 @@ async function loadGatewayConfig() {
|
|
|
204
272
|
if (!pluginMode) return
|
|
205
273
|
try {
|
|
206
274
|
const res = await fetch(`${API}/config`, {
|
|
207
|
-
headers:
|
|
275
|
+
headers: adminHeaders(), credentials: 'same-origin'
|
|
208
276
|
})
|
|
209
277
|
const out = await res.json().catch(() => ({}))
|
|
210
278
|
if (out.ok) {
|
|
@@ -355,15 +423,19 @@ async function loadState() {
|
|
|
355
423
|
if (!token && !pluginMode) return
|
|
356
424
|
try {
|
|
357
425
|
const res = await fetch(`${API}/state`, {
|
|
358
|
-
headers:
|
|
426
|
+
headers: adminHeaders(), credentials: 'same-origin'
|
|
359
427
|
})
|
|
360
|
-
if (res.status === 401) throw new Error('AUTH')
|
|
428
|
+
if (res.status === 401) throw new Error(pluginMode ? 'AUTH_LAYER' : 'AUTH')
|
|
429
|
+
if (pluginMode && !String(res.headers.get('content-type') || '').includes('application/json')) throw new Error('AUTH_LAYER')
|
|
361
430
|
const st = await res.json()
|
|
362
431
|
render(st)
|
|
363
432
|
} catch (e) {
|
|
364
433
|
if (e.message === 'AUTH') {
|
|
365
434
|
toast(t('toast.tokenInvalid'), 'err')
|
|
366
435
|
logout()
|
|
436
|
+
} else if (e.message === 'AUTH_LAYER') {
|
|
437
|
+
$('conn-badge').textContent = t('toast.authLayer')
|
|
438
|
+
$('conn-badge').className = 'conn-badge off'
|
|
367
439
|
} else {
|
|
368
440
|
$('conn-badge').textContent = t('toast.connFailed')
|
|
369
441
|
$('conn-badge').className = 'conn-badge off'
|
|
@@ -530,7 +602,7 @@ async function setNote(ip, current) {
|
|
|
530
602
|
if (name === null) return
|
|
531
603
|
const res = await fetch(`${API}/note`, {
|
|
532
604
|
method: 'POST',
|
|
533
|
-
headers: { 'content-type': 'application/json',
|
|
605
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
534
606
|
body: JSON.stringify({ ip, name })
|
|
535
607
|
})
|
|
536
608
|
if (res.ok) {
|
|
@@ -545,7 +617,7 @@ async function kick(ip) {
|
|
|
545
617
|
if (!confirm(t('confirm.kick'))) return
|
|
546
618
|
const res = await fetch(`${API}/kick`, {
|
|
547
619
|
method: 'POST',
|
|
548
|
-
headers: { 'content-type': 'application/json',
|
|
620
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
549
621
|
body: JSON.stringify({ ip })
|
|
550
622
|
})
|
|
551
623
|
if (res.ok) {
|
|
@@ -563,7 +635,7 @@ async function deviceKeyMutation(action, payload = {}) {
|
|
|
563
635
|
try {
|
|
564
636
|
const res = await fetch(`${API}/device-keys/${action}`, {
|
|
565
637
|
method: 'POST',
|
|
566
|
-
headers: { 'content-type': 'application/json',
|
|
638
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
567
639
|
body: JSON.stringify(payload),
|
|
568
640
|
})
|
|
569
641
|
const out = await res.json().catch(() => ({}))
|
|
@@ -755,6 +827,8 @@ $('btn-qr').addEventListener('click', () => {
|
|
|
755
827
|
renderQr(lastState || { mode: '', token: shownToken })
|
|
756
828
|
})
|
|
757
829
|
|
|
830
|
+
$('btn-host-ip-add').addEventListener('click', addManualHostIP)
|
|
831
|
+
|
|
758
832
|
/* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
|
|
759
833
|
$('conn-badge').addEventListener('click', () => {
|
|
760
834
|
const st = lastState
|
|
@@ -774,7 +848,7 @@ $('btn-rotate').addEventListener('click', async () => {
|
|
|
774
848
|
try {
|
|
775
849
|
const res = await fetch(`${API}/token/rotate`, {
|
|
776
850
|
method: 'POST',
|
|
777
|
-
headers: {
|
|
851
|
+
headers: adminHeaders({}, token || shownToken), credentials: 'same-origin'
|
|
778
852
|
})
|
|
779
853
|
const out = await res.json().catch(() => ({}))
|
|
780
854
|
if (out.ok && out.token) {
|
|
@@ -799,7 +873,7 @@ $('btn-gateway').addEventListener('click', async () => {
|
|
|
799
873
|
try {
|
|
800
874
|
const res = await fetch(`${API}/gateway`, {
|
|
801
875
|
method: 'POST',
|
|
802
|
-
headers: { 'content-type': 'application/json',
|
|
876
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
803
877
|
body: JSON.stringify({ action: gatewayRunning ? 'stop' : 'start' })
|
|
804
878
|
})
|
|
805
879
|
const out = await res.json().catch(() => ({}))
|
|
@@ -829,7 +903,7 @@ $('btn-save-port').addEventListener('click', async () => {
|
|
|
829
903
|
try {
|
|
830
904
|
const res = await fetch(`${API}/config`, {
|
|
831
905
|
method: 'PUT',
|
|
832
|
-
headers: { 'content-type': 'application/json',
|
|
906
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
833
907
|
body: JSON.stringify({ port })
|
|
834
908
|
})
|
|
835
909
|
const out = await res.json().catch(() => ({}))
|