Compare commits

...
2 Commits
Author SHA1 Message Date
nomdn 10f48f8619 feat: 一键安装脚本(install.sh 交互式部署 + systemd),快速开始只保留交互式部署
## install.sh(新增)

一键安装后端节点:自动检测架构 → 下载最新 release 二进制 → 交互式输入配置
(环境变量注入,无需 setting.json)→ 生成并启动 systemd 守护进程。

- 前置检查:root 权限 / systemd 存在 / wget|curl 可用
- 交互式配置项:安装目录、端口、access_token、DNS(主从逗号分隔)、DNSSEC 专用
  DNS、ipdb、CORS、远端配置地址、WS 通道接入、其他环境变量
- 下载用 GitHub API 解析最新 tag(releases/download/latest 会 404,latest 非真实 tag)
- WS 接入规则:WS_URL 留空默认 wss://middleware-1.api-ipw.wsmdn.top;
  NODE_ID 强制自动生成 UUID(/proc → uuidgen → /dev/urandom 三级降级);
  NODE_KEY 必填(不加 key 禁止启用 WS),安装完成提示加入中间件 apiKeys
- 生成 systemd 服务:配置以 Environment="K=V" 注入(仅非空写入),
  daemon-reload + enable --now 自动启动
- service 描述使用中性名(Lemon IPW Backend Node),不含站点标识

## 文档

- getting-started.md 重写:只保留交互式一键部署(install.sh),删除手动构建、
  配置示例与手写 systemd 章节
- deploy-node.md 新增「方案五:一键安装(install.sh)」:配置项表格、WS 规则、
  systemd 管理命令、版本兼容提醒(v1.1.0 读 IPW_ 旧变量名)
- deploy-middleware.md / deploy-node.md / index.md:systemd 指引改指向方案五

## 其他

