dsh-mesh-access 1.3.0 → 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 CHANGED
@@ -143,3 +143,17 @@
143
143
  - **客户端半边**:`sidebar.panellist` + `main` 两个槽位的面板(状态、开/关端口、手机地址与复制、
144
144
  首次使用星空组网的提示),每 5 秒轮询一次。
145
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(() => {
@@ -357,6 +367,79 @@ window.__ModuleLoader__.load({
357
367
  : null,
358
368
  ),
359
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
+ ),
360
443
  h(
361
444
  Card, { title: '第一次用星空组网?' },
362
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',
@@ -650,6 +802,14 @@ transport.useEncryption = true
650
802
  const action = String(body.action ?? '')
651
803
  if (action === 'start') return json(res, 200, await startTunnel())
652
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
+ }
653
813
  if (action === 'config') {
654
814
  try {
655
815
  writeTunnelConfig(body)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mesh-access",
3
- "version": "1.3.0",
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",
@@ -175,8 +175,8 @@ server {
175
175
  ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
176
176
 
177
177
  # 浏览器弹窗式登录:挡掉所有没带账号密码的请求
178
- auth_basic "DSH";
179
- auth_basic_user_file /etc/nginx/.dsh_htpasswd;
178
+ # auth_basic 已由 dsh-mesh-access 插件面板接管(1.4.0+),服务器只做转发
179
+ # 认证配置见插件面板「入口登录」
180
180
 
181
181
  client_max_body_size 64m;
182
182