Files

384 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SMS Forwarding
一个运行在 Node.js 上的 4G 短信网关。程序通过串口控制 4G 模组,接收 PDU 短信,合并长短信,并把短信转发到邮件、钉钉、飞书、Telegram、PushPlus、Server 酱、Bark 或自定义 Webhook。
项目同时提供 Web 管理台和 HTTP API,可用于查看模组状态、收件箱、日志、推送通道,发送短信,切换发送短信使用的 SIM,以及执行 AT 命令。
## 功能
- 通过 AT 指令和 4G 模组通信
- 自动探测可用 AT 串口
- PDU 模式接收短信
- 长短信自动合并
- 收件箱持久化到本地文件
- 多推送通道转发短信
- Web 管理台
- Token 鉴权的 HTTP API
- Web/API 发送短信
- 双 SIM 状态识别和发送前切卡
- 移动数据开关和启动断网保护
- AT 命令调试入口
## 环境要求
- Node.js 18 或更高版本
- 可通过串口访问的 4G 模组
- 已插入可收发短信的 SIM 卡
- Linux 下通常需要访问 `/dev/ttyUSB*` 的权限
已主要围绕 ML307A 调试。其他 AT 指令兼容模组可以运行,但双卡、移动数据、URC 卡槽识别等能力取决于模组返回的指令结果。
## 快速启动
```bash
npm install
cp config.example.json config.json
```
编辑 `config.json`,至少确认:
```json
{
"serial": {
"path": "/dev/ttyUSB0",
"baudRate": 115200,
"autoDetect": true
},
"api": {
"port": 3000,
"webToken": "change-this-token"
}
}
```
启动:
```bash
npm start
```
访问管理台:
```text
http://localhost:3000/admin
```
第一次访问会要求输入 `api.webToken`。也可以临时通过 URL 传入:
```text
http://localhost:3000/admin?token=change-this-token
```
## Docker 运行
先准备配置:
```bash
cp config.example.json config.json
```
然后启动:
```bash
docker compose up --build -d
```
查看日志:
```bash
docker compose logs -f sms-gateway
```
`docker-compose.yml` 会挂载:
- `./config.json:/app/config.json`
- `./logs:/app/logs`
- `./data:/app/data`
- `/dev:/dev`
- `/sys:/sys:ro`
默认端口是 `3000`。如需修改宿主机端口:
```bash
SMS_GATEWAY_PORT=8080 docker compose up -d
```
## PM2 运行
```bash
npm install
cp config.example.json config.json
pm2 start ecosystem.config.js
pm2 save
```
## 配置
`config.json` 必须存在,否则程序会退出。完整模板见 `config.example.json`
### 必填项
| 配置项 | 说明 |
| --- | --- |
| `serial.path` | 串口路径。Linux 常见为 `/dev/ttyUSB0`Windows 可写 `COM3``serial.autoDetect=true` 时它是优先探测项;`false` 时它就是实际打开的串口。 |
| `serial.baudRate` | 串口波特率,通常是 `115200`。 |
| `api.port` | Web 管理台和 HTTP API 监听端口。 |
| `api.webToken` | 唯一访问凭据。项目没有默认账号密码,也没有 `admin/admin123` Basic Auth。 |
### 可选项
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `serial.autoDetect` | `true` | 是否自动探测可响应 `AT` 的串口。 |
| `serial.probeTimeout` | `1200` | 单个串口探测超时,单位毫秒。 |
| `mobileData.cid` | `1` | 移动数据 PDP CID。有效范围 `1-15`,不要使用 `8`。 |
| `simCards` | `[]` | 只影响 Web 显示的 SIM 手机号或标签,不影响实际收发。`slot=0` 是 SIM1`slot=1` 是 SIM2。 |
| `smtp` | 未启用 | 配置后会额外发送邮件通知。填写 `smtp.server` 后需要同时正确填写 `port``user``pass``sendTo`。 |
| `inbox.maxReceivedMessages` | `200` | 收件箱最多保留条数,代码上限是 `5000`。 |
| `inbox.receivedMessagesFile` | `data/received-messages.json` | 收件箱持久化文件。相对路径按项目根目录解析。 |
| `inbox.partitionBySim` | `true` | 查询收件箱时是否按 SIM 信息隔离。 |
| `inbox.allowAllSimMessages` | `false` | 是否允许显式查询全部 SIM 历史。 |
| `inbox.dualSimDefaultScope` | `all` | 双卡场景下默认收件箱范围,可选 `all``current`。 |
| `pushChannels` | `[]` | 推送通道列表。通道 `enabled=true` 时必须填写该类型要求的目标字段。 |
`api.webToken` 支持以下传入方式:
- `Authorization: Bearer <token>`
- `X-Web-Token: <token>`
- `?token=<token>`
- Web 管理台登录页
## 推送通道
支持的 `pushChannels[].type`
| 类型 | 必填字段 |
| --- | --- |
| `dingtalk` | `url``secret` 可选 |
| `feishu` | `url``secret` 可选 |
| `telegram` | `url` 填 Bot Token`key1` 填 Chat ID |
| `pushplus` | `key1` 填 PushPlus Token |
| `serverchan` | `key1` 填 Server 酱 SendKey |
| `bark` | `url` |
| `post_json` | `url` |
| `get` | `url` |
| `custom` | `url``customBody` 可选 |
`customBody` 必须是合法 JSON 字符串,支持以下占位符:
- `{sender}`
- `{message}`
- `{timestamp}`
`enabled=false` 的通道可以保留空字段;`enabled=true` 时会校验目标字段。
## SIM 行为
启动初始化时程序会探测 SIM 状态。运行期间的普通状态刷新不会为了探测卡槽而持续切换 SIM。
发送短信时使用当前绑定的发送 SIM。Web 管理台的切卡入口放在“发送短信”区域,切换会同时设置模组的 `SWITCHSIM``BINDSIM`,然后恢复短信直出配置。
短信接收使用 `CNMI` 直出模式。收到短信时,如果模组 URC 携带卡槽信息,会记录为 SIM1 或 SIM2;如果 URC 没有卡槽信息,收件箱会显示为未知 SIM。
如果只想给管理台显示手机号,可配置:
```json
{
"simCards": [
{ "slot": 0, "phoneNumber": "+8613800138000" },
{ "slot": 1, "phoneNumber": "+447700900000" }
]
}
```
## HTTP API
所有 `/api/*` 接口都需要 token。下面示例使用 `X-Web-Token`
```bash
TOKEN=change-this-token
BASE=http://localhost:3000
```
### 状态和日志
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/status"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/info"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/signal"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/logs"
```
### 收件箱
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&scope=current"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=0"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=1"
```
### 发送短信
```bash
curl -X POST "$BASE/api/sms/send" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"phone":"13800138000","message":"测试短信"}'
```
### SIM 状态和切换
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/sim"
curl -X POST "$BASE/api/modem/sim/switch" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"slot":1}'
```
`slot=0` 表示 SIM1`slot=1` 表示 SIM2。
### 移动数据
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/mobile-data"
curl -X POST "$BASE/api/modem/mobile-data" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"enabled":false}'
curl -X POST "$BASE/api/modem/mobile-data/consume" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"target":"8.8.8.8"}'
```
### AT 命令
```bash
curl -X POST "$BASE/api/modem/at" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"command":"AT+CSQ"}'
```
### 推送通道管理
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/push/channels"
curl -X PUT "$BASE/api/push/channels" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"channels":[]}'
curl -X POST "$BASE/api/push/test" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"enabled":true,"type":"post_json","name":"测试","url":"https://example.com/webhook"}'
```
## 目录结构
```text
.
├── src/
│ ├── index.js # 程序入口
│ ├── modem.js # 串口、AT 指令、SIM 和移动数据控制
│ ├── sms.js # PDU 解析、长短信处理后的转发、收件箱记录
│ ├── concat.js # 长短信分段合并
│ ├── push.js # 推送通道
│ ├── api.js # Web 管理台和 HTTP API
│ └── logger.js # 日志
├── public/ # Web 管理台静态文件
├── data/ # 收件箱持久化数据
├── logs/ # 运行日志
├── config.example.json
├── config.json # 本地配置,不应提交
├── docker-compose.yml
├── Dockerfile
└── ecosystem.config.js
```
## 常见问题
### 串口打不开
Linux 下先确认设备存在:
```bash
ls -l /dev/ttyUSB*
```
如果是权限问题,把运行用户加入串口权限组,重新登录后生效:
```bash
sudo usermod -a -G dialout "$USER"
```
Docker 场景下确认 `docker-compose.yml` 已挂载 `/dev`,并保留了 `device_cgroup_rules`
### 自动探测不到模组
- 确认 USB 连接和供电。
- 确认 `serial.baudRate` 正确。
- 临时关闭自动探测,把 `serial.autoDetect` 设为 `false`,并把 `serial.path` 写成真实 AT 串口。
- 查看日志中每个候选串口的探测结果。
### ML307A 首次上电被识别成网卡
ML307A 首次上电或恢复默认 USB 模式时,系统可能先把它识别为 RNDIS 网卡,而不是 USB 串口。此时 `lsusb` 能看到设备,但 `/dev/ttyUSB*` 里找不到可用 AT 串口。
可以用下面命令确认 USB 设备和内核识别结果:
```bash
lsusb
dmesg | grep -E 'ML307A|2ecc|3012|rndis|ttyUSB'
```
典型日志会包含类似内容:
```text
usb 1-3: New USB device found, idVendor=2ecc, idProduct=3012
usb 1-3: Product: ML307A
usb 1-3: Manufacturer: CMIOT
rndis_host 1-3:1.0: rndis media connect
rndis_host 1-3:1.0 enxac0c29a39b6d: renamed from eth0
```
如果出现上述 RNDIS 网卡日志,并且系统没有生成 USB 串口,可以把该 USB ID 添加到 `option` 串口驱动:
```bash
sudo modprobe option
echo 2ecc 3012 | sudo tee /sys/bus/usb-serial/drivers/option1/new_id
```
执行后重新查看 `/dev/ttyUSB*`,再把 `config.json` 里的 `serial.path` 指向实际 AT 串口。不同内核、发行版或模组固件的枚举行为可能不完全一致;如果后续仍会被识别为网卡,可按系统发行版方式配置 udev/modprobe 规则。
### 收不到短信
- 确认 SIM 卡已插好且可以注册网络。
- 查看 `/api/status` 里的 `ready``operator``signal`
- 确认日志里有 `PDU模式设置完成``CNMI参数设置完成`
- 部分模组不会在 URC 里带卡槽信息,这只影响收件箱显示的 SIM 归属,不代表短信没有收到。
### 发送短信失败
- 先在管理台确认当前发送 SIM。
- 确认目标号码格式正确。
- 查看日志里的 AT 响应。
- 如果刚切换 SIM,等待模组重新注册网络后再发。
## 安全说明
- 不要提交 `config.json`、token、手机号、Webhook URL、SMTP 密码和日志。
- 管理台和 API 只使用 `api.webToken` 鉴权。
- `POST /api/modem/at` 可以执行任意 AT 命令,只应暴露在可信网络内。
## License
MIT