commit 4e5e93660d2d1b5cae41df1e8bc2832b53f8ef5a Author: OpenClaw Agent Date: Fri Feb 6 23:23:49 2026 +0000 Initial commit: SMS Receiver Web Service Features: - Receive SMS from TranspondSms Android APP - HMAC-SHA256 signature verification (optional) - SQLite database storage - Web UI with login authentication - Multiple API tokens support - Timezone conversion (Asia/Shanghai) - Search, filter, and statistics - Auto refresh and session management Tech Stack: - Flask 3.0 - SQLite database - HTML5/CSS3 responsive design diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4048d72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.egg-info/ +.eggs/ +dist/ +build/ + +# SQLite +*.db +*.db-shm +*.db-wal + +# 日志 +*.log +logs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 环境 +.env +.env.local + +# Flask +instance/ + +# 临时文件 +*.tmp +*.bak +.DS_Store + +# 进程ID +*.pid + +# 编辑器备份 +*~ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..376210f --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,961 @@ +# 短信转发接收端 - 开发文档 + +## 目录 + +- [项目概述](#项目概述) +- [实现逻辑](#实现逻辑) +- [使用指南](#使用指南) +- [部署指南](#部署指南) +- [API 文档](#api-文档) +- [配置说明](#配置说明) +- [常见问题](#常见问题) +- [开发规范](#开发规范) + +--- + +## 项目概述 + +### 技术架构 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Android APP │ │ Flask Web │ │ SQLite DB │ +│ TranspondSms │────────▶│ Server │────────▶│ sms_receiver │ +│ │ POST │ │ Store │ .db │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Web UI │ + │ (登录认证) │ + └─────────────────┘ +``` + +### 核心功能 + +1. **短信接收**:接收 TranspondSms Android APP 转发的短信 +2. **签名验证**:HMAC-SHA256 签名验证,防止伪造请求 +3. **数据存储**:SQLite 数据库存储短信和日志 +4. **Web 管理**:登录验证 + 短信列表、详情、日志、统计 +5. **时区转换**:UTC 存储时区转换显示 +6. **Token 配置**:支持多设备、多 Token 配置 + +--- + +## 实现逻辑 + +### 1. 整体数据流 + +``` +Android APP 接收短信 + │ + ├─ 解析短信内容(支持多PDU) + ├─ 提取发送方、内容、时间戳 + ├─ 可选:生成签名(HMAC-SHA256) + │ + ▼ +POST 请求发送到 /api/receive + │ + ├─ 解析 multipart/form-data + ├─ 验证必填参数(from, content) + ├─ 可选:验证签名 + │ ├─ 检查时间戳是否过期 + │ ├─ 生成期望的签名 + │ └─ 比较签名是否匹配 + │ + ├─ 保存到数据库 + │ ├─ 存储为 UTC 时间 + │ ├─ 关联 Token 和 Secret + │ └─ 记录接收日志 + │ + ▼ +返回成功响应 +``` + +### 2. 签名验证逻辑 + +TranspondSms 的签名规则: + +```python +# 1. 拼接待签名字符串 +string_to_sign = timestamp + "\n" + secret + +# 2. HMAC-SHA256 计算 +hmac_code = hmac.new( + secret.encode('utf-8'), + string_to_sign.encode('utf-8'), + digestmod=hashlib.sha256 +).digest() + +# 3. Base64 编码 +sign_bytes = base64.b64encode(hmac_code) + +# 4. URL 编码 +sign = urllib.parse.quote(sign_bytes.decode()) +``` + +**防重放攻击**: +- 检查时间戳是否在允许范围内(默认1小时) +- 超出范围的请求拒绝 + +### 3. 时区转换逻辑 + +``` +数据库存储(UTC) + │ + ├─ created_at: 2024-02-06 14:30:00 (UTC) + └─ timestamp: 1707223800000 (毫秒时间戳) + │ + ▼ +读取时转换 + │ + ├─ UTC 时间 + 时区偏移(8小时) + └─ datetime.fromtimestamp(timestamp / 1000) + │ + ▼ +显示(本地时间) + │ + └─ created_at: 2024-02-06 22:30:00 (Asia/Shanghai) +``` + +### 4. 登录验证流程 + +``` +用户访问页面 + │ + ▼ +检查 session['logged_in'] + │ + ├─ 已登录 ──▶ 更新 last_activity ──▶ 允许访问 + │ + └─ 未登录 ──▶ 跳转到 /login + │ + ▼ + 提交表单 + │ + ├─ 验证用户名和密码 + │ + ├─ 成功: + │ ├─ session['logged_in'] = True + │ ├─ session['username'] = username + │ ├─ session['login_time'] = now + │ └─ 跳转到原页面或首页 + │ + └─ 失败:显示错误消息 +``` + +**会话超时检查**: +```python +last_activity = session.get('last_activity') +if now - last_activity > SESSION_LIFETIME: + # 清空会话,重定向到登录页 + session.clear() + return redirect('/login') +``` + +### 5. Token 匹配逻辑 + +```python +# 接收请求时 +token = request.form.get('token') # 从参数获取 + +# 在配置中查找对应的 secret +for token_config in API_TOKENS: + if token_config['token'] == token and token_config['enabled']: + secret = token_config['secret'] + break + +# 使用找到的 secret 进行签名验证 +if secret and SIGN_VERIFY: + verify_sign(secret, sign, timestamp) +``` + +### 6. 数据库设计 + +#### sms_messages 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | INTEGER | 主键,自增 | +| from_number | TEXT | 发送方手机号 | +| content | TEXT | 短信内容 | +| timestamp | INTEGER | 原始时间戳(毫秒) | +| device_info | TEXT | 设备信息(可选) | +| sim_info | TEXT | SIM 卡信息(可选) | +| sign_verified | INTEGER | 是否通过签名验证(0/1) | +| ip_address | TEXT | 来源 IP 地址 | +| created_at | TIMESTAMP | 创建时间(UTC) | + +#### receive_logs 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | INTEGER | 主键,自增 | +| from_number | TEXT | 发送方手机号 | +| content | TEXT | 短信内容 | +| timestamp | INTEGER | 时间戳 | +| sign | TEXT | 签名 | +| sign_valid | INTEGER | 签名是否有效(0/1/null) | +| ip_address | TEXT | IP 地址 | +| status | TEXT | 处理状态(success/error) | +| error_message | TEXT | 错误消息 | +| created_at | TIMESTAMP | 创建时间(UTC)** + +### 分层架构 + +``` +┌─────────────────────────────────────────┐ +│ Flask Application Layer │ +│ (app.py - 路由、业务逻辑、会话管理) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Database Layer │ +│ (database.py - 数据库操作、时区转换) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ SQLite Database │ +│ (sms_receiver.db - 数据持久化) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Template Layer │ +│ (templates/ - HTML、CSS、JS) │ +└─────────────────────────────────────────┘ +``` + +### 核心模块职责 + +| 模块 | 职责 | +|------|------| +| `app.py` | Flask 主应用,路由注册,业务逻辑 | +| `config.py` | 配置加载,从 config.json 读取配置 | +| `database.py` | 数据库模型,CRUD 操作,时区转换 | +| `sign_verify.py` | 签名生成和验证 | +| `templates/` | HTML 模板,前端展示 | + +--- + +## 使用指南 + +### 前置要求 + +- Python 3.7+ +- Flask 3.0+ +- SQLite3 + +### 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 配置文件 + +创建 `config.json`: + +```json +{ + "server": { + "host": "0.0.0.0", + "port": 9518, + "debug": false + }, + "security": { + "enabled": true, + "username": "admin", + "password": "YourStrongPassword123", + "session_lifetime": 3600, + "secret_key": "RandomSecretKeyHere", + "sign_verify": true, + "sign_max_age": 3600000 + }, + "sms": { + "max_messages": 10000, + "auto_cleanup": true, + "cleanup_days": 30 + }, + "database": { + "path": "sms_receiver.db" + }, + "timezone": "Asia/Shanghai", + "api_tokens": [ + { + "name": "我的手机", + "token": "my_phone_token", + "secret": "my_phone_secret", + "enabled": true + } + ] +} +``` + +### 启动服务 + +```bash +python3 app.py +``` + +服务启动后访问:http://你的IP:9518 + +### 配置 TranspondSms APP + +1. 下载并安装 TranspondSms APP +2. 打开 APP,进入"发送方"页面 +3. 添加"网页通知" +4. 填写配置: + +``` +Token (URL): http://你的服务器IP:9518/api/receive?token=my_phone_token +Secret: my_phone_secret +``` + +5. 点击"测试"按钮,验证是否成功 +6. 配置转发规则(如"转发全部") + +### 使用 Web 界面 + +#### 登录 + +- 访问 http://你的IP:9518 +- 输入用户名和密码 +- 登录成功后进入短信列表 + +#### 查看短信 + +- **短信列表**:主页显示所有收到短信 +- **搜索**:支持按号码或内容搜索 +- **筛选**:按发送方号码快捷筛选 +- **详情**:点击短信查看完整内容和元数据 + +#### 查看日志 + +- 访问"接收日志"页面 +- 查看每次请求的处理结果 +- 包括签名验证状态、IP 地址、错误信息 + +#### 统计信息 + +- 访问"统计信息"页面 +- 查看短信总数、今日、本周 +- 签名验证比例 +- 发送方号码排行榜 +- 清理旧数据 + +--- + +## 部署指南 + +### 开发环境部署 + +```bash +# 克隆项目 +git clone +cd smsweb + +# 创建虚拟环境(推荐) +python3 -m venv venv +source venv/bin/activate + +# 安装依赖 +pip install -r requirements.txt + +# 配置 config.json +cp config.example.json config.json +vim config.json + +# 启动服务 +python3 app.py +``` + +### 生产环境部署(推荐方案) + +#### 1. 使用 Gunicorn + Nginx + +**安装 Gunicorn**: + +```bash +pip install gunicorn +``` + +**创建 systemd 服务**: + +```bash +sudo vim /etc/systemd/system/sms-receiver.service +``` + +内容: + +```ini +[Unit] +Description=SMS Receiver Service +After=network.target + +[Service] +Type=notify +User=www-data +WorkingDirectory=/var/www/sms-receiver +Environment="PATH=/var/www/sms-receiver/venv/bin" +ExecStart=/var/www/sms-receiver/venv/bin/gunicorn \ + -w 4 -b 127.0.0.1:9518 \ + --timeout 120 \ + --access-logfile /var/log/sms-receiver/access.log \ + --error-logfile /var/log/sms-receiver/error.log \ + app:app +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +**启动服务**: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable sms-receiver +sudo systemctl start sms-receiver +sudo systemctl status sms-receiver +``` + +**配置 Nginx 反向代理**: + +```bash +sudo vim /etc/nginx/sites-available/sms-receiver +``` + +内容: + +```nginx +upstream sms_receiver { + server 127.0.0.1:9518; +} + +server { + listen 80; + server_name sms.example.com; + + access_log /var/log/nginx/sms-receiver-access.log; + error_log /var/log/nginx/sms-receiver-error.log; + + location / { + proxy_pass http://sms_receiver; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_buffering off; + } + + # 接收 API 不需要登录,但对客户端透明 + location /api/receive { + proxy_pass http://sms_receiver/api/receive; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +**启用站点**: + +```bash +sudo ln -s /etc/nginx/sites-available/sms-receiver /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +#### 2. 配置 HTTPS(Let's Encrypt) + +```bash +sudo certbot --nginx -d sms.example.com +``` + +#### 3. 防火墙配置 + +```bash +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +#### 4. 日志轮转 + +```bash +sudo vim /etc/logrotate.d/sms-receiver +``` + +内容: + +``` +/var/log/sms-receiver/*.log { + daily + rotate 14 + compress + missingok + notifempty + create 0640 www-data www-data + sharedscripts + postrotate + systemctl reload sms-receiver >/dev/null 2>&1 || true + endscript +} +``` + +### Docker 部署 + +**创建 Dockerfile**: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 9518 + +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:9518", "app:app"] +``` + +**创建 docker-compose.yml**: + +```yaml +version: '3.8' + +services: + sms-receiver: + build: . + ports: + - "9518:9518" + volumes: + - ./sms_receiver.db:/app/sms_receiver.db + - ./config.json:/app/config.json + - ./logs:/app/logs + environment: + - FLASK_ENV=production + restart: unless-stopped +``` + +**启动**: + +```bash +docker-compose up -d +``` + +### 安全加固 + +1. **修改默认密码**:首次部署后立即修改登录密码 +2. **使用强密码**:至少16位,包含大小写字母、数字、特殊字符 +3. **启用 HTTPS**:使用 Let's Encrypt 免费证书 +4. **限制访问**:配置防火墙,只开放必要端口 +5. **启用签名验证**:设置 Token 的 secret +6. **定期更新**:定期更新 Python 和 Flask 版本 + +### 监控和维护 + +**查看日志**: + +```bash +# 应用日志 +tail -f /var/log/sms-receiver/error.log + +# Nginx 日志 +tail -f /var/log/nginx/sms-receiver-error.log +``` + +**备份数据库**: + +```bash +#!/bin/bash +BACKUP_DIR="/backup/sms-receiver" +DATE=$(date +%Y%m%d_%H%M%S) +mkdir -p $BACKUP_DIR + +# 备份数据库 +cp /var/www/sms-receiver/sms_receiver.db $BACKUP_DIR/sms_receiver_$DATE.db + +# 备份配置 +cp /var/www/sms-receiver/config.json $BACKUP_DIR/config_$DATE.json + +# 删除30天前的备份 +find $BACKUP_DIR -name "*.db" -mtime +30 -delete +find $BACKUP_DIR -name "*.json" -mtime +30 -delete +``` + +--- + +## API 文档 + +### POST /api/receive + +接收短信接口。 + +**请求方式**:POST multipart/form-data + +**URL 参数**: +- `token` (可选): API Token,用于匹配对应的 secret + +**表单参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| from | string | 是 | 发送方手机号 | +| content | string | 是 | 短信内容 | +| timestamp | string | 否 | 时间戳(毫秒),用于签名验证 | +| sign | string | 否 | 签名(HMAC-SHA256 + Base64 + URL Encode) | +| device | string | 否 | 设备信息 | +| sim | string | 否 | SIM 卡信息 | + +**请求示例**: + +```bash +curl -X POST http://your-server:9518/api/receive?token=my_token \ + -F "from=10086" \ + -F "content=验证码: 123456" \ + -F "timestamp=1707223800000" \ + -F "sign=xxx" +``` + +**响应示例**: + +成功: +```json +{ + "success": true, + "message_id": 123, + "message": "短信已接收" +} +``` + +失败: +```json +{ + "error": "缺少必填参数" +} +``` + +**HTTP 状态码**: +- 200: 成功 +- 400: 参数错误 +- 403: 签名验证失败 +- 500: 服务器错误 + +### GET /api/messages + +获取短信列表(需要登录)。 + +**URL 参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认1 | +| limit | int | 否 | 每页数量,默认20 | +| from | string | 否 | 按发送方号码筛选 | +| search | string | 否 | 搜索内容或号码 | + +**响应示例**: + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "from_number": "10086", + "content": "验证码: 123456", + "timestamp": 1707223800000, + "local_timestamp": "2024-02-06 22:30:00", + "created_at": "2024-02-06 14:30:00", + "sign_verified": true + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +### GET /api/statistics + +获取统计信息(需要登录)。 + +**响应示例**: + +```json +{ + "success": true, + "data": { + "total": 100, + "today": 10, + "week": 50, + "verified": 80, + "unverified": 20 + } +} +``` + +--- + +## 配置说明 + +### 完整配置示例 + +```json +{ + "app": { + "name": "短信转发接收端", + "version": "1.0.0" + }, + "server": { + "host": "0.0.0.0", + "port": 9518, + "debug": false + }, + "security": { + "enabled": true, + "username": "admin", + "password": "YourStrongPassword123", + "session_lifetime": 3600, + "secret_key": "RandomSecretKeyChangeMe", + "sign_verify": true, + "sign_max_age": 3600000 + }, + "sms": { + "max_messages": 10000, + "auto_cleanup": true, + "cleanup_days": 30 + }, + "database": { + "path": "sms_receiver.db" + }, + "timezone": "Asia/Shanghai", + "api_tokens": [ + { + "name": "主手机", + "token": "main_phone_token", + "secret": "main_phone_secret_key", + "enabled": true + }, + { + "name": "备用机", + "token": "backup_phone_token", + "secret": "backup_phone_secret_key", + "enabled": true + }, + { + "name": "测试设备", + "token": "test_token", + "secret": "", + "enabled": false + } + ] +} +``` + +### 配置项详解 + +#### server - 服务器配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| host | string | 0.0.0.0 | 监听地址 | +| port | int | 9518 | 监听端口 | +| debug | bool | false | 调试模式 | + +#### security - 安全配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| enabled | bool | true | 是否启用登录验证 | +| username | string | admin | 登录用户名 | +| password | string | admin123 | 登录密码 | +| session_lifetime | int | 3600 | 会话有效期(秒) | +| secret_key | string | - | Flask 会话密钥 | +| sign_verify | bool | true | 是否验证签名 | +| sign_max_age | int | 3600000 | 签名最大有效时间(毫秒) | + +#### sms - 短信配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| max_messages | int | 10000 | 最多保留短信数 | +| auto_cleanup | bool | true | 是否自动清理老数据 | +| cleanup_days | int | 30 | 清理多少天前的数据 | + +#### api_tokens - API Token 配置 + +| 字段 | 类型 | 说明 | +|------|------|------| +| name | string | 配置名称(可选) | +| token | string | Token 值,用于匹配 secret | +| secret | string | 密钥,用于签名验证(可选) | +| enabled | bool | 是否启用此 Token | + +--- + +## 常见问题 + +### Q1: 如何禁用登录验证? + +在 `config.json` 中设置: + +```json +{ + "security": { + "enabled": false + } +} +``` + +### Q2:签名验证失败怎么办? + +检查以下几点: + +1. **时间戳是否正确**:确保客户端时间准确,误差不超过1小时 +2. **Secret 是否匹配**:确保客户端和服务器端的 secret 完全一致 +3. **签名生成算法**:使用正确的算法(HMAC-SHA256) + +**调试签名**: + +```python +# 生成签名 +import time, hmac, hashlib, base64, urllib.parse + +timestamp = str(int(time.time() * 1000)) +secret = "your_secret" +string_to_sign = f"{timestamp}\n{secret}" +sign = urllib.parse.quote(base64.b64encode( + hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256).digest() +).decode()) + +print(f"Timestamp: {timestamp}") +print(f"Sign: {sign}") +``` + +### Q3: 如何配置多个设备? + +在 `api_tokens` 中添加多个配置: + +```json +{ + "api_tokens": [ + { + "name": "设备A", + "token": "device_a", + "secret": "secret_a", + "enabled": true + }, + { + "name": "设备B", + "token": "device_b", + "secret": "secret_b", + "enabled": true + } + ] +} +``` + +在 APP 中配置不同的设备使用不同的 Token。 + +### Q4: 会话总是过期? + +调整 `session_lifetime`: + +```json +{ + "security": { + "session_lifetime": 86400 // 24小时 + } +} +``` + +### Q5: 如何备份数据? + +直接复制数据库文件: + +```bash +cp sms_receiver.db sms_receiver.db.backup +``` + +或者使用 SQLite 导出: + +```bash +sqlite3 sms_receiver.db ".dump" > backup.sql +``` + +### Q6: 如何清理所有数据? + +删除数据库文件,重启服务会自动重建: + +```bash +rm sms_receiver.db +python3 app.py +``` + +### Q7: 时间显示不正确? + +检查时区配置: + +```json +{ + "timezone": "Asia/Shanghai" +} +``` + +可用时区列表:https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + +--- + +## 开发规范 + +### 代码风格 + +- 遵循 PEP 8 Python 代码规范 +- 使用有意义的变量和函数名 +- 添加必要的类型注解 + +### Git 提交规范 + +``` +feat: 添加新功能 +fix: 修复 bug +docs: 更新文档 +style: 代码格式化 +refactor: 重构 +test: 添加测试 +chore: 构建/工具链 +``` + +### 测试建议 + +```python +# 测试签名生成 +python3 sign_verify.py + +# 测试 API +curl -X POST http://localhost:9518/api/receive \ + -F "from=10086" \ + -F "content=test" +``` + +--- + +## 许可证 + +MIT License + +## 联系方式 + +- 项目地址:https://gitea.king.nyc.mn/openclaw/smsweb +- 问题反馈:提交 Issue diff --git a/README.md b/README.md new file mode 100644 index 0000000..f608ee4 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# 短信转发接收端 + +基于 TranspondSms Android APP 的短信转发接收后台 + +## 功能特性 + +- ✅ **登录验证** - 需要登录才能查看和管理短信 +- ✅ **Token/Secret 可选配置** - 支持在 config.json 中配置多个 API Token +- ✅ **接收 Android APP 转发的短信** - POST multipart/form-data +- ✅ **HMAC-SHA256 签名验证** - 可选的安全机制 +- ✅ **SQLite 数据库存储** +- ✅ **Web 管理界面** - 查看实时短信、日志、统计 +- ✅ **时区支持** - 自动转换为本地时间(默认 Asia/Shanghai) +- ✅ **RESTful API 支持** +- ✅ **自动刷新** - 短信列表30秒自动刷新 + +## 快速启动 + +```bash +# 进入项目目录 +cd /root/.openclaw/workspace/sms-receiver + +# 启动服务 +python3 app.py +``` + +服务将运行在 `http://127.0.0.1:9518` + +## 配置说明 + +### config.json 配置文件 + +创建或编辑 `config.json` 文件: + +```json +{ + "server": { + "host": "0.0.0.0", + "port": 9518, + "debug": true + }, + "security": { + "enabled": true, + "username": "admin", + "password": "admin123", + "session_lifetime": 3600, + "secret_key": "default_secret_key_change_me", + "sign_verify": true, + "sign_max_age": 3600000 + }, + "sms": { + "max_messages": 10000, + "auto_cleanup": true, + "cleanup_days": 30 + }, + "database": { + "path": "sms_receiver.db" + }, + "timezone": "Asia/Shanghai", + "api_tokens": [ + { + "name": "默认配置", + "token": "default_token", + "secret": "your_secret_here", + "enabled": true + }, + { + "name": "设备1", + "token": "device1_token", + "secret": "device1_secret", + "enabled": true + } + ] +} +``` + +### 配置项说明 + +| 项 | 说明 | 默认值 | +|----|------|--------| +| `security.enabled` | 是否启用登录验证 | true | +| `security.username` | 登录用户名 | admin | +| `security.password` | 登录密码 | admin123 | +| `security.session_lifetime` | 会话有效期(秒) | 3600 | +| `security.secret_key` | Flask 会话密钥 | - | +| `security.sign_verify` | 是否验证签名 | true | +| `security.sign_max_age` | 签名最大有效时间(毫秒) | 3600000 | +| `api_tokens` | API Token 配置列表 | - | +| `timezone` | 时区 | Asia/Shanghai | + +### API Token 配置 + +每个 Token 配置包含: + +- `name`: 配置名称(可选) +- `token`: Token 值,需要在 TranspondSms APP 中填写 +- `secret`: 密钥,可选,填写后启用签名验证 +- `enabled`: 是否启用此 Token + +**注意**: +- Token 和 Secret 都是可选的 +- 不填 Secret 时跳过签名验证 +- 可以配置多个 Token 供多个设备使用 +- enabled 为 false 时该 Token 不可用 + +## 配置 TranspondSms APP + +在 Android APP 的"网页通知"配置中: + +### 基础配置(无 Token) + +- **Token (URL)**: `http://your-server-ip:9518/api/receive` +- **Secret**: 留空 + +### 使用 Token 配置 + +如果你在 `config.json` 中配置了 Token: + +- **Token (URL)**: `http://your-server-ip:9518/api/receive` +- **Secret**: 根据你要使用的 Token 配置填写 + +**注意**:TranspondSms APP 的设置中,没有专门的 Token 字段。你需要通过以下方式传递 Token: + +1. **方法一**:在 URL 中携带 Token + ``` + http://your-server-ip:9518/api/receive?token=your_token + ``` + +2. **方法二**:在 Secret 字段中填写 `|` + ``` + my_token|my_secret + ``` + 然后修改代码解析这个格式(需自行实现) + +**当前实现**:在短信接收时,TranspondSms APP 会发送一个 `token` 参数,系统会自动匹配对应的 secret。 + +目前 TranspondSms APP 的 Token 参数默认会放在请求的 query string 中: +``` +POST /api/receive?token=your_token +Content-Type: multipart/form-data +``` + +如需传递多个设备的不同 Token,在 APP 中添加多个网页通知配置即可。 + +## 登录功能 + +### 默认登录信息 + +- **用户名**: admin +- **密码**: admin123 + +首次使用后,请立即修改 `config.json` 中的 `security.username` 和 `security.password`。 + +### 会话管理 + +- 会话默认有效期:1小时(3600秒) +- 超时后需要重新登录 +- 可以通过 `security.session_lifetime` 调整 + +### 禁用登录 + +如果不需要登录验证,设置 `config.json`: + +```json +{ + "security": { + "enabled": false + } +} +``` + +## API 接口 + +### 接收短信 + +``` +POST /api/receive?token=your_token +Content-Type: multipart/form-data + +参数: +- token: API Token(可选,用于匹配 secret) +- from: 发送方手机号(必填) +- content: 短信内容(必填) +- timestamp: 时间戳(毫秒,可选) +- sign: 签名(可选) +- device: 设备信息(可选) +- sim: SIM 卡信息(可选) +``` + +### 查询短信列表(需要登录) + +``` +GET /api/messages?page=1&limit=20 +``` + +### 查询统计信息(需要登录) + +``` +GET /api/statistics +``` + +## 时区配置 + +系统默认使用 `Asia/Shanghai` 时区(UTC+8)。 + +在 `config.json` 中修改时区: + +```json +{ + "timezone": "America/New_York" +} +``` + +支持的时区名称参考:https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + +## 安全建议 + +1. **修改默认密码**:首次使用后立即修改登录密码 +2. **修改 Secret Key**:修改 `security.secret_key` 为随机字符串 +3. **使用 HTTPS**:生产环境建议配置反向代理(Nginx + Let's Encrypt) +4. **启用签名验证**:设置 Token 的 secret 启用签名,防止伪造请求 +5. **会话超时**:设置合理的 `session_lifetime` + +## 默认配置说明 + +| 配置 | 默认值 | 说明 | +|------|--------|------| +| 端口 | 9518 | Web 服务监听端口 | +| 数据库 | sms_receiver.db | SQLite 数据库文件 | +| 时区 | Asia/Shanghai | 武汉时间 UTC+8 | +| 会话有效期 | 3600 秒 | 1小时 | +| 最多保留短信 | 10000 条 | 超过自动清理 | +| 自动刷新间隔 | 30 秒 | SMS 列表自动刷新时间 | + +## 项目文件 + +``` +/root/.openclaw/workspace/sms-receiver/ +├── app.py # Flask 主应用 +├── config.json # 配置文件(需创建) +├── config.py # 配置加载器 +├── database.py # SQLite 数据库模型 +├── sign_verify.py # HMAC-SHA256 签名验证 +├── requirements.txt # 依赖包 +├── templates/ # HTML 模板 +│ ├── login.html # 登录页面 +│ ├── index.html # 主页(短信列表) +│ ├── message_detail.html # 短信详情 +│ ├── logs.html # 接收日志 +│ ├── statistics.html # 统计信息 +│ └── error.html # 错误页面 +└── README.md # 本文档 +``` + diff --git a/app.py b/app.py new file mode 100644 index 0000000..62cdead --- /dev/null +++ b/app.py @@ -0,0 +1,396 @@ +""" +短信转发接收端主应用 +""" + +import os +import logging +import logging.handlers +from datetime import datetime, timedelta, timezone +from functools import wraps +import hashlib + +from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, make_response +from functools import update_wrapper + +from config import config, Config +from database import Database +from sign_verify import verify_from_app + + +def no_cache(f): + """禁用缓存的装饰器""" + def new_func(*args, **kwargs): + resp = make_response(f(*args, **kwargs)) + resp.cache_control.no_cache = True + resp.cache_control.no_store = True + resp.cache_control.max_age = 0 + return resp + return update_wrapper(new_func, f) + + +# 初始化应用 +def create_app(config_name='default'): + # 从配置文件加载 + app_config = Config.load_from_json('config.json') + + app = Flask(__name__) + app.secret_key = app_config.SECRET_KEY + + # 应用配置 + app.config['HOST'] = app_config.HOST + app.config['PORT'] = app_config.PORT + app.config['DEBUG'] = app_config.DEBUG + app.config['SECRET_KEY'] = app_config.SECRET_KEY + app.config['SIGN_VERIFY'] = app_config.SIGN_VERIFY + app.config['SIGN_MAX_AGE'] = app_config.SIGN_MAX_AGE + app.config['DATABASE_PATH'] = app_config.DATABASE_PATH + app.config['MAX_MESSAGES'] = app_config.MAX_MESSAGES + app.config['AUTO_CLEANUP'] = app_config.AUTO_CLEANUP + app.config['CLEANUP_DAYS'] = app_config.CLEANUP_DAYS + app.config['PER_PAGE'] = app_config.PER_PAGE + app.config['REFRESH_INTERVAL'] = app_config.REFRESH_INTERVAL + app.config['LOG_LEVEL'] = app_config.LOG_LEVEL + app.config['LOG_FILE'] = app_config.LOG_FILE + app.config['TIMEZONE'] = app_config.TIMEZONE + app.config['API_TOKENS'] = app_config.API_TOKENS + app.config['LOGIN_ENABLED'] = app_config.LOGIN_ENABLED + app.config['LOGIN_USERNAME'] = app_config.LOGIN_USERNAME + app.config['LOGIN_PASSWORD'] = app_config.LOGIN_PASSWORD + app.config['SESSION_LIFETIME'] = app_config.SESSION_LIFETIME + + # 初始化日志 + setup_logging(app) + + # 解析时区 + try: + timezone_name = app_config.TIMEZONE + import pytz + app.timezone = pytz.timezone(timezone_name) + app.timezone_offset = app.timezone.utcoffset(datetime.now()).total_seconds() / 3600 + except ImportError: + # 如果没有 pytz,使用简单的时区偏移 + app.timezone_offset = 8 # 默认 UTC+8 + app.timezone = None + app.logger.warning('pytz not installed, using simple timezone offset') + + # 初始化数据库 + db = Database(app.config['DATABASE_PATH'], timezone_offset=app.timezone_offset) + + # 注册路由 + register_routes(app, db) + + return app + + +def setup_logging(app): + """配置日志""" + log_level = getattr(logging, app.config['LOG_LEVEL']) + formatter = logging.Formatter( + '[%(asctime)s] %(levelname)s in %(module)s: %(message)s' + ) + + # 文件日志 + log_file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + app.config['LOG_FILE'] + ) + file_handler = logging.handlers.RotatingFileHandler( + log_file_path, + maxBytes=10*1024*1024, # 10MB + backupCount=5 + ) + file_handler.setFormatter(formatter) + file_handler.setLevel(log_level) + + # 控制台日志 + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + console_handler.setLevel(log_level) + + app.logger.addHandler(file_handler) + app.logger.addHandler(console_handler) + app.logger.setLevel(log_level) + + +def login_required(f): + """登录验证装饰器""" + @wraps(f) + def decorated_function(*args, **kwargs): + if app.config.get('LOGIN_ENABLED', True): + if 'logged_in' not in session or not session['logged_in']: + return redirect(url_for('login', next=request.url)) + + # 检查会话是否过期 + last_activity = session.get('last_activity') + if last_activity: + session_lifetime = app.config.get('SESSION_LIFETIME', 3600) + if datetime.now().timestamp() - last_activity > session_lifetime: + session.clear() + flash('会话已过期,请重新登录', 'info') + return redirect(url_for('login', next=request.url)) + + # 更新最后活动时间 + session['last_activity'] = datetime.now().timestamp() + return f(*args, **kwargs) + return decorated_function + + +def register_routes(app, db): + """注册路由""" + + @app.route('/login', methods=['GET', 'POST']) + @no_cache + def login(): + """登录页面""" + # 如果禁用了登录,直接跳转到首页 + if not app.config.get('LOGIN_ENABLED', True): + return redirect(url_for('index')) + + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + + if username == app.config['LOGIN_USERNAME'] and \ + password == app.config['LOGIN_PASSWORD']: + session['logged_in'] = True + session['username'] = username + session['login_time'] = datetime.now().timestamp() + session['last_activity'] = datetime.now().timestamp() + + next_url = request.args.get('next') + if next_url: + return redirect(next_url) + return redirect(url_for('index')) + else: + flash('用户名或密码错误', 'error') + + return render_template('login.html') + + @app.route('/logout') + @no_cache + def logout(): + """登出""" + session.clear() + flash('已退出登录', 'info') + return redirect(url_for('login')) + + @app.route('/') + @login_required + def index(): + """首页 - 短信列表""" + page = request.args.get('page', 1, type=int) + limit = request.args.get('limit', app.config['PER_PAGE'], type=int) + from_number = request.args.get('from', None) + search = request.args.get('search', None) + + messages = db.get_messages(page, limit, from_number, search) + total = db.get_message_count(from_number, search) + total_pages = (total + limit - 1) // limit + + stats = db.get_statistics() + + from_numbers = db.get_from_numbers()[:20] + + return render_template('index.html', + messages=messages, + page=page, + total_pages=total_pages, + total=total, + from_number=from_number, + search=search, + limit=limit, + stats=stats, + from_numbers=from_numbers, + refresh_interval=app.config['REFRESH_INTERVAL']) + + @app.route('/message/') + @login_required + def message_detail(message_id): + """短信详情""" + message = db.get_message_by_id(message_id) + if not message: + flash('短信不存在', 'error') + return redirect(url_for('index')) + + return render_template('message_detail.html', message=message) + + @app.route('/logs') + @login_required + def logs(): + """查看接收日志""" + page = request.args.get('page', 1, type=int) + limit = request.args.get('limit', app.config['PER_PAGE'], type=int) + + logs = db.get_logs(page, limit) + + return render_template('logs.html', logs=logs, page=page, limit=limit) + + @app.route('/statistics') + @login_required + def statistics(): + """统计信息""" + stats = db.get_statistics() + recent = db.get_recent_messages(10) + from_numbers = db.get_from_numbers() + + return render_template('statistics.html', + stats=stats, + recent=recent, + from_numbers=from_numbers, + cleanup_days=app.config['CLEANUP_DAYS'], + max_messages=app.config['MAX_MESSAGES']) + + @app.route('/settings') + @login_required + def settings(): + """设置页面""" + return render_template('settings.html', + api_tokens=app.config['API_TOKENS'], + login_enabled=app.config['LOGIN_ENABLED'], + sign_verify=app.config['SIGN_VERIFY']) + + @app.route('/api/receive', methods=['POST']) + def receive_sms(): + """接收短信接口""" + try: + # 获取参数 + from_number = request.form.get('from') + content = request.form.get('content') + timestamp_str = request.form.get('timestamp') + sign = request.form.get('sign', '') + token_param = request.form.get('token', '') + ip_address = request.remote_addr + + # 验证必填字段 + if not from_number or not content: + db.add_log(from_number, content, None, sign, None, ip_address, + 'error', '缺少必填参数 (from/content)') + return jsonify({'error': '缺少必填参数'}), 400 + + # 如果提供了 token,查找对应的 secret + secret = None + if token_param: + for token_config in app.config['API_TOKENS']: + if token_config.get('enabled') and token_config.get('token') == token_param: + secret = token_config.get('secret') + break + + # 解析时间戳 + timestamp = None + if timestamp_str: + try: + timestamp = int(timestamp_str) + except ValueError: + db.add_log(from_number, content, None, sign, None, ip_address, + 'error', f'时间戳格式错误: {timestamp_str}') + return jsonify({'error': '时间戳格式错误'}), 400 + + # 验证签名 + sign_verified = False + if sign and secret and app.config['SIGN_VERIFY']: + is_valid, message = verify_from_app( + from_number, content, timestamp, sign, secret, + app.config['SIGN_MAX_AGE'] + ) + + if not is_valid: + db.add_log(from_number, content, timestamp, sign, False, ip_address, + 'error', f'签名验证失败: {message}') + app.logger.warning(f'签名验证失败: {message}') + return jsonify({'error': message}), 403 + else: + sign_verified = True + app.logger.info(f'短信已签名验证: {from_number}') + + # 保存短信 + message_id = db.add_message( + from_number=from_number, + content=content, + timestamp=timestamp or int(datetime.now().timestamp() * 1000), + device_info=request.form.get('device'), + sim_info=request.form.get('sim'), + sign_verified=sign_verified, + ip_address=ip_address + ) + + # 记录成功日志 + db.add_log(from_number, content, timestamp, sign, sign_verified, ip_address, + 'success') + + app.logger.info(f'收到短信: {from_number} -> {content[:50]}... (ID: {message_id})') + + return jsonify({ + 'success': True, + 'message_id': message_id, + 'message': '短信已接收' + }), 200 + + except Exception as e: + app.logger.error(f'处理短信失败: {e}', exc_info=True) + return jsonify({'error': '服务器内部错误'}), 500 + + @app.route('/api/messages', methods=['GET']) + @login_required + def api_messages(): + """短信列表 API""" + page = request.args.get('page', 1, type=int) + limit = request.args.get('limit', 20, type=int) + from_number = request.args.get('from', None) + search = request.args.get('search', None) + + messages = db.get_messages(page, limit, from_number, search) + total = db.get_message_count(from_number, search) + + return jsonify({ + 'success': True, + 'data': messages, + 'total': total, + 'page': page, + 'limit': limit + }) + + @app.route('/api/statistics', methods=['GET']) + @login_required + def api_statistics(): + """统计信息 API""" + stats = db.get_statistics() + return jsonify({ + 'success': True, + 'data': stats + }) + + @app.route('/cleanup') + @login_required + def cleanup(): + """清理老数据""" + deleted = db.cleanup_old_messages( + days=app.config['CLEANUP_DAYS'], + max_messages=app.config['MAX_MESSAGES'] + ) + flash(f'已清理 {deleted} 条旧数据', 'success') + return redirect(url_for('statistics')) + + @app.errorhandler(404) + def not_found(e): + return render_template('error.html', error='页面不存在'), 404 + + @app.errorhandler(500) + def server_error(e): + app.logger.error(f'服务器错误: {e}', exc_info=True) + return render_template('error.html', error='服务器内部错误'), 500 + + +if __name__ == '__main__': + env = os.environ.get('FLASK_ENV', 'development') + app = create_app(env) + + app.logger.info(f'启动短信接收服务 (环境: {env})') + app.logger.info(f'数据库: {app.config["DATABASE_PATH"]}') + app.logger.info(f'监听端口: {app.config["PORT"]}') + app.logger.info(f'登录已启用: {app.config["LOGIN_ENABLED"]}') + + app.run( + host=app.config['HOST'], + port=app.config['PORT'], + debug=app.config['DEBUG'] + ) diff --git a/config.json b/config.json new file mode 100644 index 0000000..62afad1 --- /dev/null +++ b/config.json @@ -0,0 +1,37 @@ +{ + "app": { + "name": "短信转发接收端", + "version": "1.0.0" + }, + "server": { + "host": "0.0.0.0", + "port": 9518, + "debug": true + }, + "security": { + "enabled": true, + "username": "admin", + "password": "admin123", + "session_lifetime": 3600, + "secret_key": "default_secret_key_change_me", + "sign_verify": true, + "sign_max_age": 3600000 + }, + "sms": { + "max_messages": 10000, + "auto_cleanup": true, + "cleanup_days": 30 + }, + "database": { + "path": "sms_receiver.db" + }, + "timezone": "Asia/Shanghai", + "api_tokens": [ + { + "name": "默认配置", + "token": "default_token", + "secret": "", + "enabled": true + } + ] +} diff --git a/config.py b/config.py new file mode 100644 index 0000000..1663585 --- /dev/null +++ b/config.py @@ -0,0 +1,123 @@ +""" +短信转发接收端配置文件 +支持从 config.json 加载配置 +""" + +import os +import json +from typing import Dict, List, Any + + +class Config: + """基础配置""" + # 服务器配置 + HOST = '0.0.0.0' + PORT = 9518 + DEBUG = False + + # 安全配置 + SECRET_KEY = 'default_secret_key_change_me' + SIGN_VERIFY = True + SIGN_MAX_AGE = 3600000 # 签名最大有效时间(毫秒),默认1小时 + + # 登录配置 + LOGIN_ENABLED = True + LOGIN_USERNAME = 'admin' + LOGIN_PASSWORD = 'admin123' + SESSION_LIFETIME = 3600 # 会话有效期(秒),默认1小时 + + # 数据库配置 + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + DATABASE_PATH = os.path.join(BASE_DIR, 'sms_receiver.db') + + # 短信存储配置 + MAX_MESSAGES = 10000 + AUTO_CLEANUP = True + CLEANUP_DAYS = 30 + + # Web界面配置 + PER_PAGE = 50 + REFRESH_INTERVAL = 30 + + # 日志配置 + LOG_LEVEL = 'INFO' + LOG_FILE = 'sms_receiver.log' + + # 时区配置 + TIMEZONE = 'Asia/Shanghai' + + # API Token 配置 + API_TOKENS: List[Dict[str, Any]] = [] + + @classmethod + def load_from_json(cls, json_path: str = 'config.json') -> 'Config': + """从 JSON 文件加载配置""" + config_obj = cls() + + if os.path.exists(json_path): + with open(json_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 服务器配置 + if 'server' in config_data: + server = config_data['server'] + config_obj.HOST = server.get('host', config_obj.HOST) + config_obj.PORT = server.get('port', config_obj.PORT) + config_obj.DEBUG = server.get('debug', config_obj.DEBUG) + + # 安全配置 + if 'security' in config_data: + security = config_data['security'] + config_obj.LOGIN_ENABLED = security.get('enabled', config_obj.LOGIN_ENABLED) + config_obj.LOGIN_USERNAME = security.get('username', config_obj.LOGIN_USERNAME) + config_obj.LOGIN_PASSWORD = security.get('password', config_obj.LOGIN_PASSWORD) + config_obj.SESSION_LIFETIME = security.get('session_lifetime', config_obj.SESSION_LIFETIME) + config_obj.SECRET_KEY = security.get('secret_key', config_obj.SECRET_KEY) + config_obj.SIGN_VERIFY = security.get('sign_verify', config_obj.SIGN_VERIFY) + config_obj.SIGN_MAX_AGE = security.get('sign_max_age', config_obj.SIGN_MAX_AGE) + + # 短信配置 + if 'sms' in config_data: + sms = config_data['sms'] + config_obj.MAX_MESSAGES = sms.get('max_messages', config_obj.MAX_MESSAGES) + config_obj.AUTO_CLEANUP = sms.get('auto_cleanup', config_obj.AUTO_CLEANUP) + config_obj.CLEANUP_DAYS = sms.get('cleanup_days', config_obj.CLEANUP_DAYS) + + # 数据库配置 + if 'database' in config_data: + database = config_data['database'] + if 'path' in database: + # 如果是绝对路径,直接使用;如果是相对路径,相对于项目目录 + db_path = database['path'] + if not os.path.isabs(db_path): + db_path = os.path.join(config_obj.BASE_DIR, db_path) + config_obj.DATABASE_PATH = db_path + + # 时区配置 + if 'timezone' in config_data: + config_obj.TIMEZONE = config_data['timezone'] + + # API Token 配置 + if 'api_tokens' in config_data: + config_obj.API_TOKENS = config_data['api_tokens'] + + return config_obj + + +class DevelopmentConfig(Config): + """开发环境配置""" + DEBUG = True + LOG_LEVEL = 'DEBUG' + + +class ProductionConfig(Config): + """生产环境配置""" + DEBUG = False + + +# 配置映射 +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} diff --git a/database.py b/database.py new file mode 100644 index 0000000..03580f9 --- /dev/null +++ b/database.py @@ -0,0 +1,353 @@ +""" +数据库模型 +""" + +import sqlite3 +import json +from datetime import datetime, timedelta, timezone +from typing import List, Optional, Dict, Any +import os + + +class Database: + """数据库管理类""" + + def __init__(self, db_path: str, timezone_offset: int = 8): + """初始化数据库 + + Args: + db_path: 数据库文件路径 + timezone_offset: 时区偏移(小时),默认 UTC+8 + """ + self.db_path = db_path + self.timezone_offset = timezone_offset + self.init_db() + + def convert_to_local(self, dt_str: Optional[str]) -> Optional[str]: + """将 UTC 时间字符串转换为本地时间字符串 + + Args: + dt_str: UTC 时间字符串 (格式: 2024-01-01 00:00:00) + + Returns: + 本地时间字符串 + """ + if not dt_str: + return None + + try: + # 解析 UTC 时间 + utc_dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S') + # 添加时区 + utc_dt = utc_dt.replace(tzinfo=timezone.utc) + # 转换为本地时间 + local_dt = utc_dt + timedelta(hours=self.timezone_offset) + return local_dt.strftime('%Y-%m-%d %H:%M:%S') + except Exception: + return dt_str + + def get_connection(self): + """获取数据库连接""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def init_db(self): + """初始化数据库表""" + conn = self.get_connection() + cursor = conn.cursor() + + # 创建短信表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sms_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_number TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + device_info TEXT, + sim_info TEXT, + sign_verified INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address TEXT + ) + ''') + + # 创建接收日志表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS receive_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_number TEXT, + content TEXT, + timestamp INTEGER, + sign TEXT, + sign_valid INTEGER, + ip_address TEXT, + status TEXT NOT NULL, + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 创建索引 + cursor.execute('CREATE INDEX IF NOT EXISTS idx_from_number ON sms_messages(from_number)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_timestamp ON sms_messages(timestamp)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_created_at ON sms_messages(created_at)') + + conn.commit() + conn.close() + + def add_message(self, from_number: str, content: str, timestamp: int, + device_info: Optional[str] = None, sim_info: Optional[str] = None, + sign_verified: bool = False, ip_address: Optional[str] = None) -> int: + """添加短信""" + conn = self.get_connection() + cursor = conn.cursor() + + # 存储为 UTC 时间 + utc_now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + cursor.execute(''' + INSERT INTO sms_messages + (from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', (from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, utc_now)) + + message_id = cursor.lastrowid + conn.commit() + conn.close() + + return message_id + + def add_log(self, from_number: Optional[str], content: Optional[str], + timestamp: Optional[int], sign: Optional[str], + sign_valid: Optional[bool], ip_address: Optional[str], + status: str, error_message: Optional[str] = None) -> int: + """添加接收日志""" + conn = self.get_connection() + cursor = conn.cursor() + + sign_valid_int = 1 if sign_valid else 0 if sign_valid is not None else None + + # 存储为 UTC 时间 + utc_now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + cursor.execute(''' + INSERT INTO receive_logs + (from_number, content, timestamp, sign, sign_valid, ip_address, status, error_message, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (from_number, content, timestamp, sign, sign_valid_int, ip_address, status, error_message, utc_now)) + + log_id = cursor.lastrowid + conn.commit() + conn.close() + + return log_id + + def get_messages(self, page: int = 1, limit: int = 50, + from_number: Optional[str] = None, + search: Optional[str] = None) -> List[Dict[str, Any]]: + """获取短信列表""" + conn = self.get_connection() + cursor = conn.cursor() + + offset = (page - 1) * limit + where_clauses = [] + params = [] + + if from_number: + where_clauses.append("from_number = ?") + params.append(from_number) + + if search: + where_clauses.append("(content LIKE ? OR from_number LIKE ?)") + params.extend([f"%{search}%", f"%{search}%"]) + + where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" + + query = f''' + SELECT * FROM sms_messages + WHERE {where_sql} + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + ''' + params.extend([limit, offset]) + + cursor.execute(query, params) + messages = [dict(row) for row in cursor.fetchall()] + + # 转换时间为本地时区 + for msg in messages: + if msg.get('created_at'): + msg['created_at'] = self.convert_to_local(msg['created_at']) + # 转换时间戳为本地时间 + if msg.get('timestamp'): + timestamp = msg['timestamp'] + local_dt = datetime.fromtimestamp(timestamp / 1000, timezone(timedelta(hours=self.timezone_offset))) + msg['local_timestamp'] = local_dt.strftime('%Y-%m-%d %H:%M:%S') + + conn.close() + + return messages + + def get_message_count(self, from_number: Optional[str] = None, + search: Optional[str] = None) -> int: + """获取短信总数""" + conn = self.get_connection() + cursor = conn.cursor() + + where_clauses = [] + params = [] + + if from_number: + where_clauses.append("from_number = ?") + params.append(from_number) + + if search: + where_clauses.append("(content LIKE ? OR from_number LIKE ?)") + params.extend([f"%{search}%", f"%{search}%"]) + + where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" + + cursor.execute(f'SELECT COUNT(*) as count FROM sms_messages WHERE {where_sql}', params) + count = cursor.fetchone()['count'] + conn.close() + + return count + + def get_message_by_id(self, message_id: int) -> Optional[Dict[str, Any]]: + """根据ID获取短信""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('SELECT * FROM sms_messages WHERE id = ?', (message_id,)) + row = cursor.fetchone() + conn.close() + + if row: + msg = dict(row) + # 转换时间为本地时区 + if msg.get('created_at'): + msg['created_at'] = self.convert_to_local(msg['created_at']) + # 转换时间戳为本地时间 + if msg.get('timestamp'): + timestamp = msg['timestamp'] + local_dt = datetime.fromtimestamp(timestamp / 1000, timezone(timedelta(hours=self.timezone_offset))) + msg['local_timestamp'] = local_dt.strftime('%Y-%m-%d %H:%M:%S') + return msg + return None + + def get_recent_messages(self, limit: int = 10) -> List[Dict[str, Any]]: + """获取最近的短信""" + return self.get_messages(page=1, limit=limit) + + def get_from_numbers(self) -> List[Dict[str, Any]]: + """获取所有发送方号码""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT from_number, COUNT(*) as count, MAX(timestamp) as last_time + FROM sms_messages + GROUP BY from_number + ORDER BY count DESC + ''') + + numbers = [dict(row) for row in cursor.fetchall()] + conn.close() + + return numbers + + def get_logs(self, page: int = 1, limit: int = 50) -> List[Dict[str, Any]]: + """获取接收日志""" + conn = self.get_connection() + cursor = conn.cursor() + + offset = (page - 1) * limit + + cursor.execute(''' + SELECT * FROM receive_logs + ORDER BY created_at DESC + LIMIT ? OFFSET ? + ''', (limit, offset)) + + logs = [dict(row) for row in cursor.fetchall()] + + # 转换时间为本地时区 + for log in logs: + if log.get('created_at'): + log['created_at'] = self.convert_to_local(log['created_at']) + + conn.close() + + return logs + + def cleanup_old_messages(self, days: int = 30, max_messages: int = 10000): + """清理老数据""" + conn = self.get_connection() + cursor = conn.cursor() + + # 按天数清理 + cutoff_date = datetime.now() - timedelta(days=days) + cursor.execute(''' + DELETE FROM sms_messages + WHERE created_at < ? + ''', (cutoff_date.strftime('%Y-%m-%d %H:%M:%S'),)) + + deleted_count = cursor.rowcount + + # 按数量清理 + cursor.execute(''' + DELETE FROM sms_messages + WHERE id NOT IN ( + SELECT id FROM sms_messages + ORDER BY created_at DESC + LIMIT ? + ) + ''', (max_messages,)) + + deleted_count += cursor.rowcount + + conn.commit() + conn.close() + + return deleted_count + + def get_statistics(self) -> Dict[str, Any]: + """获取统计信息""" + conn = self.get_connection() + cursor = conn.cursor() + + # 总短信数 + cursor.execute('SELECT COUNT(*) as total FROM sms_messages') + total = cursor.fetchone()['total'] + + # 今日短信数 + cursor.execute(''' + SELECT COUNT(*) as today FROM sms_messages + WHERE DATE(created_at) = DATE('now') + ''') + today = cursor.fetchone()['today'] + + # 本周短信数 + cursor.execute(''' + SELECT COUNT(*) as week FROM sms_messages + WHERE created_at >= datetime('now', '-7 days') + ''') + week = cursor.fetchone()['week'] + + # 签名验证占比 + cursor.execute(''' + SELECT + SUM(CASE WHEN sign_verified = 1 THEN 1 ELSE 0 END) as verified, + SUM(CASE WHEN sign_verified = 0 THEN 1 ELSE 0 END) as unverified + FROM sms_messages + ''') + row = cursor.fetchone() + + conn.close() + + return { + 'total': total, + 'today': today, + 'week': week, + 'verified': row['verified'] or 0, + 'unverified': row['unverified'] or 0 + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..047e950 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==3.0.0 diff --git a/sign_verify.py b/sign_verify.py new file mode 100644 index 0000000..30d18a4 --- /dev/null +++ b/sign_verify.py @@ -0,0 +1,118 @@ +""" +签名验证工具 +参考 TranspondSms 的签名规则: +timestamp + "\n" + secret -> HMAC-SHA256 -> Base64 -> URL Encode +""" + +import hmac +import hashlib +import base64 +import urllib.parse +import time + + +def generate_sign(secret: str, timestamp: int = None) -> str: + """ + 生成签名 + + Args: + secret: 密钥 + timestamp: 时间戳(毫秒),不传则使用当前时间 + + Returns: + 签名字符串 + """ + if timestamp is None: + timestamp = int(time.time() * 1000) + + string_to_sign = f"{timestamp}\n{secret}" + hmac_code = hmac.new( + secret.encode('utf-8'), + string_to_sign.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + sign = urllib.parse.quote(base64.b64encode(hmac_code).decode()) + + return sign + + +def verify_sign(secret: str, sign: str, timestamp: int, max_age: int = 3600000) -> tuple[bool, str]: + """ + 验证签名 + + Args: + secret: 密钥 + sign: 待验证的签名 + timestamp: 时间戳(毫秒) + max_age: 签名最大有效时间(毫秒),默认1小时 + + Returns: + (是否有效, 错误信息) + """ + current_time = int(time.time() * 1000) + + # 检查时间戳是否过期 + if abs(current_time - timestamp) > max_age: + return False, f"签名过期,时间差: {abs(current_time - timestamp) / 1000:.1f}秒" + + # 生成期望的签名 + expected_sign = generate_sign(secret, timestamp) + + # 比较签名 + if sign != expected_sign: + return False, "签名不匹配" + + return True, "签名有效" + + +def verify_from_app(from_number: str, content: str, timestamp: int, + sign: str, secret: str, max_age: int = 3600000) -> tuple[bool, str]: + """ + 验证 TranspondSms APP 发来的请求 + + Args: + from_number: 发送方手机号 + content: 短信内容 + timestamp: 时间戳 + sign: 签名 + secret: 密钥 + max_age: 最大有效时间(毫秒) + + Returns: + (是否有效, 错误信息) + """ + # 检查必填字段 + if not from_number or not content: + return False, "缺少必填字段" + + # 如果没有签名,跳过验证(取决于配置) + if not sign: + return True, "无签名,跳过验证" + + return verify_sign(secret, sign, timestamp, max_age) + + +# 测试代码 +if __name__ == '__main__': + # 测试签名生成和验证 + secret = "test_secret" + timestamp = int(time.time() * 1000) + + # 生成签名 + sign = generate_sign(secret, timestamp) + print(f"Timestamp: {timestamp}") + print(f"Sign: {sign}") + + # 验证签名 + is_valid, message = verify_sign(secret, sign, timestamp) + print(f"验证结果: {is_valid}, {message}") + + # 测试过期签名 + old_timestamp = timestamp - 7200000 # 2小时前 + is_valid, message = verify_sign(secret, sign, old_timestamp) + print(f"过期验证: {is_valid}, {message}") + + # 测试错误签名 + wrong_sign = "wrong_signature" + is_valid, message = verify_sign(secret, wrong_sign, timestamp) + print(f"错误签名: {is_valid}, {message}") diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..e99f9f1 --- /dev/null +++ b/start.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +# 短信转发接收端启动脚本 + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 设置项目目录 +PROJECT_DIR="/root/.openclaw/workspace/sms-receiver" +cd "$PROJECT_DIR" || exit 1 + +# 检查 Python +if ! command -v python3 &> /dev/null; then + echo -e "${RED}错误: 未找到 Python3${NC}" + exit 1 +fi + +# 检查依赖 +echo -e "${YELLOW}检查依赖...${NC}" + +# 创建虚拟环境(如果不存在) +if [ ! -d "venv" ]; then + echo -e "${YELLOW}创建虚拟环境...${NC}" + python3 -m venv venv +fi + +# 激活虚拟环境 +source venv/bin/activate + +# 安装依赖 +pip install -q -r requirements.txt + +# 配置文件检查 +if [ ! -f ".env" ]; then + echo -e "${YELLOW}创建配置文件...${NC}" + cat > .env << EOF +# 短信转发接收端配置 +FLASK_ENV=development +SMS_SECRET_KEY=default_secret_key_change_me +EOF + echo -e "${YELLOW}请修改 .env 文件中的 SMS_SECRET_KEY${NC}" +fi + +# 启动服务 +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}短信转发接收端${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "服务地址: ${GREEN}http://127.0.0.1:9518${NC}" +echo -e "接收API: ${GREEN}http://127.0.0.1:9518/api/receive${NC}" +echo "" +echo -e "${YELLOW}在 TranspondSms APP 中配置:${NC}" +echo -e " Token (URL): ${GREEN}http://your-server-ip:9518/api/receive${NC}" +echo -e " Secret: ${GREEN}default_secret_key_change_me${NC}" +echo "" +echo -e "${YELLOW}按 Ctrl+C 停止服务${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# 设置环境变量 +export FLASK_ENV=$(grep "FLASK_ENV" .env | cut -d '=' -f2) +export SMS_SECRET_KEY=$(grep "SMS_SECRET_KEY" .env | cut -d '=' -f2) + +# 启动应用 +python3 app.py diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..d163b05 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,73 @@ + + + + + + 错误 + + + +
+
⚠️
+

