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 +21 -0
- package/README.en.md +12 -0
- package/README.md +17 -0
- package/docs/AI-SETUP.md +3 -4
- package/docs/VPS.md +5 -3
- package/lib/client.js +84 -0
- package/lib/index.js +163 -3
- package/package.json +1 -1
- package/tools/frps-setup.sh +2 -2
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
70
|
-
|
|
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
|
+
"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",
|
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
|
|