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.
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 === root || real.startsWith(root + path.sep)) return true
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 === root || abs.startsWith(root + path.sep)
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.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
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, { path: resolved.abs, entries })
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.16",
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-badge" data-i18n="hostIPs.badge">手机连接候选</div>
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">暂无可用的非回环 IPv4 地址</div>
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': '暂无可用的非回环 IPv4 地址', 'hostIPs.keepOne': '至少保留一个可用地址',
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 IPv4 address', 'hostIPs.keepOne': 'Keep at least one usable address enabled',
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
- return Array.isArray(st?.lanIPs)
38
- ? [...new Set(st.lanIPs.map(value => String(value || '').trim()).filter(value => value && value !== '127.0.0.1' && value !== '0.0.0.0'))]
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: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
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: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
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: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
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', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
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', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
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', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
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: { authorization: 'Bearer ' + (token || shownToken), 'x-dsh-remote-client': 'admin' }
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', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
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', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
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(() => ({}))