dsh-remote-plugin 0.6.15 → 0.6.17

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.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
 
@@ -83,6 +85,9 @@ const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(
83
85
  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'
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'
86
91
  const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
87
92
  const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
88
93
  const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
@@ -103,6 +108,52 @@ function gatewayVersion() {
103
108
  return '0.0.0'
104
109
  }
105
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()
106
157
  const VERSION = gatewayVersion()
107
158
  const PROTOCOL_VERSION = 1
108
159
  const CAPABILITIES = Object.freeze({
@@ -110,7 +161,7 @@ const CAPABILITIES = Object.freeze({
110
161
  eventPolling: 1,
111
162
  workspaceFiles: 2,
112
163
  imagePromptTransport: 1,
113
- dshLifecycle: 2,
164
+ dshLifecycle: DSH_CONTROL_SUPPORT.supported ? 2 : 0,
114
165
  centralAnnouncements: 2,
115
166
  feedback: 1,
116
167
  deviceKeys: 1,
@@ -140,10 +191,16 @@ const MIME = {
140
191
  // 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
141
192
  // 复核一次, 防止 ../ 穿越与符号链接逃逸。
142
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
+ }
143
200
  const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
144
201
  .split(path.delimiter)
202
+ .map(fsConfiguredRoot)
145
203
  .filter(Boolean)
146
- .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
147
204
  const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
148
205
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
149
206
  const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
@@ -158,7 +215,7 @@ function fsRootReals() {
158
215
  }
159
216
  function fsInsideReal(real) {
160
217
  for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
161
- if (real === root || real.startsWith(root + path.sep)) return true
218
+ if (fsInsideRoot(real, root)) return true
162
219
  }
163
220
  return false
164
221
  }
@@ -840,13 +897,94 @@ function classifySystemctlFailure(result) {
840
897
  return { code: 'COMMAND_FAILED', message: 'systemctl 未能接受 DSH 控制命令', detail }
841
898
  }
842
899
 
843
- async function dshServiceStatus() {
844
- if (process.platform === 'win32') {
845
- return { ok: true, supported: false, running: false, service: DSH_SERVICE, code: 'PLATFORM_UNSUPPORTED', message: 'Windows 暂不支持通过 systemd 远程控制 DSH' }
900
+ const WINDOWS_SERVICE_STATE_NAMES = Object.freeze({
901
+ 1: 'STOPPED',
902
+ 2: 'START_PENDING',
903
+ 3: 'STOP_PENDING',
904
+ 4: 'RUNNING',
905
+ 5: 'CONTINUE_PENDING',
906
+ 6: 'PAUSE_PENDING',
907
+ 7: 'PAUSED',
908
+ })
909
+
910
+ function parseWindowsServiceQuery(output) {
911
+ const text = String(output || '')
912
+ const state = /\bSTATE\s*:\s*(\d+)(?:\s+([^\r\n(]+))?/i.exec(text)
913
+ if (!state) return null
914
+ const stateCode = Number(state[1])
915
+ const stateName = WINDOWS_SERVICE_STATE_NAMES[stateCode] || String(state[2] || 'UNKNOWN').trim().split(/\s+/)[0].toUpperCase()
916
+ const pid = /\bPID\s*:\s*(\d+)/i.exec(text)
917
+ const pending = [2, 3, 5, 6].includes(stateCode)
918
+ return {
919
+ stateCode,
920
+ stateName,
921
+ pending,
922
+ running: stateCode === 4,
923
+ mainPid: Number(pid?.[1]) || 0,
924
+ }
925
+ }
926
+
927
+ function classifyWindowsServiceFailure(result) {
928
+ const detail = [result?.stderr, result?.stdout, result?.error].filter(Boolean).join(' · ').slice(0, 1000)
929
+ const code = String(result?.code ?? '')
930
+ if (result?.timedOut) return { code: 'COMMAND_TIMEOUT', message: 'Windows 服务控制命令超时', detail }
931
+ if (code === 'ENOENT') return { code: 'SERVICE_CONTROL_NOT_FOUND', message: '系统中找不到 sc.exe', detail }
932
+ if (code === '1060' || /1060|does not exist|cannot find the file specified|找不到指定的服务/i.test(detail)) {
933
+ return { code: 'SERVICE_NOT_FOUND', message: `未找到 Windows 服务 ${DSH_SERVICE}`, detail }
934
+ }
935
+ if (code === '1058' || /1058|disabled|禁用/i.test(detail)) return { code: 'SERVICE_DISABLED', message: `Windows 服务 ${DSH_SERVICE} 已被禁用`, detail }
936
+ if (/access is denied|permission denied|not authorized|需要提升|拒绝访问/i.test(detail)) return { code: 'PERMISSION_DENIED', message: '当前用户无权控制 Windows DSH 服务', detail }
937
+ if (code === 'SERVICE_STOP_TIMEOUT') return { code, message: `Windows 服务 ${DSH_SERVICE} 停止超时`, detail }
938
+ return { code: 'COMMAND_FAILED', message: 'Windows 服务控制命令失败', detail }
939
+ }
940
+
941
+ function classifyDshServiceFailure(result) {
942
+ return process.platform === 'win32' ? classifyWindowsServiceFailure(result) : classifySystemctlFailure(result)
943
+ }
944
+
945
+ async function windowsServiceStatus() {
946
+ const r = await execFileResult(WINDOWS_SC, ['queryex', DSH_SERVICE], 5000)
947
+ if (!r.ok) {
948
+ const failure = classifyWindowsServiceFailure(r)
949
+ if (failure.code === 'SERVICE_NOT_FOUND') {
950
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...failure }
951
+ }
952
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, ...failure }
846
953
  }
954
+ const parsed = parseWindowsServiceQuery([r.stdout, r.stderr].filter(Boolean).join('\n'))
955
+ if (!parsed) {
956
+ return {
957
+ ok: false, supported: false, running: false, service: DSH_SERVICE,
958
+ code: 'STATUS_PARSE_FAILED', message: '无法解析 Windows DSH 服务状态', detail: r.stdout || r.stderr || 'sc.exe 没有返回 STATE',
959
+ }
960
+ }
961
+ const activeState = parsed.running ? 'active' : parsed.stateCode === 7 ? 'paused' : parsed.pending ? 'activating' : 'inactive'
962
+ return {
963
+ ok: true,
964
+ supported: true,
965
+ running: parsed.running,
966
+ service: DSH_SERVICE,
967
+ state: activeState,
968
+ loadState: 'loaded',
969
+ activeState,
970
+ subState: parsed.stateName.toLowerCase(),
971
+ unitFileState: 'windows-service',
972
+ mainPid: parsed.mainPid,
973
+ result: '',
974
+ execMainStatus: 0,
975
+ serviceStateCode: parsed.stateCode,
976
+ serviceState: parsed.stateName,
977
+ }
978
+ }
979
+
980
+ async function dshServiceStatus() {
847
981
  if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
848
982
  return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
849
983
  }
984
+ if (!DSH_CONTROL_SUPPORT.supported) {
985
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...DSH_CONTROL_SUPPORT }
986
+ }
987
+ if (process.platform === 'win32') return windowsServiceStatus()
850
988
  const r = await execFileResult(SYSTEMCTL, [
851
989
  '--user', 'show', DSH_SERVICE,
852
990
  '--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
@@ -884,6 +1022,31 @@ async function dshServiceStatus() {
884
1022
  }
885
1023
  }
886
1024
 
1025
+ async function executeDshServiceAction(action, initial) {
1026
+ if (process.platform !== 'win32') {
1027
+ return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
1028
+ }
1029
+ if (action === 'restart' && initial?.running) {
1030
+ const stop = await execFileResult(WINDOWS_SC, ['stop', DSH_SERVICE], 5000)
1031
+ if (!stop.ok) {
1032
+ const current = await windowsServiceStatus()
1033
+ if (!current.supported || current.running || current.serviceStateCode !== 1) return stop
1034
+ }
1035
+ let stopped = false
1036
+ const checks = Math.max(1, Math.ceil(DSH_CONTROL_TIMEOUT_MS / Math.max(50, DSH_CONTROL_POLL_MS)))
1037
+ for (let i = 0; i < checks; i++) {
1038
+ const current = await windowsServiceStatus()
1039
+ if (!current.supported) {
1040
+ return { ok: false, code: current.code || 'SERVICE_STATUS_FAILED', error: current.message, stderr: current.detail }
1041
+ }
1042
+ if (current.serviceStateCode === 1) { stopped = true; break }
1043
+ await delay(DSH_CONTROL_POLL_MS)
1044
+ }
1045
+ if (!stopped) return { ok: false, code: 'SERVICE_STOP_TIMEOUT', error: `Windows 服务 ${DSH_SERVICE} 停止超时` }
1046
+ }
1047
+ return execFileResult(WINDOWS_SC, ['start', DSH_SERVICE], 5000)
1048
+ }
1049
+
887
1050
  function delay(ms) {
888
1051
  return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
889
1052
  }
@@ -949,7 +1112,8 @@ function reconnectDshEventCollectors() {
949
1112
 
950
1113
  async function runDshControlOperation(operation) {
951
1114
  try {
952
- dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
1115
+ const manager = process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
1116
+ dshOperationStep(operation, 'checking', `正在检查 ${manager} ${DSH_SERVICE}`)
953
1117
  const initial = await dshServiceStatus()
954
1118
  operation.initialStatus = initial
955
1119
  operation.observed = initial
@@ -964,11 +1128,11 @@ async function runDshControlOperation(operation) {
964
1128
  return
965
1129
  }
966
1130
 
967
- dshOperationStep(operation, 'command', `正在向 systemd 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
968
- const command = await execFileResult(SYSTEMCTL, ['--user', '--no-block', operation.action, DSH_SERVICE], 5000)
1131
+ dshOperationStep(operation, 'command', `正在向 ${manager} 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
1132
+ const command = await executeDshServiceAction(operation.action, initial)
969
1133
  operation.command = { ok: command.ok, code: command.code, signal: command.signal }
970
1134
  if (!command.ok) {
971
- const failure = classifySystemctlFailure(command)
1135
+ const failure = classifyDshServiceFailure(command)
972
1136
  failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
973
1137
  return
974
1138
  }
@@ -1921,6 +2085,7 @@ function serveAdminApi(req, res, url) {
1921
2085
  port: PORT,
1922
2086
  protocol: { version: PROTOCOL_VERSION },
1923
2087
  capabilities: CAPABILITIES,
2088
+ dshControl: DSH_CONTROL_SUPPORT,
1924
2089
  upstream: { url: UPSTREAM.origin, reachable },
1925
2090
  latest: {
1926
2091
  version: latestState.version,
@@ -2138,8 +2303,19 @@ function fsAuthorized(req, url, res) {
2138
2303
  return true
2139
2304
  }
2140
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
+
2141
2317
  function fsInsideRoot(abs, root) {
2142
- return abs === root || abs.startsWith(root + path.sep)
2318
+ return fsInsideRootFor(path, abs, root, process.platform === 'win32')
2143
2319
  }
2144
2320
 
2145
2321
  function fsWorkspacePath(value) {
@@ -2184,7 +2360,7 @@ async function fsResolve(input) {
2184
2360
  const raw = String(input ?? '').trim()
2185
2361
  let abs
2186
2362
  if (!raw || raw === '~') abs = FS_ROOTS[0]
2187
- 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))
2188
2364
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
2189
2365
  else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
2190
2366
  if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
@@ -2281,6 +2457,7 @@ async function fsList(req, res, url) {
2281
2457
  if (!info.isFile() && !info.isDirectory()) continue
2282
2458
  entries.push({
2283
2459
  name: d.name,
2460
+ path: full,
2284
2461
  type: info.isDirectory() ? 'dir' : 'file',
2285
2462
  size: info.isDirectory() ? 0 : info.size,
2286
2463
  mtimeMs: Math.round(info.mtimeMs)
@@ -2293,7 +2470,13 @@ async function fsList(req, res, url) {
2293
2470
  if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
2294
2471
  return a.name.localeCompare(b.name, 'zh-CN', { numeric: true })
2295
2472
  })
2296
- 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
+ })
2297
2480
  }
2298
2481
 
2299
2482
  async function fsFile(req, res, url) {
@@ -3204,6 +3387,7 @@ async function serveHealth(req, res, url) {
3204
3387
  version: VERSION,
3205
3388
  protocol: { version: PROTOCOL_VERSION },
3206
3389
  capabilities: CAPABILITIES,
3390
+ dshControl: DSH_CONTROL_SUPPORT,
3207
3391
  pid: process.pid,
3208
3392
  upstream: UPSTREAM.origin,
3209
3393
  upstreamProbe: DSH_HEALTH_PATH,
@@ -3217,12 +3401,12 @@ async function serveHealth(req, res, url) {
3217
3401
  }
3218
3402
 
3219
3403
  function lanAddresses() {
3220
- const out = []
3404
+ const out = [...ADVERTISED_HOSTS]
3221
3405
  let groups
3222
3406
  try { groups = Object.values(os.networkInterfaces()) } catch { return out }
3223
3407
  for (const infos of groups) {
3224
3408
  for (const info of infos || []) {
3225
- 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)
3226
3410
  }
3227
3411
  }
3228
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.15",
3
+ "version": "0.6.17",
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
@@ -108,6 +108,30 @@
108
108
  .gateway-port-row input { width: 92px; background: var(--dsr-bg); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 8px; padding: 6px 8px; font: inherit; font-size: 13px; outline: none; }
109
109
  .gateway-port-row .muted { font-size: 12px; }
110
110
  .gateway-port-row .mini-btn { margin-left: auto; }
111
+ .host-ip-card { margin: 0 0 14px; padding: 18px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); }
112
+ .host-ip-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
113
+ .host-ip-title { font-size: 16px; font-weight: 750; }
114
+ .host-ip-head .muted { margin-top: 4px; font-size: 12px; }
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; }
118
+ .host-ip-desc { margin: 12px 0; color: var(--dsr-muted); font-size: 12px; line-height: 1.55; }
119
+ .host-ip-table-wrap { overflow: hidden; border: 1px solid var(--dsr-line); border-radius: 13px; }
120
+ .host-ip-table { width: 100%; border-collapse: collapse; }
121
+ .host-ip-table th, .host-ip-table td { padding: 10px 12px; border-bottom: 1px solid var(--dsr-divider); text-align: left; font-size: 12px; }
122
+ .host-ip-table th { color: var(--dsr-muted); background: var(--dsr-bg-2); font-size: 11px; font-weight: 750; }
123
+ .host-ip-table tr:last-child td { border-bottom: none; }
124
+ .host-ip-toggle { width: 74px; }
125
+ .host-ip-value { font-size: 13px !important; }
126
+ .host-ip-use { color: var(--dsr-muted); }
127
+ .host-ip-switch { position: relative; display: inline-block; width: 38px; height: 22px; vertical-align: middle; }
128
+ .host-ip-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
129
+ .host-ip-switch span { position: absolute; inset: 0; border: 1px solid var(--dsr-line); border-radius: 999px; background: var(--dsr-bg-2); cursor: pointer; transition: .18s ease; }
130
+ .host-ip-switch span::after { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; border-radius: 50%; background: var(--dsr-muted); transition: .18s ease; }
131
+ .host-ip-switch input:checked + span { border-color: var(--dsr-accent-line); background: var(--dsr-accent-soft); }
132
+ .host-ip-switch input:checked + span::after { transform: translateX(16px); background: var(--dsr-accent-strong); }
133
+ .host-ip-switch input:focus-visible + span { outline: 2px solid var(--dsr-accent-strong); outline-offset: 2px; }
134
+ .host-ip-empty { padding: 18px; color: var(--dsr-muted); text-align: center; font-size: 12px; }
111
135
  .conn-badge { white-space: nowrap; }
112
136
  #btn-close-drawer { white-space: nowrap; }
113
137
  @media (max-width: 720px) {
@@ -220,7 +244,7 @@
220
244
  .tb-btn { border-radius: 11px; background: linear-gradient(135deg, var(--dsr-panel-2), var(--dsr-bg-2)); border-color: var(--dsr-line); transition: transform .18s ease, border-color .18s ease, background .18s ease; }
221
245
  .tb-btn:hover { transform: translateY(-1px); border-color: var(--dsr-accent-line); background: var(--dsr-accent-soft); }
222
246
  .tb-btn svg { width: 17px; height: 17px; }
223
- .login-card, .stat-card, .stats-chart, .table-wrap, .gateway-port-row, .pair-box { background: linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); border-color: var(--dsr-line); border-radius: 18px; box-shadow: 0 12px 30px var(--dsr-shadow); }
247
+ .login-card, .stat-card, .stats-chart, .table-wrap, .gateway-port-row, .host-ip-card, .pair-box { background: linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); border-color: var(--dsr-line); border-radius: 18px; box-shadow: 0 12px 30px var(--dsr-shadow); }
224
248
  .login-card { padding: 18px; }
225
249
  .stat-grid { gap: 12px; }
226
250
  .stat-card { padding: 15px 16px; box-shadow: inset 0 1px var(--dsr-divider), 0 10px 26px var(--dsr-shadow); }
@@ -293,6 +317,9 @@
293
317
  .token-row { display: flex; flex-direction: column; align-items: stretch; }
294
318
  .token-actions { justify-content: stretch; }
295
319
  .token-actions .mini-btn { flex: 1 1 auto; }
320
+ .host-ip-card { padding: 15px; }
321
+ .host-ip-table-wrap { overflow-x: auto; }
322
+ .host-ip-table { min-width: 360px; }
296
323
  .device-key-toggle { align-items:flex-start; }
297
324
  .device-key-toggle-actions { align-items:flex-end; flex-direction:column-reverse; }
298
325
  .device-key-grid.head { display:none; }
@@ -434,6 +461,27 @@
434
461
  <button id="btn-save-port" class="mini-btn" data-i18n="savePort">保存</button>
435
462
  </div>
436
463
 
464
+ <section id="host-ip-card" class="host-ip-card" aria-live="polite">
465
+ <div class="host-ip-head">
466
+ <div>
467
+ <div class="host-ip-title" data-i18n="hostIPs.title">主机 IP 地址</div>
468
+ <div id="host-ip-summary" class="muted" data-i18n="hostIPs.loading">正在读取主机地址…</div>
469
+ </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>
474
+ </div>
475
+ <div class="host-ip-desc" data-i18n="hostIPs.desc">关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。</div>
476
+ <div class="host-ip-table-wrap">
477
+ <table class="host-ip-table">
478
+ <thead><tr><th data-i18n="hostIPs.enabledColumn">启用</th><th data-i18n="hostIPs.addressColumn">主机 IP</th><th data-i18n="hostIPs.statusColumn">状态</th></tr></thead>
479
+ <tbody id="host-ip-rows"></tbody>
480
+ </table>
481
+ <div id="host-ip-empty" class="host-ip-empty hidden" data-i18n="hostIPs.empty">暂无可用的非回环地址</div>
482
+ </div>
483
+ </section>
484
+
437
485
  <div id="pair-box" class="pair-box hidden">
438
486
  <div class="pair-title" data-i18n="pairTitle">手机 App 扫码配对</div>
439
487
  <div class="pair-qr" id="pair-qr"></div>
@@ -558,12 +606,13 @@
558
606
  'badge.embedded': '内嵌', 'badge.gateway': '网关', 'badge.connected': '已连接',
559
607
  'badge.gateway.title': '新标签页打开网关管理面板', 'badge.gatewayDown': '网关未运行',
560
608
  'token.pluginNoGateway': '插件模式 · 未接网关, 无需令牌', 'token.unavailable': '未获取到令牌',
561
- 'toast.tokenInvalid': '令牌无效', 'toast.connFailed': '连接失败',
609
+ 'toast.tokenInvalid': '令牌无效', 'toast.connFailed': '连接失败', 'toast.authLayer': '登录代理拦截了管理 API,请检查 /remote/* 的认证规则',
562
610
  'stat.pluginVersion': '插件版本', 'stat.gatewayVersion': '网关版本',
563
611
  'stat.updateAvailable': '{version} 可用', 'stat.currentV': '当前 v{version}',
564
612
  'stat.download': '去下载', 'stat.embedded': 'DSH 内嵌 · 免网关',
565
613
  'stat.updateCheck': '更新检查: {error}', 'stat.latest': '已是最新(来源检查)', 'stat.notChecked': '未检查更新',
566
614
  'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连 {port} 网关)', 'stat.phoneThis': ' (手机连这个地址)',
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': '删除',
567
616
  'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
568
617
  'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
569
618
  'stat.uptime': '运行时长 · {host}:{port}',
@@ -667,12 +716,13 @@
667
716
  'badge.embedded': 'Embedded', 'badge.gateway': 'Gateway', 'badge.connected': 'Connected',
668
717
  'badge.gateway.title': 'Open gateway admin in a new tab', 'badge.gatewayDown': 'Gateway not running',
669
718
  'token.pluginNoGateway': 'Plugin mode · no gateway, no token needed', 'token.unavailable': 'Token unavailable',
670
- '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/*',
671
720
  'stat.pluginVersion': 'Plugin version', 'stat.gatewayVersion': 'Gateway version',
672
721
  'stat.updateAvailable': 'v{version} available', 'stat.currentV': 'Current v{version}',
673
722
  'stat.download': 'Download', 'stat.embedded': 'Embedded in DSH · no gateway',
674
723
  'stat.updateCheck': 'Update check: {error}', 'stat.latest': 'Up to date (source check)', 'stat.notChecked': 'Not checked',
675
724
  'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway {port})', 'stat.phoneThis': ' (phone connects to this address)',
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',
676
726
  'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
677
727
  'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
678
728
  'stat.uptime': 'Uptime · {host}:{port}',