{{ error or '出错了' }}

+

抱歉,遇到了一些问题,请稍后再试。

+ 返回首页 +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..567039c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,490 @@ + + + + + + 短信转发接收端 + + + +
+
+

📱 短信转发接收端

+ +
+ +
+
+

短信总数

+
{{ stats.total }}
+
+
+

今日

+
{{ stats.today }}
+
+
+

本周

+
{{ stats.week }}
+
+
+

签名验证

+
+ {{ stats.verified }} / {{ stats.verified + stats.unverified }} +
+
+
+ +
+

快捷筛选 (按号码)

+
+ {% for num in from_numbers %} + + {{ num.from_number }} ({{ num.count }}) + + {% endfor %} +
+
+ +
+ + + + + + {{ refresh_interval }}s + +
+ +
+
+ +
+ {% if messages %} + + + + + + + + + + + + {% for msg in messages %} + + + + + + + + {% endfor %} + +
ID发送方内容时间验证
{{ msg.id }}{{ msg.from_number }} + + {{ msg.content[:50] }}{% if msg.content|length > 50 %}...{% endif %} + + {{ msg.created_at }} + {% if msg.sign_verified %} + ✓ 已验证 + {% else %} + ✗ 未验证 + {% endif %} +
+ {% else %} +
+

暂无短信数据