- setting.json 清理测试残留值(remote-config-url / ws-url 的 ws://localhost)
2026-08-21 22:17:52 +08:00
nomdn 694ddac6eb refactor: WS 配置去 IPW 前缀、删除 command 指令、注册被拒 30s 重试
## WS 客户端配置改名(去 IPW 前缀)

- 环境变量:IPW_WS_URL → WS_URL、IPW_NODE_ID → NODE_ID、IPW_NODE_KEY → NODE_KEY
- setting.json / 远端配置键:ipw-ws-url → ws-url、ipw-node-id → node-id、ipw-node-key → node-key
- 同步位置:main.go(env 读取 / viper 键 / 远端 configValue + ignored 判断)、
  ws.go(配置注释 / 日志)、setting.json、docs/guide/config.md
- 全局变量名 WS_URL / WS_NODE_ID / WS_NODE_KEY 保持不变

## 删除 WS command 指令(两侧)

- 中间件(middleware-go):删除 handleWSCommand(reload_config 热重载,原本即死代码)、
  onCommand 回调字段、newWSServer 参数、Handler 的 case "command" 分支
- 节点侧:撤销未落地的 wsActive / wsSendCommand / /v1/ws-command 路由
- wsCommand 类型保留(register_error 错误消息仍使用)

## 注册被拒重试间隔 3s → 30s

- wsClientOnce 返回值从 bool 改为 time.Duration(重试等待时长):
  连接失败/断开/心跳失败 → 3s;注册被拒(register_error,多为 key 配置错误)
  → 30s 避免空转刷日志;配置缺失 → 0 停止
- wsClientLoop 直接使用返回时长,多中间件场景各 URL 独立计间隔

## 文档

- deploy-node.md 新增「WS 通道接入(可选)」:WS_URL/NODE_ID/NODE_KEY 三配置
  用法、主备切换、双向心跳、注册 key 校验、NODE_ID 与中间件节点 id 对应关系
- deploy-middleware.md 节点侧接入步骤交叉引用 deploy-node.md WS 章节
- config.md 同步 ws-url/node-id/node-key 新键名(remote-ingore-config 示例、安全说明)

验证:go build/vet 通过;全站文档无 IPW 前缀残留。
2026-08-21 18:22:51 +08:00
11 changed files with 423 additions and 221 deletions

No files matched your search

+2 -2
View File
@@ -199,7 +199,7 @@ apiBaseUrls: [
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access_token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access_token` 会被忽略
- 中间件 `apiKeys`:不随远端覆盖,远端里的 `apiKeys` 会被忽略
- `remote-ingore-config`(后端键名 `remote-ingore-config` / env `REMOTE_INGORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access_token / apiKeys 之外的敏感项(如 `ipw-node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
- `remote-ingore-config`(后端键名 `remote-ingore-config` / env `REMOTE_INGORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 access_token / apiKeys 之外的敏感项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
@@ -256,7 +256,7 @@ systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**安全与排查**:
- **⚠️ 敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`、`access_token`、节点 key(`ipw-node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- **⚠️ 敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`apiKeys`、`access_token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
- 常见失败排查:
+2 -2
View File
@@ -39,7 +39,7 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `APIKEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件 - 远端配置](/guide/config#远端配置remoteconfigurl))。
需要守护运行时,参考 [快速入门](/guide/getting-started) 中的 systemd 配置(`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
需要守护运行时,参考 [后端节点部署 - 方案五:一键安装](/guide/deploy-node#方案五一键安装installsh) 的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
## WS 通道(拨测数据经 WebSocket 传输)
@@ -87,7 +87,7 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
### 节点侧接入(WS 客户端)
1. 连接 `ws://<中间件IP>:8092/ws`
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `apiKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
+62 -1
View File
@@ -53,8 +53,69 @@ go build -o lemonipw main.go
./lemonipw
```
首次启动自动下载 IP 数据库(约 200MB),之后每 24 小时自动更新一次。需要守护运行时,参考 [快速入门](/guide/getting-started) 中的 systemd 配置。
首次启动自动下载 IP 数据库(约 200MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
## 方案五:一键安装(install.sh)
仓库根目录提供 `install.sh` 一键安装脚本:自动检测架构 → 下载最新 release 二进制 → **交互式输入配置**(无需准备 setting.json,配置以环境变量注入)→ 生成 systemd 服务并守护进程。
```bash
sudo bash install.sh
```
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | `PORTS` |
| access_token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | `119.28.28.28:53` | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`) |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 200MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束 |
**WS 接入特殊规则**:
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top`(可改填自己的中间件地址,逗号分隔多备)
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `apiKeys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `apiKeys`
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
```bash
systemctl status lemon-ipw # 状态
journalctl -u lemon-ipw -f # 日志
sudo systemctl restart lemon-ipw # 重启(改配置后)
```
修改配置:`sudo systemctl edit --full lemon-ipw` 编辑环境变量,保存后 `sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw` 生效。
> **版本兼容提醒**:v1.1.0 及更早的 release 二进制读的是旧环境变量名 `IPW_WS_URL` / `IPW_NODE_ID` / `IPW_NODE_KEY`(8-20 及之前构建)。install.sh 生成的 service 用的是新名 `WS_URL` / `NODE_ID` / `NODE_KEY`——**旧二进制 + 新 service 会因读不到配置而完全不启用 WS**(启动日志无任何 ws 输出)。升级到包含改名后的新 release 即可;急用可临时把 service 环境变量改回 `IPW_` 前缀。
## 配置
后端配置见 [配置文件](/guide/config):`setting.json` 或环境变量,支持远端配置(`REMOTE_CONFIG_URL`,优先级:远端 > 环境变量 > setting.json)。
## WS 通道接入(可选)
后端节点可作为 WS 客户端接入 [独立中间件](/guide/deploy-middleware) 的 WS 通道,拨测请求经 WebSocket 转发,节点本地执行探针后回传。不配置则完全走原 HTTP 接口。
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ingore-config`):
```bash
WS_URL=ws://<中间件IP>:8092/ws \
NODE_ID=<节点id,与中间件 apiBaseUrls 的节点 id 一致> \
NODE_KEY=<注册key,与中间件 apiKeys[节点id] 一致;节点未配置 key 可留空> \
./lemonipw
```
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://主:8092/ws,ws://备:8092/ws`),第一个为主,主故障自动切换下一个
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
- 节点注册带 `NODE_KEY`:中间件 `apiKeys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
**注意**:`NODE_ID` 必须与中间件 `apiBaseUrls` / `TCPing` / `SpeedTest` 等配置中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
+59 -142
View File
@@ -1,155 +1,72 @@
# 快速开始
> 本章节将指导您以最低成本部署一个功能完整的测试站
## 尝试一下
我们的开发团队已经部署了一个功能完整的预览站:
我们的开发团队已经部署了一个功能完整的预览站:
[预览站点](https://ipw.wsmdn.top/)
## 前置准备
1. Git
2. Node.js 24+
3. Go1.26
4. 一台双栈网络环境的服务器(Linux+Systemd)
- 一台 Linux 服务器(需 root 权限 + systemd)
- 服务器上可访问 GitHub(下载 release 二进制)
- 无需本地编译环境,无需准备配置文件
## 开始部署
### 构建后端
1. 拉取代码
```bash
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn
```
2. 安装依赖
```bash
go mod tidy
```
3. 构建程序
```bash
go build
```
### 构建中间层(可选)
> [!TIP]
> 如果你的服务器到测速节点的链接延迟不大,可以跳过这一步
1. 切换目录
```bash
cd middleware-go
```
2. 安装依赖
```bash
go mod tidy
```
3. 构建程序
```bash
go build
```
### 构建前端
1. 切换目录
```bash
cd frontend-ssr
```
2. 安装依赖
```bash
pnpm install
```
3. 本地开发(可选)
```bash
pnpm dev
```
4. 生产构建
```bash
pnpm build
```
> 前端部署方式(Cloudflare Workers / EdgeOne Makers 等)见 [前端部署](/guide/deploy-frontend)。
### 配置文件
> 完整字段说明、模板与远端配置用法见 [配置文件](/guide/config)。以下为三个组件的最小示例。
**后端**(`setting.json`,env 可覆盖 `PORTS` / `DNS_SERVER` / `CORS` / `ACCESS_TOKEN` 等):
```json
{
"port": 8080,
"dns-server": "119.28.28.28:53",
"cors": "",
"access_token": ""
}
```
**前端**(`frontend-ssr/config/index.ts`,节点 `id` 与中间件 `apiKeys` 键对应):
```ts
const config = {
siteUrl: "https://your-domain.com/",
siteName: "你的站点名称",
EnableInternalMiddleware: true,
rateLimitPerMinute: 120,
Middleware: <string[]>[],
umamiHost: "", umamiScriptUrl: "", umamiWebsiteId: "",
ICP: "", GongAn: "",
noindex: false,
v4OnlyAPI: "https://4.wsmdn.top", v6OnlyAPI: "https://6.wsmdn.top", DualStackAPI: "https://test.wsmdn.top",
apiBaseUrls: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPLocationAPIs: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
TCPing: { DualStack: [], IPv4: [ { label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" } ], IPv6: [] },
SpeedTest: { DualStack: [], IPv4: [ { label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" } ], IPv6: [] },
NSLookup: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
]
}
export { config }
```
**独立中间件**(`middleware-go/setting.json`):
```json
{
"port": "8091",
"cors": "",
"rate-limit": 120,
"remote-config-url": "",
"apiBaseUrls": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"apiKeys": { "cn-jiangsu": "" }
}
```
> 完整字段(`http-timeout-seconds` / `ws-port` / `remote-ingore-config` 等)见 [配置文件 - 独立中间件](/guide/config#独立中间件middleware-go)。**敏感凭据(`apiKeys` / `access_token`)不得上传远端配置,仅放本地或环境变量。**
前端内置中间件通过环境变量 **`NUXT_APIKEYS`**(JSON 字符串,如 `'{"cn-jiangsu":"your-key"}'`)注入 key。
### systemd 守护进程
以 `/opt/lemon-ipw` 为安装目录(二进制与 `setting.json` 放同一目录),创建服务文件:
```ini
# /etc/systemd/system/lemon-ipw.service
[Unit]
Description=Lemon IPW Backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/lemon-ipw
ExecStart=/opt/lemon-ipw/lemonipw
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
> `WorkingDirectory` 必须指向 `setting.json` 所在目录,后端从当前目录读取配置。
启动与管理:
### 交互式一键部署(推荐)
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now lemon-ipw
sudo systemctl status lemon-ipw
sudo journalctl -u lemon-ipw -f
# 获取安装脚本(任选其一)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -LO https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh
# 执行安装(root 权限)
sudo bash install.sh
```
验证:`curl http://127.0.0.1:8080/` 返回 `{"status":"ok"}` 即正常。中间件也可用同样的方式守护(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
脚本会自动完成:检测架构 → 下载最新 release 二进制 → **交互式输入配置** → 生成并启动 systemd 守护进程。
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | 后端 HTTP 端口 |
| access_token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | `119.28.28.28:53` | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`) |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server |
| IP 数据库 | `Y` | 首次启动下载约 200MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束 |
**WS 通道接入规则**(选 `y` 时):
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top`,可改填自己的中间件地址
- `NODE_ID` **强制自动生成 UUID**(无需输入)
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `apiKeys`
### 验证安装
```bash
systemctl status lemon-ipw # 服务状态(active running)
curl http://127.0.0.1:8080/ # 应返回 {"status":"ok"}
journalctl -u lemon-ipw -f # 实时日志
```
### 修改配置
```bash
sudo systemctl edit --full lemon-ipw # 编辑环境变量
sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw
```
## 下一步
- 部署**独立中间件**(WS 通道 / 多节点转发):见 [中间件部署](/guide/deploy-middleware)
- 部署**前端**(Cloudflare Workers / EdgeOne Makers 等):见 [前端部署](/guide/deploy-frontend)
- 完整**配置说明**(后端 / 前端 / 中间件 / 远端配置):见 [配置文件](/guide/config)
- 手动**编译部署**(源码构建三个组件):见 [后端节点部署](/guide/deploy-node) / [中间件部署](/guide/deploy-middleware) / [前端部署](/guide/deploy-frontend)
+1 -1
View File
@@ -14,7 +14,7 @@ Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前
## 快速开始
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):后端构建 → 前端构建 → 配置文件 → systemd 守护。
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
## CI/CD
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/bash
# ============================================================
# 1IPW.CN 后端节点一键安装脚本
# - 自动检测架构并下载最新 release 二进制
# - 交互式输入配置(环境变量注入,无需 setting.json)
# - 生成并启用 systemd 守护进程
# 用法:sudo bash install.sh
# ============================================================
text="
██╗██████╗ ██╗ ██╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗
██║██╔══██╗██║ ██║ ████╗ ██║██╔═══██╗██╔══██╗██╔════╝
██║██████╔╝██║ █╗ ██║█████╗██╔██╗ ██║██║ ██║██║ ██║█████╗
██║██╔═══╝ ██║███╗██║╚════╝██║╚██╗██║██║ ██║██║ ██║██╔══╝
██║██║ ╚███╔███╔╝ ██║ ╚████║╚██████╔╝██████╔╝███████╗
╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝
"
echo "$text"
set -e
# ---------- 前置检查 ----------
if [ "$(id -u)" -ne 0 ]; then
echo "错误:需要 root 权限(写入 /etc/systemd/system 与安装目录),请用 sudo 运行" >&2
exit 1
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
exit 1
fi
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
echo "错误:未找到 wget 或 curl,无法下载" >&2
exit 1
fi
# ---------- 架构检测 ----------
systemArch=$(uname -m)
case "$systemArch" in
x86_64) BINARY="lemonipw-linux-amd64" ;;
i386|i486|i586|i686) BINARY="lemonipw-linux-386" ;;
aarch64|arm64) BINARY="lemonipw-linux-arm64" ;;
armv7l|armv7hl) BINARY="lemonipw-linux-armv7" ;;
armv6l) BINARY="lemonipw-linux-armv6" ;;
loongarch64) BINARY="lemonipw-linux-loong64" ;;
*)
echo "错误:不支持的架构 '$systemArch'" >&2
exit 1
;;
esac
echo "检测到架构 $systemArch,将下载 $BINARY"
# ---------- 交互式配置输入 ----------
# 生成 UUID(优先 /proc,其次 uuidgen,最后 /dev/urandom)
gen_uuid() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
elif command -v uuidgen >/dev/null 2>&1; then
uuidgen | tr 'A-Z' 'a-z'
else
od -An -N16 -tx1 /dev/urandom | tr -d ' \n' | sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/'
fi
}
echo ""
echo "========================================"
echo " 配置后端节点(直接回车使用默认值)"
echo "========================================"
read -r -p "安装目录 [/opt/lemon-ipw]: " INSTALL_DIR
INSTALL_DIR=${INSTALL_DIR:-/opt/lemon-ipw}
read -r -p "监听端口 [8080]: " PORTS
PORTS=${PORTS:-8080}
read -r -p "访问令牌 access_token(留空=不启用鉴权): " ACCESS_TOKEN
read -r -p "DNS 服务器 [119.28.28.28:53](主从逗号分隔,如 119.28.28.28:53,223.5.5.5:53): " DNS_SERVER
DNS_SERVER=${DNS_SERVER:-119.28.28.28:53}
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 200MB)[Y/n]: " IPDB_CHOICE
case "${IPDB_CHOICE,,}" in
n|no) IPDB="false" ;;
*) IPDB="true" ;;
esac
read -r -p "CORS 允许来源(逗号分隔,留空=不限): " CORS
read -r -p "远端配置地址 remote-config-url(留空=不启用): " REMOTE_CONFIG_URL
echo ""
echo "--- WS 通道接入(可选,接入独立中间件)---"
read -r -p "接入中间件 WS 通道?[y/N]: " WS_CHOICE
WS_URL=""
NODE_ID=""
NODE_KEY=""
WS_USED_DEFAULT=""
case "${WS_CHOICE,,}" in
y|yes)
read -r -p " 中间件 WS 地址(留空=默认 wss://middleware-1.api-ipw.wsmdn.top,逗号分隔可多备): " WS_URL
if [ -z "$WS_URL" ]; then
WS_URL="wss://middleware-1.api-ipw.wsmdn.top"
WS_USED_DEFAULT="true"
fi
NODE_ID=$(gen_uuid)
echo " 节点 id(自动生成 UUID): $NODE_ID"
while [ -z "$NODE_KEY" ]; do
read -r -p " 注册 key(必填,中间件 apiKeys 必须包含此节点,否则注册被拒 401): " NODE_KEY
if [ -z "$NODE_KEY" ]; then
echo " 错误:不加 key 禁止启用 WS,必须提供注册 key"
fi
done
;;
esac
echo ""
echo "--- 其他环境变量(可选)---"
echo "输入额外的 Environment= 项,每行一个(如 GH_PROXY=https://ghproxy.com/),空行结束:"
EXTRA_ENVS=""
while true; do
read -r -p " > " EXTRA
[ -z "$EXTRA" ] && break
if [ -z "$EXTRA_ENVS" ]; then
EXTRA_ENVS="$EXTRA"
else
EXTRA_ENVS="$EXTRA_ENVS
$EXTRA"
fi
done
# ---------- 汇总展示 ----------
echo ""
echo "========================================"
echo " 配置汇总"
echo "========================================"
echo "安装目录: $INSTALL_DIR"
echo "服务名: lemon-ipw"
echo "监听端口: $PORTS"
echo "access_token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
echo "DNS: $DNS_SERVER"
echo "ipdb: $IPDB"
echo "WS 接入: ${WS_URL:+$WS_URL (id=$NODE_ID, key=${NODE_KEY:+已设置})}${WS_URL:-未启用}"
echo "========================================"
read -r -p "确认安装?[Y/n]: " CONFIRM
case "${CONFIRM,,}" in
n|no) echo "已取消" ; exit 0 ;;
*) : ;;
esac
# ---------- 下载二进制 ----------
echo ""
echo "正在获取最新版本..."
if command -v curl >/dev/null 2>&1; then
LATEST_TAG=$(curl -s https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
else
LATEST_TAG=$(wget -qO- https://api.github.com/repos/nomdn/ipw-cn/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
fi
if [ -z "$LATEST_TAG" ]; then
echo "错误:无法获取最新版本号(检查网络或 GitHub API 速率限制)" >&2
exit 1
fi
echo "最新版本:$LATEST_TAG"
DOWNLOAD_URL="https://github.com/nomdn/ipw-cn/releases/download/$LATEST_TAG/$BINARY"
mkdir -p "$INSTALL_DIR"
echo "下载 $BINARY ..."
if command -v wget >/dev/null 2>&1; then
wget -q -O "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
else
curl -sL -o "$INSTALL_DIR/lemonipw" "$DOWNLOAD_URL"
fi
chmod +x "$INSTALL_DIR/lemonipw"
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
# ---------- 生成 systemd 服务 ----------
SERVICE_FILE="/etc/systemd/system/lemon-ipw.service"
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
ENV_LINES=""
[ -n "$PORTS" ] && ENV_LINES="${ENV_LINES}Environment=\"PORTS=$PORTS\"
"
[ -n "$ACCESS_TOKEN" ] && ENV_LINES="${ENV_LINES}Environment=\"ACCESS_TOKEN=$ACCESS_TOKEN\"
"
[ -n "$DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNS_SERVER=$DNS_SERVER\"
"
[ -n "$DNSSEC_DNS_SERVER" ] && ENV_LINES="${ENV_LINES}Environment=\"DNSSEC_DNS_SERVER=$DNSSEC_DNS_SERVER\"
"
[ -n "$IPDB" ] && ENV_LINES="${ENV_LINES}Environment=\"IPDB=$IPDB\"
"
[ -n "$CORS" ] && ENV_LINES="${ENV_LINES}Environment=\"CORS=$CORS\"
"
[ -n "$REMOTE_CONFIG_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"REMOTE_CONFIG_URL=$REMOTE_CONFIG_URL\"
"
[ -n "$WS_URL" ] && ENV_LINES="${ENV_LINES}Environment=\"WS_URL=$WS_URL\"
"
[ -n "$NODE_ID" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_ID=$NODE_ID\"
"
[ -n "$NODE_KEY" ] && ENV_LINES="${ENV_LINES}Environment=\"NODE_KEY=$NODE_KEY\"
"
if [ -n "$EXTRA_ENVS" ]; then
while IFS= read -r line; do
[ -n "$line" ] && ENV_LINES="${ENV_LINES}Environment=\"$line\"
"
done <<< "$EXTRA_ENVS"
fi
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Lemon IPW Backend Node
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/lemonipw
${ENV_LINES}Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
echo "已生成服务文件 $SERVICE_FILE"
# ---------- 启动服务 ----------
systemctl daemon-reload
systemctl enable --now lemon-ipw
systemctl status lemon-ipw --no-pager | head -15
echo ""
echo "========================================"
echo " 安装完成"
echo "========================================"
echo "常用命令:"
echo " systemctl status lemon-ipw # 查看状态"
echo " journalctl -u lemon-ipw -f # 查看日志"
echo " systemctl restart lemon-ipw # 重启(改配置后)"
echo "验证:curl http://127.0.0.1:$PORTS/ 应返回 {\"status\":\"ok\"}"
if [ -n "$WS_URL" ]; then
echo ""
echo "WS 通道已启用,请把节点注册信息加入中间件 setting.json 的 apiKeys:"
echo " \"$NODE_ID\": \"$NODE_KEY\""
fi
if [ -n "$WS_USED_DEFAULT" ]; then
echo ""
echo "========================================"
echo "感谢您的贡献,请向 iduhih777@outlook.com 提交您的节点信息,谢谢!"
echo " 节点 id: $NODE_ID"
echo " 注册 key: $NODE_KEY"
echo " WS 地址: $WS_URL"
echo " 监听端口: $PORTS"
echo " access_token: ${ACCESS_TOKEN:+已设置 (隐藏)}"
echo " CORS: ${CORS:-不限}"
echo "========================================"
fi
echo "========================================"
+11 -11
View File
@@ -1026,13 +1026,13 @@ func applyRemoteConfig() {
ssrf.SetEnabled(v != "false" && v != "0")
}
// WS 客户端配置(远端可覆盖,除非在 ignore 列表中)
if v := configValue(CONFIG, "ipw-ws-url"); v != "" && !ignored("ipw-ws-url") {
if v := configValue(CONFIG, "ws-url"); v != "" && !ignored("ws-url") {
WS_URL = v
}
if v := configValue(CONFIG, "ipw-node-id"); v != "" && !ignored("ipw-node-id") {
if v := configValue(CONFIG, "node-id"); v != "" && !ignored("node-id") {
WS_NODE_ID = v
}
if v := configValue(CONFIG, "ipw-node-key"); v != "" && !ignored("ipw-node-key") {
if v := configValue(CONFIG, "node-key"); v != "" && !ignored("node-key") {
WS_NODE_KEY = v
}
// access_token 不在此覆盖:保持原有优先级(环境变量 > setting.json)
@@ -1099,18 +1099,18 @@ func readConfig() {
if REMOTE_CONFIG_URL == "" {
REMOTE_CONFIG_URL = viper.GetString("remote-config-url")
}
// WS 客户端(接入中间件 WS 通道):IPW_WS_URL / IPW_NODE_ID / IPW_NODE_KEY,env 优先
WS_URL = os.Getenv("IPW_WS_URL")
// WS 客户端(接入中间件 WS 通道):WS_URL / NODE_ID / NODE_KEY,env 优先
WS_URL = os.Getenv("WS_URL")
if WS_URL == "" {
WS_URL = viper.GetString("ipw-ws-url")
WS_URL = viper.GetString("ws-url")
}
WS_NODE_ID = os.Getenv("IPW_NODE_ID")
WS_NODE_ID = os.Getenv("NODE_ID")
if WS_NODE_ID == "" {
WS_NODE_ID = viper.GetString("ipw-node-id")
WS_NODE_ID = viper.GetString("node-id")
}
WS_NODE_KEY = os.Getenv("IPW_NODE_KEY")
WS_NODE_KEY = os.Getenv("NODE_KEY")
if WS_NODE_KEY == "" {
WS_NODE_KEY = viper.GetString("ipw-node-key")
WS_NODE_KEY = viper.GetString("node-key")
}
// REMOTE_INGORE_CONFIG:不被远端覆盖的配置项列表(JSON 数组字符串,env 优先)
if raw := os.Getenv("REMOTE_INGORE_CONFIG"); raw != "" {
@@ -1150,7 +1150,7 @@ func main() {
slog.Info("Starting server", "port", PORTS, "gh_proxy", GH_PROXY, "single_stack", SINGLE_STACK, "dns_server", DNS_SERVER, "CORS_ACCEPT", ACCEPT_DOMAINS)
// WS 客户端:接入中间件 WS 通道(配置 IPW_WS_URL 时启用,HTTP 接口不变)
// WS 客户端:接入中间件 WS 通道(配置 WS_URL 时启用,HTTP 接口不变)
if WS_URL != "" {
go wsClientLoop()
slog.Info("WS client enabled", "url", WS_URL, "nodeId", WS_NODE_ID)
+1 -1
View File
@@ -677,7 +677,7 @@ func main() {
// WS 服务端(端口由配置 wsPort / 环境变量 WS_PORT 决定,缺省 8092,0 = 关闭;统一走 readConfig)。
// 后端节点作为 WS 客户端连入;节点配置 "ws": true 时拨测请求改走 WS 通道。现有 HTTP 路由不受影响。
if WS_PORT > 0 {
wsSrv = newWSServer(handleWSCommand)
wsSrv = newWSServer()
go wsSrv.Start(fmt.Sprintf(":%d", WS_PORT))
go wsSrv.maintenanceLoop()
log.Printf("[ws] ws channel enabled on port %d (node flag \"ws\": true to use)", WS_PORT)
+1 -38
View File
@@ -64,9 +64,6 @@ type wsServer struct {
probeMu sync.Mutex
probeWait map[string]chan wsProbeResult
// 指令回调(后端节点下发 command 时触发),由 main 注入
onCommand func(cmd wsCommand)
// 统计
statMu sync.Mutex
totalReqs int64
@@ -74,11 +71,10 @@ type wsServer struct {
startedAt time.Time
}
func newWSServer(onCommand func(cmd wsCommand)) *wsServer {
func newWSServer() *wsServer {
return &wsServer{
peers: make(map[string]*wsPeer),
probeWait: make(map[string]chan wsProbeResult),
onCommand: onCommand,
startedAt: time.Now(),
}
}
@@ -183,20 +179,6 @@ func (s *wsServer) Handler(w http.ResponseWriter, r *http.Request) {
s.sendJSON(c, wsMessage{Type: "pong", NodeID: peer.id, TS: time.Now().Unix()})
}
case "command":
if !registered {
continue
}
var cmd wsCommand
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
log.Printf("[ws] bad command from %s: %v", peer.id, err)
continue
}
log.Printf("[ws] command from %s: %s", peer.id, cmd.Command)
if s.onCommand != nil {
s.onCommand(cmd)
}
default:
log.Printf("[ws] unknown message type from %s: %s", peer.id, msg.Type)
}
@@ -347,22 +329,3 @@ func wsProbeTimeout() time.Duration {
}
return time.Duration(t) * time.Second
}
// handleWSCommand 处理后端节点经 WS 下发的指令(配置/指令下发)。
// 注意:reload_config 会整体重写配置全局变量,运行中重载期间存在与并发请求的竞态,
// 生产环境仍建议以重启方式应用配置;此命令主要用于调试与低峰期热更新。
func handleWSCommand(cmd wsCommand) {
switch cmd.Command {
case "reload_config":
log.Printf("[ws] command reload_config received, reloading config...")
if err := readConfig(); err != nil {
log.Printf("[ws] reload_config FAILED: %v", err)
return
}
log.Printf("[ws] config reloaded from %s", CONFIG_SOURCE)
case "ping":
// 兼容节点发来的 ping(视为心跳)
default:
log.Printf("[ws] unknown command: %s", cmd.Command)
}
}
+2 -2
View File
@@ -10,6 +10,6 @@
"remote-config-url": "",
"remote-ingore-config": [],
"access_token": "",
"ipw-ws-url": "",
"ipw-node-id": ""
"ws-url": "",
"node-id": "test"
}
+21 -21
View File
@@ -18,15 +18,15 @@ import (
//
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
//
// IPW_WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
// IPW_NODE_ID 节点 id(与中间件 apiKeys 键 / 前端配置 id 一致)
// IPW_NODE_KEY 注册 key(与中间件 apiKeys[节点id] 一致;中间件未配置该节点 key 时留空)
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
// NODE_ID 节点 id(与中间件 apiKeys 键 / 前端配置 id 一致)
// NODE_KEY 注册 key(与中间件 apiKeys[节点id] 一致;中间件未配置该节点 key 时留空)
//
// 与 HTTP handler 共用同一批 webtest 探针函数和缓存:收到 probe 后按 apiType 取数(带缓存),
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。
//
// 心跳:NODE 与 MIDDLEWARE 双向心跳。本端每 10s 发一次 ping,发送失败重试,连续失败 3 次
// 判定当前中间件不可用,断开并切换下一个 IPW_WS_URL;收到中间件 ping 回 pong。
// 判定当前中间件不可用,断开并切换下一个 WS_URL;收到中间件 ping 回 pong。
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
@@ -360,17 +360,17 @@ const (
wsHeartbeatTimeout = 5 * time.Second
)
// wsClientOnce 连接并服务单个中间件 URL:
// - 连接/注册失败 → 返回 true(上层切换下一个中间件)
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,返回 true(切换下一个中间件)
// - 读循环断开 → 返回 true
// - 返回 false = 配置缺失(不再重试)
func wsClientOnce(url string) bool {
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长:
// - 连接/读循环断开 → 3s(切换下一个中间件)
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
// - 返回 0 = 配置缺失(不再重试)
func wsClientOnce(url string) time.Duration {
ctx := context.Background()
c, _, err := websocket.Dial(ctx, url, nil)
if err != nil {
slog.Warn("ws client dial failed", "url", url, "error", err)
return true
return 3 * time.Second
}
defer c.CloseNow()
@@ -383,19 +383,19 @@ func wsClientOnce(url string) bool {
_, data, err := c.Read(ctx)
if err != nil {
slog.Warn("ws client register read failed", "url", url, "error", err)
return true
return 3 * time.Second
}
var m wsMsg
if json.Unmarshal(data, &m) != nil {
return true
return 3 * time.Second
}
if m.Type == "register_error" {
slog.Warn("ws client register rejected", "nodeId", WS_NODE_ID, "url", url, "data", string(m.Data))
return true
return 30 * time.Second
}
if m.Type != "register_ok" {
slog.Warn("ws client unexpected message", "type", m.Type)
return true
return 3 * time.Second
}
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
@@ -428,7 +428,7 @@ func wsClientOnce(url string) bool {
_, data, err := c.Read(ctx)
if err != nil {
slog.Warn("ws client disconnected", "url", url, "error", err)
return true
return 3 * time.Second
}
var msg wsMsg
if json.Unmarshal(data, &msg) != nil {
@@ -446,21 +446,21 @@ func wsClientOnce(url string) bool {
}
// wsClientLoop 主循环:依次尝试所有配置的中间件 URL(逗号分隔,第一个为主),
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;全部失败循环重试。
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;注册被拒 → 30s;全部失败循环重试。
func wsClientLoop() {
urls := splitWSURLs(WS_URL)
if len(urls) == 0 {
slog.Info("ws client disabled (IPW_WS_URL not set)")
slog.Info("ws client disabled (WS_URL not set)")
return
}
for {
for _, url := range urls {
shouldRetry := wsClientOnce(url)
if !shouldRetry {
retryDelay := wsClientOnce(url)
if retryDelay == 0 {
slog.Info("ws client stopped")
return
}
time.Sleep(3 * time.Second)
time.Sleep(retryDelay)
}
}
}