From 4e5e93660d2d1b5cae41df1e8bc2832b53f8ef5a Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Fri, 6 Feb 2026 23:23:49 +0000 Subject: [PATCH] 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 --- .gitignore | 47 ++ DEVELOPMENT.md | 961 ++++++++++++++++++++++++++++++++++ README.md | 254 +++++++++ app.py | 396 ++++++++++++++ config.json | 37 ++ config.py | 123 +++++ database.py | 353 +++++++++++++ requirements.txt | 1 + sign_verify.py | 118 +++++ start.sh | 68 +++ templates/error.html | 73 +++ templates/index.html | 490 +++++++++++++++++ templates/login.html | 156 ++++++ templates/logs.html | 195 +++++++ templates/message_detail.html | 225 ++++++++ templates/statistics.html | 257 +++++++++ 16 files changed, 3754 insertions(+) create mode 100644 .gitignore create mode 100644 DEVELOPMENT.md create mode 100644 README.md create mode 100644 app.py create mode 100644 config.json create mode 100644 config.py create mode 100644 database.py create mode 100644 requirements.txt create mode 100644 sign_verify.py create mode 100755 start.sh create mode 100644 templates/error.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/logs.html create mode 100644 templates/message_detail.html create mode 100644 templates/statistics.html 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 }} 条 +

+ +
+
+ +