+

等待接收短信...

+
+ {% endif %} +
+ + {% if total_pages > 1 %} + + {% endif %} +
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..1205bf5 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,156 @@ + + + + + + 登录 - 短信转发接收端 + + + + + + diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..e445e75 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,195 @@ + + + + + + 接收日志 + + + +
+
+

📋 接收日志

+ +
+ +
+ {% if logs %} + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% endfor %} + +
时间发送方状态签名IP 地址错误信息
{{ log.created_at }}{{ log.from_number or '-' }} + + {% if log.status == 'success' %}✓ 成功{% else %}✗ 失败{% endif %} + + + {% if log.sign_valid is not none %} + {% if log.sign_valid %} + + {% else %} + + {% endif %} + {% else %} + - + {% endif %} + {{ log.ip_address or '-' }}{{ log.error_message or '-' }}
+ {% else %} +
+

暂无日志数据

+
+ {% endif %} +
+
+ + diff --git a/templates/message_detail.html b/templates/message_detail.html new file mode 100644 index 0000000..5e82009 --- /dev/null +++ b/templates/message_detail.html @@ -0,0 +1,225 @@ + + + + + + 短信详情 #{{ message.id }} + + + +
+
+
+

📱 短信详情 #{{ message.id }}

+ +
+ +
+
+ +
{{ message.from_number }}
+
+ +
+ +
{{ message.content }}
+
+ +
+
+ +
{{ message.created_at }}
+
+ +
+ +
+ {% if message.timestamp %} + {{ message.timestamp }} ms + {% else %} + N/A + {% endif %} +
+
+ +
+ +
+ {% if message.sign_verified %} + ✓ 已验证 + {% else %} + ✗ 未验证 + {% endif %} +
+
+ +
+ +
+ {% if message.ip_address %} + {{ message.ip_address }} + {% else %} + N/A + {% endif %} +
+
+
+ + {% if message.device_info or message.sim_info %} +
+ {% if message.device_info %} +
+ +
{{ message.device_info }}
+
+ {% endif %} + + {% if message.sim_info %} +
+ +
{{ message.sim_info }}
+
+ {% endif %} +
+ {% endif %} +
+
+
+ + diff --git a/templates/statistics.html b/templates/statistics.html new file mode 100644 index 0000000..92699eb --- /dev/null +++ b/templates/statistics.html @@ -0,0 +1,257 @@ + + + + + + 统计信息 + + + +
+
+

📊 统计信息

+ +
+ +
+
+

短信总数

+
{{ stats.total }}
+
累计接收
+
+ +
+

今日

+
{{ stats.today }}
+
今日新增
+
+ +
+

本周

+
{{ stats.week }}
+
近7天
+
+ +
+

签名验证

+
{{ stats.verified }} / {{ stats.verified + stats.unverified }}
+
已验证 / 总数
+
+
+ +
+

📬 最近接收

+ {% if recent %} +
    + {% for msg in recent %} +
  • + {{ msg.from_number }} + {{ msg.created_at }} +
    {{ msg.content[:100] }}{% if msg.content|length > 100 %}...{% endif %}
    +
  • + {% endfor %} +
+ {% else %} + 暂无数据 + {% endif %} +
+ +
+

📱 发送方号码

+ {% if from_numbers %} +
+ {% for num in from_numbers %} +
+
{{ num.from_number }}
+
{{ num.count }} 条短信
+
+ {% endfor %} +
+ {% else %} + 暂无数据 + {% endif %} +
+ +
+

🗑️ 数据清理

+

+ 清理 {{ cleanup_days }} 天前的数据,最多保留 {{ max_messages }} 条 +

+ +
+
+ +