dsh-mesh-access 1.1.0 → 1.2.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
@@ -1,5 +1,28 @@
1
1
  # 更新日志
2
2
 
3
+ ## 1.2.0 — 面板里就能填服务器信息
4
+
5
+ 1.1.0 仍然要求你先去跑脚本写 `frpc.toml`,面板只是个看板。现在不用了:
6
+
7
+ - 「公网隧道」卡新增**改配置 / 填服务器信息**:服务器地址、`bindPort`、`remotePort`、本地端口、域名、token 直接在面板里填,保存后自动启动;
8
+ - token 留空表示沿用 `frpc.toml` 里已有的那个(不用每次重填,也不会把密钥显示在页面上);
9
+ - 填了域名,复制的地址就是 **`https://域名/?token=…`**(域名记在同目录的 `dsh-mesh-access.json` 侧车里,`frpc.toml` 保持标准 frp 格式);
10
+ - 输入校验:地址只认 IP/域名、端口必须 1–65535、域名格式检查,错误直接在面板上说明;
11
+ - 面板写出来的 `frpc.toml` 和 `tools/frpc-setup.sh` 生成的是同一种文件,先跑脚本后来用面板改也不会打架。
12
+
13
+ 仍然要**手动做一次**的事(脚本和面板都替你做不了):DNS 里给域名加 A 记录指向服务器、云安全组放行端口、
14
+ 在服务器上跑一次 `tools/frps-setup.sh`(它会顺带把证书和 Basic 认证配好)。
15
+
16
+ ## 1.1.1 — 面板把「外部进程」说清楚
17
+
18
+ 1.1.0 里如果 frpc 是你自己的 systemd 单元在跑,面板会同时显示「frpc 未运行」和「公网可达」,看着自相矛盾。改成:
19
+
20
+ - 第一颗状态改成三态:`frpc 运行中(本插件)` / `frpc 运行中(外部进程)` / `frpc 未运行`;
21
+ - 「停止」按钮在外部进程时禁用并给出提示,告诉你用 `systemctl --user stop dsh-frpc` 停;
22
+ - 说明文字写清为什么不让重复启动(两个 frpc 抢同一个 `remotePort`)。
23
+
24
+ 功能没有变化。
25
+
3
26
  ## 1.1.0 — 把「自己的 VPS 隧道」做进面板
4
27
 
5
28
  组网这条路在某些网络下注定要中继(校园网只能一台设备在线、手机蜂窝 CGNAT),TCP 中继 RTT 800–990 ms,长连接撑不住。所以面板多了一张「公网隧道」卡片,用你自己的服务器 + frp 走第二条路:
