dsh-mesh-access 1.3.0 → 1.4.1

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,24 @@
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 账密(认证归插件)。
160
+
161
+ ## 1.4.1 — 修复面板空白
162
+
163
+ - 1.4.0 的 client.js 使用了未声明的 `authMsg` state(补插卡片脚本失败遗留),
164
+ 渲染「入口登录」卡片即抛 `ReferenceError`,导致整个「组网访问」面板空白。
165
+ 已补上 `const [authMsg, setAuthMsg] = react.useState('')`,
166
+ 三种模式(密钥/账号密码/关闭)各完成组件树渲染冒烟验证。
package/README.en.md CHANGED
@@ -52,6 +52,18 @@ Restart the profile. A **组网访问 / Mesh access** entry appears in the left
52
52
  | Public tunnel (optional) | use **your own** server as a public entry point: start/stop frpc, show the public URL, probe it for real |
53
53
  | 第一次用星空组网? | the referral sign-up link and the member-account rule |
54
54
 
55
+ ## Entry auth (since 1.4.0)
56
+
57
+ The lock on the public entry now lives in the plugin panel — the server needs zero auth config:
58
+
59
+ | Mode | Experience | For |
60
+ | --- | --- | --- |
61
+ | Key (recommended) | One-time URL from the panel; that device stays signed in for 30 days, no popup ever | Your own devices |
62
+ | Password | Browser popup once; that device stays signed in for 24 h | Borrowed devices |
63
+ | Off | No lock (only DSH's own token) | Not recommended |
64
+
65
+ Reset the key any time — the old key dies instantly. Changing the password kicks every signed-in device. In key mode, unauthorized requests get a plain 404.
66
+
55
67
  ## HTTP surface
56
68
 
57
69
  The panel is a view over two routes kept deliberately small:
package/README.md CHANGED
@@ -75,6 +75,21 @@ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
75
75
  完整说明(前置条件、域名/TLS/Basic 认证、故障表、卸载)见 **[docs/VPS.md](docs/VPS.md)**。
76
76
  想让 AI 帮你配:把 **[docs/AI-SETUP.md](docs/AI-SETUP.md)** 整份丢给它,它就知道该跑什么、怎么验证。
77
77
 
78
+ ## 入口登录(1.4.0 起)
79
+
80
+ 公网地址的门锁做在**插件面板**里(「入口登录」卡片),服务器零配置——nginx 不再需要账密:
81
+
82
+ | 模式 | 体验 | 适合 |
83
+ | --- | --- | --- |
84
+ | 密钥(推荐) | 面板给一次性地址,手机打开一次,**30 天内免登录、全程无弹窗** | 自己的设备 |
85
+ | 账号密码 | 浏览器弹窗登录一次,**24 小时内仅该设备免登录**,其他设备各自登录 | 偶尔借别人设备 |
86
+ | 关闭 | 不加门锁(只剩 DSH 自己的 token 一层) | 不推荐 |
87
+
88
+ - 密钥怀疑泄露 → 面板点「重置」,旧密钥立即作废,所有设备重新认证。
89
+ - 改账号密码 → 所有已登录设备立刻下线。
90
+ - 密钥模式下无效请求一律返回 404,不暴露这里有个服务。
91
+ - 认证状态存本机 `~/.config/frp/dsh-mesh-access.json`(侧车文件),密码 scrypt 哈希存储。
92
+
78
93
  ## HTTP 接口
79
94
 
80
95
  面板只是两个接口的外壳,故意做得极小:
@@ -82,6 +97,8 @@ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
82
97
  ```
83
98
  GET /api/dsh-mesh-access/status → { ok, host:{upstream,port}, interfaces[], mesh[], proxy{...}, referral }
84
99
  POST /api/dsh-mesh-access/proxy → { action:'start' | 'stop', port? }
100
+ GET /api/dsh-mesh-access/tunnel → frpc 隧道状态(1.1.0+)
101
+ GET /api/dsh-mesh-access/auth → 入口登录状态;POST 切换模式 / 改账密 / 重置密钥(1.4.0+)
85
102
  ```
86
103
 
87
104
  不开浏览器也能排查:
package/docs/AI-SETUP.md CHANGED
@@ -9,7 +9,7 @@ their StarVPN/Tailscale/WireGuard network — a public entry point is optional.
9
9
 
10
10
  ## 0. Hard rules
11
11
 
12
- 1. **Never invent credentials.** The frp token and the Basic-auth password are generated by the scripts on the
12
+ 1. **Never invent credentials.** The frp token is generated by the scripts on the
13
13
  human's machine. Do not type your own, do not reuse one from a doc example.
14
14
  2. **Only touch the human's own machines.** The server must be theirs. Do not point `frpc` at a server you cannot
15
15
  justify, and do not run these scripts on a host they have not named.
@@ -30,7 +30,7 @@ phone ──https──> their server: frps ──frp tunnel──> their comput
30
30
  ```
31
31
 
32
32
  - `tools/frps-setup.sh` — runs on the **server** (root). Installs `frps`, writes `/etc/frp/frps.toml`, systemd unit,
33
- firewall rule; with `--domain` also writes the nginx reverse proxy + Let's Encrypt cert + Basic auth.
33
+ firewall rule; with `--domain` also writes the nginx reverse proxy + Let's Encrypt cert (auth lives in the plugin panel since 1.4.0).
34
34
  - `tools/frpc-setup.sh` — runs on the **computer** (normal user). Installs `frpc` if missing, writes
35
35
  `~/.config/frp/frpc.toml`, writes the systemd **user** unit.
36
36
  - The plugin reads `~/.config/frp/frpc.toml` and shows a "public tunnel" card: start / stop / live status / public URL.
@@ -74,7 +74,7 @@ tail -20 ~/.config/frp/frpc.log # expect: "login to server success" + "[d
74
74
 
75
75
  # 4. end to end from outside — plain HTTP entry:
76
76
  curl -s -o /dev/null -w '%{http_code}\n' "http://<server>:21081/" # 401 (DSH wants a token) is correct
77
- # with the domain + Basic auth:
77
+ # with the domain (auth is configured later in the plugin panel):
78
78
  curl -s -o /dev/null -w '%{http_code}\n' -u dsh:<password> "https://<domain>/" # 401 again, still correct
79
79
 
80
80
  # 5. WebSocket path must answer 101, not 403/404 (this is what "keeps reconnecting" looks like when broken):
@@ -98,7 +98,6 @@ the computer shows up on the phone within a second.
98
98
  | `curl http://server:21081/` times out | security group blocks 21081 | console → allow TCP 21081 (and 7000) |
99
99
  | `connection refused` on 21081 | frpc never connected | `journalctl -u frps -n 50`, check token equality |
100
100
  | 502 from nginx | nothing listening on the local port | open the port in the DSH panel; `ss -Hltn \| grep 3081` |
101
- | 500 from nginx | `.dsh_htpasswd` unreadable by the worker | `chown root:<nginx group>` + `chmod 640` |
102
101
  | page loads, then "reconnecting" | WebSocket not forwarded | `proxy_http_version 1.1` + `Upgrade`/`Connection` headers |
103
102
  | `start error: port already used` | two frpc on one `remotePort` | stop the systemd unit **or** the panel tunnel, keep one |
104
103
  | certbot fails | 80 blocked, or DNS not pointing here yet | check A record, open 80, rerun with `--domain` |
package/docs/VPS.md CHANGED
@@ -53,7 +53,7 @@ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
53
53
  2. 写 `/etc/frp/frps.toml`:`bindPort`、`auth.token`(没给就随机生成 24 位)、日志、本机管理面板 `127.0.0.1:7500`
54
54
  3. 建 `frps.service` 并 enable + start,核对 `:7000` 真的在听
55
55
  4. firewalld / ufw 有就放行 7000
56
- 5. 给了 `--domain` 且有 nginx:写 ACME 探针、申请 Let's Encrypt 证书、写 443 反代、加 Basic 认证(随机密码)
56
+ 5. 给了 `--domain` 且有 nginx:写 ACME 探针、申请 Let's Encrypt 证书、写 443 反代(认证由插件面板「入口登录」接管,服务器不再配账密)
57
57
 
58
58
  `tools/frpc-setup.sh`(你的电脑上,普通用户):
59
59
 
@@ -66,8 +66,10 @@ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
66
66
  ## 认证是两层
67
67
 
68
68
  1. **DSH 自己的 token**:面板里「访问地址」那一行就是带 `?token=` 的完整地址,换完 cookie 30 天有效。
69
- 2. **你加的入口认证**(推荐):`frps-setup.sh --domain` 会顺手配好 nginx Basic 认证,浏览器打开先弹账号密码框。
70
- 不用 Basic 也可以只暴露 IP:端口——但那样等于把 DSH 登录页直接挂在公网,靠 token 一层兜底。
69
+ 2. **入口认证**(推荐,1.4.0+):装好插件后,在面板「入口登录」卡片里选模式:
70
+ - 密钥(推荐):面板给出带 `?meshkey=` 的一次性地址,手机打开一次,30 天内免登录、无弹窗;可随时重置。
71
+ - 账号密码:浏览器弹窗登录一次,24 小时内仅该设备免登录;改密码会踢掉所有已登录设备。
72
+ 认证状态存本机侧车文件,服务器零配置。
71
73
 
72
74
  想更硬一点:把 nginx 换成 mTLS(客户端证书)或者只允许公司 IP 访问(`allow`/`deny`),这些都在 nginx 那一层做,
73
75
  插件不用改。
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,11 @@ 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: '' })
106
+ const [authMsg, setAuthMsg] = react.useState('')
100
107
  const [busy, setBusy] = react.useState(false)
