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 +38 -0
- package/README.en.md +25 -0
- package/README.md +23 -0
- package/docs/AI-SETUP.md +112 -0
- package/docs/VPS.md +104 -0
- package/lib/client.js +162 -0
- package/lib/index.js +340 -2
- package/package.json +3 -2
- package/tools/frpc-setup.sh +147 -0
- package/tools/frps-setup.sh +218 -0
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
|
面板只是两个接口的外壳,故意做得极小:
|
package/docs/AI-SETUP.md
ADDED
|
@@ -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 = {
|
|
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
|
|
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 的「组网访问」面板点「启动隧道」。"
|