package/lib/client.js CHANGED
@@ -41,6 +41,16 @@ window.__ModuleLoader__.load({
41
41
  dot: (on) => ({ width: 7, height: 7, borderRadius: 999, background: on ? '#3fb950' : '#8b949e' }),
42
42
  err: { color: '#f85149', fontSize: 13, marginTop: 8 },
43
43
  hint: { fontSize: 12, opacity: 0.6, marginTop: 10, lineHeight: 1.6 },
44
+ input: {
45
+ padding: '6px 10px',
46
+ borderRadius: 8,
47
+ border: '1px solid rgba(127,127,127,.35)',
48
+ background: 'transparent',
49
+ color: 'inherit',
50
+ fontSize: 13,
51
+ width: 200,
52
+ },
53
+ label: { fontSize: 12, opacity: 0.7, marginBottom: 4, display: 'block' },
44
54
  }
45
55
 
46
56
  /** 带硬超时的 fetch:超时就 abort,绝不让调用方永远挂着。
@@ -61,7 +71,7 @@ window.__ModuleLoader__.load({
61
71
  fetchJson('/api/dsh-mesh-access/tunnel', {
62
72
  method: 'POST',
63
73
  headers: { 'content-type': 'application/json' },
64
- body: JSON.stringify({ action }),
74
+ body: JSON.stringify(typeof action === 'string' ? { action } : action),
65
75
  }, 20000),
66
76
  action: (action, port) =>
67
77
  fetchJson('/api/dsh-mesh-access/proxy', {
@@ -92,6 +102,8 @@ window.__ModuleLoader__.load({
92
102
  const [err, setErr] = react.useState(null)
93
103
  const [tunnelErr, setTunnelErr] = react.useState(null)
94
104
  const [copied, setCopied] = react.useState(false)
105
+ const [formOpen, setFormOpen] = react.useState(false)
106
+ const [form, setForm] = react.useState({})
95
107
 
96
108
  const refresh = react.useCallback(() => {
97
109
  api
@@ -128,6 +140,49 @@ window.__ModuleLoader__.load({
128
140
  .finally(() => setTunnelBusy(false))
129
141
  }
130
142
 
143
+ const runConfig = () => {
144
+ const payload = Object.assign({ action: 'config' }, form)
145
+ setTunnelBusy(true)
146
+ setTunnelErr(null)
147
+ api
148
+ .tunnelAction(payload)
149
+ .then((t) => {
150
+ if (t && t.ok === false) {
151
+ setTunnelErr(t.error ?? '保存失败')
152
+ return null
153
+ }
154
+ setFormOpen(false)
155
+ setForm({})
156
+ refresh()
157
+ // 存完顺手起隧道;起不来也只报错,不静默
158
+ return api.tunnelAction('start')
159
+ })
160
+ .then((t) => {
161
+ if (!t) return
162
+ if (t && t.ok === false) setTunnelErr(t.error ?? '隧道没起来')
163
+ else if (t && t.error) setTunnelErr(t.error)
164
+ refresh()
165
+ })
166
+ .catch((e) => setTunnelErr(String(e && e.message ? e.message : e)))
167
+ .finally(() => setTunnelBusy(false))
168
+ }
169
+
170
+ const openForm = () => {
171
+ const tn0 = tunnel || (state && state.tunnel) || {}
172
+ setForm({
173
+ serverAddr: tn0.serverAddr || '',
174
+ serverPort: String(tn0.serverPort || 7000),
175
+ remotePort: String(tn0.remotePort || 21081),
176
+ localPort: String(tn0.localPort || (state && state.proxy && state.proxy.port) || 3081),
177
+ localIP: tn0.localIP || (state && state.mesh && state.mesh[0] && state.mesh[0].address) || '127.0.0.1',
178
+ domain: tn0.domain || '',
179
+ token: '',
180
+ })
181
+ setFormOpen(true)
182
+ }
183
+
184
+ const setField = (k) => (e) => setForm(Object.assign({}, form, { [k]: e.target.value }))
185
+
131
186
  const run = (action) => {
132
187
  setBusy(true)
133
188
  setErr(null)
@@ -215,7 +270,8 @@ window.__ModuleLoader__.load({
215
270
  h(
216
271
  Card, { title: '公网隧道(自己的 VPS,可选的第二条路)' },
217
272
  h('div', { style: S.row },
218
- h('span', { style: S.pill(Boolean(tn.running)) }, h('span', { style: S.dot(Boolean(tn.running)) }), tn.running ? 'frpc 运行中' : 'frpc 未运行'),
273
+ h('span', { style: S.pill(Boolean(tn.running || tn.external)) }, h('span', { style: S.dot(Boolean(tn.running || tn.external)) }),
274
+ tn.running ? 'frpc 运行中(本插件)' : tn.external ? 'frpc 运行中(外部进程)' : 'frpc 未运行'),
219
275
  h('span', { style: S.pill(Boolean(tn.reachable)) }, h('span', { style: S.dot(Boolean(tn.reachable)) }), tn.reachable ? '公网可达' : '公网不可达'),
220
276
  h('span', { style: S.pill(Boolean(tn.url)) }, h('span', { style: S.dot(Boolean(tn.url)) }), tn.domain ? '域名 ' + tn.domain : tn.remotePort ? '端口 ' + tn.remotePort : '未配置'),
221
277
  ),
@@ -225,9 +281,48 @@ window.__ModuleLoader__.load({
225
281
  disabled: tunnelBusy || Boolean(tn.running) || Boolean(tn.external) || !tn.configFound,
226
282
  onClick: () => runTunnel('start'),
227
283
  }, tunnelBusy ? '处理中…' : tn.external ? '外部进程已在跑' : '启动隧道'),
228
- h('button', { style: S.btn, disabled: tunnelBusy || !tn.running, onClick: () => runTunnel('stop') }, '停止'),
284
+ h('button', {
285
+ style: S.btn,
286
+ disabled: tunnelBusy || !tn.running,
287
+ title: tn.external && !tn.running ? '这个 frpc 是外部进程(systemd 单元),请用 systemctl --user stop dsh-frpc' : '',
288
+ onClick: () => runTunnel('stop'),
289
+ }, '停止'),
229
290
  h('button', { style: S.btn, disabled: tunnelBusy, onClick: refresh }, '刷新'),
291
+ h('button', { style: S.btn, disabled: tunnelBusy, onClick: formOpen ? () => setFormOpen(false) : openForm }, formOpen ? '收起' : tn.configFound ? '改配置' : '填服务器信息'),
230
292
  ),
293
+ formOpen
294
+ ? h('div', { style: { marginTop: 12, padding: 12, borderRadius: 8, border: '1px dashed rgba(127,127,127,.35)' } },
295
+ h('div', { style: S.hint, marginTop: 0, opacity: 0.8 },
296
+ '填的是你自己的服务器。不知道填什么就问给你配服务器的那个人,或者把 docs/AI-SETUP.md 丢给你的 AI。'),
297
+ h('div', { style: S.row, marginTop: 10, gap: 10 },
298
+ h('div', null, h('label', { style: S.label }, '服务器地址(IP 或域名)'),
299
+ h('input', { style: S.input, value: form.serverAddr || '', placeholder: '1.2.3.4', onChange: setField('serverAddr') })),
300
+ h('div', null, h('label', { style: S.label }, '绑定端口 bindPort'),
301
+ h('input', { style: S.input, value: form.serverPort || '', placeholder: '7000', onChange: setField('serverPort') })),
302
+ h('div', null, h('label', { style: S.label }, '公网端口 remotePort'),
303
+ h('input', { style: S.input, value: form.remotePort || '', placeholder: '21081', onChange: setField('remotePort') })),
304
+ ),
305
+ h('div', { style: S.row, marginTop: 10, gap: 10 },
306
+ h('div', null, h('label', { style: S.label }, '域名(可选,填了就用 https)'),
307
+ h('input', { style: S.input, value: form.domain || '', placeholder: 'dsh.example.com', onChange: setField('domain') })),
308
+ h('div', null, h('label', { style: S.label }, '本地端口(组网 IP 上那个)'),
309
+ h('input', { style: S.input, value: form.localPort || '', placeholder: '3081', onChange: setField('localPort') })),
310
+ h('div', null, h('label', { style: S.label }, 'token' + (tn.tokenSet ? '(已存,留空不变)' : '')),
311
+ h('input', {
312
+ style: S.input,
313
+ type: 'password',
314
+ value: form.token || '',
315
+ placeholder: tn.tokenSet ? '••••••••(保持不变)' : '服务端 /etc/frp/frps.toml 里的 auth.token',
316
+ onChange: setField('token'),
317
+ })),
318
+ ),
319
+ h('div', { style: S.row, marginTop: 12 },
320
+ h('button', { style: S.btnPrimary, disabled: tunnelBusy, onClick: runConfig }, tunnelBusy ? '保存中…' : '保存并启动'),
321
+ h('button', { style: S.btn, disabled: tunnelBusy, onClick: () => setFormOpen(false) }, '取消'),
322
+ ),
323
+ h('div', { style: S.hint }, '保存会写 ~/.config/frp/frpc.toml(600)和同目录的 dsh-mesh-access.json(只存域名)。'),
324
+ )
325
+ : null,
231
326
  tunnelErr ? h('div', { style: S.err }, tunnelErr) : null,
232
327
  tn.urlSigned
233
328
  ? h('div', { style: S.row, marginTop: 12 },
@@ -240,7 +335,7 @@ window.__ModuleLoader__.load({
240
335
  ? 'frpc 配置:' + tn.configPath + (tn.serverAddr ? '(' + tn.serverAddr + ',本地 ' + tn.localPort + ' → 远端 ' + tn.remotePort + ')' : '')
241
336
  : '没找到 frpc 配置 ' + (tn.configPath || '') + '。这条是可选的:用你自己的服务器做公网入口,就不受组网中继心跳超时的影响。装法见 docs/VPS.md,或者把 docs/AI-SETUP.md 丢给你的 AI 让它跑 tools/frps-setup.sh + tools/frpc-setup.sh。',
242
337
  tn.external && !tn.running
243
- ? ' 检测到公网已经可达、但 frpc 不是这个进程拉起的(大概是你自己的 systemd 单元在跑),所以这里不让重复启动,免得抢同一个 remotePort。'
338
+ ? ' 现在这个隧道是你自己的外部进程在跑(多半是 systemd 单元 dsh-frpc),所以这里显示为「外部进程」并且不让重复启动,免得抢同一个 remotePort。想停:systemctl --user stop dsh-frpc。'
244
339
  : '',
245
340
  ' 组网走中继时 RTT 800–990 ms(最坏 3.7 s),长连接会被心跳判死;你自己的服务器通常 30–60 ms。',
246
341
  ),
package/lib/index.js CHANGED
@@ -19,6 +19,7 @@ import os from 'node:os'
19
19
  import fs from 'node:fs'
20
20
  import http from 'node:http'
21
21
  import net from 'node:net'
22
+ import path from 'node:path'
22
23
  import { spawn } from 'node:child_process'
23
24
 
24
25
  export const name = 'dsh-mesh-access'
@@ -187,6 +188,7 @@ export function apply(ctx, config = {}) {
187
188
  // 公网隧道(frp)。默认路径与 frp 官网安装脚本一致,用户换位置就在配置里覆盖。
188
189
  frpcBin: asText(config.frpcBin, `${os.homedir()}/.local/bin/frpc`),
189
190
  frpcConfig: asText(config.frpcConfig, `${os.homedir()}/.config/frp/frpc.toml`),
191
+ frpcLog: asText(config.frpcLog, `${os.homedir()}/.config/frp/frpc.log`),
190
192
  // 隧道对外暴露的域名(有 TLS 时写域名,没有就留空 —— 会自动退回 http://serverAddr:remotePort)
191
193
  tunnelDomain: asText(config.tunnelDomain, ''),
192
194
  }
@@ -260,6 +262,83 @@ export function apply(ctx, config = {}) {
260
262
  return Number.isFinite(num) ? num : raw
261
263
  }
262
264
 
265
+ const sidecarPath = () => `${path.dirname(cfg.frpcConfig)}/dsh-mesh-access.json`
266
+
267
+ const readSidecar = () => {
268
+ try {
269
+ return JSON.parse(fs.readFileSync(sidecarPath(), 'utf8'))
270
+ } catch {
271
+ return {}
272
+ }
273
+ }
274
+
275
+ const writeSidecar = (data) => {
276
+ try {
277
+ fs.mkdirSync(path.dirname(sidecarPath()), { recursive: true })
278
+ fs.writeFileSync(sidecarPath(), JSON.stringify(data, null, 2) + '\n')
279
+ } catch {
280
+ /* 侧车只是记域名,写不进去就退回插件配置 */
281
+ }
282
+ }
283
+
284
+ /**
285
+ * 写 frpc.toml + 侧车(域名)。面板上的「设置」和 tools/frpc-setup.sh 产出的是同一种文件,
286
+ * 所以用户先跑脚本、后来改用面板改,两边不会打架。
287
+ */
288
+ const writeTunnelConfig = (input) => {
289
+ const str = (v) => (typeof v === 'string' ? v.trim() : '')
290
+ const serverAddr = str(input.serverAddr)
291
+ const domain = str(input.domain).replace(/^https?:\/\//, '').replace(/\/+$/, '')
292
+ const serverPort = Number(input.serverPort ?? 7000)
293
+ const remotePort = Number(input.remotePort ?? 21081)
294
+ const localPort = Number(input.localPort ?? cfg.proxyPort)
295
+ const localIP = str(input.localIP) || '127.0.0.1'
296
+ const token = str(input.token)
297
+
298
+ if (!/^[A-Za-z0-9._-]+$/.test(serverAddr)) throw new Error('服务器地址只能填 IP 或域名')
299
+ for (const [name, v] of [['绑定端口', serverPort], ['公网端口', remotePort], ['本地端口', localPort]]) {
300
+ if (!Number.isInteger(v) || v < 1 || v > 65535) throw new Error(`${name}要填 1–65535 之间的整数`)
301
+ }
302
+ if (domain && !/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(domain)) throw new Error('域名格式不对,例如 dsh.example.com')
303
+
304
+ let oldToken = ''
305
+ try {
306
+ oldToken = String(tomlValue(fs.readFileSync(cfg.frpcConfig, 'utf8'), 'auth.token') || '')
307
+ } catch {
308
+ /* 没有旧配置就算了 */
309
+ }
310
+ const finalToken = token || oldToken
311
+ if (!finalToken) throw new Error('token 不能为空(服务端 /etc/frp/frps.toml 里的 auth.token)')
312
+
313
+ const text = `# 由 dsh-mesh-access 面板生成
314
+ serverAddr = "${serverAddr}"
315
+ serverPort = ${serverPort}
316
+
317
+ auth.method = "token"
318
+ auth.token = "${finalToken}"
319
+
320
+ log.to = "${cfg.frpcLog}"
321
+ log.level = "info"
322
+
323
+ [[proxies]]
324
+ name = "dsh-mesh"
325
+ type = "tcp"
326
+ localIP = "${localIP}"
327
+ localPort = ${localPort}
328
+ remotePort = ${remotePort}
329
+ transport.useEncryption = true
330
+ `
331
+ fs.mkdirSync(path.dirname(cfg.frpcConfig), { recursive: true })
332
+ fs.writeFileSync(cfg.frpcConfig, text, { mode: 0o600 })
333
+ try {
334
+ fs.chmodSync(cfg.frpcConfig, 0o600)
335
+ } catch {
336
+ /* ignore */
337
+ }
338
+ writeSidecar({ domain: domain || null, updatedAt: new Date().toISOString() })
339
+ return true
340
+ }
341
+
263
342
  const tunnelCfg = () => {
264
343
  let text = ''
265
344
  try {
@@ -271,7 +350,8 @@ export function apply(ctx, config = {}) {
271
350
  const serverPort = Number(tomlValue(text, 'serverPort') ?? 7000)
272
351
  const remotePort = Number(tomlValue(text, 'remotePort') ?? 0)
273
352
  const localPort = Number(tomlValue(text, 'localPort') ?? cfg.proxyPort)
274
- const domain = cfg.tunnelDomain || ''
353
+ const localIP = String(tomlValue(text, 'localIP') || '127.0.0.1')
354
+ const domain = readSidecar().domain || cfg.tunnelDomain || ''
275
355
  // 有域名就走 https://domain/(VPS 上 nginx 已经接管 TLS),否则直连 IP:remotePort
276
356
  const base = domain ? `https://${domain}/` : remotePort && serverAddr ? `http://${serverAddr}:${remotePort}/` : null
277
357
  return {
@@ -282,6 +362,8 @@ export function apply(ctx, config = {}) {
282
362
  serverPort,
283
363
  remotePort,
284
364
  localPort,
365
+ localIP,
366
+ tokenSet: Boolean(tomlValue(text, 'auth.token')),
285
367
  domain: domain || null,
286
368
  url: base,
287
369
  urlSigned: signed(base),
@@ -558,6 +640,14 @@ export function apply(ctx, config = {}) {
558
640
  const action = String(body.action ?? '')
559
641
  if (action === 'start') return json(res, 200, await startTunnel())
560
642
  if (action === 'stop') return json(res, 200, await stopTunnel())
643
+ if (action === 'config') {
644
+ try {
645
+ writeTunnelConfig(body)
646
+ } catch (err) {
647
+ return json(res, 400, { ok: false, error: String(err?.message ?? err) })
648
+ }
649
+ return json(res, 200, { ok: true, ...tunnelSnapshot() })
650
+ }
561
651
  return json(res, 400, { ok: false, error: "action 只能是 'start' 或 'stop'" })
562
652
  },
563
653
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mesh-access",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",