101
108
  const [tunnelBusy, setTunnelBusy] = react.useState(false)
102
109
  const [err, setErr] = react.useState(null)
@@ -117,6 +124,10 @@ window.__ModuleLoader__.load({
117
124
  .tunnel()
118
125
  .then((t) => setTunnel(t))
119
126
  .catch(() => {})
127
+ api
128
+ .auth()
129
+ .then((a) => setAuth(a))
130
+ .catch(() => {})
120
131
  }, [])
121
132
 
122
133
  react.useEffect(() => {
@@ -357,6 +368,79 @@ window.__ModuleLoader__.load({
357
368
  : null,
358
369
  ),
359
370
 
371
+ h(
372
+ Card, { title: '入口登录(公网地址的门锁)' },
373
+ (() => {
374
+ const a = auth || {}
375
+ const runAuth = (body, okMsg) => {
376
+ setAuthBusy(true); setAuthErr(null); setAuthMsg(null)
377
+ api.authPost(body)
378
+ .then((r) => {
379
+ setAuthBusy(false)
380
+ if (r && r.ok === false) { setAuthErr(r.error || '失败'); return }
381
+ setAuth(r); if (okMsg) setAuthMsg(okMsg)
382
+ })
383
+ .catch((e) => { setAuthBusy(false); setAuthErr(String(e && e.message ? e.message : e)) })
384
+ }
385
+ const setMode = (mode) => runAuth({ mode },
386
+ mode === 'key' ? '已切到密钥模式:手机打开下面带 meshkey 的地址一次,30 天内免登录、无弹窗'
387
+ : mode === 'basic' ? '已切到账号密码模式:弹窗登录一次,24 小时内该设备免登录'
388
+ : '已关闭入口登录:任何知道地址的人都能打开,请确认你接受')
389
+ const firstUrl = tn.urlPlain && a.key ? tn.urlPlain + '?meshkey=' + encodeURIComponent(a.key) : null
390
+ const modeBtn = (m, label) => h('button', {
391
+ style: (a.mode === m ? S.btnPrimary : S.btn),
392
+ disabled: authBusy || a.mode === m, onClick: () => setMode(m),
393
+ }, label)
394
+ return h('div', null,
395
+ h('div', { style: S.row },
396
+ modeBtn('key', '密钥(无弹窗)'),
397
+ modeBtn('basic', '账号密码'),
398
+ modeBtn('off', '关闭(危险)'),
399
+ ),
400
+ authMsg ? h('div', { style: { ...S.hint, color: '#7ee787', marginTop: 8 } }, authMsg) : null,
401
+ a.mode === 'key'
402
+ ? h('div', { style: { marginTop: 10 } },
403
+ h('div', { style: S.hint }, '密钥(只读,可复制;怀疑泄露就点重置,旧密钥立即作废):'),
404
+ h('div', { style: S.row, marginTop: 6 },
405
+ h('input', { style: { ...S.input, ...S.mono, flex: 1 }, readOnly: true, value: a.key || '', onFocus: (e) => e.target.select() }),
406
+ h('button', { style: S.btn, onClick: () => copy(a.key) }, copied ? '已复制' : '复制'),
407
+ h('button', { style: S.btn, disabled: authBusy, onClick: () => { if (window.confirm('重置后旧密钥立刻作废,所有用旧密钥的设备都要重新认证。继续?')) runAuth({ resetKey: true }, '已重置:用新地址重新认证各设备') } }, '重置'),
408
+ ),
409
+ firstUrl ? h('div', { style: { marginTop: 8 } },
410
+ h('div', { style: S.hint }, '手机首次打开(认证一次,30 天内该设备免登录、无弹窗):'),
411
+ h('div', { style: S.row, marginTop: 6 },
412
+ h('code', { style: { ...S.mono, wordBreak: 'break-all', flex: 1 } }, firstUrl),
413
+ h('button', { style: S.btn, onClick: () => copy(firstUrl) }, copied ? '已复制' : '复制'),
414
+ ),
415
+ ) : null,
416
+ )
417
+ : null,
418
+ a.mode === 'basic'
419
+ ? h('div', { style: { marginTop: 10 } },
420
+ h('div', { style: S.row, gap: 10 },
421
+ h('div', null, h('label', { style: S.label }, '账号'),
422
+ h('input', { style: S.input, value: authForm.username, placeholder: a.username || 'dsh', onChange: (e) => setAuthForm(Object.assign({}, authForm, { username: e.target.value })) })),
423
+ h('div', null, h('label', { style: S.label }, '新密码(6 位起)'),
424
+ h('input', { style: S.input, type: 'password', value: authForm.password, onChange: (e) => setAuthForm(Object.assign({}, authForm, { password: e.target.value })) })),
425
+ ),
426
+ h('div', { style: S.row, marginTop: 8 },
427
+ h('button', {
428
+ style: S.btnPrimary, disabled: authBusy || !authForm.password,
429
+ onClick: () => {
430
+ if (authForm.password.length < 6) { setAuthErr('密码至少 6 位'); return }
431
+ runAuth({ username: authForm.username || a.username, password: authForm.password }, '已保存,立即生效;所有已登录设备被踢下线')
432
+ setAuthForm({ username: '', password: '' })
433
+ },
434
+ }, '保存(立即生效)'),
435
+ ),
436
+ h('div', { style: S.hint }, '弹窗输一次账密,这台设备 24 小时内免登录;其他设备仍要各自登录。改密码会把所有设备踢下线。'),
437
+ )
438
+ : null,
439
+ a.mode === 'off' ? h('div', { style: { ...S.hint, marginTop: 8, color: '#f85149' } }, '入口登录已关闭:只剩 DSH 自己的 token 一层,任何拿到地址的人都能看到登录页。') : null,
440
+ authErr ? h('div', { style: S.err, marginTop: 8 }, authErr) : null,
441
+ )
442
+ })(),
443
+ ),
360
444
  h(
361
445
  Card, { title: '第一次用星空组网?' },
362
446
  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.1",
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