dsh-mesh-access 1.2.1 → 1.4.0
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/CHANGELOG.md +22 -0
- package/lib/client.js +92 -0
- package/lib/index.js +174 -4
- package/package.json +1 -1
- package/tools/frps-setup.sh +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 1.3.0 — 面板显示登录弹窗状态,讲清账号密码在哪改
|
|
4
|
+
|
|
5
|
+
用户问「网页的账号密码没有配置项,我上哪知道是啥、也没法改?」——账号密码是服务器端 nginx 的 Basic 认证(`/etc/nginx/.dsh_htpasswd`),插件改不了服务器文件,但至少要把状态和操作说清楚:
|
|
6
|
+
|
|
7
|
+
- 状态区新增第四颗 pill:真实 GET 一次公网入口,`401 + WWW-Authenticate: Basic` → 「有登录弹窗(Basic)」;200/303 → 「无登录弹窗」(此时给出安全提醒:知道地址的人都能打开你的 DSH 登录页);
|
|
8
|
+
- 有弹窗时提示:账号密码是服务器上跑 `tools/frps-setup.sh` 时它打印给你的(默认账号 `dsh`);忘了就 `sudo htpasswd /etc/nginx/.dsh_htpasswd dsh` 重设;
|
|
9
|
+
- 无弹窗时提示:加登录的两条命令(同上)。
|
|
10
|
+
|
|
3
11
|
## 1.2.1 — 表单里写清服务器端怎么装
|
|
4
12
|
|
|
5
13
|
用户问「服务器端的 frps / nginx 是面板一键装还是让 AI 装?」——面板够不着你的服务器,写明白:
|
|
@@ -135,3 +143,17 @@
|
|
|
135
143
|
- **客户端半边**:`sidebar.panellist` + `main` 两个槽位的面板(状态、开/关端口、手机地址与复制、
|
|
136
144
|
首次使用星空组网的提示),每 5 秒轮询一次。
|
|
137
145
|
- **没有构建步骤**:两边都是普通的 ESM / 浏览器 JS,所以发布出去的包就是源码本身。
|
|
146
|
+
|
|
147
|
+
## 1.4.0 — 入口登录收进插件:密钥 / 账号密码 / 关闭
|
|
148
|
+
|
|
149
|
+
- 公网地址的门锁从 nginx 挪进插件本身:服务器只做转发,零配置零账号。
|
|
150
|
+
- 三种模式(面板「入口登录」卡片切换,立即生效):
|
|
151
|
+
- **密钥(推荐,无弹窗)**:面板给一次性地址(带 `?meshkey=`),手机打开一次,
|
|
152
|
+
30 天内该设备免登录、全程没有登录框;怀疑泄露随时「重置」,旧密钥立即作废。
|
|
153
|
+
- **账号密码**:浏览器弹窗登录一次,24 小时内**仅该设备**免登录,其他设备各自登录;
|
|
154
|
+
改账号密码会把所有已登录设备踢下线。
|
|
155
|
+
- **关闭**:不加门锁,只剩 DSH 自己的 token 一层。
|
|
156
|
+
- 认证状态存本机 `~/.config/frp/dsh-mesh-access.json`(侧车),密码 scrypt 哈希,密钥仅本地可见。
|
|
157
|
+
- 拿不到有效凭据的请求一律 404 伪装(密钥模式)/ 401 弹窗(账号密码模式),WebSocket 升级同样先过门锁。
|
|
158
|
+
- `x-forwarded-proto` 透传:https 域名下种 cookie 自动带 `Secure`。
|
|
159
|
+
- frps-setup.sh 不再生成 nginx Basic 账密(认证归插件)。
|
package/lib/client.js
CHANGED
|
@@ -67,6 +67,8 @@ window.__ModuleLoader__.load({
|
|
|
67
67
|
const api = {
|
|
68
68
|
status: () => fetchJson('/api/dsh-mesh-access/status', {}, 6000),
|
|
69
69
|
tunnel: () => fetchJson('/api/dsh-mesh-access/tunnel', {}, 8000),
|
|
70
|
+
auth: () => fetchJson('/api/dsh-mesh-access/auth', {}, 8000),
|
|
71
|
+
authPost: (body) => fetchJson('/api/dsh-mesh-access/auth', { method: 'POST', body: JSON.stringify(body) }, 20000),
|
|
70
72
|
tunnelAction: (action) =>
|
|
71
73
|
fetchJson('/api/dsh-mesh-access/tunnel', {
|
|
72
74
|
method: 'POST',
|
|
@@ -97,6 +99,10 @@ window.__ModuleLoader__.load({
|
|
|
97
99
|
function Panel() {
|
|
98
100
|
const [state, setState] = react.useState(null)
|
|
99
101
|
const [tunnel, setTunnel] = react.useState(null)
|
|
102
|
+
const [auth, setAuth] = react.useState(null)
|
|
103
|
+
const [authBusy, setAuthBusy] = react.useState(false)
|
|
104
|
+
const [authErr, setAuthErr] = react.useState(null)
|
|
105
|
+
const [authForm, setAuthForm] = react.useState({ username: '', password: '' })
|
|
100
106
|
const [busy, setBusy] = react.useState(false)
|
|
101
107
|
const [tunnelBusy, setTunnelBusy] = react.useState(false)
|
|
102
108
|
const [err, setErr] = react.useState(null)
|
|
@@ -117,6 +123,10 @@ window.__ModuleLoader__.load({
|
|
|
117
123
|
.tunnel()
|
|
118
124
|
.then((t) => setTunnel(t))
|
|
119
125
|
.catch(() => {})
|
|
126
|
+
api
|
|
127
|
+
.auth()
|
|
128
|
+
.then((a) => setAuth(a))
|
|
129
|
+
.catch(() => {})
|
|
120
130
|
}, [])
|
|
121
131
|
|
|
122
132
|
react.useEffect(() => {
|
|
@@ -274,6 +284,10 @@ window.__ModuleLoader__.load({
|
|
|
274
284
|
tn.running ? 'frpc 运行中(本插件)' : tn.external ? 'frpc 运行中(外部进程)' : 'frpc 未运行'),
|
|
275
285
|
h('span', { style: S.pill(Boolean(tn.reachable)) }, h('span', { style: S.dot(Boolean(tn.reachable)) }), tn.reachable ? '公网可达' : '公网不可达'),
|
|
276
286
|
h('span', { style: S.pill(Boolean(tn.url)) }, h('span', { style: S.dot(Boolean(tn.url)) }), tn.domain ? '域名 ' + tn.domain : tn.remotePort ? '端口 ' + tn.remotePort : '未配置'),
|
|
287
|
+
(tn.reachable && tn.webAuth
|
|
288
|
+
? h('span', { style: S.pill(tn.webAuth !== 'none') }, h('span', { style: S.dot(tn.webAuth !== 'none') }),
|
|
289
|
+
tn.webAuth === 'basic' ? '有登录弹窗(Basic)' : tn.webAuth === 'none' ? '无登录弹窗' : '有访问控制')
|
|
290
|
+
: null),
|
|
277
291
|
),
|
|
278
292
|
h('div', { style: S.row, marginTop: 12 },
|
|
279
293
|
h('button', {
|
|
@@ -342,12 +356,90 @@ window.__ModuleLoader__.load({
|
|
|
342
356
|
? ' 现在这个隧道是你自己的外部进程在跑(多半是 systemd 单元 dsh-frpc),所以这里显示为「外部进程」并且不让重复启动,免得抢同一个 remotePort。想停:systemctl --user stop dsh-frpc。'
|
|
343
357
|
: '',
|
|
344
358
|
' 组网走中继时 RTT 800–990 ms(最坏 3.7 s),长连接会被心跳判死;你自己的服务器通常 30–60 ms。',
|
|
359
|
+
tn.reachable && tn.webAuth === 'none'
|
|
360
|
+
? ' 注意:这个入口现在没有登录弹窗,知道地址的人都能打开你的 DSH 登录页。给入口加登录:SSH 到服务器跑 tools/frps-setup.sh --domain 你的域名(会生成随机账号密码并打印出来);已有账号想改密码:sudo htpasswd /etc/nginx/.dsh_htpasswd dsh。账号密码是服务器端的东西,面板这里只负责告诉你状态,改要上服务器。'
|
|
361
|
+
: tn.webAuth === 'basic'
|
|
362
|
+
? ' 打开地址时浏览器会先弹账号密码框(nginx Basic 认证)。账号密码是你在服务器上跑 tools/frps-setup.sh 时它打印给你的(默认账号 dsh);忘了就上服务器 sudo htpasswd /etc/nginx/.dsh_htpasswd dsh 重设。'
|
|
363
|
+
: '',
|
|
345
364
|
),
|
|
346
365
|
tn.log && tn.log.length
|
|
347
366
|
? h('div', { style: { ...S.hint, ...S.mono, marginTop: 8, whiteSpace: 'pre-wrap', opacity: 0.5 } }, tn.log.slice(-4).join('\n'))
|
|
348
367
|
: null,
|
|
349
368
|
),
|
|
350
369
|
|
|
370
|
+
h(
|
|
371
|
+
Card, { title: '入口登录(公网地址的门锁)' },
|
|
372
|
+
(() => {
|
|
373
|
+
const a = auth || {}
|
|
374
|
+
const runAuth = (body, okMsg) => {
|
|
375
|
+
setAuthBusy(true); setAuthErr(null); setAuthMsg(null)
|
|
376
|
+
api.authPost(body)
|
|
377
|
+
.then((r) => {
|
|
378
|
+
setAuthBusy(false)
|
|
379
|
+
if (r && r.ok === false) { setAuthErr(r.error || '失败'); return }
|
|
380
|
+
setAuth(r); if (okMsg) setAuthMsg(okMsg)
|
|
381
|
+
})
|
|
382
|
+
.catch((e) => { setAuthBusy(false); setAuthErr(String(e && e.message ? e.message : e)) })
|
|
383
|
+
}
|
|
384
|
+
const setMode = (mode) => runAuth({ mode },
|
|
385
|
+
mode === 'key' ? '已切到密钥模式:手机打开下面带 meshkey 的地址一次,30 天内免登录、无弹窗'
|
|
386
|
+
: mode === 'basic' ? '已切到账号密码模式:弹窗登录一次,24 小时内该设备免登录'
|
|
387
|
+
: '已关闭入口登录:任何知道地址的人都能打开,请确认你接受')
|
|
388
|
+
const firstUrl = tn.urlPlain && a.key ? tn.urlPlain + '?meshkey=' + encodeURIComponent(a.key) : null
|
|
389
|
+
const modeBtn = (m, label) => h('button', {
|
|
390
|
+
style: (a.mode === m ? S.btnPrimary : S.btn),
|
|
391
|
+
disabled: authBusy || a.mode === m, onClick: () => setMode(m),
|
|
392
|
+
}, label)
|
|
393
|
+
return h('div', null,
|
|
394
|
+
h('div', { style: S.row },
|
|
395
|
+
modeBtn('key', '密钥(无弹窗)'),
|
|
396
|
+
modeBtn('basic', '账号密码'),
|
|
397
|
+
modeBtn('off', '关闭(危险)'),
|
|
398
|
+
),
|
|
399
|
+
authMsg ? h('div', { style: { ...S.hint, color: '#7ee787', marginTop: 8 } }, authMsg) : null,
|
|
400
|
+
a.mode === 'key'
|
|
401
|
+
? h('div', { style: { marginTop: 10 } },
|
|
402
|
+
h('div', { style: S.hint }, '密钥(只读,可复制;怀疑泄露就点重置,旧密钥立即作废):'),
|
|
403
|
+
h('div', { style: S.row, marginTop: 6 },
|
|
404
|
+
h('input', { style: { ...S.input, ...S.mono, flex: 1 }, readOnly: true, value: a.key || '', onFocus: (e) => e.target.select() }),
|
|
405
|
+
h('button', { style: S.btn, onClick: () => copy(a.key) }, copied ? '已复制' : '复制'),
|
|
406
|
+
h('button', { style: S.btn, disabled: authBusy, onClick: () => { if (window.confirm('重置后旧密钥立刻作废,所有用旧密钥的设备都要重新认证。继续?')) runAuth({ resetKey: true }, '已重置:用新地址重新认证各设备') } }, '重置'),
|
|
407
|
+
),
|
|
408
|
+
firstUrl ? h('div', { style: { marginTop: 8 } },
|
|
409
|
+
h('div', { style: S.hint }, '手机首次打开(认证一次,30 天内该设备免登录、无弹窗):'),
|
|
410
|
+
h('div', { style: S.row, marginTop: 6 },
|
|
411
|
+
h('code', { style: { ...S.mono, wordBreak: 'break-all', flex: 1 } }, firstUrl),
|
|
412
|
+
h('button', { style: S.btn, onClick: () => copy(firstUrl) }, copied ? '已复制' : '复制'),
|
|
413
|
+
),
|
|
414
|
+
) : null,
|
|
415
|
+
)
|
|
416
|
+
: null,
|
|
417
|
+
a.mode === 'basic'
|
|
418
|
+
? h('div', { style: { marginTop: 10 } },
|
|
419
|
+
h('div', { style: S.row, gap: 10 },
|
|
420
|
+
h('div', null, h('label', { style: S.label }, '账号'),
|
|
421
|
+
h('input', { style: S.input, value: authForm.username, placeholder: a.username || 'dsh', onChange: (e) => setAuthForm(Object.assign({}, authForm, { username: e.target.value })) })),
|
|
422
|
+
h('div', null, h('label', { style: S.label }, '新密码(6 位起)'),
|
|
423
|
+
h('input', { style: S.input, type: 'password', value: authForm.password, onChange: (e) => setAuthForm(Object.assign({}, authForm, { password: e.target.value })) })),
|
|
424
|
+
),
|
|
425
|
+
h('div', { style: S.row, marginTop: 8 },
|
|
426
|
+
h('button', {
|
|
427
|
+
style: S.btnPrimary, disabled: authBusy || !authForm.password,
|
|
428
|
+
onClick: () => {
|
|
429
|
+
if (authForm.password.length < 6) { setAuthErr('密码至少 6 位'); return }
|
|
430
|
+
runAuth({ username: authForm.username || a.username, password: authForm.password }, '已保存,立即生效;所有已登录设备被踢下线')
|
|
431
|
+
setAuthForm({ username: '', password: '' })
|
|
432
|
+
},
|
|
433
|
+
}, '保存(立即生效)'),
|
|
434
|
+
),
|
|
435
|
+
h('div', { style: S.hint }, '弹窗输一次账密,这台设备 24 小时内免登录;其他设备仍要各自登录。改密码会把所有设备踢下线。'),
|
|
436
|
+
)
|
|
437
|
+
: null,
|
|
438
|
+
a.mode === 'off' ? h('div', { style: { ...S.hint, marginTop: 8, color: '#f85149' } }, '入口登录已关闭:只剩 DSH 自己的 token 一层,任何拿到地址的人都能看到登录页。') : null,
|
|
439
|
+
authErr ? h('div', { style: S.err, marginTop: 8 }, authErr) : null,
|
|
440
|
+
)
|
|
441
|
+
})(),
|
|
442
|
+
),
|
|
351
443
|
h(
|
|
352
444
|
Card, { title: '第一次用星空组网?' },
|
|
353
445
|
h('div', { style: S.hint }, '三步走,不用记 IP,也不用改任何配置。'),
|
package/lib/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import fs from 'node:fs'
|
|
|
20
20
|
import http from 'node:http'
|
|
21
21
|
import net from 'node:net'
|
|
22
22
|
import path from 'node:path'
|
|
23
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
23
24
|
import { spawn } from 'node:child_process'
|
|
24
25
|
|
|
25
26
|
export const name = 'dsh-mesh-access'
|
|
@@ -90,7 +91,7 @@ function readBody(req, limit = 8 * 1024) {
|
|
|
90
91
|
* Host 改写成上游 authority —— dsh 的 /api 信任栅栏比对的是 Host,
|
|
91
92
|
* 而 cookie 里的 authority 也按这个 Host 签发,两边一致才不会互相打架。
|
|
92
93
|
*/
|
|
93
|
-
function createProxy({ listenAddress, listenPort, upstream, upstreamPort }) {
|
|
94
|
+
function createProxy({ listenAddress, listenPort, upstream, upstreamPort, authGate }) {
|
|
94
95
|
const target = `${upstream}:${upstreamPort}`
|
|
95
96
|
// DSH 会校验 Origin 与 Host 是否一致(不一致直接 403),所以我们转发时把 Host 改写成
|
|
96
97
|
// 上游地址,就必须把 Origin 一起改写成上游的 origin;否则浏览器的 WebSocket 升级
|
|
@@ -104,12 +105,19 @@ function createProxy({ listenAddress, listenPort, upstream, upstreamPort }) {
|
|
|
104
105
|
const incomingHost = headers.host ?? ''
|
|
105
106
|
h.host = target
|
|
106
107
|
h['x-forwarded-host'] = incomingHost
|
|
107
|
-
h['x-forwarded-proto'] = 'http'
|
|
108
|
+
h['x-forwarded-proto'] = headers['x-forwarded-proto'] || 'http'
|
|
108
109
|
if (headers.origin) h.origin = upstreamOrigin
|
|
109
110
|
return h
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
const server = http.createServer((req, res) => {
|
|
114
|
+
const gate = authGate(req)
|
|
115
|
+
if (gate && gate.status) {
|
|
116
|
+
res.writeHead(gate.status, gate.headers)
|
|
117
|
+
res.end(gate.body || '')
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const extra = gate && gate.extraHeaders
|
|
113
121
|
const up = http.request(
|
|
114
122
|
{ host: upstream, port: upstreamPort, method: req.method, path: req.url, headers: rewrite(req.headers) },
|
|
115
123
|
(r) => {
|
|
@@ -119,6 +127,14 @@ function createProxy({ listenAddress, listenPort, upstream, upstreamPort }) {
|
|
|
119
127
|
if (r.statusCode === 200 && CACHEABLE.test(req.url || '')) {
|
|
120
128
|
headers['cache-control'] = 'public, max-age=31536000, immutable'
|
|
121
129
|
}
|
|
130
|
+
if (extra) {
|
|
131
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
132
|
+
if (k.toLowerCase() === 'set-cookie') {
|
|
133
|
+
const prev = headers['set-cookie']
|
|
134
|
+
headers['set-cookie'] = prev ? [].concat(prev, v) : v
|
|
135
|
+
} else headers[k] = v
|
|
136
|
+
}
|
|
137
|
+
}
|
|
122
138
|
res.writeHead(r.statusCode ?? 502, headers)
|
|
123
139
|
r.pipe(res)
|
|
124
140
|
},
|
|
@@ -132,6 +148,13 @@ function createProxy({ listenAddress, listenPort, upstream, upstreamPort }) {
|
|
|
132
148
|
|
|
133
149
|
// WebSocket / HTTP upgrade:裸 socket 双向转发(DSH 的实时通道靠它)
|
|
134
150
|
server.on('upgrade', (req, socket, head) => {
|
|
151
|
+
const gate = authGate(req)
|
|
152
|
+
if (gate && gate.status) {
|
|
153
|
+
socket.write(`HTTP/1.1 ${gate.status} ${gate.status === 404 ? 'Not Found' : 'Unauthorized'}\r\n` +
|
|
154
|
+
Object.entries(gate.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') + '\r\nConnection: close\r\n\r\n')
|
|
155
|
+
socket.destroy()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
135
158
|
socket.setNoDelay(true)
|
|
136
159
|
socket.setKeepAlive(true, 30_000)
|
|
137
160
|
const up = net.connect(upstreamPort, upstream, () => {
|
|
@@ -560,6 +583,101 @@ transport.useEncryption = true
|
|
|
560
583
|
}
|
|
561
584
|
}
|
|
562
585
|
|
|
586
|
+
// ===== 公网入口认证(1.4.0):关 / 账号密码 / 密钥,全在本机,面板改完即生效 =====
|
|
587
|
+
const genKey = () => randomBytes(32).toString('base64url')
|
|
588
|
+
const hashPassword = (pass) => {
|
|
589
|
+
const salt = randomBytes(16).toString('hex')
|
|
590
|
+
return 'scrypt:' + salt + ':' + scryptSync(String(pass), salt, 32).toString('hex')
|
|
591
|
+
}
|
|
592
|
+
const verifyPassword = (pass, stored) => {
|
|
593
|
+
try {
|
|
594
|
+
const [scheme, salt, hex] = String(stored).split(':')
|
|
595
|
+
if (scheme !== 'scrypt' || !salt || !hex) return false
|
|
596
|
+
const want = Buffer.from(hex, 'hex')
|
|
597
|
+
const got = scryptSync(String(pass), salt, want.length)
|
|
598
|
+
return want.length === got.length && timingSafeEqual(want, got)
|
|
599
|
+
} catch {
|
|
600
|
+
return false
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const readAuth = () => {
|
|
604
|
+
const side = readSidecar()
|
|
605
|
+
if (side && side.auth && side.auth.key) return side.auth
|
|
606
|
+
const fresh = {
|
|
607
|
+
mode: 'key',
|
|
608
|
+
username: 'dsh',
|
|
609
|
+
passwordHash: hashPassword(randomBytes(9).toString('base64url')),
|
|
610
|
+
key: genKey(),
|
|
611
|
+
}
|
|
612
|
+
writeSidecar({ ...side, auth: fresh, authInitAt: new Date().toISOString() })
|
|
613
|
+
return fresh
|
|
614
|
+
}
|
|
615
|
+
const writeAuth = (patch) => {
|
|
616
|
+
const side = readSidecar()
|
|
617
|
+
const next = { ...(side.auth || {}), ...patch }
|
|
618
|
+
writeSidecar({ ...side, auth: next, authUpdatedAt: new Date().toISOString() })
|
|
619
|
+
return next
|
|
620
|
+
}
|
|
621
|
+
const COOKIE = 'dshmeshkey'
|
|
622
|
+
const SESSION_COOKIE = 'dshsession'
|
|
623
|
+
const SESSION_TTL = 24 * 3600 * 1000
|
|
624
|
+
const sessions = new Map() // sid -> expiresAt(ms),进程内存;重启后各设备重登一次
|
|
625
|
+
const sweepSessions = () => {
|
|
626
|
+
const now = Date.now()
|
|
627
|
+
for (const [sid, exp] of sessions) if (exp < now) sessions.delete(sid)
|
|
628
|
+
}
|
|
629
|
+
const bakeCookie = (key, secure) =>
|
|
630
|
+
`${COOKIE}=${key}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`
|
|
631
|
+
const parseCookies = (req) => {
|
|
632
|
+
const out = {}
|
|
633
|
+
for (const part of String(req.headers.cookie || '').split(';')) {
|
|
634
|
+
const i = part.indexOf('=')
|
|
635
|
+
if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim()
|
|
636
|
+
}
|
|
637
|
+
return out
|
|
638
|
+
}
|
|
639
|
+
// null=放行;{status,headers,body}=拦截
|
|
640
|
+
const authGate = (req) => {
|
|
641
|
+
const a = readAuth()
|
|
642
|
+
if (!a.mode || a.mode === 'off') return null
|
|
643
|
+
if (a.mode === 'basic') {
|
|
644
|
+
const viaHttps = String(req.headers['x-forwarded-proto'] || '').includes('https')
|
|
645
|
+
// 已有 24h 会话的设备直接放行(仅该设备;改密码或换模式会清空全部会话)
|
|
646
|
+
sweepSessions()
|
|
647
|
+
const sid = parseCookies(req)[SESSION_COOKIE]
|
|
648
|
+
if (sid && sessions.has(sid)) return null
|
|
649
|
+
const m = /^Basic\s+(.+)$/i.exec(String(req.headers.authorization || ''))
|
|
650
|
+
if (m) {
|
|
651
|
+
const [u, ...rest] = Buffer.from(m[1], 'base64').toString('utf8').split(':')
|
|
652
|
+
if (u === a.username && verifyPassword(rest.join(':'), a.passwordHash)) {
|
|
653
|
+
const newSid = randomBytes(24).toString('base64url')
|
|
654
|
+
sessions.set(newSid, Date.now() + SESSION_TTL)
|
|
655
|
+
return {
|
|
656
|
+
pass: true,
|
|
657
|
+
extraHeaders: { 'set-cookie': `${SESSION_COOKIE}=${newSid}; Max-Age=86400; Path=/; HttpOnly; SameSite=Strict${viaHttps ? '; Secure' : ''}` },
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return { status: 401, headers: { 'www-authenticate': 'Basic realm="DSH"', 'content-type': 'text/plain; charset=utf-8' }, body: '401 unauthorized\n' }
|
|
662
|
+
}
|
|
663
|
+
// key 模式:?meshkey= 一次性换 cookie + 顺手注入 DSH token;cookie 常驻放行;其余 404 伪装成不存在
|
|
664
|
+
const url = new URL(req.url, 'http://x')
|
|
665
|
+
const viaHttps = String(req.headers['x-forwarded-proto'] || '').includes('https')
|
|
666
|
+
const fromParam = url.searchParams.get('meshkey')
|
|
667
|
+
const fromCookie = parseCookies(req)[COOKIE]
|
|
668
|
+
if (fromCookie && fromCookie === a.key && !fromParam) return null
|
|
669
|
+
if (fromParam && fromParam === a.key) {
|
|
670
|
+
const base = `http://${req.headers.host || 'localhost'}${url.pathname}`
|
|
671
|
+
const redirect = new URL(signed(base))
|
|
672
|
+
redirect.searchParams.delete('meshkey')
|
|
673
|
+
return {
|
|
674
|
+
status: 302,
|
|
675
|
+
headers: { location: redirect.pathname + (redirect.search || ''), 'set-cookie': bakeCookie(a.key, viaHttps) },
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: 'Not Found\n' }
|
|
679
|
+
}
|
|
680
|
+
|
|
563
681
|
let starting = null
|
|
564
682
|
async function run(listenPort, mesh, target) {
|
|
565
683
|
if (state.server) {
|
|
@@ -569,7 +687,7 @@ transport.useEncryption = true
|
|
|
569
687
|
}
|
|
570
688
|
const address = mesh[0].address
|
|
571
689
|
try {
|
|
572
|
-
state.server = await createProxy({ listenAddress: address, listenPort, upstream: cfg.upstream, upstreamPort: target })
|
|
690
|
+
state.server = await createProxy({ listenAddress: address, listenPort, upstream: cfg.upstream, upstreamPort: target, authGate })
|
|
573
691
|
state.address = address
|
|
574
692
|
state.port = listenPort
|
|
575
693
|
state.startedAt = Date.now()
|
|
@@ -617,6 +735,40 @@ transport.useEncryption = true
|
|
|
617
735
|
return json(res, 400, { ok: false, error: "action 只能是 'start' 或 'stop'" })
|
|
618
736
|
},
|
|
619
737
|
},
|
|
738
|
+
{
|
|
739
|
+
kind: 'exact',
|
|
740
|
+
path: '/api/dsh-mesh-access/auth',
|
|
741
|
+
handler: async (req, res) => {
|
|
742
|
+
if (req.method === 'GET') {
|
|
743
|
+
const a = readAuth()
|
|
744
|
+
return json(res, 200, { ok: true, mode: a.mode || 'off', username: a.username || '', key: a.key || '', hasPassword: Boolean(a.passwordHash) })
|
|
745
|
+
}
|
|
746
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'GET or POST only' })
|
|
747
|
+
let body = {}
|
|
748
|
+
try { body = JSON.parse(await readBody(req) || '{}') } catch { return json(res, 400, { ok: false, error: 'body 不是合法 JSON' }) }
|
|
749
|
+
const patch = {}
|
|
750
|
+
const mode = String(body.mode ?? '')
|
|
751
|
+
if (mode) {
|
|
752
|
+
if (!['off', 'basic', 'key'].includes(mode)) return json(res, 400, { ok: false, error: 'mode 只能是 off / basic / key' })
|
|
753
|
+
patch.mode = mode
|
|
754
|
+
}
|
|
755
|
+
if (body.username !== undefined) {
|
|
756
|
+
const u = String(body.username).trim()
|
|
757
|
+
if (!/^[A-Za-z0-9._-]{1,32}$/.test(u)) return json(res, 400, { ok: false, error: '账号只能字母数字点下划线连字符,1–32 位' })
|
|
758
|
+
patch.username = u
|
|
759
|
+
}
|
|
760
|
+
if (body.password !== undefined) {
|
|
761
|
+
const pw = String(body.password)
|
|
762
|
+
if (pw.length < 6 || pw.length > 128) return json(res, 400, { ok: false, error: '密码 6–128 位' })
|
|
763
|
+
patch.passwordHash = hashPassword(pw)
|
|
764
|
+
}
|
|
765
|
+
if (body.resetKey) patch.key = genKey()
|
|
766
|
+
if (!Object.keys(patch).length) return json(res, 400, { ok: false, error: '没给任何要改的字段' })
|
|
767
|
+
const next = writeAuth(patch)
|
|
768
|
+
sessions.clear()
|
|
769
|
+
return json(res, 200, { ok: true, mode: next.mode, username: next.username, key: next.key, hasPassword: Boolean(next.passwordHash) })
|
|
770
|
+
},
|
|
771
|
+
},
|
|
620
772
|
{
|
|
621
773
|
kind: 'exact',
|
|
622
774
|
path: '/api/dsh-mesh-access/tunnel',
|
|
@@ -627,8 +779,18 @@ transport.useEncryption = true
|
|
|
627
779
|
const port = t.domain ? 443 : t.remotePort
|
|
628
780
|
const snap = tunnelSnapshot()
|
|
629
781
|
const reachable = await probe(host, port)
|
|
782
|
+
let webAuth = null
|
|
783
|
+
if (reachable) {
|
|
784
|
+
try {
|
|
785
|
+
const r = await fetch(`${t.domain ? 'https' : 'http'}://${host}${t.domain ? '' : ':' + t.remotePort}/`, { redirect: 'manual', signal: AbortSignal.timeout(4000) })
|
|
786
|
+
const www = r.headers.get('www-authenticate') || ''
|
|
787
|
+
if (r.status === 401 && /basic/i.test(www)) webAuth = 'basic'
|
|
788
|
+
else if (r.status === 401 || r.status === 403) webAuth = 'other'
|
|
789
|
+
else webAuth = 'none'
|
|
790
|
+
} catch { webAuth = null }
|
|
791
|
+
}
|
|
630
792
|
// external:公网已经通了,但 frpc 不是这个进程拉起来的(用户自己的 systemd 单元在跑)
|
|
631
|
-
return json(res, 200, { ok: true, ...snap, reachable, external: reachable && !snap.running })
|
|
793
|
+
return json(res, 200, { ok: true, ...snap, reachable, webAuth, external: reachable && !snap.running })
|
|
632
794
|
}
|
|
633
795
|
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'GET or POST only' })
|
|
634
796
|
let body = {}
|
|
@@ -640,6 +802,14 @@ transport.useEncryption = true
|
|
|
640
802
|
const action = String(body.action ?? '')
|
|
641
803
|
if (action === 'start') return json(res, 200, await startTunnel())
|
|
642
804
|
if (action === 'stop') return json(res, 200, await stopTunnel())
|
|
805
|
+
if (action === 'auth') {
|
|
806
|
+
try {
|
|
807
|
+
const out = await setWebAuth(body)
|
|
808
|
+
return json(res, out.ok ? 200 : 400, { ...out, action: 'auth' })
|
|
809
|
+
} catch (e) {
|
|
810
|
+
return json(res, 400, { ok: false, error: String(e?.message ?? e) })
|
|
811
|
+
}
|
|
812
|
+
}
|
|
643
813
|
if (action === 'config') {
|
|
644
814
|
try {
|
|
645
815
|
writeTunnelConfig(body)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mesh-access",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Control your DSH from your phone: opens a port on the mesh/VPN interface from inside the running server process, so a phone browser gets the same instance and the same session list — no SSH, no second dsh. Also reachable from another PC, a GUI NAS, or a TV box.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/tools/frps-setup.sh
CHANGED
|
@@ -175,8 +175,8 @@ server {
|
|
|
175
175
|
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
|
176
176
|
|
|
177
177
|
# 浏览器弹窗式登录:挡掉所有没带账号密码的请求
|
|
178
|
-
auth_basic
|
|
179
|
-
|
|
178
|
+
# auth_basic 已由 dsh-mesh-access 插件面板接管(1.4.0+),服务器只做转发
|
|
179
|
+
# 认证配置见插件面板「入口登录」
|
|
180
180
|
|
|
181
181
|
client_max_body_size 64m;
|
|
182
182
|
|