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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +199 -15
- package/index.mjs +21 -3
- package/package.json +1 -1
- package/public/admin.html +53 -3
- package/public/admin.js +163 -19
- package/public/announcements.json +33 -0
- package/public/app.js +1040 -61
- package/public/desktop/desktop.css +1 -0
- package/public/desktop/desktop.html +4 -3
- package/public/desktop/desktop.js +167 -20
- package/public/index.html +95 -7
- package/public/styles.css +76 -0
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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', `正在向
|
|
968
|
-
const command = await
|
|
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 =
|
|
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
|
|
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
|
|
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, {
|
|
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.
|
|
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}',
|