dsh-mesh-access 1.0.4 → 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,43 @@
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
+
26
+ ## 1.1.0 — 把「自己的 VPS 隧道」做进面板
27
+
28
+ 组网这条路在某些网络下注定要中继(校园网只能一台设备在线、手机蜂窝 CGNAT),TCP 中继 RTT 800–990 ms,长连接撑不住。所以面板多了一张「公网隧道」卡片,用你自己的服务器 + frp 走第二条路:
29
+
30
+ - 新增 `GET/POST /api/dsh-mesh-access/tunnel`:读 `~/.config/frp/frpc.toml`(`serverAddr` / `serverPort` / `localPort` / `remotePort`),面板显示公网地址与连通性;
31
+ - 「启动隧道」在本进程内 `spawn frpc -c frpc.toml`,等到 `start proxy success` 才算成功(8 秒超时,失败把 frpc 的日志贴到面板上);
32
+ - 「公网可达」是**真连一次**面板域名/端口再判断,不是看进程在不在;
33
+ - 新增配置 `frpcBin` / `frpcConfig` / `tunnelDomain`(有域名就走 `https://域名/`,没有就退回 `http://IP:remotePort/`);
34
+ - 新增 `tools/frps-setup.sh`:在你**自己的**服务器上一把装好 frps(systemd、随机 token、防火墙、可选 nginx + Let's Encrypt + Basic 认证),结尾打印本机要用的命令;
35
+ - 新增 `tools/frpc-setup.sh`:在本机装 frpc、写 `frpc.toml`、写(不 enable)用户单元,缺参数时交互提问;
36
+ - 新增 `docs/VPS.md`(给人看)与 `docs/AI-SETUP.md`(给 AI 执行:硬规则、运行顺序、四条验证、故障表);
37
+ - 面板文案改成「用你自己的服务器」,并在检测到公网可达但 frpc 不是本进程拉起时禁用启动按钮(避免两个 frpc 抢同一个 remotePort)。
38
+
39
+ 配套:`README` 增「用你自己的服务器(可选)」,`docker`/`systemd` 不是必需项。
40
+
3
41
  ## 1.0.4 — 把「性能与限制」写清楚
4
42
 
5
43
  功能不变。补了一节实测表格与判断方法,用来回答「卡是不是插件的问题」:
package/README.en.md CHANGED
@@ -49,6 +49,7 @@ Restart the profile. A **组网访问 / Mesh access** entry appears in the left
49
49
  | 当前状态 | proxy state, DSH's own listen port, how many mesh interfaces were found |
50
50
  | 动作 | open the mesh port / close it / refresh |
51
51
  | 手机地址 | the URL to open on the phone, with a copy button |
52
+ | Public tunnel (optional) | use **your own** server as a public entry point: start/stop frpc, show the public URL, probe it for real |
52
53
  | 第一次用星空组网? | the referral sign-up link and the member-account rule |
53
54
 
54
55
  ## HTTP surface
@@ -142,6 +143,30 @@ mesh works — a phone or tablet, another Windows/macOS/Linux laptop, a NAS with
142
143
  Requirements are only: same mesh, a route to the mesh IP, and a browser. **Nothing** has to be
143
144
  installed on the other device, and no SSH is involved.
144
145
 
146
+ ## Use your own server (optional)
147
+
148
+ The mesh path is fine on a LAN or a P2P link, but it can fall back to a public relay: measured RTT 800-990 ms (worst
149
+ 3.7 s), and long-lived connections get dropped by heartbeat timeouts — on a phone that looks like a page stuck on
150
+ "reconnecting". If you have your own server with a public IP, point the tunnel at it: usually 30-60 ms.
151
+
152
+ **We do not provide a server, and you do not use somebody else's.** It is your box, your credentials. Two scripts ship
153
+ in this repo:
154
+
155
+ ```bash
156
+ # 1) on your server (root): install frps
157
+ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
158
+ # with HTTPS: add --domain dsh.example.com --email you@example.com
159
+
160
+ # 2) on the machine running DSH: install frpc and point it at that server
161
+ ./tools/frpc-setup.sh --server <server-ip> --token <token printed in step 1> --remote-port 21081
162
+ ```
163
+
164
+ Then open "组网访问" → "公网隧道" → start. Step 1 prints the token and the exact step-2 command for you.
165
+ The cloud security group must allow **7000/tcp** and **21081/tcp** — only you can do that.
166
+
167
+ Full guide (prerequisites, domain/TLS/Basic auth, troubleshooting, uninstall): **[docs/VPS.md](docs/VPS.md)**.
168
+ To let an AI do the setup, hand it **[docs/AI-SETUP.md](docs/AI-SETUP.md)**.
169
+
145
170
  ## Companion
146
171
 
147
172
  The shell-script edition (Linux/macOS/Windows launchers that start a *second* dsh instance bound
package/README.md CHANGED
@@ -50,8 +50,31 @@ dsh plugin --profile web add link:/绝对路径/dsh-mesh-access
50
50
  | 当前状态 | 反代是否在跑、DSH 自己的监听端口、找到几张组网网卡 |
51
51
  | 动作 | 在组网地址上开端口 / 关闭 / 刷新 |
52
52
  | 访问地址 | 给组网内任何设备打开的地址(带 token,可直接复制) |
53
+ | 公网隧道(可选) | 用**你自己的**服务器做公网入口:启停 frpc、显示公网地址、真实探测是否可达 |
53
54
  | 第一次用星空组网? | 邀请注册链接 + 成员账号规则 |
54
55
 
56
+ ## 用你自己的服务器(可选)
57
+
58
+ 组网那条路对局域网/P2P 很够用,但它要过公网中继:实测 RTT 800–990 ms(最坏 3.7 s),长连接会被心跳判死,
59
+ 手机上就表现为一直「重新连接中」。如果你自己有一台带公网 IP 的服务器,可以把它当公网入口,延迟通常 30–60 ms。
60
+
61
+ **插件不提供服务器,也不用别人的服务器** —— 这一步完全是你自己的机器。两个脚本,都在这个仓库里:
62
+
63
+ ```bash
64
+ # 1) 在你自己的服务器上(root):装 frps
65
+ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
66
+ # 想直接上 HTTPS:加 --domain dsh.example.com --email you@example.com
67
+
68
+ # 2) 在跑 DSH 的这台电脑上:装 frpc,指向上面那台服务器
69
+ ./tools/frpc-setup.sh --server <服务器IP> --token <第 1 步打印的 token> --remote-port 21081
70
+ ```
71
+
72
+ 然后回到左侧「组网访问」→「公网隧道」→ 启动隧道。第 1 步会打印 token 和给你复制的第 2 步命令;
73
+ 安全组要放行 **7000/tcp** 与 **21081/tcp**(这一步只能在云控制台做)。
74
+
75
+ 完整说明(前置条件、域名/TLS/Basic 认证、故障表、卸载)见 **[docs/VPS.md](docs/VPS.md)**。
76
+ 想让 AI 帮你配:把 **[docs/AI-SETUP.md](docs/AI-SETUP.md)** 整份丢给它,它就知道该跑什么、怎么验证。
77
+
55
78
  ## HTTP 接口
56
79
 
57
80
  面板只是两个接口的外壳,故意做得极小:
@@ -0,0 +1,112 @@
1
+ # AI-SETUP — give this file to an AI agent
2
+
3
+ You are helping a human expose **their own** DeepSeek Harness instance on **their own** server, so they can open it
4
+ from a phone or another computer over the public internet. Read all of it before running anything.
5
+
6
+ This is not a hosted service. You never get a server from us. The human provides one (any Linux VPS with a public IP,
7
+ or a home box with port forwarding). If they have none, stop and tell them the mesh-only path already works inside
8
+ their StarVPN/Tailscale/WireGuard network — a public entry point is optional.
9
+
10
+ ## 0. Hard rules
11
+
12
+ 1. **Never invent credentials.** The frp token and the Basic-auth password are generated by the scripts on the
13
+ human's machine. Do not type your own, do not reuse one from a doc example.
14
+ 2. **Only touch the human's own machines.** The server must be theirs. Do not point `frpc` at a server you cannot
15
+ justify, and do not run these scripts on a host they have not named.
16
+ 3. **Do not open more public ports than necessary.** Two TCP ports, nothing else: `bindPort` (default 7000) and the
17
+ web entry (`remotePort`, default 21081). If they go the domain route, 80 is needed once for the ACME challenge.
18
+ 4. **Do not disable TLS verification, do not run `curl -k` in a verification step** and call it success. Use the real
19
+ certificate or accept that the check failed and say so.
20
+ 5. **Do not edit the plugin's `lib/*.js`.** Configuration lives in three keys (`frpcBin`, `frpcConfig`,
21
+ `tunnelDomain`). If the human wants different behaviour, that is a feature request, not a patch.
22
+ 6. **Never enable the frpc unit.** `dsh-frpc.service` is written but intentionally not enabled; either the panel
23
+ starts `frpc`, or the human starts the unit by hand. Two frpc processes fight over the same `remotePort`.
24
+ 7. **Verify before you claim success** (§4). "The script finished" is not verification.
25
+
26
+ ## 1. What the pieces are
27
+
28
+ ```
29
+ phone ──https──> their server: frps ──frp tunnel──> their computer: frpc ──> 192.168.188.1:3081 ──> DSH 127.0.0.1:3080
30
+ ```
31
+
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.
34
+ - `tools/frpc-setup.sh` — runs on the **computer** (normal user). Installs `frpc` if missing, writes
35
+ `~/.config/frp/frpc.toml`, writes the systemd **user** unit.
36
+ - The plugin reads `~/.config/frp/frpc.toml` and shows a "public tunnel" card: start / stop / live status / public URL.
37
+
38
+ The port the tunnel points at is **not** DSH's own port. It is the mesh address the *plugin* opened, e.g.
39
+ `192.168.188.1:3081`. The human must press "在组网地址上开端口" (open a port on the mesh address) once; otherwise
40
+ the tunnel comes up and every request 502s.
41
+
42
+ ## 2. Run order
43
+
44
+ ```bash
45
+ # A) on the server, as root — ask the human for: nothing but consent and the optional domain
46
+ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
47
+ # with a domain (recommended, gives https + browser login prompt):
48
+ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081 --domain dsh.example.com --email them@example.com
49
+
50
+ # B) copy from the output of A: server address, token, ports. Then on the computer:
51
+ ./tools/frpc-setup.sh --server <IP or domain> --port 7000 --token <token> \
52
+ --remote-port 21081 [--domain dsh.example.com]
53
+ ```
54
+
55
+ Both scripts are idempotent. Re-running A keeps the existing token unless `--token` is passed; re-running B rewrites
56
+ the config file.
57
+
58
+ ## 3. The one manual step you must also do
59
+
60
+ Tell the human to open **Cloud console → Security group / firewall** and allow the two TCP ports. A script cannot do
61
+ this, and no amount of debugging on the box will fix a blocked port.
62
+
63
+ ## 4. Verification (all four, in order)
64
+
65
+ ```bash
66
+ # 1. server: frps is up and listening
67
+ systemctl is-active frps && ss -Hltn | grep -E ':(7000|21081)\b'
68
+
69
+ # 2. server: frpc actually connected (look for a client, not just the listener)
70
+ tail -20 /var/log/frp/frps.log
71
+
72
+ # 3. computer: frpc logged in and proxy started
73
+ tail -20 ~/.config/frp/frpc.log # expect: "login to server success" + "[dsh-mesh] start proxy success"
74
+
75
+ # 4. end to end from outside — plain HTTP entry:
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:
78
+ curl -s -o /dev/null -w '%{http_code}\n' -u dsh:<password> "https://<domain>/" # 401 again, still correct
79
+
80
+ # 5. WebSocket path must answer 101, not 403/404 (this is what "keeps reconnecting" looks like when broken):
81
+ curl -si --http1.1 -u dsh:<password> \
82
+ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
83
+ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
84
+ -H "Origin: https://<domain>" "https://<domain>/api/remote.mux" | head -1
85
+ ```
86
+
87
+ `101 Switching Protocols` = good. `403` = the proxy rewrote `Host` but not `Origin` (plugin < 1.0.1, or your own
88
+ nginx overriding headers). `404` = you are on HTTP/2; browsers open WS over 1.1, but a `--http1.1` flag is needed
89
+ when you test with curl.
90
+
91
+ Then have the human open the URL on the phone, and confirm their **whole session list** appears and a message typed on
92
+ the computer shows up on the phone within a second.
93
+
94
+ ## 5. Failure table
95
+
96
+ | Symptom | Cause | Fix |
97
+ |---|---|---|
98
+ | `curl http://server:21081/` times out | security group blocks 21081 | console → allow TCP 21081 (and 7000) |
99
+ | `connection refused` on 21081 | frpc never connected | `journalctl -u frps -n 50`, check token equality |
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
+ | page loads, then "reconnecting" | WebSocket not forwarded | `proxy_http_version 1.1` + `Upgrade`/`Connection` headers |
103
+ | `start error: port already used` | two frpc on one `remotePort` | stop the systemd unit **or** the panel tunnel, keep one |
104
+ | certbot fails | 80 blocked, or DNS not pointing here yet | check A record, open 80, rerun with `--domain` |
105
+ | works on Wi-Fi, fails on cellular | frpc points at a LAN address | `--server` must be the public IP or domain |
106
+
107
+ ## 6. Report back with
108
+
109
+ - which machine is the server (distro), which is the computer
110
+ - `bindPort`, `remotePort`, whether a domain/TLS was used
111
+ - the four verification outputs from §4, pasted as-is
112
+ - anything that failed, including the exact error line
package/docs/VPS.md ADDED
@@ -0,0 +1,104 @@
1
+ # 用自己的服务器做公网入口(可选)
2
+
3
+ 插件默认只把 DSH 挂到**组网网卡**上——手机、平板、另一台电脑在同一个星空组网里就能打开。
4
+ 这条路的代价是流量要过组网中继:实测 RTT 800–990 ms(最坏 3.7 s),长连接会被心跳判死,表现为手机页面
5
+ 一直「重新连接中」。
6
+
7
+ 如果你**自己有一台带公网 IP 的服务器**(阿里云轻量、腾讯云、搬瓦工、家里的固定 IP 都行),可以让它当公网入口,
8
+ 延迟通常 30–60 ms,稳定得多。插件不是要你用别人的服务器——这一步完全是你自己的机器,你自己掌握账号密码。
9
+
10
+ ```
11
+ 手机 / 平板 / 公司电脑
12
+ │ https://你的域名 (可选,走 nginx + 证书)
13
+ ▼
14
+ 你自己的服务器 frps ←── 公网,只开一个 7000
15
+ │ 隧道(frp,token 认证 + 加密)
16
+ ▼
17
+ 你的电脑 frpc → 192.168.188.1:3081(插件在组网地址上开出来的那个口)
18
+ │
19
+ ▼
20
+ DSH 本体 127.0.0.1:3080
21
+ ```
22
+
23
+ ## 一句话版本
24
+
25
+ ```bash
26
+ # 1) 在你自己的服务器上(root)
27
+ sudo ./tools/frps-setup.sh --port 7000 --http-port 21081
28
+ # 想直接上 HTTPS:加 --domain dsh.example.com --email you@example.com
29
+
30
+ # 2) 在你跑 DSH 的电脑上
31
+ ./tools/frpc-setup.sh --server <服务器IP> --port 7000 \
32
+ --token <第 1 步打印的 token> --remote-port 21081 [--domain dsh.example.com]
33
+
34
+ # 3) 回到 DSH 左侧「组网访问」面板 → 公网隧道卡片 → 启动隧道
35
+ ```
36
+
37
+ 第 1 步结束会打印 token、端口和给你复制粘贴的第 2 步命令。两个脚本都可以重复跑(幂等)。
38
+
39
+ ## 前置条件
40
+
41
+ | 项 | 要求 |
42
+ |---|---|
43
+ | 服务器 | Linux(systemd),1 核 512 MB 够用,公网 IP |
44
+ | 端口 | 云控制台安全组放行 **7000/tcp**(frp 控制)和 **21081/tcp**(网页入口) |
45
+ | 域名 | 可选。要用 https 才需要,且要先把 A 记录指到服务器 |
46
+ | 本机 | 已经能在面板上「在组网地址上开端口」;`~/.local/bin` 在 PATH 上 |
47
+
48
+ ## 两个脚本各自做什么
49
+
50
+ `tools/frps-setup.sh`(服务器上,root):
51
+
52
+ 1. 装 frp v0.72.0 的 `frps` 到 `/usr/local/bin/frps`(GitHub 不通时自动走 `gh-proxy.com`)
53
+ 2. 写 `/etc/frp/frps.toml`:`bindPort`、`auth.token`(没给就随机生成 24 位)、日志、本机管理面板 `127.0.0.1:7500`
54
+ 3. 建 `frps.service` 并 enable + start,核对 `:7000` 真的在听
55
+ 4. firewalld / ufw 有就放行 7000
56
+ 5. 给了 `--domain` 且有 nginx:写 ACME 探针、申请 Let's Encrypt 证书、写 443 反代、加 Basic 认证(随机密码)
57
+
58
+ `tools/frpc-setup.sh`(你的电脑上,普通用户):
59
+
60
+ 1. 没 `frpc` 就装到 `~/.local/bin/frpc`
61
+ 2. 写 `~/.config/frp/frpc.toml`(600):`serverAddr`、`serverPort`、token、`[[proxies]]` 映射
62
+ `192.168.188.1:3081 → 服务器:21081`,开 `transport.useEncryption`
63
+ 3. 检查组网端口是否已经在听(不对就去面板点「在组网地址上开端口」)
64
+ 4. 写 `~/.config/systemd/user/dsh-frpc.service`(**不 enable**,按需启动;插件自己也能拉起 frpc)
65
+
66
+ ## 认证是两层
67
+
68
+ 1. **DSH 自己的 token**:面板里「访问地址」那一行就是带 `?token=` 的完整地址,换完 cookie 30 天有效。
69
+ 2. **你加的入口认证**(推荐):`frps-setup.sh --domain` 会顺手配好 nginx Basic 认证,浏览器打开先弹账号密码框。
70
+ 不用 Basic 也可以只暴露 IP:端口——但那样等于把 DSH 登录页直接挂在公网,靠 token 一层兜底。
71
+
72
+ 想更硬一点:把 nginx 换成 mTLS(客户端证书)或者只允许公司 IP 访问(`allow`/`deny`),这些都在 nginx 那一层做,
73
+ 插件不用改。
74
+
75
+ ## 常见问题
76
+
77
+ | 现象 | 原因 | 处理 |
78
+ |---|---|---|
79
+ | 面板显示「frpc 未运行」但公网能打开 | 你自己的 systemd 单元在跑 frpc | 正常。面板会显示「外部进程已在跑」,不让重复启动 |
80
+ | 面板「公网不可达」 | 安全组没放行、frps 没起、`remotePort` 冲突 | 服务器上 `ss -Hltn \| grep 21081`、`journalctl -u frps -n 50` |
81
+ | 隧道起来了,打开是 502 | 本机 `192.168.188.1:3081` 没监听 | 回面板点「在组网地址上开端口」;`ss -Hltn \| grep 3081` |
82
+ | https 打开 500 | `/etc/nginx/.dsh_htpasswd` 权限不对 | `chown root:<nginx组>` + `chmod 640`(600 会让 worker 读不到) |
83
+ | 页面能开但一直「重新连接中」 | WebSocket 没被转发 | nginx 里必须有 `Upgrade`/`Connection "upgrade"`,且 `proxy_http_version 1.1` |
84
+ | `frpc` 报 `start error: port already used` | 同一 `remotePort` 被占 | 换 `--remote-port`,或在 frps 上 `allowPorts` 收窄 |
85
+
86
+ ## 只想临时用一下
87
+
88
+ ```bash
89
+ # 本机前台跑,Ctrl+C 就停
90
+ ~/.local/bin/frpc -c ~/.config/frp/frpc.toml
91
+ ```
92
+
93
+ ## 卸载
94
+
95
+ ```bash
96
+ # 本机
97
+ systemctl --user disable --now dsh-frpc 2>/dev/null
98
+ rm -f ~/.config/systemd/user/dsh-frpc.service ~/.config/frp/frpc.toml ~/.local/bin/frpc
99
+
100
+ # 服务器
101
+ systemctl disable --now frps
102
+ rm -f /etc/systemd/system/frps.service /etc/frp/frps.toml /usr/local/bin/frps
103
+ rm -f /etc/nginx/conf.d/dsh-https.conf /etc/nginx/conf.d/dsh-acme.conf
104
+ ```
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,绝不让调用方永远挂着。
@@ -56,6 +66,13 @@ window.__ModuleLoader__.load({
56
66
 
57
67
  const api = {
58
68
  status: () => fetchJson('/api/dsh-mesh-access/status', {}, 6000),
69
+ tunnel: () => fetchJson('/api/dsh-mesh-access/tunnel', {}, 8000),
70
+ tunnelAction: (action) =>
71
+ fetchJson('/api/dsh-mesh-access/tunnel', {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify(typeof action === 'string' ? { action } : action),
75
+ }, 20000),
59
76
  action: (action, port) =>
60
77
  fetchJson('/api/dsh-mesh-access/proxy', {
61
78
  method: 'POST',
@@ -79,9 +96,14 @@ window.__ModuleLoader__.load({
79
96
 
80
97
  function Panel() {
81
98
  const [state, setState] = react.useState(null)
99
+ const [tunnel, setTunnel] = react.useState(null)
82
100
  const [busy, setBusy] = react.useState(false)
101
+ const [tunnelBusy, setTunnelBusy] = react.useState(false)
83
102
  const [err, setErr] = react.useState(null)
103
+ const [tunnelErr, setTunnelErr] = react.useState(null)
84
104
  const [copied, setCopied] = react.useState(false)
105
+ const [formOpen, setFormOpen] = react.useState(false)
106
+ const [form, setForm] = react.useState({})
85
107
 
86
108
  const refresh = react.useCallback(() => {
87
109
  api
@@ -91,6 +113,10 @@ window.__ModuleLoader__.load({
91
113
  setErr(s && s.ok === false ? s.error ?? '未知错误' : null)
92
114
  })
93
115
  .catch((e) => setErr(String(e && e.message ? e.message : e)))
116
+ api
117
+ .tunnel()
118
+ .then((t) => setTunnel(t))
119
+ .catch(() => {})
94
120
  }, [])
95
121
 
96
122
  react.useEffect(() => {
@@ -99,6 +125,64 @@ window.__ModuleLoader__.load({
99
125
  return () => clearInterval(t)
100
126
  }, [refresh])
101
127
 
128
+ const runTunnel = (action) => {
129
+ setTunnelBusy(true)
130
+ setTunnelErr(null)
131
+ api
132
+ .tunnelAction(action)
133
+ .then((t) => {
134
+ if (t && t.ok === false) setTunnelErr(t.error ?? '操作失败')
135
+ else setTunnelErr(t && t.error ? t.error : null)
136
+ setTunnel(t && t.tunnel ? t.tunnel : t)
137
+ refresh()
138
+ })
139
+ .catch((e) => setTunnelErr(String(e && e.message ? e.message : e)))
140
+ .finally(() => setTunnelBusy(false))
141
+ }
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
+
102
186
  const run = (action) => {
103
187
  setBusy(true)
104
188
  setErr(null)
@@ -135,6 +219,7 @@ window.__ModuleLoader__.load({
135
219
  const host = (state && state.host) || {}
136
220
  const url = proxy.url
137
221
  const referral = (state && state.referral) || 'https://starvpn.cn/register.html?referralCode=Kurali'
222
+ const tn = tunnel || (state && state.tunnel) || {}
138
223
 
139
224
  return h(
140
225
  'div',
@@ -182,6 +267,83 @@ window.__ModuleLoader__.load({
182
267
  )
183
268
  : null,
184
269
 
270
+ h(
271
+ Card, { title: '公网隧道(自己的 VPS,可选的第二条路)' },
272
+ h('div', { style: S.row },
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 未运行'),
275
+ h('span', { style: S.pill(Boolean(tn.reachable)) }, h('span', { style: S.dot(Boolean(tn.reachable)) }), tn.reachable ? '公网可达' : '公网不可达'),
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 : '未配置'),
277
+ ),
278
+ h('div', { style: S.row, marginTop: 12 },
279
+ h('button', {
280
+ style: S.btnPrimary,
281
+ disabled: tunnelBusy || Boolean(tn.running) || Boolean(tn.external) || !tn.configFound,
282
+ onClick: () => runTunnel('start'),
283
+ }, tunnelBusy ? '处理中…' : tn.external ? '外部进程已在跑' : '启动隧道'),
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
+ }, '停止'),
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 ? '改配置' : '填服务器信息'),
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,
326
+ tunnelErr ? h('div', { style: S.err }, tunnelErr) : null,
327
+ tn.urlSigned
328
+ ? h('div', { style: S.row, marginTop: 12 },
329
+ h('code', { style: { ...S.mono, wordBreak: 'break-all' } }, tn.urlSigned),
330
+ h('button', { style: S.btn, onClick: () => copy(tn.urlSigned) }, copied ? '已复制' : '复制'),
331
+ )
332
+ : null,
333
+ h('div', { style: S.hint },
334
+ tn.configFound
335
+ ? 'frpc 配置:' + tn.configPath + (tn.serverAddr ? '(' + tn.serverAddr + ',本地 ' + tn.localPort + ' → 远端 ' + tn.remotePort + ')' : '')
336
+ : '没找到 frpc 配置 ' + (tn.configPath || '') + '。这条是可选的:用你自己的服务器做公网入口,就不受组网中继心跳超时的影响。装法见 docs/VPS.md,或者把 docs/AI-SETUP.md 丢给你的 AI 让它跑 tools/frps-setup.sh + tools/frpc-setup.sh。',
337
+ tn.external && !tn.running
338
+ ? ' 现在这个隧道是你自己的外部进程在跑(多半是 systemd 单元 dsh-frpc),所以这里显示为「外部进程」并且不让重复启动,免得抢同一个 remotePort。想停:systemctl --user stop dsh-frpc。'
339
+ : '',
340
+ ' 组网走中继时 RTT 800–990 ms(最坏 3.7 s),长连接会被心跳判死;你自己的服务器通常 30–60 ms。',
341
+ ),
342
+ tn.log && tn.log.length
343
+ ? h('div', { style: { ...S.hint, ...S.mono, marginTop: 8, whiteSpace: 'pre-wrap', opacity: 0.5 } }, tn.log.slice(-4).join('\n'))
344
+ : null,
345
+ ),
346
+
185
347
  h(
186
348
  Card, { title: '第一次用星空组网?' },
187
349
  h('div', { style: S.hint }, '三步走,不用记 IP,也不用改任何配置。'),
package/lib/index.js CHANGED
@@ -9,10 +9,18 @@
9
9
  * 1. 找出组网网卡(StarVPN / utun* / tap* / tun* / wg* / gpd* / ppp*)上的 IPv4
10
10
  * 2. GET /api/dsh-mesh-access/status 看状态
11
11
  * 3. POST /api/dsh-mesh-access/proxy 启停反代({action:'start'|'stop', port?})
12
+ * 4. GET/POST /api/dsh-mesh-access/tunnel 公网隧道(frpc)状态与启停
13
+ *
14
+ * 组网只适合「两端都能连上同一个虚拟网」的场景;校园网/蜂窝网络下走中继,
15
+ * 心跳撑不住长连接。所以额外支持「自己的 VPS + frp」:这里只管 spawn/停止 frpc,
16
+ * 公网地址与证书交给 VPS 上已有的 nginx。
12
17
  */
13
18
  import os from 'node:os'
19
+ import fs from 'node:fs'
14
20
  import http from 'node:http'
15
21
  import net from 'node:net'
22
+ import path from 'node:path'
23
+ import { spawn } from 'node:child_process'
16
24
 
17
25
  export const name = 'dsh-mesh-access'
18
26
  export const inject = ['webServer', 'connection']
@@ -177,9 +185,25 @@ export function apply(ctx, config = {}) {
177
185
  addressPrefix: typeof config.addressPrefix === 'string' ? config.addressPrefix : '192.168.',
178
186
  proxyPort: asPort(config.proxyPort, 3081),
179
187
  upstream: asText(config.upstream, '127.0.0.1'),
188
+ // 公网隧道(frp)。默认路径与 frp 官网安装脚本一致,用户换位置就在配置里覆盖。
189
+ frpcBin: asText(config.frpcBin, `${os.homedir()}/.local/bin/frpc`),
190
+ frpcConfig: asText(config.frpcConfig, `${os.homedir()}/.config/frp/frpc.toml`),
191
+ frpcLog: asText(config.frpcLog, `${os.homedir()}/.config/frp/frpc.log`),
192
+ // 隧道对外暴露的域名(有 TLS 时写域名,没有就留空 —— 会自动退回 http://serverAddr:remotePort)
193
+ tunnelDomain: asText(config.tunnelDomain, ''),
180
194
  }
181
195
 
182
- const state = { server: null, address: null, port: null, startedAt: null, error: null }
196
+ const state = {
197
+ server: null,
198
+ address: null,
199
+ port: null,
200
+ startedAt: null,
201
+ error: null,
202
+ frpc: null,
203
+ tunnelLog: [],
204
+ tunnelError: null,
205
+ tunnelStartedAt: null,
206
+ }
183
207
 
184
208
  const upstreamPort = () => ctx.webServer?.port ?? null
185
209
 
@@ -221,9 +245,288 @@ export function apply(ctx, config = {}) {
221
245
  error: state.error,
222
246
  },
223
247
  referral: REFERRAL,
248
+ tunnel: tunnelSnapshot(),
249
+ }
250
+ }
251
+
252
+ /* ---------------------------------------------------------------- 公网隧道 */
253
+ // frp 的配置是 TOML,但这里只读几个扁平键,手写一个极简取值器就够 ——
254
+ // 引入 TOML 解析库只为读 4 个字符串不划算(插件想保持零运行时依赖)。
255
+ const tomlValue = (text, key) => {
256
+ const m = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, 'm'))
257
+ if (!m) return null
258
+ const raw = m[1].split('#')[0].trim()
259
+ const quoted = raw.match(/^"(.*)"$/)
260
+ if (quoted) return quoted[1]
261
+ const num = Number(raw)
262
+ return Number.isFinite(num) ? num : raw
263
+ }
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
+
342
+ const tunnelCfg = () => {
343
+ let text = ''
344
+ try {
345
+ text = fs.readFileSync(cfg.frpcConfig, 'utf8')
346
+ } catch {
347
+ text = ''
348
+ }
349
+ const serverAddr = tomlValue(text, 'serverAddr')
350
+ const serverPort = Number(tomlValue(text, 'serverPort') ?? 7000)
351
+ const remotePort = Number(tomlValue(text, 'remotePort') ?? 0)
352
+ const localPort = Number(tomlValue(text, 'localPort') ?? cfg.proxyPort)
353
+ const localIP = String(tomlValue(text, 'localIP') || '127.0.0.1')
354
+ const domain = readSidecar().domain || cfg.tunnelDomain || ''
355
+ // 有域名就走 https://domain/(VPS 上 nginx 已经接管 TLS),否则直连 IP:remotePort
356
+ const base = domain ? `https://${domain}/` : remotePort && serverAddr ? `http://${serverAddr}:${remotePort}/` : null
357
+ return {
358
+ configFound: Boolean(text),
359
+ configPath: cfg.frpcConfig,
360
+ binary: cfg.frpcBin,
361
+ serverAddr,
362
+ serverPort,
363
+ remotePort,
364
+ localPort,
365
+ localIP,
366
+ tokenSet: Boolean(tomlValue(text, 'auth.token')),
367
+ domain: domain || null,
368
+ url: base,
369
+ urlSigned: signed(base),
370
+ }
371
+ }
372
+
373
+ /** 探测「公网入口是否真的连得上」——比看进程在不在更接近用户感受。 */
374
+ const probe = (host, port, ms = 1500) =>
375
+ new Promise((resolve) => {
376
+ if (!host || !port) return resolve(false)
377
+ const socket = net.connect({ host, port })
378
+ const done = (ok) => {
379
+ socket.destroy()
380
+ resolve(ok)
381
+ }
382
+ socket.setTimeout(ms)
383
+ socket.once('connect', () => done(true))
384
+ socket.once('timeout', () => done(false))
385
+ socket.once('error', () => done(false))
386
+ })
387
+
388
+ const tunnelSnapshot = () => {
389
+ const t = tunnelCfg()
390
+ const running = Boolean(state.frpc)
391
+ return {
392
+ ...t,
393
+ running,
394
+ startedAt: state.tunnelStartedAt,
395
+ error: state.tunnelError,
396
+ log: state.tunnelLog.slice(-12),
397
+ // 进程在跑 ≠ 公网通;反之进程没了但端口仍可连(外部 systemd 起的)也算可用。
398
+ // 这里只给「本插件在看什么」,真正的连通性由 GET /tunnel 现测。
399
+ mode: running ? 'plugin' : 'stopped',
224
400
  }
225
401
  }
226
402
 
403
+ let startingTunnel = null
404
+ // frpc 默认把日志写进配置里的 log.to,stdout 是空的 —— 所以既要抓管道,也要跟着日志文件读。
405
+ function logFileOffset(logPath) {
406
+ try {
407
+ return fs.statSync(logPath).size
408
+ } catch {
409
+ return 0
410
+ }
411
+ }
412
+
413
+ function startTunnel() {
414
+ if (state.frpc) return Promise.resolve({ ok: true, ...snapshot() })
415
+ if (startingTunnel) return startingTunnel
416
+ startingTunnel = new Promise((resolve) => {
417
+ const t = tunnelCfg()
418
+ if (!t.configFound) {
419
+ state.tunnelError = `没找到 frpc 配置:${cfg.frpcConfig}(先按 docs/VPS.md 装 frpc 并写好 frpc.toml)`
420
+ return resolve({ ok: false, error: state.tunnelError })
421
+ }
422
+ let text = ''
423
+ try {
424
+ text = fs.readFileSync(cfg.frpcConfig, 'utf8')
425
+ } catch {
426
+ /* 读不到就当没有 log.to */
427
+ }
428
+ const logPath = tomlValue(text, 'log.to') || ''
429
+ let child
430
+ try {
431
+ child = spawn(cfg.frpcBin, ['-c', cfg.frpcConfig], { stdio: ['ignore', 'pipe', 'pipe'] })
432
+ } catch (err) {
433
+ state.tunnelError = String(err?.message ?? err)
434
+ return resolve({ ok: false, error: state.tunnelError })
435
+ }
436
+ state.frpc = child
437
+ state.tunnelError = null
438
+ state.tunnelStartedAt = Date.now()
439
+ let settled = false
440
+ let offset = logPath ? logFileOffset(logPath) : 0
441
+ let stopTail = () => {}
442
+
443
+ const settle = (ok, error) => {
444
+ if (settled) return
445
+ settled = true
446
+ stopTail()
447
+ if (!ok) {
448
+ state.tunnelError = error ?? 'frpc 启动失败'
449
+ try {
450
+ child.kill('SIGTERM')
451
+ } catch {
452
+ /* ignore */
453
+ }
454
+ state.frpc = null
455
+ state.tunnelStartedAt = null
456
+ }
457
+ resolve(ok ? { ok: true, ...snapshot() } : { ok: false, error: state.tunnelError })
458
+ }
459
+
460
+ const push = (line) => {
461
+ if (!line.trim()) return
462
+ state.tunnelLog.push(line.slice(0, 300))
463
+ if (state.tunnelLog.length > 40) state.tunnelLog.splice(0, state.tunnelLog.length - 40)
464
+ if (/start proxy success/i.test(line)) settle(true)
465
+ else if (/start error|port already used|login to server failed/i.test(line)) settle(false, line.slice(0, 200))
466
+ }
467
+
468
+ const onLine = (buf) => {
469
+ for (const line of String(buf).split(/\r?\n/)) push(line)
470
+ }
471
+ child.stdout?.on('data', onLine)
472
+ child.stderr?.on('data', onLine)
473
+
474
+ const poll = () => {
475
+ if (!logPath || settled) return
476
+ let size = 0
477
+ try {
478
+ size = fs.statSync(logPath).size
479
+ } catch {
480
+ return
481
+ }
482
+ if (size <= offset) return
483
+ try {
484
+ const fd = fs.openSync(logPath, 'r')
485
+ const len = size - offset
486
+ const buf = Buffer.alloc(len)
487
+ fs.readSync(fd, buf, 0, len, offset)
488
+ fs.closeSync(fd)
489
+ offset = size
490
+ for (const line of buf.toString('utf8').split(/\r?\n/)) push(line)
491
+ } catch {
492
+ /* 文件被轮转之类,下一轮再说 */
493
+ }
494
+ }
495
+ const timer = setInterval(poll, 150)
496
+ stopTail = () => clearInterval(timer)
497
+
498
+ child.on('error', (err) => settle(false, String(err?.message ?? err)))
499
+ child.on('exit', (code, signal) => {
500
+ state.frpc = null
501
+ state.tunnelStartedAt = null
502
+ if (!settled) settle(false, `frpc 退出(code=${code} signal=${signal})`)
503
+ else if (state.tunnelError === null) state.tunnelError = `frpc 已退出(code=${code} signal=${signal})`
504
+ })
505
+ setTimeout(() => {
506
+ if (!settled) settle(false, '等了 8 秒没收到「start proxy success」,看下面日志')
507
+ }, 8000)
508
+ }).finally(() => {
509
+ startingTunnel = null
510
+ })
511
+ return startingTunnel
512
+ }
513
+
514
+ async function stopTunnel() {
515
+ const child = state.frpc
516
+ state.frpc = null
517
+ state.tunnelStartedAt = null
518
+ if (child) {
519
+ try {
520
+ child.kill('SIGTERM')
521
+ } catch {
522
+ /* ignore */
523
+ }
524
+ await new Promise((resolve) => setTimeout(resolve, 150))
525
+ }
526
+ state.tunnelError = null
527
+ return { ok: true, ...snapshot() }
528
+ }
529
+
227
530
  /** 关掉一个代理服务:先拒绝新连接,再强断旧连接,最多等 500ms,绝不挂死。 */
228
531
  async function closeServer(server) {
229
532
  if (!server) return
@@ -314,11 +617,46 @@ export function apply(ctx, config = {}) {
314
617
  return json(res, 400, { ok: false, error: "action 只能是 'start' 或 'stop'" })
315
618
  },
316
619
  },
620
+ {
621
+ kind: 'exact',
622
+ path: '/api/dsh-mesh-access/tunnel',
623
+ handler: async (req, res) => {
624
+ if (req.method === 'GET') {
625
+ const t = tunnelCfg()
626
+ const host = t.domain || t.serverAddr
627
+ const port = t.domain ? 443 : t.remotePort
628
+ const snap = tunnelSnapshot()
629
+ const reachable = await probe(host, port)
630
+ // external:公网已经通了,但 frpc 不是这个进程拉起来的(用户自己的 systemd 单元在跑)
631
+ return json(res, 200, { ok: true, ...snap, reachable, external: reachable && !snap.running })
632
+ }
633
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'GET or POST only' })
634
+ let body = {}
635
+ try {
636
+ body = JSON.parse((await readBody(req)) || '{}')
637
+ } catch {
638
+ return json(res, 400, { ok: false, error: 'body 不是合法 JSON' })
639
+ }
640
+ const action = String(body.action ?? '')
641
+ if (action === 'start') return json(res, 200, await startTunnel())
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
+ }
651
+ return json(res, 400, { ok: false, error: "action 只能是 'start' 或 'stop'" })
652
+ },
653
+ },
317
654
  ]
318
655
  const disposers = routes.map((route) => ctx.webServer.register(route))
319
656
  return () => {
320
657
  for (const d of disposers) d()
321
658
  void stop()
659
+ void stopTunnel()
322
660
  }
323
- }, 'dsh-mesh-access: routes + proxy')
661
+ }, 'dsh-mesh-access: routes + proxy + tunnel')
324
662
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mesh-access",
3
- "version": "1.0.4",
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",
@@ -25,7 +25,8 @@
25
25
  "LICENSE",
26
26
  "docs",
27
27
  "screenshots.json",
28
- "assets"
28
+ "assets",
29
+ "tools"
29
30
  ],
30
31
  "engines": {
31
32
  "node": ">=22"
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bash
2
+ # frpc-setup.sh —— 在**你自己的电脑**(跑 DSH 的那台)上装 frpc,把面板开出来的
3
+ # 组网端口(默认 192.168.188.1:3081)挂到你自己服务器的公网入口上。
4
+ #
5
+ # 用法:
6
+ # ./tools/frpc-setup.sh --server 1.2.3.4 --token <token> [--port 7000] [--remote-port 21081]
7
+ # [--local 192.168.188.1:3081] [--domain dsh.example.com] [--no-service]
8
+ #
9
+ # 不带参数跑会进入问答模式。写完之后 DSH 面板的「公网隧道」卡片会自动认到这份配置。
10
+ set -uo pipefail
11
+
12
+ FRP_VERSION="${FRP_VERSION:-0.72.0}"
13
+ SERVER=""
14
+ BIND_PORT=7000
15
+ REMOTE_PORT=21081
16
+ TOKEN=""
17
+ LOCAL="192.168.188.1:3081"
18
+ DOMAIN=""
19
+ SERVICE=1
20
+ BIN_DIR="${HOME}/.local/bin"
21
+ CONF="${HOME}/.config/frp/frpc.toml"
22
+
23
+ while [ $# -gt 0 ]; do
24
+ case "$1" in
25
+ --server) SERVER="$2"; shift 2 ;;
26
+ --port) BIND_PORT="$2"; shift 2 ;;
27
+ --remote-port) REMOTE_PORT="$2"; shift 2 ;;
28
+ --token) TOKEN="$2"; shift 2 ;;
29
+ --local) LOCAL="$2"; shift 2 ;;
30
+ --domain) DOMAIN="$2"; shift 2 ;;
31
+ --no-service) SERVICE=0; shift ;;
32
+ -h|--help) sed -n '2,16p' "$0"; exit 0 ;;
33
+ *) echo "未知参数:$1(--help 看用法)" >&2; exit 2 ;;
34
+ esac
35
+ done
36
+
37
+ say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
38
+ ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; }
39
+ warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
40
+ die() { printf ' \033[31m✘\033[0m %s\n' "$*" >&2; exit 1; }
41
+
42
+ ask() { # ask <变量名> <提示> [默认值]
43
+ local __var="$1" __prompt="$2" __def="${3:-}" __ans=""
44
+ if [ -t 0 ]; then
45
+ printf '%s%s: ' "$__prompt" "${__def:+(默认 $__def)}"
46
+ read -r __ans
47
+ fi
48
+ [ -n "$__ans" ] || __ans="$__def"
49
+ printf -v "$__var" '%s' "$__ans"
50
+ }
51
+
52
+ [ -n "$SERVER" ] || ask SERVER "服务器地址(IP 或域名)"
53
+ [ -n "$TOKEN" ] || ask TOKEN "token(服务端 /etc/frp/frps.toml 里的 auth.token)"
54
+ [ -n "$SERVER" ] || die "服务器地址不能为空"
55
+ [ -n "$TOKEN" ] || die "token 不能为空"
56
+
57
+ LOCAL_IP="${LOCAL%%:*}"
58
+ LOCAL_PORT="${LOCAL##*:}"
59
+
60
+ say "[1/4] 装 frpc v${FRP_VERSION}"
61
+ case "$(uname -m)" in
62
+ x86_64|amd64) FRP_ARCH=amd64 ;;
63
+ aarch64|arm64) FRP_ARCH=arm64 ;;
64
+ armv7l) FRP_ARCH=arm ;;
65
+ *) die "不认识的架构:$(uname -m)" ;;
66
+ esac
67
+ if command -v frpc >/dev/null; then
68
+ ok "已有 frpc:$(command -v frpc)($(frpc --version 2>/dev/null || echo 版本未知))"
69
+ else
70
+ mkdir -p "$BIN_DIR"
71
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
72
+ NAME="frp_${FRP_VERSION}_linux_${FRP_ARCH}.tar.gz"
73
+ URL="https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${NAME}"
74
+ fetch() {
75
+ if command -v curl >/dev/null; then curl -fL --retry 3 --connect-timeout 10 -o "$TMP/$NAME" "$1"
76
+ else wget -q -O "$TMP/$NAME" "$1"; fi
77
+ }
78
+ fetch "$URL" || { warn "直连 GitHub 失败,改用加速镜像"; fetch "https://gh-proxy.com/${URL}" || die "下载 frpc 失败"; }
79
+ tar -xzf "$TMP/$NAME" -C "$TMP" || die "解包失败,包可能不完整"
80
+ B="$(find "$TMP" -maxdepth 2 -type f -name frpc | head -1)"
81
+ [ -n "$B" ] || die "包里没有 frpc"
82
+ install -m 755 "$B" "$BIN_DIR/frpc"
83
+ ok "frpc → $BIN_DIR/frpc"
84
+ fi
85
+
86
+ say "[2/4] 写 $CONF"
87
+ mkdir -p "$(dirname "$CONF")"
88
+ umask 077
89
+ cat > "$CONF" <<EOF
90
+ # 由 frpc-setup.sh 生成
91
+ serverAddr = "${SERVER}"
92
+ serverPort = ${BIND_PORT}
93
+
94
+ auth.method = "token"
95
+ auth.token = "${TOKEN}"
96
+
97
+ log.to = "${HOME}/.config/frp/frpc.log"
98
+ log.level = "info"
99
+
100
+ [[proxies]]
101
+ name = "dsh-mesh"
102
+ type = "tcp"
103
+ localIP = "${LOCAL_IP}"
104
+ localPort = ${LOCAL_PORT}
105
+ remotePort = ${REMOTE_PORT}
106
+ transport.useEncryption = true
107
+ EOF
108
+ chmod 600 "$CONF"
109
+ ok "配置写入 $CONF(600)"
110
+
111
+ say "[3/4] 校验组网端口在听"
112
+ if ss -Hltn 2>/dev/null | grep -q "${LOCAL_IP}:${LOCAL_PORT}\b"; then
113
+ ok "${LOCAL_IP}:${LOCAL_PORT} 在监听"
114
+ else
115
+ warn "${LOCAL_IP}:${LOCAL_PORT} 还没监听 —— 先去 DSH 面板点「在组网地址上开端口」,再回来启动 frpc"
116
+ fi
117
+
118
+ if [ "$SERVICE" = "1" ] && command -v systemctl >/dev/null; then
119
+ say "[4/4] systemd 用户单元(不 enable,只按需 start;插件自己也能拉起 frpc)"
120
+ mkdir -p "$HOME/.config/systemd/user"
121
+ cat > "$HOME/.config/systemd/user/dsh-frpc.service" <<EOF
122
+ [Unit]
123
+ Description=frpc for dsh-mesh-access
124
+
125
+ [Service]
126
+ Type=simple
127
+ ExecStart=${BIN_DIR}/frpc -c ${CONF}
128
+ Restart=always
129
+ RestartSec=5
130
+
131
+ [Install]
132
+ WantedBy=default.target
133
+ EOF
134
+ systemctl --user daemon-reload 2>/dev/null || true
135
+ ok "单元已写:dsh-frpc.service(systemctl --user start dsh-frpc)"
136
+ else
137
+ say "[4/4] 跳过 systemd(--no-service 或没有 systemctl)"
138
+ fi
139
+
140
+ say "搞定"
141
+ cat <<EOF
142
+ 手动启动(前台看日志): ${BIN_DIR}/frpc -c ${CONF}
143
+ 或后台 : systemctl --user start dsh-frpc
144
+ 公网地址 : ${DOMAIN:+https://${DOMAIN}/}${DOMAIN:-http://${SERVER}:${REMOTE_PORT}/}
145
+
146
+ 之后在 DSH 左侧「组网访问」面板看「公网隧道」卡片:状态应是 frpc 运行中 + 公网可达。
147
+ EOF
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env bash
2
+ # frps-setup.sh —— 在你**自己的**服务器上装 frps(公网入口),给 dsh-mesh-access 用。
3
+ #
4
+ # 用法(在服务器上、用 root 或能 sudo 的账号执行):
5
+ # sudo ./frps-setup.sh --port 7000 --http-port 21081
6
+ # sudo ./frps-setup.sh --port 7000 --http-port 21081 --domain dsh.example.com --email you@example.com
7
+ #
8
+ # 它会:
9
+ # 1. 装 frps(v0.72.0,从 GitHub 取;失败可加 --mirror 用 gh-proxy)
10
+ # 2. 写 /etc/frp/frps.toml(随机 token,权限 600)
11
+ # 3. 建 systemd 单元并启动(开机自启)
12
+ # 4. 放行 firewalld / ufw 的 bindPort 与 http-port(有才放)
13
+ # 5. 给了 --domain 且本机有 nginx:写反代 + 申请 Let's Encrypt 证书 + Basic 认证
14
+ #
15
+ # 结束时打印:token、bindPort、remotePort、以及本机要执行的 frpc-setup 命令。
16
+ set -uo pipefail
17
+
18
+ FRP_VERSION="${FRP_VERSION:-0.72.0}"
19
+ BIND_PORT=7000
20
+ HTTP_PORT=21081
21
+ DOMAIN=""
22
+ EMAIL=""
23
+ TOKEN=""
24
+ MIRROR=0
25
+ BASIC_USER="dsh"
26
+ BASIC_PASS=""
27
+
28
+ while [ $# -gt 0 ]; do
29
+ case "$1" in
30
+ --port) BIND_PORT="$2"; shift 2 ;;
31
+ --http-port) HTTP_PORT="$2"; shift 2 ;;
32
+ --domain) DOMAIN="$2"; shift 2 ;;
33
+ --email) EMAIL="$2"; shift 2 ;;
34
+ --token) TOKEN="$2"; shift 2 ;;
35
+ --mirror) MIRROR=1; shift ;;
36
+ -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
37
+ *) echo "未知参数:$1(--help 看用法)" >&2; exit 2 ;;
38
+ esac
39
+ done
40
+
41
+ say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
42
+ ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; }
43
+ warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
44
+ die() { printf ' \033[31m✘\033[0m %s\n' "$*" >&2; exit 1; }
45
+
46
+ [ "$(id -u)" = "0" ] || die "要用 root 跑(sudo ./frps-setup.sh …)"
47
+ command -v systemctl >/dev/null || die "没有 systemd,这个脚本不支持你的发行版"
48
+
49
+ ARCH="$(uname -m)"
50
+ case "$ARCH" in
51
+ x86_64|amd64) FRP_ARCH=amd64 ;;
52
+ aarch64|arm64) FRP_ARCH=arm64 ;;
53
+ armv7l) FRP_ARCH=arm ;;
54
+ *) die "不认识的架构:$ARCH" ;;
55
+ esac
56
+
57
+ say "[1/5] 装 frps v${FRP_VERSION} (${FRP_ARCH})"
58
+ TMP="$(mktemp -d)"
59
+ trap 'rm -rf "$TMP"' EXIT
60
+ NAME="frp_${FRP_VERSION}_linux_${FRP_ARCH}.tar.gz"
61
+ URL="https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${NAME}"
62
+ fetch() {
63
+ local u="$1"
64
+ if command -v curl >/dev/null; then curl -fL --retry 3 --connect-timeout 10 -o "$TMP/$NAME" "$u"
65
+ else wget -q -O "$TMP/$NAME" "$u"; fi
66
+ }
67
+ if ! fetch "$URL"; then
68
+ warn "直连 GitHub 失败,改用加速镜像"
69
+ fetch "https://gh-proxy.com/${URL}" || die "下载 frp 失败:$URL"
70
+ fi
71
+ # 只认 tar 里真有两个二进制,避免拿到半截文件(GitHub 偶发截断)
72
+ tar -xzf "$TMP/$NAME" -C "$TMP" || die "解包失败,包可能不完整"
73
+ BIN="$(find "$TMP" -maxdepth 2 -type f -name frps | head -1)"
74
+ [ -n "$BIN" ] || die "包里没有 frps"
75
+ install -m 755 "$BIN" /usr/local/bin/frps
76
+ ok "frps → /usr/local/bin/frps($(/usr/local/bin/frps --version 2>/dev/null || echo '版本未知'))"
77
+
78
+ say "[2/5] 写 /etc/frp/frps.toml"
79
+ mkdir -p /etc/frp /var/log/frp
80
+ [ -n "$TOKEN" ] || TOKEN="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)"
81
+ umask 077
82
+ cat > /etc/frp/frps.toml <<EOF
83
+ # 由 frps-setup.sh 生成。token 是这台服务器的唯一凭据,别公开。
84
+ bindPort = ${BIND_PORT}
85
+
86
+ auth.method = "token"
87
+ auth.token = "${TOKEN}"
88
+
89
+ log.to = "/var/log/frp/frps.log"
90
+ log.level = "info"
91
+ log.maxDays = 7
92
+
93
+ # 本机管理面板,只监听回环
94
+ webServer.addr = "127.0.0.1"
95
+ webServer.port = 7500
96
+ webServer.user = "admin"
97
+ webServer.password = "admin"
98
+ EOF
99
+ chmod 600 /etc/frp/frps.toml
100
+ ok "配置写入 /etc/frp/frps.toml(600)"
101
+
102
+ say "[3/5] systemd 单元"
103
+ cat > /etc/systemd/system/frps.service <<'EOF'
104
+ [Unit]
105
+ Description=frp server (dsh-mesh-access public entry)
106
+ After=network-online.target
107
+ Wants=network-online.target
108
+
109
+ [Service]
110
+ Type=simple
111
+ ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
112
+ Restart=always
113
+ RestartSec=3
114
+ LimitNOFILE=1048576
115
+
116
+ [Install]
117
+ WantedBy=multi-user.target
118
+ EOF
119
+ systemctl daemon-reload
120
+ systemctl enable --now frps.service >/dev/null 2>&1 || warn "frps 启动失败,看 journalctl -u frps -n 50"
121
+ sleep 1
122
+ if systemctl is-active --quiet frps.service; then ok "frps 已运行"; else warn "frps 没起来,看 journalctl -u frps -n 50"; fi
123
+ ss -Hltn 2>/dev/null | grep -q ":${BIND_PORT}\b" && ok "监听 :${BIND_PORT}" || warn "没看到 :${BIND_PORT} 在监听"
124
+
125
+ say "[4/5] 防火墙"
126
+ if command -v firewall-cmd >/dev/null && firewall-cmd --state >/dev/null 2>&1; then
127
+ firewall-cmd --permanent --add-port=${BIND_PORT}/tcp >/dev/null && firewall-cmd --reload >/dev/null && ok "firewalld 放行 ${BIND_PORT}/tcp"
128
+ elif command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q active; then
129
+ ufw allow ${BIND_PORT}/tcp >/dev/null && ok "ufw 放行 ${BIND_PORT}/tcp"
130
+ else
131
+ warn "没有活动的 firewalld / ufw,跳过(云厂商安全组仍需自己放行)"
132
+ fi
133
+ warn "别忘了在云控制台「安全组 / 防火墙」放行 ${BIND_PORT}/tcp 和 ${HTTP_PORT}/tcp"
134
+
135
+ if [ -n "$DOMAIN" ]; then
136
+ say "[5/5] nginx + TLS + Basic 认证(${DOMAIN})"
137
+ if ! command -v nginx >/dev/null; then
138
+ warn "没装 nginx,跳过;先 dnf/apt install nginx 再重跑本脚本加 --domain"
139
+ else
140
+ mkdir -p /var/www/acme
141
+ cat > /etc/nginx/conf.d/dsh-acme.conf <<EOF
142
+ server {
143
+ listen 80;
144
+ server_name ${DOMAIN};
145
+ location /.well-known/acme-challenge/ { root /var/www/acme; }
146
+ location / { return 301 https://\$host\$request_uri; }
147
+ }
148
+ EOF
149
+ nginx -t >/dev/null 2>&1 && systemctl reload nginx
150
+ if [ ! -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
151
+ if command -v certbot >/dev/null; then
152
+ certbot certonly --webroot -w /var/www/acme -d "$DOMAIN" --non-interactive --agree-tos \
153
+ ${EMAIL:+--email "$EMAIL"} ${EMAIL:---register-unsafely-without-email} >/dev/null 2>&1 \
154
+ && ok "证书已签发" || warn "certbot 失败,看 /var/log/letsencrypt/letsencrypt.log"
155
+ else
156
+ warn "没有 certbot:dnf install certbot / apt install certbot 后重跑本脚本"
157
+ fi
158
+ else
159
+ ok "证书已存在,复用"
160
+ fi
161
+ BASIC_PASS="$(head -c 12 /dev/urandom | base64 | tr -d '/+=' | head -c 14)"
162
+ if command -v openssl >/dev/null; then
163
+ printf '%s:%s\n' "$BASIC_USER" "$(openssl passwd -6 "$BASIC_PASS")" > /etc/nginx/.dsh_htpasswd
164
+ # 属主必须是跑 nginx worker 的那个用户,否则 error log 报 Permission denied、页面 500
165
+ chown "root:$(id -gn nginx 2>/dev/null || echo nginx)" /etc/nginx/.dsh_htpasswd 2>/dev/null || true
166
+ chmod 640 /etc/nginx/.dsh_htpasswd
167
+ fi
168
+ cat > /etc/nginx/conf.d/dsh-https.conf <<EOF
169
+ server {
170
+ listen 443 ssl;
171
+ http2 on;
172
+ server_name ${DOMAIN};
173
+
174
+ ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
175
+ ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
176
+
177
+ # 浏览器弹窗式登录:挡掉所有没带账号密码的请求
178
+ auth_basic "DSH";
179
+ auth_basic_user_file /etc/nginx/.dsh_htpasswd;
180
+
181
+ client_max_body_size 64m;
182
+
183
+ location / {
184
+ proxy_pass http://127.0.0.1:${HTTP_PORT};
185
+ proxy_http_version 1.1;
186
+ # WebSocket(DSH 的实时通道)
187
+ proxy_set_header Upgrade \$http_upgrade;
188
+ proxy_set_header Connection "upgrade";
189
+ proxy_set_header Host 127.0.0.1:${HTTP_PORT};
190
+ proxy_set_header X-Forwarded-Proto https;
191
+ proxy_buffering off;
192
+ proxy_request_buffering off;
193
+ proxy_read_timeout 3600s;
194
+ proxy_send_timeout 3600s;
195
+ }
196
+ }
197
+ EOF
198
+ if nginx -t >/dev/null 2>&1; then systemctl reload nginx; ok "nginx 反代已生效"; else warn "nginx 配置校验失败:nginx -t"; fi
199
+ fi
200
+ else
201
+ say "[5/5] 跳过域名/TLS(没给 --domain)"
202
+ fi
203
+
204
+ say "记下来(本机 frpc 要用)"
205
+ printf ' 服务器地址 : %s\n' "$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo '<你的公网IP>')"
206
+ printf ' bindPort : %s\n' "$BIND_PORT"
207
+ printf ' remotePort : %s\n' "$HTTP_PORT"
208
+ printf ' token : %s\n' "$TOKEN"
209
+ [ -n "$DOMAIN" ] && printf ' 域名 : %s(https://%s/)\n' "$DOMAIN" "$DOMAIN"
210
+ [ -n "$BASIC_PASS" ] && printf ' 网页账号 : %s / %s\n' "$BASIC_USER" "$BASIC_PASS"
211
+
212
+ say "下一步(在你自己的电脑上跑)"
213
+ cat <<EOF
214
+ ./tools/frpc-setup.sh \\
215
+ --server <上面那个服务器地址> --port ${BIND_PORT} \\
216
+ --token ${TOKEN} --remote-port ${HTTP_PORT}${DOMAIN:+ --domain ${DOMAIN}}
217
+ EOF
218
+ echo "然后在 DSH 的「组网访问」面板点「启动隧道」。"