diff --git a/README.md b/README.md index f7feafa..f0ea37e 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,580 @@ -## 核云IDC服务商VPS自动监测重启程序 +# VPS Hub - 多平台VPS监控与管理系统 -### API接口信息 +
+![License](https://img.shields.io/badge/license-MIT-red) -- **获取登录Token** - `https://www.heyunidc.cn/v1/login_api?account={phone number/email acc}&password={API KEY}` - 备注:POST方法 +**一个强大的多平台VPS监控、管理和自动重启系统** -- **获取VPS列表** - `https://www.heyunidc.cn/v1/hosts?page=1&limit=100` - 备注:GET方法,使用"Authorization: JWT {Token}"进行认证 - 注意:若您的VPS数量超过100,需要更改limit数量 +[功能特性](#-功能特性) • [快速开始](#-快速开始) • [架构设计](#-架构设计) • [API文档](#-api文档) • [常见问题](#-常见问题) -- **获取VPS状态** - `https://www.heyunidc.cn/v1/hosts/{id}/module/status?type=host` - 备注:GET方法,使用"Authorization: JWT {Token}"进行认证 +
-- **操作VPS** - `PUT https://www.heyunidc.cn/v1/hosts/:id/module/hard_reboot` - 备注:PUT方法,使用"Authorization: JWT {Token}"进行认证 - 操作类型:hard_reboot - 硬重启,on - 开机,off - 关机,reboot - 重启 +--- -### 使用方法 +## 📋 目录 -#### 获取API_KEY -如图所示: +- [功能特性](#-功能特性) +- [系统架构](#-系统架构) +- [技术栈](#-技术栈) +- [快速开始](#-快速开始) +- [配置说明](#-配置说明) +- [使用指南](#-使用指南) +- [数据库设计](#-数据库设计) +- [API文档](#-api文档) +- [监控服务](#-监控服务) +- [安全说明](#-安全说明) +- [常见问题](#-常见问题) +- [更新日志](#-更新日志) +- [许可证](#-许可证) + +--- + +## ✨ 功能特性 + +### 🎯 核心功能 + +- **多平台支持**:支持魔方平台(智简魔方)、阿里云、腾讯云等主流VPS平台 +- **实时监控**:每5分钟自动Ping检测所有VPS的可用性 +- **自动开机**:检测到VPS关机时自动执行开机操作 +- **状态管理**:实时显示VPS的运行状态(开机/关机/未知) +- **详细信息**:展示CPU、内存、磁盘、带宽、操作系统等配置信息 + +### 🔧 管理功能 + +- **Web管理面板**:美观的响应式Web界面 +- **手动刷新**:一键刷新VPS列表和详细信息 +- **远程控制**:支持开机、关机、硬重启操作 +- **配置管理**:轻松添加和管理多个VPS平台配置 +- **统计信息**:实时统计VPS总数、运行中数量、已关机数量 + +### 📊 监控功能 + +- **Ping检测**:定期检测VPS网络可达性 +- **延迟记录**:记录每次Ping的延迟时间 +- **状态持久化**:将VPS状态保存到SQLite数据库 +- **历史数据**:保留30天的Ping历史记录 +- **每日摘要**:每天0点生成前一天的可用性统计报告 + +### 🚀 智能优化 + +- **API调用优化**:Ping成功时不调用API,减少不必要的请求 +- **Token缓存**:JWT Token缓存2小时,避免频繁登录 +- **动态更新**:只更新实际变化的字段,避免数据覆盖 +- **防重复机制**:使用唯一约束防止重复数据 +- **错误重试**:开机失败后自动重试并记录真实状态 + +--- + +## 🏗️ 系统架构 + +``` +┌─────────────────────────────────────────────────────┐ +│ Web前端 (index.php) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ VPS列表 │ │ 操作控制 │ │ 统计信息展示 │ │ +│ └──────────┘ └──────────┘ └──────────────────┘ │ +└──────────────────────┬──────────────────────────────┘ + │ HTTP POST/GET +┌──────────────────────▼──────────────────────────────┐ +│ PHP后端 (mofangidc.php) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ API封装 │ │Token管理 │ │ 数据库操作 │ │ +│ └──────────┘ └──────────┘ └──────────────────┘ │ +└──────────────────────┬──────────────────────────────┘ + │ SQLite +┌──────────────────────▼──────────────────────────────┐ +│ 数据库层 (SQLite) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ vps.db │ │vpslist.db│ │ status.db │ │ +│ │(配置) │ │ (VPS列表)│ │ (监控记录) │ │ +│ └──────────┘ └──────────┘ └──────────────────┘ │ +└──────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────┐ +│ Python监控服务 (monitor.py) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ Ping检测 │ │ API调用 │ │ 定时任务 │ │ +│ └──────────┘ └──────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +### 架构特点 + +1. **前后端分离**:PHP负责Web界面和API调用,Python负责后台监控 +2. **多数据库设计**:三个独立的SQLite数据库,职责清晰 +3. **混合架构**:PHP处理用户交互,Python处理定时任务 +4. **适配器模式**:支持多平台扩展,易于添加新平台 + +--- + +## 💻 技术栈 + +### 后端 +- **PHP 7.4+**:Web服务器端逻辑 +- **Python 3.6+**:后台监控服务 +- **SQLite 3**:轻量级数据库 + +### 前端 +- **HTML5/CSS3**:页面结构和样式 +- **JavaScript**:交互逻辑 +- **响应式设计**:支持移动端访问 + +### 依赖库 +- **PHP**: cURL, PDO-SQLite +- **Python**: requests, schedule, sqlite3 + +--- + +## 🚀 快速开始 + +### 环境要求 + +- PHP 7.4 或更高版本 +- Python 3.6 或更高版本 +- SQLite 3 +- Linux/Windows/macOS + +### 安装步骤 + +#### 1. 克隆项目 + +```bash +git clone https://git.masonliu.com/MasonLiu/VPSHUB.git +cd VPSHUB +``` + +#### 2. 配置Web + +直接将VPSHUB文件夹下的所有文件放置于网站路径下即可 + +**温馨提示**: + +请将app路径设置为禁止外网访问(或添加Basic认证)。 + +#### 3. 一键快速启动 + +```bash +chmod 755 app/install.sh +./app/install.sh +``` + +#### 4. 访问Web界面 + +浏览器访问:`http://your-domain.com/` + +首次访问会自动跳转到配置页面,添加第一个VPS平台配置。 + +--- + +## ⚙️ 配置说明 + +### 添加VPS平台配置 + +1. 访问 `config_add.php?pass=YOUR_API_PASS` +2. 填写配置信息: + - **API标识**:自定义名称(唯一) + - **网站类型**:选择平台(目前支持魔方平台) + - **网站链接**:API根域名(不要包含路径) + - **账户**:登录账号 + - **API密钥**:登录密码或API Key + - **自动监控**:是否启用自动监控 + +3. 点击"保存配置" + +### 配置示例 + +**魔方平台配置**: +``` +API标识: 核云IDC +网站类型: 魔方平台 +网站链接: https://www.heyunidc.cn +账户: 邮箱或电话号码 +API密钥: your_api_key +自动监控: ✓ +``` ![API_KEY](./src/api.png) -#### Web端 -将web目录部分部署到服务器上,分配域名或直接IP访问即可 +⚠️ **重要提示**: +- 网站链接只填写根域名,不要包含 `/v1` 或 `/api` 等路径 +- API标识必须唯一,不能重复 +- 确保账户和密码正确 -首次使用会进入配置页,依次输入API_PASS,ACCOUNT,API_KEY(其中API_PASS是您自定义的网站访问密码,支持大小写字母以及数字) +--- -随后访问`http://example.com/?pass=API_PASS`即可 +## 📖 使用指南 -切记:切勿将app部分放置于网站目录下 +### Web管理面板 -#### 监控端 +#### 查看VPS列表 -**安装:** -1. chmod +x ./install.sh -2. ./install.sh +访问 `index.php?pass=YOUR_API_PASS`,可以看到: +- 按配置分组的VPS列表 +- 每个VPS的状态、IP、配置信息 +- 实时统计数据 -系统将自动注册名为idc-monitor的system服务 +#### 操作VPS -注意:若您的服务器必须要求禁ping,程序可能无法正常运行,在安装脚本前,请手动将config.yml中的WAY改为http,然后再启动安装脚本 +每个VPS卡片提供三个操作按钮: +- **⚡ 开机**:启动VPS +- **🔴 关机**:关闭VPS +- **🔄 硬重启**:强制重启VPS -**卸载:** -1. chmod +x ./uninstall.sh -2. ./uninstall.sh +操作后会显示成功/失败提示,5秒后自动消失。 + +#### 刷新VPS列表 + +点击顶部工具栏的 "🔄 手动刷新VPS列表" 按钮: +1. 从API获取最新的VPS列表 +2. 更新数据库中的VPS信息 +3. 获取每个VPS的详细配置(CPU、内存、磁盘、带宽、系统) + +### 监控服务 + +#### 启动监控 + +```bash +sudo systemctl start idc_monitor +``` + +#### 查看状态 + +```bash +sudo systemctl status idc_monitor +``` + + +#### 停止监控 + +```bash +sudo systemctl stop idc_monitor +``` + +#### 重启监控 + +```bash +sudo systemctl restart idc_monitor +``` + +### 监控流程 + +``` +每5分钟执行一次监控循环 + ↓ +1. Ping所有VPS + ├─ Ping成功 → 标记status='on',不调用API + └─ Ping失败 → 标记为abnormal,等待下一步 + ↓ +2. 检查Ping失败的VPS + ├─ 全部正常 → 跳过 + └─ 有失败的 → 调用API查询真实状态 + ├─ 状态为'on' → 可能是禁Ping,更新数据库 + └─ 状态为'off' → 执行开机操作 + ├─ 等待60秒 + └─ 验证开机结果(最多重试2次) + ↓ +3. 清理30天前的旧数据 + ↓ +等待5分钟后下一次循环 +``` + +--- + +## 🗄️ 数据库设计 + +### 数据库文件 + +| 文件名 | 用途 | 主要表 | +|--------|------|--------| +| `vps.db` | 存储平台配置 | `configs` | +| `vpslist.db` | 存储VPS列表 | `vps_list` | +| `status.db` | 存储监控记录 | `ping_status`, `vps_summary` | + +### 表结构 + +#### configs 表 (vps.db) + +```sql +CREATE TABLE configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_label TEXT NOT NULL UNIQUE, -- API标识 + site_type TEXT NOT NULL, -- 平台类型 + site_url TEXT, -- API地址 + account TEXT NOT NULL, -- 账户 + api_key TEXT NOT NULL, -- API密钥 + auto_monitor BOOLEAN DEFAULT 1, -- 是否自动监控 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +#### vps_list 表 (vpslist.db) + +```sql +CREATE TABLE vps_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + config_id INTEGER NOT NULL, -- 配置ID + vps_id INTEGER NOT NULL, -- VPS平台ID + domain TEXT, -- 域名 + ip_address TEXT, -- IP地址 + product_name TEXT, -- 产品名称 + cpu_cores INTEGER, -- CPU核数 + memory_size TEXT, -- 内存大小 + disk_size TEXT, -- 磁盘大小 + bandwidth TEXT, -- 带宽 + os_type TEXT, -- 操作系统 + status TEXT, -- 状态(on/off/unknown) + section BOOLEAN DEFAULT 0, -- 是否监控 + last_check TIMESTAMP, -- 最后检查时间 + FOREIGN KEY (config_id) REFERENCES configs(id), + UNIQUE(config_id, vps_id) +); +``` + +#### ping_status 表 (status.db) + +```sql +CREATE TABLE ping_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vps_id INTEGER NOT NULL, -- VPS ID + target TEXT NOT NULL, -- 目标(IP或域名) + status TEXT NOT NULL, -- 状态(normal/abnormal) + latency_ms REAL, -- 延迟(ms) + check_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +#### vps_summary 表 (status.db) + +```sql +CREATE TABLE vps_summary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vps_id INTEGER NOT NULL, -- VPS ID + date DATE NOT NULL, -- 日期 + avg_latency_ms REAL, -- 平均延迟 + max_latency_ms REAL, -- 最大延迟 + min_latency_ms REAL, -- 最小延迟 + count_under_100 INTEGER, -- <100ms次数 + count_100_to_300 INTEGER, -- 100-300ms次数 + count_300_to_500 INTEGER, -- 300-500ms次数 + count_abnormal INTEGER, -- 异常次数 + availability TEXT, -- 可用性评分 + UNIQUE(vps_id, date) +); +``` + +--- + +## 📡 API文档 + +### 魔方平台API + +- `POST /login_api?account={acc}&password={api key}` - 登录获取JWT Token + +以下接口通过请求头进行认证 +Authorization: JWT {token} + +- `GET /hosts?page=&limit=` - 获取VPS列表 +- `GET /hosts/{id}` - 获取VPS详情 +- `GET /hosts/{id}/module/status?type=host` - 获取VPS状态 +- `PUT /hosts/{id}/module/on` - 开机 +- `PUT /hosts/{id}/module/off` - 关机 +- `PUT /hosts/{id}/module/hard_reboot` - 硬重启 + +## 🔍 监控服务 + +### 配置文件 + +监控服务配置文件位于:`/etc/systemd/system/idc_monitor.service` + +```ini +[Unit] +Description=VPS Hub Monitor Service +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/path/to/VPSHUB/app +ExecStart=/usr/bin/python3 monitor.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +### 日志文件 + +- **正常日志**:`app/logs/monitor.log` +- **错误日志**:`app/logs/error.log` + +### 定时任务 + +- **监控循环**:每5分钟执行一次 +- **每日摘要**:每天00:00生成前一天的统计报告 +- **数据清理**:每次循环清理30天前的Ping记录 + +--- + +## 🔒 安全说明 + +### 访问控制 + +- **API密码保护**:所有页面都需要通过 `?pass=API_PASS` 验证 +- **密码文件**:存储在 `app/pass.php`,权限设置为 600 +- **数据库保护**:Nginx配置禁止直接访问 `.db` 文件,或者限制访问app路径需要basic认证 + +### Token安全 + +- **Token缓存**:JWT Token缓存2小时,减少登录次数 +- **自动刷新**:Token失效时自动重新登录 +- **缓存文件**:`app/token.php`,权限设置为 600 + +### 最佳实践 + +1. **修改默认密码**:首次使用后立即修改API密码 +2. **HTTPS**:生产环境务必使用HTTPS +3. **防火墙**:限制访问IP范围 +4. **定期备份**:定期备份数据库文件 +5. **日志审计**:定期检查日志文件 + +--- + +## ❓ 常见问题 + +### Q1: 页面显示"0台VPS"怎么办? + +**A**: +1. 点击"手动刷新VPS列表"按钮 +2. 检查配置是否正确(特别是site_url不要包含路径) +3. 查看PHP错误日志:`tail -f /var/log/php/error.log` + +--- + +### Q2: 监控服务无法启动? + +**A**: +```bash +# 检查服务状态 +sudo systemctl status idc_monitor + +# 查看详细日志 +journalctl -u idc_monitor -n 50 + +# 常见原因: +# 1. Python依赖未安装:pip install -r requirements.txt +# 2. 数据库文件权限问题:chown www-data:www-data app/db/*.db +# 3. 配置文件路径错误:检查service文件中的WorkingDirectory +``` + +--- + +### Q3: exec函数被禁用怎么办? + +**A**: +系统已经做了兼容处理: +- 如果exec被禁用,会显示友好提示 +- 需要手动执行安装脚本:`sudo bash app/install.sh` +- 监控服务仍然可以正常运行 + +--- + +### Q4: VPS状态显示"未知"? + +**A**: +可能原因: +1. 数据库中还没有该VPS的记录 +2. 多次调用导致状态被覆盖(已修复) +3. API返回异常 + +解决方法: +- 手动刷新VPS列表 +- 检查日志确认API调用是否正常 +- 等待下一次监控循环 + +--- + +### Q5: 如何添加新的VPS平台? + +**A**: +1. 在 `app/monitor.py` 中创建新的适配器类 +2. 继承 `PlatformAdapter` 基类 +3. 实现必要的方法(login, get_vps_list, get_vps_status等) +4. 在 `load_configs_and_create_adapters()` 中添加判断逻辑 + +示例: +```python +class AliyunAdapter(PlatformAdapter): + def login(self): + # 实现阿里云登录逻辑 + pass + + def get_vps_list(self): + # 实现获取VPS列表逻辑 + pass +``` + +--- + +### Q6: 数据库文件在哪里? + +**A**: +- `app/db/vps.db` - 配置数据库 +- `app/db/vpslist.db` - VPS列表数据库 +- `app/db/status.db` - 监控状态数据库 + +可以使用SQLite工具查看: +```bash +sqlite3 app/db/vpslist.db +.tables +SELECT * FROM vps_list; +``` + +--- + +### Q7: 如何备份数据? + +**A**: +```bash +# 备份所有数据库 +cp app/db/*.db backup/$(date +%Y%m%d)/ + +# 或使用sqlite3导出 +sqlite3 app/db/vpslist.db ".dump" > backup/vpslist.sql +``` + +--- + +## 📄 许可证 + +本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件 + +--- + + +## 📧 联系方式 + +- **项目地址**: [https://git.masonliu.com/MasonLiu/VPSHUB](https://git.masonliu.com/MasonLiu/VPSHUB) +- **作者**: MasonLiu +- **邮箱**: admin@masonliu.com + +--- + +## 🙏 致谢 + +感谢以下开源项目: +- [PHP](https://www.php.net/) +- [Python](https://www.python.org/) +- [SQLite](https://www.sqlite.org/) +- [智简魔方财务系统](https://www.idcsmart.com/) + +--- + +
+ +**⭐ 如果这个项目对您有帮助,请给个Star!** + +Made with ❤️ by MasonLiu + +
diff --git a/app/config.yml b/app/config.yml deleted file mode 100644 index 629d18c..0000000 --- a/app/config.yml +++ /dev/null @@ -1,7 +0,0 @@ -ACCOUNT: '' # 填写核云IDC账号(手机号或邮箱) -API_KEY: '' # 填写核云IDC API密钥 -WAY: ping # 填写检测方式:ping 或 http,默认为ping -DOMAIN: '' # 当WAY为http时,填写要检测的域名(多个域名用英文逗号分隔) -SPAN: 300 # 监控间隔时间(秒),默认300秒(5分钟) -EXCEPTION_IPS: [] # 例外IP列表,这些IP关机时不会自动开机(例如:["1.2.3.4", "5.6.7.8"]) -JWT: '' # JWT Token(自动管理,无需手动填写) \ No newline at end of file diff --git a/app/db_helper.php b/app/db_helper.php new file mode 100644 index 0000000..113a23f --- /dev/null +++ b/app/db_helper.php @@ -0,0 +1,253 @@ +dbPath = $dbPath; + + // 确保目录存在 + $dir = dirname($dbPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + try { + $this->db = new PDO("sqlite:" . $dbPath); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + } catch (PDOException $e) { + throw new Exception("数据库连接失败: " . $e->getMessage()); + } + } + + /** + * 执行查询并返回所有结果 + * @param string $sql SQL语句 + * @param array $params 参数数组 + * @return array 结果数组 + */ + public function query($sql, $params = []) { + try { + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log("数据库查询错误: " . $e->getMessage() . " | SQL: " . $sql); + return []; + } + } + + /** + * 执行查询并返回单条结果 + * @param string $sql SQL语句 + * @param array $params 参数数组 + * @return array|false 结果数组或false + */ + public function queryOne($sql, $params = []) { + try { + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $stmt->fetch(); + } catch (PDOException $e) { + error_log("数据库查询错误: " . $e->getMessage() . " | SQL: " . $sql); + return false; + } + } + + /** + * 执行插入、更新、删除操作 + * @param string $sql SQL语句 + * @param array $params 参数数组 + * @return bool 是否成功 + */ + public function execute($sql, $params = []) { + try { + $stmt = $this->db->prepare($sql); + return $stmt->execute($params); + } catch (PDOException $e) { + error_log("数据库执行错误: " . $e->getMessage() . " | SQL: " . $sql); + return false; + } + } + + /** + * 插入数据并返回最后插入的ID + * @param string $sql SQL语句 + * @param array $params 参数数组 + * @return int|false 最后插入的ID或false + */ + public function insert($sql, $params = []) { + try { + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $this->db->lastInsertId(); + } catch (PDOException $e) { + error_log("数据库插入错误: " . $e->getMessage() . " | SQL: " . $sql); + return false; + } + } + + /** + * 开始事务 + */ + public function beginTransaction() { + return $this->db->beginTransaction(); + } + + /** + * 提交事务 + */ + public function commit() { + return $this->db->commit(); + } + + /** + * 回滚事务 + */ + public function rollBack() { + return $this->db->rollBack(); + } + + /** + * 获取数据库连接对象(用于高级操作) + * @return PDO + */ + public function getConnection() { + return $this->db; + } +} + +// 便捷函数:获取vps.db的DBHelper实例 +function getVpsDB() { + static $db = null; + if ($db === null) { + $dbPath = __DIR__ . '/db/vps.db'; + $db = new DBHelper($dbPath); + // 初始化表结构 + initVpsTables($db); + } + return $db; +} + +// 便捷函数:获取vpslist.db的DBHelper实例 +function getVpsListDB() { + static $db = null; + if ($db === null) { + $dbPath = __DIR__ . '/db/vpslist.db'; + $db = new DBHelper($dbPath); + initVpsListTables($db); + } + return $db; +} + +// 便捷函数:获取status.db的DBHelper实例 +function getStatusDB() { + static $db = null; + if ($db === null) { + $dbPath = __DIR__ . '/db/status.db'; + $db = new DBHelper($dbPath); + // 初始化表结构 + initStatusTables($db); + } + return $db; +} + +/** + * 初始化vps.db表结构 + */ +function initVpsTables($db) { + $db->execute(" + CREATE TABLE IF NOT EXISTS configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_label TEXT NOT NULL UNIQUE, + site_type TEXT NOT NULL, + site_url TEXT, + account TEXT NOT NULL, + api_key TEXT NOT NULL, + auto_monitor BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "); +} + +/** + * 初始化vpslist.db表结构 + */ +function initVpsListTables($db) { + $db->execute(" + CREATE TABLE IF NOT EXISTS vps_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + config_id INTEGER NOT NULL, + vps_id INTEGER NOT NULL, + domain TEXT, + ip_address TEXT, + product_name TEXT, + cpu_cores INTEGER, + memory_size TEXT, + disk_size TEXT, + bandwidth TEXT, + os_type TEXT, + status TEXT, + section BOOLEAN DEFAULT 0, + last_check TIMESTAMP, + FOREIGN KEY (config_id) REFERENCES configs(id), + UNIQUE(config_id, vps_id) + ) + "); + + $db->execute('CREATE INDEX IF NOT EXISTS idx_vps_config ON vps_list(config_id)'); + $db->execute('CREATE INDEX IF NOT EXISTS idx_vps_vps_id ON vps_list(vps_id)'); +} + +/** + * 初始化status.db表结构 + */ +function initStatusTables($db) { + // Ping状态记录表 + $db->execute(" + CREATE TABLE IF NOT EXISTS ping_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vps_id INTEGER NOT NULL, + target TEXT NOT NULL, + status TEXT NOT NULL, + latency_ms REAL, + check_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "); + + // VPS摘要统计表 + $db->execute(" + CREATE TABLE IF NOT EXISTS vps_summary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vps_id INTEGER NOT NULL, + date DATE NOT NULL, + avg_latency_ms REAL, + max_latency_ms REAL, + min_latency_ms REAL, + count_under_100 INTEGER DEFAULT 0, + count_100_to_300 INTEGER DEFAULT 0, + count_300_to_500 INTEGER DEFAULT 0, + count_abnormal INTEGER DEFAULT 0, + availability TEXT, + UNIQUE(vps_id, date) + ) + "); + + // 创建索引 + $db->execute('CREATE INDEX IF NOT EXISTS idx_ping_vps ON ping_status(vps_id)'); + $db->execute('CREATE INDEX IF NOT EXISTS idx_ping_time ON ping_status(check_time)'); + $db->execute('CREATE INDEX IF NOT EXISTS idx_summary_vps ON vps_summary(vps_id)'); + $db->execute('CREATE INDEX IF NOT EXISTS idx_summary_date ON vps_summary(date)'); +} +?> diff --git a/app/db_manager.py b/app/db_manager.py new file mode 100644 index 0000000..0631a19 --- /dev/null +++ b/app/db_manager.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +VPS Hub 数据库管理模块 +负责初始化和管理三个SQLite数据库: vps.db, vpslist.db, status.db +""" + +import os +import sqlite3 +from datetime import datetime + + +class DatabaseManager: + """数据库管理器""" + + def __init__(self, db_dir=None): + """初始化数据库管理器 + + Args: + db_dir: 数据库文件目录,默认为app/db/ + """ + if db_dir is None: + db_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'db') + + self.db_dir = db_dir + os.makedirs(db_dir, exist_ok=True) + + # 数据库文件路径 + self.vps_db = os.path.join(db_dir, 'vps.db') + self.vpslist_db = os.path.join(db_dir, 'vpslist.db') + self.status_db = os.path.join(db_dir, 'status.db') + + # 初始化所有数据库 + self.init_vps_db() + self.init_vpslist_db() + self.init_status_db() + + def get_connection(self, db_path): + """获取数据库连接 + + Args: + db_path: 数据库文件路径 + + Returns: + SQLite连接对象 + """ + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row # 使结果可以通过列名访问 + return conn + + def init_vps_db(self): + """初始化vps.db - VPS配置表""" + conn = self.get_connection(self.vps_db) + cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_label TEXT NOT NULL UNIQUE, + site_type TEXT NOT NULL, + site_url TEXT, + account TEXT NOT NULL, + api_key TEXT NOT NULL, + auto_monitor BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + conn.commit() + conn.close() + + def init_vpslist_db(self): + """初始化vpslist.db - VPS列表缓存表""" + conn = self.get_connection(self.vpslist_db) + cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS vps_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + config_id INTEGER NOT NULL, + vps_id INTEGER NOT NULL, + domain TEXT, + ip_address TEXT, + product_name TEXT, + cpu_cores INTEGER, + memory_size TEXT, + disk_size TEXT, + bandwidth TEXT, + os_type TEXT, + status TEXT, + section BOOLEAN DEFAULT 0, + last_check TIMESTAMP, + FOREIGN KEY (config_id) REFERENCES configs(id), + UNIQUE(config_id, vps_id) + ) + ''') + + # 创建索引以提高查询性能 + cursor.execute('CREATE INDEX IF NOT EXISTS idx_vps_config ON vps_list(config_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_vps_vps_id ON vps_list(vps_id)') + + conn.commit() + conn.close() + + def init_status_db(self): + """初始化status.db - Ping状态记录和摘要统计表""" + conn = self.get_connection(self.status_db) + cursor = conn.cursor() + + # Ping状态记录表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS ping_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vps_id INTEGER NOT NULL, + target TEXT NOT NULL, + status TEXT NOT NULL, + latency_ms REAL, + check_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # VPS摘要统计表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS vps_summary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vps_id INTEGER NOT NULL, + date DATE NOT NULL, + avg_latency_ms REAL, + max_latency_ms REAL, + min_latency_ms REAL, + count_under_100 INTEGER DEFAULT 0, + count_100_to_300 INTEGER DEFAULT 0, + count_300_to_500 INTEGER DEFAULT 0, + count_abnormal INTEGER DEFAULT 0, + availability TEXT, + UNIQUE(vps_id, date) + ) + ''') + + # 创建索引 + cursor.execute('CREATE INDEX IF NOT EXISTS idx_ping_vps ON ping_status(vps_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_ping_time ON ping_status(check_time)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_summary_vps ON vps_summary(vps_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_summary_date ON vps_summary(date)') + + conn.commit() + conn.close() + + # ==================== vps.db 操作 ==================== + + def add_config(self, api_label, site_type, account, api_key, site_url=None, auto_monitor=True): + """添加VPS配置 + + Args: + api_label: API标识(必填,唯一) + site_type: 网站类型 (mofang/aliyun/tencent) + account: 账户 + api_key: API密钥 + site_url: 网站链接 + auto_monitor: 是否开启自动监控 + + Returns: + 新配置的ID + """ + conn = self.get_connection(self.vps_db) + cursor = conn.cursor() + + try: + cursor.execute(''' + INSERT INTO configs (api_label, site_type, site_url, account, api_key, auto_monitor) + VALUES (?, ?, ?, ?, ?, ?) + ''', (api_label, site_type, site_url, account, api_key, auto_monitor)) + + config_id = cursor.lastrowid + conn.commit() + return config_id + except Exception as e: + conn.rollback() + raise e + finally: + conn.close() + + def get_all_configs(self): + """获取所有配置 + + Returns: + 配置列表 + """ + conn = self.get_connection(self.vps_db) + cursor = conn.cursor() + + cursor.execute('SELECT * FROM configs ORDER BY id') + configs = [dict(row) for row in cursor.fetchall()] + + conn.close() + return configs + + def get_config_by_id(self, config_id): + """根据ID获取配置 + + Args: + config_id: 配置ID + + Returns: + 配置字典或None + """ + conn = self.get_connection(self.vps_db) + cursor = conn.cursor() + + cursor.execute('SELECT * FROM configs WHERE id = ?', (config_id,)) + row = cursor.fetchone() + + conn.close() + return dict(row) if row else None + + def update_config(self, config_id, **kwargs): + """更新配置 + + Args: + config_id: 配置ID + **kwargs: 要更新的字段 + + Returns: + 是否成功 + """ + if not kwargs: + return False + + conn = self.get_connection(self.vps_db) + cursor = conn.cursor() + + # 构建UPDATE语句 + fields = ', '.join([f"{key} = ?" for key in kwargs.keys()]) + values = list(kwargs.values()) + values.append(config_id) + + cursor.execute(f'UPDATE configs SET {fields}, updated_at = CURRENT_TIMESTAMP WHERE id = ?', values) + + affected = cursor.rowcount + conn.commit() + conn.close() + + return affected > 0 + + def delete_config(self, config_id): + """删除配置 + + Args: + config_id: 配置ID + + Returns: + 是否成功 + """ + conn = self.get_connection(self.vps_db) + cursor = conn.cursor() + + cursor.execute('DELETE FROM configs WHERE id = ?', (config_id,)) + + affected = cursor.rowcount + conn.commit() + conn.close() + + return affected > 0 + + # ==================== vpslist.db 操作 ==================== + + def add_vps(self, config_id, vps_id, domain=None, ip_address=None, + product_name=None, section=False): + """添加VPS到列表 + + Args: + config_id: 配置ID + vps_id: VPS在平台的ID + domain: 域名 + ip_address: IP地址 + product_name: 产品名称 + section: 是否标记为需要监控 + + Returns: + 新记录的ID + """ + conn = self.get_connection(self.vpslist_db) + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO vps_list (config_id, vps_id, domain, ip_address, product_name, section, last_check) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ''', (config_id, vps_id, domain, ip_address, product_name, section)) + + record_id = cursor.lastrowid + conn.commit() + conn.close() + + return record_id + + def batch_add_vps(self, vps_list): + """批量添加VPS + + Args: + vps_list: VPS信息列表,每个元素是字典 + """ + conn = self.get_connection(self.vpslist_db) + cursor = conn.cursor() + + for vps in vps_list: + cursor.execute(''' + INSERT OR REPLACE INTO vps_list + (config_id, vps_id, domain, ip_address, product_name, cpu_cores, memory_size, disk_size, bandwidth, os_type, section, last_check) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ''', ( + vps['config_id'], + vps['vps_id'], + vps.get('domain'), + vps.get('ip_address'), + vps.get('product_name'), + vps.get('cpu_cores'), + vps.get('memory_size'), + vps.get('disk_size'), + vps.get('bandwidth'), + vps.get('os_type'), + vps.get('section', False) + )) + + conn.commit() + conn.close() + + def get_all_vps(self): + """获取所有VPS + + Returns: + VPS列表 + """ + conn = self.get_connection(self.vpslist_db) + cursor = conn.cursor() + + cursor.execute('SELECT * FROM vps_list ORDER BY config_id, vps_id') + vps_list = [dict(row) for row in cursor.fetchall()] + + conn.close() + return vps_list + + def get_vps_by_config(self, config_id): + """根据配置ID获取VPS列表 + + Args: + config_id: 配置ID + + Returns: + VPS列表 + """ + conn = self.get_connection(self.vpslist_db) + cursor = conn.cursor() + + cursor.execute('SELECT * FROM vps_list WHERE config_id = ? ORDER BY vps_id', (config_id,)) + vps_list = [dict(row) for row in cursor.fetchall()] + + conn.close() + return vps_list + + def update_vps_details(self, vps_id, **kwargs): + """更新VPS详细信息 + + Args: + vps_id: VPS ID + **kwargs: 要更新的字段 + + Returns: + 是否成功 + """ + if not kwargs: + return False + + conn = self.get_connection(self.vpslist_db) + cursor = conn.cursor() + + fields = ', '.join([f"{key} = ?" for key in kwargs.keys()]) + values = list(kwargs.values()) + values.append(vps_id) + + cursor.execute(f'UPDATE vps_list SET {fields}, last_check = CURRENT_TIMESTAMP WHERE vps_id = ?', values) + + affected = cursor.rowcount + conn.commit() + conn.close() + + return affected > 0 + + def update_vps_status(self, vps_id, status): + """更新VPS状态 + + Args: + vps_id: VPS ID + status: 状态 (on/off/unknown) + + Returns: + 是否成功 + """ + return self.update_vps_details(vps_id, status=status) + + def get_monitored_vps(self): + """获取所有标记为需要监控的VPS + + Returns: + VPS列表 + """ + conn = self.get_connection(self.vpslist_db) + cursor = conn.cursor() + + cursor.execute('SELECT * FROM vps_list WHERE section = 1 ORDER BY config_id, vps_id') + vps_list = [dict(row) for row in cursor.fetchall()] + + conn.close() + return vps_list + + # ==================== status.db 操作 ==================== + + def save_ping_status(self, vps_id, target, status, latency_ms=None): + """保存Ping状态记录 + + Args: + vps_id: VPS ID + target: 目标(IP或域名) + status: 状态 (normal/abnormal) + latency_ms: 延迟(ms) + """ + conn = self.get_connection(self.status_db) + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO ping_status (vps_id, target, status, latency_ms) + VALUES (?, ?, ?, ?) + ''', (vps_id, target, status, latency_ms)) + + conn.commit() + conn.close() + + def get_ping_records(self, vps_id, date=None): + """获取Ping记录 + + Args: + vps_id: VPS ID + date: 日期 (YYYY-MM-DD),为空则获取所有记录 + + Returns: + Ping记录列表 + """ + conn = self.get_connection(self.status_db) + cursor = conn.cursor() + + if date: + cursor.execute(''' + SELECT * FROM ping_status + WHERE vps_id = ? AND DATE(check_time) = ? + ORDER BY check_time + ''', (vps_id, date)) + else: + cursor.execute(''' + SELECT * FROM ping_status + WHERE vps_id = ? + ORDER BY check_time DESC + ''', (vps_id,)) + + records = [dict(row) for row in cursor.fetchall()] + conn.close() + + return records + + def cleanup_old_ping_records(self, days=30): + """清理旧的Ping记录 + + Args: + days: 保留天数,默认30天 + """ + from datetime import timedelta + + conn = self.get_connection(self.status_db) + cursor = conn.cursor() + + cutoff_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S') + cursor.execute('DELETE FROM ping_status WHERE check_time < ?', (cutoff_date,)) + + deleted = cursor.rowcount + conn.commit() + conn.close() + + return deleted + + def save_vps_summary(self, vps_id, date, avg_latency, max_latency, min_latency, + count_under_100, count_100_to_300, count_300_to_500, + count_abnormal, availability): + """保存VPS摘要统计 + + Args: + vps_id: VPS ID + date: 日期 (YYYY-MM-DD) + avg_latency: 平均延迟 + max_latency: 最大延迟 + min_latency: 最小延迟 + count_under_100: <100ms次数 + count_100_to_300: 100-300ms次数 + count_300_to_500: 300-500ms次数 + count_abnormal: 异常次数 + availability: 可用性评分 + """ + conn = self.get_connection(self.status_db) + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO vps_summary + (vps_id, date, avg_latency_ms, max_latency_ms, min_latency_ms, + count_under_100, count_100_to_300, count_300_to_500, count_abnormal, availability) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (vps_id, date, avg_latency, max_latency, min_latency, + count_under_100, count_100_to_300, count_300_to_500, + count_abnormal, availability)) + + conn.commit() + conn.close() + + def get_vps_summary(self, vps_id, date=None): + """获取VPS摘要统计 + + Args: + vps_id: VPS ID + date: 日期,为空则获取最新一条 + + Returns: + 摘要统计字典或None + """ + conn = self.get_connection(self.status_db) + cursor = conn.cursor() + + if date: + cursor.execute('SELECT * FROM vps_summary WHERE vps_id = ? AND date = ?', (vps_id, date)) + else: + cursor.execute('SELECT * FROM vps_summary WHERE vps_id = ? ORDER BY date DESC LIMIT 1', (vps_id,)) + + row = cursor.fetchone() + conn.close() + + return dict(row) if row else None + + def get_all_summaries(self, date=None): + """获取所有VPS的摘要统计 + + Args: + date: 日期,为空则获取最新 + + Returns: + 摘要统计列表 + """ + conn = self.get_connection(self.status_db) + cursor = conn.cursor() + + if date: + cursor.execute('SELECT * FROM vps_summary WHERE date = ? ORDER BY vps_id', (date,)) + else: + # 获取每个VPS的最新摘要 + cursor.execute(''' + SELECT vs.* FROM vps_summary vs + INNER JOIN ( + SELECT vps_id, MAX(date) as max_date + FROM vps_summary + GROUP BY vps_id + ) latest ON vs.vps_id = latest.vps_id AND vs.date = latest.max_date + ORDER BY vs.vps_id + ''') + + summaries = [dict(row) for row in cursor.fetchall()] + conn.close() + + return summaries + + +if __name__ == '__main__': + # 测试代码 + db = DatabaseManager() + print("数据库初始化完成") + print(f"vps.db: {db.vps_db}") + print(f"vpslist.db: {db.vpslist_db}") + print(f"status.db: {db.status_db}") diff --git a/app/install.sh b/app/install.sh index 9e7b849..4b64426 100644 --- a/app/install.sh +++ b/app/install.sh @@ -1,7 +1,7 @@ #!/bin/bash -# 核云IDC服务商VPS自动监测重启程序 - 安装脚本 -# 该脚本会检查配置并创建systemd服务,持续化运行monitor.py +# VPS Hub 监控程序 - 安装脚本 +# 该脚本会创建systemd服务,持续化运行monitor.py # 颜色定义 RED='\033[0;31m' @@ -13,12 +13,11 @@ NC='\033[0m' # No Color SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 定义服务名称 -SERVICE_NAME="idc-monitor" +SERVICE_NAME="idc_monitor" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" -CONFIG_FILE="${SCRIPT_DIR}/config.yml" echo "==========================================" -echo " 核云IDC VPS监控程序 - 安装向导" +echo " VPS Hub 监控程序 - 安装向导" echo "==========================================" echo "" @@ -81,189 +80,12 @@ fi echo -e "${GREEN}✅${NC} 检测到pip: ${PIP_CMD}" -# 检查配置文件是否存在 -if [ ! -f "${CONFIG_FILE}" ]; then - echo -e "${RED}错误: 配置文件不存在: ${CONFIG_FILE}${NC}" - exit 1 -fi - -# 读取现有配置 -echo "" -echo "正在检查配置文件..." - -# 使用grep解析YAML配置 -ACCOUNT=$(grep "^ACCOUNT:" "${CONFIG_FILE}" | sed 's/ACCOUNT:[[:space:]]*//' | sed 's/[[:space:]]*#.*//' | tr -d "'" | tr -d '"') -API_KEY=$(grep "^API_KEY:" "${CONFIG_FILE}" | sed 's/API_KEY:[[:space:]]*//' | sed 's/[[:space:]]*#.*//' | tr -d "'" | tr -d '"') -WAY=$(grep "^WAY:" "${CONFIG_FILE}" | sed 's/WAY:[[:space:]]*//' | sed 's/[[:space:]]*#.*//' | tr -d "'" | tr -d '"') -DOMAIN=$(grep "^DOMAIN:" "${CONFIG_FILE}" | sed 's/DOMAIN:[[:space:]]*//' | sed 's/[[:space:]]*#.*//' | tr -d "'" | tr -d '"') -SPAN=$(grep "^SPAN:" "${CONFIG_FILE}" | sed 's/SPAN:[[:space:]]*//' | sed 's/[[:space:]]*#.*//' | tr -d "'" | tr -d '"') -EXCEPTION_IPS=$(grep "^EXCEPTION_IPS:" "${CONFIG_FILE}" | sed 's/EXCEPTION_IPS:[[:space:]]*//' | sed 's/[[:space:]]*#.*//' | tr -d "'" | tr -d '"') - -NEED_UPDATE=false - -# 检查ACCOUNT -if [ -z "$ACCOUNT" ]; then - echo "" - echo "==================================================" - echo -e -n "${RED}请输入核云IDC账号(手机号或邮箱): ${NC}" - read ACCOUNT - if [ -z "$ACCOUNT" ]; then - echo -e "${RED}错误: 账号不能为空${NC}" - exit 1 - fi - NEED_UPDATE=true -fi - -# 检查API_KEY -if [ -z "$API_KEY" ]; then - echo "" - echo "==================================================" - echo -e -n "${RED}请输入核云IDC API密钥: ${NC}" - read API_KEY - if [ -z "$API_KEY" ]; then - echo -e "${RED}错误: API密钥不能为空${NC}" - exit 1 - fi - NEED_UPDATE=true -fi - -# 检查WAY -if [ -z "$WAY" ]; then - echo "" - echo "==================================================" - echo -e "${RED}请选择检测方式:${NC}" - echo " 1. ping - Ping检测IP地址(默认)" - echo " 2. http - HTTP检测域名" - echo -e -n "${RED}请输入选项 1/2,直接回车默认为1: ${NC}" - read WAY_CHOICE - - if [ "$WAY_CHOICE" = "2" ]; then - WAY="http" - else - WAY="ping" - fi - NEED_UPDATE=true -fi - -# 如果WAY为http,检查DOMAIN -if [ "$WAY" = "http" ] && [ -z "$DOMAIN" ]; then - echo "" - echo "==================================================" - echo -e "${RED}请输入要检测的域名(多个域名用英文逗号分隔):${NC}" - echo "例如: example.com,test.com,demo.com" - echo -e -n "${RED}域名: ${NC}" - read DOMAIN - if [ -z "$DOMAIN" ]; then - echo -e "${RED}错误: 域名不能为空${NC}" - exit 1 - fi - NEED_UPDATE=true -fi - -# 检查SPAN -if [ -z "$SPAN" ]; then - echo "" - echo "==================================================" - echo -e -n "${RED}请输入监控间隔时间(秒,直接回车默认300秒): ${NC}" - read SPAN_INPUT - if [ -n "$SPAN_INPUT" ]; then - if [[ "$SPAN_INPUT" =~ ^[0-9]+$ ]]; then - if [ "$SPAN_INPUT" -lt 60 ]; then - echo -e "${RED}警告: 间隔时间过短,建议至少60秒${NC}" - fi - SPAN=$SPAN_INPUT - else - echo -e "${RED}警告: 输入无效,使用默认值300秒${NC}" - SPAN=300 - fi - else - SPAN=300 - fi - NEED_UPDATE=true -fi - -# 询问是否设置例外IP -echo "" -echo "==================================================" -echo -e "${RED}是否设置例外IP列表?(这些IP关机时不会自动开机)${NC}" -echo -e -n "${RED}请输入例外IP,多个IP用英文逗号分隔,直接回车跳过: ${NC}" -read EXCEPTION_INPUT - -if [ -n "$EXCEPTION_INPUT" ]; then - EXCEPTION_IPS="$EXCEPTION_INPUT" - NEED_UPDATE=true -else - if [ -z "$EXCEPTION_IPS" ]; then - EXCEPTION_IPS="" - fi -fi - -# 保存配置 -if [ "$NEED_UPDATE" = true ]; then - echo "" - echo "==================================================" - - # 使用Python更新YAML文件 - ${PYTHON_CMD} << PYEOF -import yaml - -config_file = "${CONFIG_FILE}" - -try: - with open(config_file, 'r', encoding='utf-8') as f: - config = yaml.safe_load(f) -except: - config = {} - -config['ACCOUNT'] = "${ACCOUNT}" -config['API_KEY'] = "${API_KEY}" -config['WAY'] = "${WAY}" -if "${WAY}" == "http": - config['DOMAIN'] = "${DOMAIN}" -config['SPAN'] = ${SPAN} - -# 处理例外IP -exception_ips_str = "${EXCEPTION_IPS}" -if exception_ips_str: - config['EXCEPTION_IPS'] = [ip.strip() for ip in exception_ips_str.split(',') if ip.strip()] -else: - config['EXCEPTION_IPS'] = [] - -# 清除JWT字段(重新生成) -if 'JWT' in config: - del config['JWT'] - -with open(config_file, 'w', encoding='utf-8') as f: - yaml.dump(config, f, allow_unicode=True, default_flow_style=False) - -print("\033[32m✅ 配置已保存\033[0m") -PYEOF - - if [ $? -ne 0 ]; then - echo -e "${RED}错误: 配置保存失败${NC}" - exit 1 - fi -else - echo -e "${GREEN}✅${NC} 配置检查完成,无需更新" -fi - -echo "" -echo "当前配置:" -echo " 账号: ${ACCOUNT}" -echo " 检测方式: ${WAY}" -if [ "$WAY" = "http" ]; then - echo " 域名: ${DOMAIN}" -fi -echo " 监控间隔: ${SPAN}秒" -if [ -n "$EXCEPTION_IPS" ]; then - echo " 例外IP: ${EXCEPTION_IPS}" -else - echo " 例外IP: 无" -fi echo "" +echo "注意: VPS配置请通过网页 config_add.php 进行管理" # 安装Python依赖 if [ -f "${SCRIPT_DIR}/requirements.txt" ]; then + echo "" echo "正在安装Python依赖包..." cd ${SCRIPT_DIR} if ${PIP_CMD} install -r requirements.txt; then @@ -283,10 +105,11 @@ if [ ! -f "${SCRIPT_DIR}/monitor.py" ]; then exit 1 fi -# 创建日志目录 +# 创建日志目录和数据库目录 LOG_DIR="${SCRIPT_DIR}/logs" -mkdir -p ${LOG_DIR} -chmod 755 ${LOG_DIR} +DB_DIR="${SCRIPT_DIR}/db" +mkdir -p ${LOG_DIR} ${DB_DIR} +chmod 755 ${LOG_DIR} ${DB_DIR} echo "" echo "正在创建systemd服务..." @@ -294,7 +117,7 @@ echo "正在创建systemd服务..." # 创建systemd服务文件 cat > ${SERVICE_FILE} << EOF [Unit] -Description=Heyun IDC VPS Monitor Service +Description=VPS Hub Monitor Service After=network.target Wants=network-online.target @@ -359,6 +182,7 @@ echo -e " ${YELLOW}停止服务:${NC} systemctl stop ${SERVICE_NAME}" echo -e " ${YELLOW}重启服务:${NC} systemctl restart ${SERVICE_NAME}" echo -e " ${YELLOW}卸载服务:${NC} cd ${SCRIPT_DIR} && ./uninstall.sh" echo "" -echo "配置文件位置: ${CONFIG_FILE}" +echo "配置文件位置: 通过网页 config_add.php 管理" echo "日志文件位置: ${LOG_DIR}" -echo "" \ No newline at end of file +echo "数据库文件位置: ${DB_DIR}" +echo "" diff --git a/app/monitor.py b/app/monitor.py index b230d37..a6e7fde 100644 --- a/app/monitor.py +++ b/app/monitor.py @@ -1,36 +1,295 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -核云IDC VPS自动监测重启程序 -功能:实时监测服务器状态,发现关机自动开机 +VPS Hub 多平台监控程序 +功能:支持多平台(魔方/阿里云/腾讯云)的VPS监控、自动开机和可用性统计 """ import os import sys import time -import yaml import json import logging import subprocess import requests -from datetime import datetime +import schedule +from datetime import datetime, timedelta from pathlib import Path +# 导入数据库管理器 +from db_manager import DatabaseManager -class IDCMonitor: - """核云IDC监控器""" + +class PlatformAdapter: + """平台适配器基类""" - def __init__(self, config_path=None): - """初始化监控器""" - # 获取配置文件路径 - if config_path is None: - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.yml') - - self.config_path = config_path - self.config = {} - self.base_url = "https://www.heyunidc.cn/v1" + def __init__(self, site_url, account, api_key): + self.site_url = site_url.rstrip('/') + self.account = account + self.api_key = api_key self.jwt_token = None - self.retry_count = 0 # 开机重试次数 + + def login(self): + """登录获取Token - 需要子类实现""" + raise NotImplementedError + + def get_vps_list(self): + """获取VPS列表 - 需要子类实现""" + raise NotImplementedError + + def get_vps_status(self, vps_id): + """获取VPS状态 - 需要子类实现""" + raise NotImplementedError + + def get_vps_details(self, vps_id): + """获取VPS详细信息 - 需要子类实现""" + raise NotImplementedError + + def power_on(self, vps_id): + """开机 - 需要子类实现""" + raise NotImplementedError + + def power_off(self, vps_id): + """关机 - 需要子类实现""" + raise NotImplementedError + + def hard_reboot(self, vps_id): + """硬重启 - 需要子类实现""" + raise NotImplementedError + + +class MofangAdapter(PlatformAdapter): + """魔方平台适配器(核云IDC等使用此适配器)""" + + def __init__(self, site_url, account, api_key): + super().__init__(site_url, account, api_key) + # 魔方平台的API路径统一为 /v1 + if not self.site_url: + raise ValueError("魔方平台必须提供网站链接(API地址)") + + def _get_headers(self): + """获取请求头""" + if not self.jwt_token: + self.login() + + return { + 'Authorization': f'JWT {self.jwt_token}', + 'Content-Type': 'application/json' + } + + def login(self): + try: + # 魔方平台API统一在 /v1 路径下 + url = f"{self.site_url}/v1/login_api" + data = { + 'account': self.account, + 'password': self.api_key + } + + response = requests.post(url, data=data, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('status') == 200 and 'jwt' in result: + self.jwt_token = result['jwt'] + return self.jwt_token + else: + logging.error(f"登录失败: {result.get('msg', '未知错误')}") + return None + else: + logging.error(f"登录请求失败,HTTP状态码: {response.status_code}") + return None + + except Exception as e: + logging.error(f"登录异常: {str(e)}") + return None + + def get_vps_list(self, page=1, limit=100): + """获取VPS列表""" + try: + headers = self._get_headers() + if not headers: + return None + + url = f"{self.site_url}/v1/hosts?page={page}&limit={limit}" + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('status') == 200: + return result['data'] + elif result.get('status') == 405: + logging.warning("Token失效,重新登录") + self.jwt_token = None + return self.get_vps_list(page, limit) + else: + logging.error(f"获取VPS列表失败: {result.get('msg', '未知错误')}") + return None + else: + logging.error(f"获取VPS列表请求失败,HTTP状态码: {response.status_code}") + return None + + except Exception as e: + logging.error(f"获取VPS列表异常: {str(e)}") + return None + + def get_vps_status(self, vps_id): + """获取VPS状态""" + try: + headers = self._get_headers() + if not headers: + return None + + url = f"{self.site_url}/v1/hosts/{vps_id}/module/status?type=host" + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('status') == 200: + return result['data'] + elif result.get('status') == 405: + logging.warning("Token失效,重新登录") + self.jwt_token = None + return self.get_vps_status(vps_id) + else: + logging.error(f"获取VPS {vps_id} 状态失败: {result.get('msg', '未知错误')}") + return None + else: + logging.error(f"获取VPS {vps_id} 状态请求失败,HTTP状态码: {response.status_code}") + return None + + except Exception as e: + logging.error(f"获取VPS {vps_id} 状态异常: {str(e)}") + return None + + def get_vps_details(self, vps_id): + """获取VPS详细信息""" + try: + headers = self._get_headers() + if not headers: + return None + + url = f"{self.site_url}/v1/hosts/{vps_id}" + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('status') == 200: + # 返回完整的数据结构,包含host信息和config_option + return result['data'] + elif result.get('status') == 405: + logging.warning("Token失效,重新登录") + self.jwt_token = None + return self.get_vps_details(vps_id) + else: + logging.error(f"获取VPS {vps_id} 详情失败: {result.get('msg', '未知错误')}") + return None + else: + logging.error(f"获取VPS {vps_id} 详情请求失败,HTTP状态码: {response.status_code}") + return None + + except Exception as e: + logging.error(f"获取VPS {vps_id} 详情异常: {str(e)}") + return None + + def power_on(self, vps_id): + """开机""" + try: + headers = self._get_headers() + if not headers: + return False + + url = f"{self.site_url}/v1/hosts/{vps_id}/module/on" + response = requests.put(url, headers=headers, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('status') == 200: + logging.info(f"VPS {vps_id} 开机指令发送成功") + return True + elif result.get('status') == 405: + logging.warning("Token失效,重新登录") + self.jwt_token = None + return self.power_on(vps_id) + else: + logging.error(f"VPS {vps_id} 开机失败: {result.get('msg', '未知错误')}") + return False + else: + logging.error(f"VPS {vps_id} 开机请求失败,HTTP状态码: {response.status_code}") + return False + + except Exception as e: + logging.error(f"VPS {vps_id} 开机异常: {str(e)}") + return False + + def power_off(self, vps_id): + """关机""" + try: + headers = self._get_headers() + if not headers: + return False + + url = f"{self.site_url}/v1/hosts/{vps_id}/module/off" + response = requests.put(url, headers=headers, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('status') == 200: + logging.info(f"VPS {vps_id} 关机指令发送成功") + return True + elif result.get('status') == 405: + logging.warning("Token失效,重新登录") + self.jwt_token = None + return self.power_off(vps_id) + else: + logging.error(f"VPS {vps_id} 关机失败: {result.get('msg', '未知错误')}") + return False + else: + logging.error(f"VPS {vps_id} 关机请求失败,HTTP状态码: {response.status_code}") + return False + + except Exception as e: + logging.error(f"VPS {vps_id} 关机异常: {str(e)}") + return False + + def hard_reboot(self, vps_id): + """硬重启""" + try: + headers = self._get_headers() + if not headers: + return False + + url = f"{self.site_url}/v1/hosts/{vps_id}/module/hard_reboot" + response = requests.put(url, headers=headers, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('status') == 200: + logging.info(f"VPS {vps_id} 硬重启指令发送成功") + return True + elif result.get('status') == 405: + logging.warning("Token失效,重新登录") + self.jwt_token = None + return self.hard_reboot(vps_id) + else: + logging.error(f"VPS {vps_id} 硬重启失败: {result.get('msg', '未知错误')}") + return False + else: + logging.error(f"VPS {vps_id} 硬重启请求失败,HTTP状态码: {response.status_code}") + return False + + except Exception as e: + logging.error(f"VPS {vps_id} 硬重启异常: {str(e)}") + return False + + +class MonitorService: + """监控服务主类""" + + def __init__(self): + """初始化监控服务""" + self.db = DatabaseManager() + self.adapters = {} # config_id -> adapter实例 # 创建日志目录 log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs') @@ -39,19 +298,13 @@ class IDCMonitor: # 配置日志 self.setup_logging(log_dir) - # 加载配置 - self.load_config() - self.logger.info("=" * 60) - self.logger.info("核云IDC VPS监控程序启动") - self.logger.info(f"监控方式: {self.config.get('WAY', 'ping')}") - self.logger.info(f"监控间隔: {self.config.get('SPAN', 300)}秒") + self.logger.info("VPS Hub 监控程序启动") self.logger.info("=" * 60) def setup_logging(self, log_dir): """配置日志系统""" - # 创建logger - self.logger = logging.getLogger('IDCMonitor') + self.logger = logging.getLogger('VPSHubMonitor') self.logger.setLevel(logging.DEBUG) # 清除已有handler @@ -89,190 +342,38 @@ class IDCMonitor: console_handler.setFormatter(console_format) self.logger.addHandler(console_handler) - def load_config(self): - """加载配置文件""" - try: - with open(self.config_path, 'r', encoding='utf-8') as f: - self.config = yaml.safe_load(f) - - # 验证必要配置 - required_keys = ['ACCOUNT', 'API_KEY', 'WAY'] - for key in required_keys: - if key not in self.config or not self.config[key]: - raise ValueError(f"配置文件中缺少必要项: {key}") - - # 设置默认值 - self.config.setdefault('SPAN', 300) - self.config.setdefault('JWT', '') - self.config.setdefault('EXCEPTION_IPS', []) - - # 如果WAY为http,检查DOMAIN - if self.config['WAY'] == 'http': - if 'DOMAIN' not in self.config or not self.config['DOMAIN']: - raise ValueError("WAY为http时,必须配置DOMAIN") - - # 加载JWT token - if self.config.get('JWT'): - self.jwt_token = self.config['JWT'] - self.logger.info("已加载缓存的JWT Token") - - # 加载例外IP列表 - self.exception_ips = self.config.get('EXCEPTION_IPS', []) - if self.exception_ips: - self.logger.info(f"已加载 {len(self.exception_ips)} 个例外IP") - - self.logger.info("配置文件加载成功") - - except FileNotFoundError: - self.logger.error(f"配置文件不存在: {self.config_path}") - sys.exit(1) - except Exception as e: - self.logger.error(f"配置文件加载失败: {str(e)}") - sys.exit(1) - - def save_jwt_token(self, token): - """保存JWT Token到配置文件""" - try: - self.config['JWT'] = token - with open(self.config_path, 'w', encoding='utf-8') as f: - yaml.dump(self.config, f, allow_unicode=True, default_flow_style=False) - self.jwt_token = token - self.logger.debug("JWT Token已保存到配置文件") - except Exception as e: - self.logger.error(f"保存JWT Token失败: {str(e)}") - - def get_login_token(self): - """获取登录Token""" - try: - url = f"{self.base_url}/login_api" - data = { - 'account': self.config['ACCOUNT'], - 'password': self.config['API_KEY'] - } - - response = requests.post(url, data=data, timeout=10) - - if response.status_code == 200: - result = response.json() - if result.get('status') == 200 and 'jwt' in result: - token = result['jwt'] - self.save_jwt_token(token) - self.logger.info("成功获取新的JWT Token") - return token - else: - self.logger.error(f"登录失败: {result.get('msg', '未知错误')}") - return None - else: - self.logger.error(f"登录请求失败,HTTP状态码: {response.status_code}") - return None - - except Exception as e: - self.logger.error(f"获取Token异常: {str(e)}") - return None - - def get_headers(self): - """获取请求头(包含JWT)""" - if not self.jwt_token: - self.jwt_token = self.get_login_token() - if not self.jwt_token: - return None + def load_configs_and_create_adapters(self): + """从数据库加载所有配置并创建适配器""" + configs = self.db.get_all_configs() - return { - 'Authorization': f'JWT {self.jwt_token}', - 'Content-Type': 'application/json' - } - - def get_vps_list(self): - """获取VPS列表""" - try: - headers = self.get_headers() - if not headers: - return None + for config in configs: + config_id = config['id'] + site_type = config['site_type'] - url = f"{self.base_url}/hosts?page=1&limit=100" - response = requests.get(url, headers=headers, timeout=10) - - if response.status_code == 200: - result = response.json() - if result.get('status') == 200: - self.logger.debug(f"成功获取VPS列表,共{result['data']['total']}台") - return result['data'] - elif result.get('status') == 405: - self.logger.warning("Token失效,重新获取Token") - self.jwt_token = None - return self.get_vps_list() # 递归调用重试 - else: - self.logger.error(f"获取VPS列表失败: {result.get('msg', '未知错误')}") - return None + # 根据网站类型创建对应的适配器 + if site_type == 'mofang': + adapter = MofangAdapter( + site_url=config['site_url'], + account=config['account'], + api_key=config['api_key'] + ) + self.adapters[config_id] = adapter + self.logger.info(f"已加载魔方平台配置: ID={config_id}, URL={config['site_url']}") + elif site_type == 'aliyun': + # TODO: 实现阿里云适配器 + self.logger.warning(f"阿里云适配器尚未实现: ID={config_id}") + elif site_type == 'tencent': + # TODO: 实现腾讯云适配器 + self.logger.warning(f"腾讯云适配器尚未实现: ID={config_id}") else: - self.logger.error(f"获取VPS列表请求失败,HTTP状态码: {response.status_code}") - return None - - except Exception as e: - self.logger.error(f"获取VPS列表异常: {str(e)}") - return None - - def get_vps_status(self, host_id): - """获取指定VPS的状态""" - try: - headers = self.get_headers() - if not headers: - return None - - url = f"{self.base_url}/hosts/{host_id}/module/status?type=host" - response = requests.get(url, headers=headers, timeout=10) - - if response.status_code == 200: - result = response.json() - if result.get('status') == 200: - return result['data'] - elif result.get('status') == 405: - self.logger.warning("Token失效,重新获取Token") - self.jwt_token = None - return self.get_vps_status(host_id) # 递归调用重试 - else: - self.logger.error(f"获取VPS {host_id} 状态失败: {result.get('msg', '未知错误')}") - return None - else: - self.logger.error(f"获取VPS {host_id} 状态请求失败,HTTP状态码: {response.status_code}") - return None - - except Exception as e: - self.logger.error(f"获取VPS {host_id} 状态异常: {str(e)}") - return None - - def power_on_vps(self, host_id): - """开机指定VPS""" - try: - headers = self.get_headers() - if not headers: - return False - - url = f"{self.base_url}/hosts/{host_id}/module/on" - response = requests.put(url, headers=headers, timeout=10) - - if response.status_code == 200: - result = response.json() - if result.get('status') == 200: - self.logger.info(f"VPS {host_id} 开机指令发送成功") - return True - elif result.get('status') == 405: - self.logger.warning("Token失效,重新获取Token") - self.jwt_token = None - return self.power_on_vps(host_id) # 递归调用重试 - else: - self.logger.error(f"VPS {host_id} 开机失败: {result.get('msg', '未知错误')}") - return False - else: - self.logger.error(f"VPS {host_id} 开机请求失败,HTTP状态码: {response.status_code}") - return False - - except Exception as e: - self.logger.error(f"VPS {host_id} 开机异常: {str(e)}") - return False + self.logger.warning(f"未知的平台类型: {site_type}, ID={config_id}") def ping_host(self, ip_address): - """Ping检测主机是否存活""" + """Ping检测主机是否存活 + + Returns: + (is_alive, latency_ms): 是否存活和延迟(ms) + """ try: # Windows和Linux的ping命令参数不同 if sys.platform == 'win32': @@ -280,293 +381,421 @@ class IDCMonitor: else: cmd = ['ping', '-c', '1', '-W', '2', ip_address] + start_time = time.time() result = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5 ) + end_time = time.time() - return result.returncode == 0 + latency_ms = (end_time - start_time) * 1000 # 转换为毫秒 + is_alive = result.returncode == 0 + + return is_alive, latency_ms if is_alive else None except Exception as e: self.logger.debug(f"Ping {ip_address} 异常: {str(e)}") - return False + return False, None - def check_http_host(self, domain): - """HTTP HEAD检测域名是否存活""" - try: - response = requests.head( - f"http://{domain}", - timeout=5, - allow_redirects=True - ) - return response.status_code < 400 - except Exception as e: - self.logger.debug(f"HTTP检测 {domain} 异常: {str(e)}") - return False - - def detect_hosts(self): - """检测所有主机存活状态""" - way = self.config.get('WAY', 'ping') - - if way == 'ping': - return self.detect_by_ping() - elif way == 'http': - return self.detect_by_http() - else: - self.logger.error(f"不支持的检测方式: {way}") - return [] - - def detect_by_ping(self): - """通过Ping检测主机""" + def ping_and_record_status(self): + """对所有VPS进行ping测试并记录状态""" self.logger.info("开始Ping检测所有VPS...") - unreachable_hosts = [] + vps_list = self.db.get_all_vps() - # 获取VPS列表 - vps_data = self.get_vps_list() - if not vps_data or 'host' not in vps_data: - self.logger.error("无法获取VPS列表") - return [] + if not vps_list: + self.logger.info("数据库中没有VPS记录") + return - for host in vps_data['host']: - host_id = host['id'] - ip = host.get('dedicatedip', '') + success_count = 0 + fail_count = 0 + + for vps in vps_list: + target = vps['ip_address'] or vps['domain'] - if not ip: - self.logger.warning(f"VPS {host_id} 没有IP地址,跳过") + if not target: + self.logger.warning(f"VPS {vps['vps_id']} 没有IP或域名,跳过") continue - self.logger.debug(f"正在Ping检测: {ip} (ID: {host_id})") + self.logger.debug(f"正在Ping检测: {target} (VPS ID: {vps['vps_id']})") - if not self.ping_host(ip): - # 检查是否为例外IP - if ip in self.exception_ips: - self.logger.info(f"VPS {host_id} ({ip}) Ping不通,但属于例外IP,跳过") - continue - - self.logger.warning(f"VPS {host_id} ({ip}) Ping不通") - unreachable_hosts.append({ - 'id': host_id, - 'ip': ip, - 'domain': host.get('domain', ''), - 'product_name': host.get('product_name', '') - }) - - if unreachable_hosts: - self.logger.warning(f"发现 {len(unreachable_hosts)} 台VPS无法Ping通") - else: - self.logger.info("所有VPS Ping检测正常") - - return unreachable_hosts - - def detect_by_http(self): - """通过HTTP检测域名""" - domains_str = self.config.get('DOMAIN', '') - if not domains_str: - self.logger.error("未配置DOMAIN") - return [] - - domains = [d.strip() for d in domains_str.split(',') if d.strip()] - - self.logger.info(f"开始HTTP检测 {len(domains)} 个域名...") - - unreachable_domains = [] - - for domain in domains: - self.logger.debug(f"正在HTTP检测: {domain}") + is_alive, latency_ms = self.ping_host(target) - if not self.check_http_host(domain): - self.logger.warning(f"域名 {domain} HTTP检测失败") - unreachable_domains.append(domain) + status = 'normal' if is_alive else 'abnormal' + self.db.save_ping_status( + vps_id=vps['vps_id'], + target=target, + status=status, + latency_ms=latency_ms + ) + + # 如果Ping成功,直接更新VPS状态为'on',无需调用API + if is_alive: + self.db.update_vps_status(vps['vps_id'], 'on') + success_count += 1 + self.logger.debug(f"VPS {vps['vps_id']} Ping成功,状态更新为 on") + else: + fail_count += 1 + self.logger.debug(f"VPS {vps['vps_id']} Ping失败,需要进一步检查") - if unreachable_domains: - self.logger.warning(f"发现 {len(unreachable_domains)} 个域名访问异常: {', '.join(unreachable_domains)}") - else: - self.logger.info("所有域名HTTP检测正常") - - return unreachable_domains + self.logger.info(f"Ping检测完成: 成功{success_count}台, 失败{fail_count}台") - def check_and_power_on(self, unreachable_hosts_or_domains): - """检查并开机无法访问的VPS + def check_and_power_on_unreachable_vps(self): + """检查无法访问的VPS并尝试开机""" + self.logger.info("检查需要开机的VPS...") - Args: - unreachable_hosts_or_domains: - - ping模式: [{'id': xxx, 'ip': xxx, ...}, ...] - - http模式: ['domain1.com', 'domain2.com', ...] - """ - if not unreachable_hosts_or_domains: - self.logger.info("未发现需要处理的异常情况") + # 获取所有标记为需要监控的VPS + monitored_vps = self.db.get_monitored_vps() + + if not monitored_vps: + self.logger.info("没有标记为需要监控的VPS") return - way = self.config.get('WAY', 'ping') + # 获取今天ping失败的VPS + today = datetime.now().strftime('%Y-%m-%d') + unreachable_vps = [] - # 如果是HTTP模式,需要先找到域名对应的VPS - if way == 'http': - unreachable_domains = unreachable_hosts_or_domains - self.logger.info(f"开始查找 {len(unreachable_domains)} 个异常域名对应的VPS...") + for vps in monitored_vps: + # 检查今天的ping记录 + records = self.db.get_ping_records(vps['vps_id'], today) - # 获取VPS列表建立域名到ID的映射 - vps_data = self.get_vps_list() - if not vps_data or 'host' not in vps_data: - self.logger.error("无法获取VPS列表") - return - - # 建立域名到VPS的映射 - domain_to_vps = {} - for host in vps_data['host']: - domain = host.get('domain', '') - if domain: - domain_to_vps[domain] = host - - # 转换域名为VPS信息 - unreachable_hosts = [] - for domain in unreachable_domains: - if domain in domain_to_vps: - host = domain_to_vps[domain] - ip = host.get('dedicatedip', '') - - # 检查是否为例外IP - if ip and ip in self.exception_ips: - self.logger.info(f"域名 {domain} 对应的VPS {host['id']} ({ip}) 属于例外IP,跳过") - continue - - unreachable_hosts.append({ - 'id': host['id'], - 'ip': ip, - 'domain': domain, - 'product_name': host.get('product_name', '') - }) - else: - self.logger.warning(f"域名 {domain} 未找到对应的VPS,跳过") - else: - # ping模式,直接使用传入的数据 - unreachable_hosts = unreachable_hosts_or_domains + # 如果今天有记录且最后一次是abnormal + if records and records[-1]['status'] == 'abnormal': + unreachable_vps.append(vps) - if not unreachable_hosts: - self.logger.info("所有异常的VPS都属于例外IP或找不到对应VPS,无需操作") + if not unreachable_vps: + self.logger.info("所有监控中的VPS都正常") return - self.logger.info(f"开始检查 {len(unreachable_hosts)} 台VPS的实际状态...") + self.logger.info(f"发现 {len(unreachable_vps)} 台VPS Ping失败,需要检查实际状态") + # 对每个异常的VPS调用API检查实际状态并尝试开机 need_power_on = [] - all_are_on = True - for host_info in unreachable_hosts: - host_id = host_info['id'] + for vps in unreachable_vps: + config_id = vps['config_id'] + vps_id = vps['vps_id'] - self.logger.info(f"检查VPS {host_id} ({host_info.get('domain', '')}) 的实际状态...") - status_data = self.get_vps_status(host_id) + if config_id not in self.adapters: + self.logger.warning(f"配置ID {config_id} 没有对应的适配器,跳过") + continue + + adapter = self.adapters[config_id] + + self.logger.info(f"调用API检查VPS {vps_id} 的实际状态...") + status_data = adapter.get_vps_status(vps_id) if status_data: status = status_data.get('status', 'unknown') des = status_data.get('des', '未知') + # 更新数据库中的VPS状态 + self.db.update_vps_status(vps_id, status) + self.logger.info(f"VPS {vps_id} API返回状态: {des} ({status})") + if status == 'on': - self.logger.info(f"VPS {host_id} 实际状态: {des} (开机中)") + self.logger.info(f"VPS {vps_id} 实际状态为开机,可能是禁Ping或网络问题") else: - self.logger.warning(f"VPS {host_id} 实际状态: {des} (关机)") - need_power_on.append(host_info) - all_are_on = False + self.logger.warning(f"VPS {vps_id} 实际状态为关机,需要开机") + need_power_on.append((config_id, vps_id)) else: - self.logger.error(f"无法获取VPS {host_id} 的状态") - all_are_on = False + self.logger.error(f"无法获取VPS {vps_id} 的状态") + # API调用失败,也尝试开机 + need_power_on.append((config_id, vps_id)) - # 如果所有VPS都是开机状态,记录日志 - if all_are_on: - self.logger.info( - "检测到所有VPS均为开机状态,可能是禁Ping、CDN缓存或网站临时异常,无需操作" - ) + if not need_power_on: + self.logger.info("所有异常VPS实际都是开机状态,无需操作") return # 对需要开机的VPS执行开机操作 - if need_power_on: - self.logger.info(f"开始对 {len(need_power_on)} 台VPS执行开机操作...") - - for host_info in need_power_on: - host_id = host_info['id'] - self.logger.info(f"正在开启VPS {host_id} ({host_info.get('domain', '')})...") - self.power_on_vps(host_id) - - # 等待60秒后验证开机结果 - self.logger.info("等待60秒后验证开机结果...") - time.sleep(60) - - # 验证开机结果,最多尝试2次 - self.verify_power_on_result(need_power_on, max_retries=2) + self.logger.info(f"开始对 {len(need_power_on)} 台VPS执行开机操作...") + + for config_id, vps_id in need_power_on: + adapter = self.adapters[config_id] + self.logger.info(f"正在开启VPS {vps_id}...") + adapter.power_on(vps_id) + + # 等待60秒后验证开机结果 + self.logger.info("等待60秒后验证开机结果...") + time.sleep(60) + + # 验证开机结果,最多尝试2次 + self.verify_power_on_result(need_power_on, max_retries=2) - def verify_power_on_result(self, hosts_to_verify, max_retries=2): - """验证开机结果""" - self.retry_count += 1 + def verify_power_on_result(self, vps_list, max_retries=2): + """验证开机结果 - if self.retry_count > max_retries: - self.logger.warning(f"已达到最大重试次数({max_retries}),仍有VPS未成功开机") - for host_info in hosts_to_verify: - self.logger.warning( - f"VPS {host_info['id']} ({host_info.get('domain', '')}) 开机失败" - ) - self.retry_count = 0 - return + Args: + vps_list: [(config_id, vps_id), ...] + max_retries: 最大重试次数 + """ + retry_count = 0 - still_off = [] - - for host_info in hosts_to_verify: - host_id = host_info['id'] + while retry_count < max_retries: + retry_count += 1 + still_off = [] - self.logger.info(f"验证VPS {host_id} 开机状态...") - status_data = self.get_vps_status(host_id) + for config_id, vps_id in vps_list: + adapter = self.adapters[config_id] + + self.logger.info(f"验证VPS {vps_id} 开机状态 (第{retry_count}次)...") + status_data = adapter.get_vps_status(vps_id) + + if status_data and status_data.get('status') == 'on': + # 开机成功,更新数据库状态 + self.db.update_vps_status(vps_id, 'on') + self.logger.info(f"✅ VPS {vps_id} 开机成功,状态已更新") + else: + # 开机失败,获取实际状态并更新数据库 + actual_status = status_data.get('status', 'unknown') if status_data else 'unknown' + actual_des = status_data.get('des', '未知') if status_data else '无法获取' + self.db.update_vps_status(vps_id, actual_status) + self.logger.warning(f"❌ VPS {vps_id} 开机失败,实际状态: {actual_des} ({actual_status}),状态已更新") + still_off.append((config_id, vps_id)) - if status_data and status_data.get('status') == 'on': - self.logger.info(f"✅ VPS {host_id} 开机成功") + if not still_off: + self.logger.info("所有VPS开机验证完成") + return + + if retry_count < max_retries: + self.logger.info(f"还有 {len(still_off)} 台VPS未开机,进行第{retry_count}次重试...") + for config_id, vps_id in still_off: + adapter = self.adapters[config_id] + adapter.power_on(vps_id) + + self.logger.info("等待60秒后再次验证...") + time.sleep(60) + + # 达到最大重试次数,更新所有仍失败的VPS状态 + self.logger.warning(f"已达到最大重试次数({max_retries}),仍有VPS未成功开机") + for config_id, vps_id in still_off: + adapter = self.adapters[config_id] + # 最后一次查询状态 + status_data = adapter.get_vps_status(vps_id) + if status_data: + final_status = status_data.get('status', 'unknown') + self.db.update_vps_status(vps_id, final_status) + self.logger.warning(f"VPS {vps_id} 最终状态: {status_data.get('des', '未知')} ({final_status})") else: - self.logger.warning(f"❌ VPS {host_id} 仍未开机") - still_off.append(host_info) - - if still_off: - self.logger.info(f"还有 {len(still_off)} 台VPS未开机,进行第{self.retry_count}次重试...") - for host_info in still_off: - self.power_on_vps(host_info['id']) - - self.logger.info("等待60秒后再次验证...") - time.sleep(60) - - self.verify_power_on_result(still_off, max_retries) - else: - self.logger.info("所有VPS开机验证完成") - self.retry_count = 0 + self.db.update_vps_status(vps_id, 'unknown') + self.logger.warning(f"VPS {vps_id} 无法获取最终状态") - def run_once(self): + def generate_daily_summary(self): + """生成前一天的VPS摘要统计(每天0点执行)""" + self.logger.info("开始生成昨日VPS摘要统计...") + + yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d') + vps_list = self.db.get_all_vps() + + processed_count = 0 + + for vps in vps_list: + vps_id = vps['vps_id'] + + # 查询昨天的所有ping记录 + records = self.db.get_ping_records(vps_id, yesterday) + + if not records: + self.logger.debug(f"VPS {vps_id} 昨天没有ping记录,跳过") + continue + + # 计算统计数据 + latencies = [r['latency_ms'] for r in records if r['latency_ms']] + + if not latencies: + avg_latency = 0 + max_latency = 0 + min_latency = 0 + else: + avg_latency = sum(latencies) / len(latencies) + max_latency = max(latencies) + min_latency = min(latencies) + + # 分级统计 + count_under_100 = sum(1 for l in latencies if l < 100) + count_100_to_300 = sum(1 for l in latencies if 100 <= l < 300) + count_300_to_500 = sum(1 for l in latencies if 300 <= l < 500) + count_abnormal = sum(1 for r in records if r['status'] == 'abnormal') + + # 计算可用性评分 + total = len(records) + if total > 0: + excellent_ratio = count_under_100 / total + good_ratio = (count_under_100 + count_100_to_300) / total + normal_ratio = (count_under_100 + count_100_to_300 + count_300_to_500) / total + + if excellent_ratio > 0.8: + availability = '优秀' + elif good_ratio > 0.8: + availability = '良好' + elif normal_ratio > 0.8: + availability = '一般' + else: + availability = '不可用' + else: + availability = '不可用' + + # 保存到数据库 + self.db.save_vps_summary( + vps_id=vps_id, + date=yesterday, + avg_latency=avg_latency, + max_latency=max_latency, + min_latency=min_latency, + count_under_100=count_under_100, + count_100_to_300=count_100_to_300, + count_300_to_500=count_300_to_500, + count_abnormal=count_abnormal, + availability=availability + ) + + processed_count += 1 + + self.logger.info(f"摘要统计生成完成,共处理{processed_count}台VPS") + + def cleanup_old_data(self): + """清理超过30天的ping状态数据""" + deleted = self.db.cleanup_old_ping_records(days=30) + if deleted > 0: + self.logger.info(f"已清理{deleted}条旧的ping记录") + + def refresh_vps_list(self): + """刷新所有配置的VPS列表""" + self.logger.info("开始刷新VPS列表...") + + configs = self.db.get_all_configs() + + for config in configs: + config_id = config['id'] + + if config_id not in self.adapters: + self.logger.warning(f"配置ID {config_id} 没有对应的适配器,跳过") + continue + + adapter = self.adapters[config_id] + + self.logger.info(f"正在获取配置 {config_id} 的VPS列表...") + vps_data = adapter.get_vps_list() + + if not vps_data or 'host' not in vps_data: + self.logger.error(f"配置 {config_id} 获取VPS列表失败") + continue + + # 准备批量插入的数据 + vps_list_to_add = [] + + for host in vps_data['host']: + # 解析详细信息 + cpu_cores = None + memory_size = None + disk_size = None + bandwidth = None + os_type = None + + if 'config_option' in host and isinstance(host['config_option'], list): + for option in host['config_option']: + key = option.get('key') + value = option.get('value') + + if key == 'cpu' and value: + # 提取数字,例如 "16核" -> 16 + import re + match = re.search(r'(\d+)', value) + if match: + cpu_cores = int(match.group(1)) + + elif key == 'memory' and value: + memory_size = value # 例如 "16G" + + elif key == 'system_disk_size' and value: + disk_size = value # 例如 "Lin50G,Win50G" + + elif key == 'bw' and value: + bandwidth = value # 例如 "70Mbps" + + elif key == 'os' and value: + os_type = value # 例如 "Debian-12.0_x64" + + vps_list_to_add.append({ + 'config_id': config_id, + 'vps_id': host['id'], + 'domain': host.get('domain'), + 'ip_address': host.get('dedicatedip'), + 'product_name': host.get('product_name'), + 'cpu_cores': cpu_cores, + 'memory_size': memory_size, + 'disk_size': disk_size, + 'bandwidth': bandwidth, + 'os_type': os_type, + 'section': config['auto_monitor'] # 根据配置的auto_monitor设置 + }) + + # 批量添加到数据库 + self.db.batch_add_vps(vps_list_to_add) + + self.logger.info(f"配置 {config_id} 已更新 {len(vps_list_to_add)} 台VPS") + + self.logger.info("VPS列表刷新完成") + + def run_monitoring_cycle(self): """执行一次监控循环""" try: self.logger.info("\n" + "=" * 60) - self.logger.info(f"开始第 {int(time.time())} 时间戳的监控循环") + self.logger.info(f"开始监控循环 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") self.logger.info("=" * 60) - # 检测主机(不调用API) - unreachable = self.detect_hosts() + # 1. 对所有VPS进行ping测试 + self.ping_and_record_status() - # 只有在检测到异常时才进入API流程 - if unreachable: - self.logger.info("检测到异常,开始调用API进行进一步检查和开机...") - self.check_and_power_on(unreachable) - else: - self.logger.info("所有检测正常,无需调用API") + # 2. 检查并开机无法访问的VPS + self.check_and_power_on_unreachable_vps() + + # 3. 清理旧数据 + self.cleanup_old_data() self.logger.info("本次监控循环完成\n") except Exception as e: self.logger.error(f"监控循环异常: {str(e)}", exc_info=True) + def setup_schedule(self): + """设置定时任务""" + # 每天0点执行摘要统计 + schedule.every().day.at("00:00").do(self.generate_daily_summary) + + self.logger.info("定时任务已设置: 每天00:00生成摘要统计") + def run(self): """运行监控程序(主循环)""" - span = self.config.get('SPAN', 300) + # 加载配置和创建适配器 + self.load_configs_and_create_adapters() + + if not self.adapters: + self.logger.warning("没有可用的平台配置,请先通过网页添加配置") + self.logger.info("程序将在60秒后退出") + time.sleep(60) + return + + # 设置定时任务 + self.setup_schedule() + + # 首次运行时刷新VPS列表 + self.logger.info("首次运行,刷新VPS列表...") + self.refresh_vps_list() + + span = 300 # 默认5分钟间隔 self.logger.info(f"监控程序开始运行,间隔 {span} 秒") try: while True: - self.run_once() + # 执行监控循环 + self.run_monitoring_cycle() + + # 运行待执行的定时任务 + schedule.run_pending() self.logger.info(f"等待 {span} 秒后进行下一次检测...") time.sleep(span) @@ -577,16 +806,10 @@ class IDCMonitor: self.logger.error(f"程序运行异常: {str(e)}", exc_info=True) sys.exit(1) - -def main(): - """主函数""" +if __name__ == '__main__': try: - monitor = IDCMonitor() + monitor = MonitorService() monitor.run() except Exception as e: print(f"程序启动失败: {str(e)}") sys.exit(1) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt index fda3075..a10d395 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,2 +1,3 @@ requests -pyyaml \ No newline at end of file +pyyaml +schedule \ No newline at end of file diff --git a/app/uninstall.sh b/app/uninstall.sh index 135891b..da5bcca 100644 --- a/app/uninstall.sh +++ b/app/uninstall.sh @@ -1,6 +1,6 @@ #!/bin/bash -# 核云IDC服务商VPS自动监测重启程序 - 卸载脚本 +# VPS Hub 监控程序 - 卸载脚本 # 该脚本会移除systemd服务 # 颜色定义 @@ -13,7 +13,7 @@ NC='\033[0m' # No Color SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 定义服务名称 -SERVICE_NAME="idc-monitor" +SERVICE_NAME="idc_monitor" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" echo "==========================================" diff --git a/app/开发文档.md b/app/开发文档.md deleted file mode 100644 index 67334b9..0000000 --- a/app/开发文档.md +++ /dev/null @@ -1,39 +0,0 @@ -### 程序运行 - -1. 用户需先向程序config内添加以下几项定义量: - ACCOUNT,API_KEY,WAY - -2. 用户运行install.sh脚本 - 脚本会先检测config.yml中的配置文件 - 缺少ACCOUNT,API_KEY,WAY向用户询问填写什么,同时WAY默认为ping(问句以红色展示) - 若用户填写http,则让用户填写域名(可以访问的路径),通过英文逗号分隔域名 - 随后用户可添加例外参数,脚本检测到例外内的IP主机关机时,直接跳过,不进行开机 - -3. 脚本运行 - 创建名为idc_monitor的systemd服务,持续化运行当前路径下的main.py - -### 程序逻辑 -1. 1. 隔一段时间(SPAN)便根据WAY来探测目标存活 - 若为ping,则直接ping一遍所有的IP地址 - 若为域名,则使用HEAD方法探测相应域名是否正常响应 - -1. 2. 若存在不通的情况,进行如下操作 - 使用config.yml中的JWT请求VPS列表并挨个查询VPS状态是否为on(开机) - 若返回响应码为405,即响应报文为 - ``` - { - "status": 405, - "msg": "请登陆后再试" - } - ``` - 时,重新请求JWT并存储,随后再次请求VPS列表并查询状态 - -1. 3. 若均为开机状态,记录日志:什么时间点 - 发生了什么情况,实际没有机器关机,可能是禁ping或者服务器网站状态异常 - -1. 4. 若发现有机器关机,则使用开机接口(on)进行开机 - -1. 5. 所有机器都操作完成后隔60秒,再次查询刚才尝试开机操作的几台机器是否开机成功,若不超过则再来一遍 - -1. 6. 尝试开机操作两次后还是非开机状态,则中断本次循环,进入SPAN间隔准备下一次循环 - -1. 7. 以上所有操作均需记录为日志,分为正常日志和异常日志(例如:未发现存在机器关机,开机失败等) \ No newline at end of file diff --git a/config_add.php b/config_add.php new file mode 100644 index 0000000..f053af3 --- /dev/null +++ b/config_add.php @@ -0,0 +1,424 @@ +\n"; + + if (file_put_contents($tokenFile, $content)) { + chmod($tokenFile, 0600); + header('Location: config_add.php?pass=' . urlencode($apiPass)); + exit; + } else { + $setupError = '保存失败,请检查目录权限!'; + } + } +} + +// 如果需要设置密码,显示设置页面 +if ($needSetup) { + ?> + + + + + + + + 初始设置 - VPS Hub + + +
+
+

🔐 初始设置

+

请设置管理面板的访问密码

+
+ + +
+ ❌ +
+ + +
+ 💡 提示:此密码用于保护配置管理页面的访问权限。
+ 密码要求:至少8位,只能包含字母(a-z,A-Z)和数字(0-9) +
+ +
+
+ + +
+ + +
+
+ + + + + 403, 'msg' => '访问密码错误'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + exit; +} + +// 处理添加配置 +$message = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (isset($_POST['add_config'])) { + $apiLabel = trim($_POST['api_label']); + $siteType = trim($_POST['site_type']); + $siteUrl = trim($_POST['site_url']); + $account = trim($_POST['account']); + $apiKey = trim($_POST['api_key']); + $autoMonitor = isset($_POST['auto_monitor']) ? 1 : 0; + + if (empty($apiLabel) || empty($siteType) || empty($siteUrl) || empty($account) || empty($apiKey)) { + $message = '
❌ API标识、网站类型、网站链接、账户和密钥不能为空!
'; + } else { + // 验证URL格式:不允许包含路径 + $parsedUrl = parse_url($siteUrl); + if (!$parsedUrl || !isset($parsedUrl['scheme']) || !isset($parsedUrl['host'])) { + $message = '
❌ 网站链接格式错误!请输入完整的URL(例如: https://www.example.com)
'; + } elseif (isset($parsedUrl['path']) && $parsedUrl['path'] !== '/') { + $message = '
❌ 网站链接严禁包含路径部分!只允许输入根域名(例如: https://www.example.com),不要添加 /v1、/api 等路径
'; + } else { + $db = getVpsDB(); + + // 检查API标识是否已存在 + $existingConfig = $db->queryOne('SELECT id FROM configs WHERE api_label = ?', [$apiLabel]); + if ($existingConfig) { + $message = '
❌ API标识 "' . htmlspecialchars($apiLabel) . '" 已存在,请使用其他标识!
'; + } else { + $configId = $db->insert( + 'INSERT INTO configs (api_label, site_type, site_url, account, api_key, auto_monitor) VALUES (?, ?, ?, ?, ?, ?)', + [$apiLabel, $siteType, $siteUrl, $account, $apiKey, $autoMonitor] + ); + + if ($configId) { + $message = '
✅ 配置添加成功!ID: ' . $configId . '
'; + + // 如果是首次添加配置,提示运行install.sh + $configCount = $db->queryOne('SELECT COUNT(*) as count FROM configs')['count']; + if ($configCount == 1) { + $message .= '
💡 这是第一个配置,请运行 app/install.sh 安装监控服务
'; + + // 尝试自动执行install.sh(需要PHP有执行权限) + $installScript = __DIR__ . '/app/install.sh'; + if (file_exists($installScript)) { + // 检查exec函数是否可用 + if (!function_exists('exec')) { + $message .= '
⚠️ exec函数被禁用,无法自动安装监控服务。请手动执行: sudo bash app/install.sh
'; + } else { + // 先设置可执行权限 + chmod($installScript, 0755); + + // 执行安装脚本 + $output = []; + $returnVar = 0; + exec("bash {$installScript} 2>&1", $output, $returnVar); + + if ($returnVar === 0) { + $message .= '
✅ 监控服务已自动安装并启动
'; + } else { + $message .= '
⚠️ 自动安装失败,请手动执行: sudo bash app/install.sh
'; + // 输出错误信息以便调试 + if (!empty($output)) { + $message .= '
查看错误详情
' . htmlspecialchars(implode("\n", $output)) . '
'; + } + } + } + } + } + + // 自动刷新VPS列表 + if ($siteType === 'mofang') { + refreshVpsListForConfig($configId); + $message .= '
✅ 已自动获取VPS列表
'; + } + } else { + $message = '
❌ 配置添加失败!
'; + } + } + } + } + } elseif (isset($_POST['delete_config'])) { + $configId = intval($_POST['config_id']); + $db = getVpsDB(); + $db->execute('DELETE FROM configs WHERE id = ?', [$configId]); + $message = '
✅ 配置已删除
'; + } +} + +// 获取所有配置 +$db = getVpsDB(); +$configs = $db->query('SELECT * FROM configs ORDER BY id'); +?> + + + + + + + + 配置管理 - VPS Hub + + + +
+
+

⚙️ VPS配置管理

+

添加和管理多平台VPS配置

+ 返回首页 +
+ +
+ + + +
+

➕ 添加新配置

+
+
+
+ + +
+
+ +
+
+ + +
+ +
+ + + ⚠️ 严禁包含路径部分(如 /v1、/api),只允许根域名 +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+

📋 现有配置

+ + +

暂无配置,请添加第一个配置

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
序号API标识网站类型网站链接账户自动监控创建时间操作
+ '魔方', + 'aliyun' => '阿里云', + 'tencent' => '腾讯云' + ]; + echo $typeMap[$config['site_type']] ?? $config['site_type']; + ?> + + + +
+ + +
+
+ +
+
+ +
+
+ + + + diff --git a/index.php b/index.php new file mode 100644 index 0000000..1b24421 --- /dev/null +++ b/index.php @@ -0,0 +1,540 @@ + 403, 'msg' => '访问密码错误或者未输入密码,请拼接?pass=API_PASS后再尝试访问'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + exit; +} + +// 处理操作请求 +$message = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + $hostId = intval($_POST['host_id'] ?? 0); + $configId = intval($_POST['config_id'] ?? 0); + + if ($action && $hostId > 0 && $configId > 0) { + $result = null; + + if ($action === 'reboot') { + $result = mofangHardReboot($configId, $hostId); + } elseif ($action === 'on') { + $result = mofangPowerOn($configId, $hostId); + } elseif ($action === 'off') { + $result = mofangPowerOff($configId, $hostId); + } + + if ($result && isset($result['status']) && $result['status'] === 200) { + $message = '

✅ 操作成功

VPS #' . $hostId . ' 已成功执行操作

'; + } else { + $errorMsg = $result['msg'] ?? '未知错误'; + $message = '

❌ 操作失败

' . htmlspecialchars($errorMsg) . '

'; + } + } elseif ($action === 'refresh_vps_list') { + // 手动刷新VPS列表 + $count = refreshAllVpsLists(); + $message = '

✅ 刷新完成

已成功刷新 ' . $count . ' 个配置的VPS列表

'; + + // 获取每个VPS的详细信息并更新数据库 + $listDb = getVpsListDB(); + $configDb = getVpsDB(); + $configs = $configDb->query('SELECT * FROM configs'); + + foreach ($configs as $config) { + if ($config['site_type'] !== 'mofang') { + continue; // 目前只支持魔方平台 + } + + // 获取该配置下的所有VPS + $vpsItems = $listDb->query('SELECT vps_id FROM vps_list WHERE config_id = ?', [$config['id']]); + + foreach ($vpsItems as $vpsItem) { + $vpsId = $vpsItem['vps_id']; + + // 获取VPS详细信息 + $details = mofangGetVpsDetails($config['id'], $vpsId); + + if ($details && isset($details['status']) && $details['status'] === 200 && isset($details['data']['host'])) { + $host = $details['data']['host']; + + // 解析CPU和内存信息 + $cpuCores = null; + $memorySize = null; + + if (isset($host['config_option']) && is_array($host['config_option'])) { + foreach ($host['config_option'] as $option) { + if ($option['key'] === 'cpu' && isset($option['value'])) { + // 提取数字,例如 "16核" -> 16 + preg_match('/(\d+)/', $option['value'], $matches); + if (!empty($matches)) { + $cpuCores = intval($matches[1]); + } + } + if ($option['key'] === 'memory' && isset($option['value'])) { + $memorySize = $option['value']; // 例如 "16G" + } + } + } + + // 更新数据库 + $listDb->execute( + 'UPDATE vps_list SET cpu_cores = ?, memory_size = ?, last_check = CURRENT_TIMESTAMP WHERE config_id = ? AND vps_id = ?', + [$cpuCores, $memorySize, $config['id'], $vpsId] + ); + } + } + } + + $message .= '
✅ 已更新VPS详细信息(CPU、内存等)
'; + } +} + +// 获取所有配置 +$db = getVpsDB(); +$configs = $db->query('SELECT * FROM configs ORDER BY id'); + +// 构建配置ID到配置的映射 +$configMap = []; +foreach ($configs as $config) { + $configMap[$config['id']] = $config; +} + +// 获取所有VPS列表(只从vpslist.db查询) +$listDb = getVpsListDB(); +$vpsList = $listDb->query('SELECT * FROM vps_list ORDER BY config_id, vps_id'); + +// 为每个VPS添加site_type和site_url信息 +foreach ($vpsList as &$vps) { + $configId = $vps['config_id']; + if (isset($configMap[$configId])) { + $vps['site_type'] = $configMap[$configId]['site_type']; + // 修正site_url:移除末尾的/v1或/v1/ + $siteUrl = $configMap[$configId]['site_url']; + $siteUrl = rtrim($siteUrl, '/'); // 移除末尾的 / + if (substr($siteUrl, -3) === '/v1') { + $siteUrl = substr($siteUrl, 0, -3); // 移除 /v1 + } + $vps['site_url'] = $siteUrl; + } else { + $vps['site_type'] = 'unknown'; + $vps['site_url'] = ''; + } +} +unset($vps); // 解除引用 + +// 调试信息(临时) +error_log("Configs count: " . count($configs)); +error_log("VPS List count: " . count($vpsList)); +if (!empty($vpsList)) { + error_log("First VPS: " . json_encode($vpsList[0])); +} + +// 按配置分组 +$vpsByConfig = []; +foreach ($vpsList as $vps) { + $configId = $vps['config_id']; + if (!isset($vpsByConfig[$configId])) { + $vpsByConfig[$configId] = []; + } + $vpsByConfig[$configId][] = $vps; +} + +// 统计信息 +$totalCount = count($vpsList); +$runningCount = 0; +$offCount = 0; + +foreach ($vpsList as $vps) { + if ($vps['status'] === 'on') { + $runningCount++; + } elseif ($vps['status'] === 'off') { + $offCount++; + } +} +?> + + + + + + + + VPS管理面板 - VPS Hub + + + +
+
+

🖥️ VPS Hub 管理面板

+

实时查看和管理您的云服务器

+
+ +
+ + + +
+
+ +
+ + + ➕ 添加配置源 + +
+ + +
+
+ 总计: 台服务器 +
+
+ 运行中: 台 +
+
+ 已关机: 台 +
+
+ + +
+

📭 暂无配置

+

请先添加VPS配置才能开始管理

+ + ➕ 添加第一个配置 + +
+ + + '魔方平台', + 'aliyun' => '阿里云', + 'tencent' => '腾讯云' + ]; + $typeName = $typeMap[$config['site_type']] ?? $config['site_type']; + ?> +
+
+ + () + - 台VPS +
+ + +
+

此配置下暂无VPS,请点击"手动刷新VPS列表"获取

+
+ +
+ +
+
+
#
+ + + +
+ +
+ + +
+ IP地址 + +
+ + + +
+ 产品名称 + +
+ + + +
+ CPU/内存 + + + +
+ + + +
+ 磁盘/带宽/系统 + +
+ +
+ +
+ + +
+ +
+ + +
+ + + +
+
+
+ +
+ +
+ + +
+ +
+
+ + + + diff --git a/mofangidc.php b/mofangidc.php new file mode 100644 index 0000000..7785631 --- /dev/null +++ b/mofangidc.php @@ -0,0 +1,723 @@ + $token, + 'timestamp' => time() + ]; + + saveTokensFile($cached_tokens); +} + +/** + * 保存tokens数组到文件 + * @param array $cached_tokens tokens数组 + */ +function saveTokensFile($cached_tokens) { + $content = "\n"; + + file_put_contents(TOKEN_CACHE_FILE, $content); + chmod(TOKEN_CACHE_FILE, 0600); +} + +/** + * 魔方平台登录获取JWT Token + * @param string $siteUrl API基础URL + * @param string $account 账户 + * @param string $apiKey API密钥 + * @return string|null JWT Token或null + */ +function mofangLogin($siteUrl, $account, $apiKey) { + try { + $url = rtrim($siteUrl, '/') . '/v1/login_api'; + $data = [ + 'account' => $account, + 'password' => $apiKey + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + error_log("魔方登录请求失败,HTTP状态码: {$httpCode}"); + return null; + } + + $result = json_decode($response, true); + + if (isset($result['status']) && $result['status'] === 200 && isset($result['jwt'])) { + return $result['jwt']; + } else { + error_log("魔方登录失败: " . ($result['msg'] ?? '未知错误')); + return null; + } + + } catch (Exception $e) { + error_log("魔方登录异常: " . $e->getMessage()); + return null; + } +} + +/** + * 获取有效的Token(先查缓存,没有则重新登录) + * @param int $configId 配置ID + * @return string|null Token或null + */ +function getValidToken($configId) { + // 先尝试从缓存获取 + $token = getCachedToken($configId); + if ($token) { + return $token; + } + + // 缓存不存在或已过期,重新获取 + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + error_log("配置ID {$configId} 不存在"); + return null; + } + + $token = mofangLogin($config['site_url'], $config['account'], $config['api_key']); + + if ($token) { + saveToken($configId, $token); + } + + return $token; +} + +/** + * 发送魔方API请求 + * @param string $siteUrl API基础URL + * @param string $endpoint API端点 + * @param string $method HTTP方法 (GET/POST/PUT) + * @param array $data 请求数据 + * @param int $configId 配置ID + * @return array|null 响应数据或null + */ +function mofangApiRequest($siteUrl, $endpoint, $method = 'GET', $data = [], $configId = null) { + // 获取Token + if ($configId) { + $token = getValidToken($configId); + } else { + // 如果没有configId,尝试从第一个配置获取 + $db = getVpsDB(); + $configs = $db->query('SELECT * FROM configs LIMIT 1'); + if (!$configs) { + return null; + } + $config = $configs[0]; + $token = getValidToken($config['id']); + $siteUrl = $config['site_url']; + } + + if (!$token) { + return ['status' => 400, 'msg' => '获取Token失败']; + } + + $url = rtrim($siteUrl, '/') . $endpoint; + $headers = [ + 'Authorization: JWT ' . $token, + 'Content-Type: application/json' + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + } elseif ($method === 'PUT') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + return ['status' => $httpCode, 'msg' => 'HTTP请求失败']; + } + + $result = json_decode($response, true); + + // 如果Token失效(405),清除缓存并重试 + if (isset($result['status']) && $result['status'] === 405 && $configId) { + // 清除缓存 + $cached_tokens = []; + if (file_exists(TOKEN_CACHE_FILE)) { + include TOKEN_CACHE_FILE; + } + unset($cached_tokens[$configId]); + saveTokensFile($cached_tokens); + + // 重试一次 + return mofangApiRequest($siteUrl, $endpoint, $method, $data, $configId); + } + + return $result; +} + +/** + * 获取VPS列表 + * @param int $configId 配置ID + * @param int $page 页码 + * @param int $limit 每页数量 + * @return array|null VPS列表数据或null + */ +function mofangGetVpsList($configId, $page = 1, $limit = 100) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return null; + } + + $endpoint = "/v1/hosts?page={$page}&limit={$limit}"; + return mofangApiRequest($config['site_url'], $endpoint, 'GET', [], $configId); +} + +/** + * 获取VPS状态 + * @param int $configId 配置ID + * @param int $vpsId VPS ID + * @param bool $updateDb 是否更新数据库,默认true + * @return array|null 状态数据或null + */ +function mofangGetVpsStatus($configId, $vpsId, $updateDb = true) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return null; + } + + $endpoint = "/v1/hosts/{$vpsId}/module/status?type=host"; + $result = mofangApiRequest($config['site_url'], $endpoint, 'GET', [], $configId); + + // 如果获取成功且需要更新数据库 + if ($updateDb && $result && isset($result['status']) && $result['status'] === 200 && isset($result['data'])) { + $statusData = $result['data']; + $status = $statusData['status'] ?? 'unknown'; + + // 只更新status字段,不影响其他字段 + $listDb = getVpsListDB(); + + // 先检查记录是否存在 + $existing = $listDb->queryOne( + 'SELECT id FROM vps_list WHERE config_id = ? AND vps_id = ?', + [$configId, $vpsId] + ); + + if ($existing) { + // 记录存在,执行UPDATE + $listDb->execute( + 'UPDATE vps_list SET status = ?, last_check = CURRENT_TIMESTAMP WHERE config_id = ? AND vps_id = ?', + [$status, $configId, $vpsId] + ); + error_log("[mofangGetVpsStatus] VPS {$vpsId} 状态已更新为: {$status}"); + } else { + // 记录不存在,插入新记录 + $listDb->execute( + 'INSERT INTO vps_list (config_id, vps_id, status, last_check) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', + [$configId, $vpsId, $status] + ); + error_log("[mofangGetVpsStatus] VPS {$vpsId} 新记录已插入,状态: {$status}"); + } + } + + return $result; +} + +/** + * 获取VPS详细信息 + * @param int $configId 配置ID + * @param int $vpsId VPS ID + * @param bool $updateDb 是否更新数据库,默认true + * @return array|null 详细信息或null + */ +function mofangGetVpsDetails($configId, $vpsId, $updateDb = true) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return null; + } + + $endpoint = "/v1/hosts/{$vpsId}"; + $result = mofangApiRequest($config['site_url'], $endpoint, 'GET', [], $configId); + + // 如果获取成功且需要更新数据库 + if ($updateDb && $result && isset($result['status']) && $result['status'] === 200 && isset($result['data']['host'])) { + $host = $result['data']['host']; + + // 解析详细信息 + $updates = []; // 存储要更新的字段 + $values = []; + + if (isset($host['config_option']) && is_array($host['config_option'])) { + foreach ($host['config_option'] as $option) { + switch ($option['key']) { + case 'cpu': + if (isset($option['value'])) { + preg_match('/(\d+)/', $option['value'], $matches); + if (!empty($matches)) { + $updates[] = 'cpu_cores = ?'; + $values[] = intval($matches[1]); + } + } + break; + case 'memory': + if (isset($option['value'])) { + $updates[] = 'memory_size = ?'; + $values[] = $option['value']; + } + break; + case 'system_disk_size': + if (isset($option['value'])) { + $updates[] = 'disk_size = ?'; + $values[] = $option['value']; + } + break; + case 'bw': + if (isset($option['value'])) { + $updates[] = 'bandwidth = ?'; + $values[] = $option['value']; + } + break; + case 'os': + if (isset($option['value'])) { + $updates[] = 'os_type = ?'; + $values[] = $option['value']; + } + break; + } + } + } + + // 只有当有字段需要更新时才执行UPDATE + if (!empty($updates)) { + // 添加last_check更新 + $updates[] = 'last_check = CURRENT_TIMESTAMP'; + + // 构建动态UPDATE语句 + $setClause = implode(', ', $updates); + $values[] = $configId; + $values[] = $vpsId; + + $listDb = getVpsListDB(); + + // 先检查记录是否存在 + $existing = $listDb->queryOne( + 'SELECT id FROM vps_list WHERE config_id = ? AND vps_id = ?', + [$configId, $vpsId] + ); + + if ($existing) { + // 记录存在,执行UPDATE + $listDb->execute( + "UPDATE vps_list SET {$setClause} WHERE config_id = ? AND vps_id = ?", + $values + ); + error_log("[mofangGetVpsDetails] VPS {$vpsId} 详细信息已更新"); + } else { + // 记录不存在,插入新记录(只插入获取到的字段) + $columns = []; + $insertValues = []; + $placeholders = []; + + // 解析SET子句中的字段 + foreach ($updates as $update) { + if (strpos($update, 'last_check') === false) { + list($column, $value) = explode(' = ', $update); + $columns[] = $column; + $insertValues[] = array_shift($values); // 从$values中取出对应的值 + $placeholders[] = '?'; + } + } + + // 添加config_id和vps_id + $columns[] = 'config_id'; + $columns[] = 'vps_id'; + $insertValues[] = $configId; + $insertValues[] = $vpsId; + + if (!empty($columns)) { + $columnStr = implode(', ', $columns); + $placeholderStr = implode(', ', $placeholders); + $listDb->execute( + "INSERT INTO vps_list ({$columnStr}) VALUES ({$placeholderStr})", + $insertValues + ); + error_log("[mofangGetVpsDetails] VPS {$vpsId} 新记录已插入"); + } + } + } + } + + return $result; +} + +/** + * VPS开机 + * @param int $configId 配置ID + * @param int $vpsId VPS ID + * @return array|null 响应数据或null + */ +function mofangPowerOn($configId, $vpsId) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return null; + } + + $endpoint = "/v1/hosts/{$vpsId}/module/on"; + return mofangApiRequest($config['site_url'], $endpoint, 'PUT', [], $configId); +} + +/** + * VPS关机 + * @param int $configId 配置ID + * @param int $vpsId VPS ID + * @return array|null 响应数据或null + */ +function mofangPowerOff($configId, $vpsId) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return null; + } + + $endpoint = "/v1/hosts/{$vpsId}/module/off"; + return mofangApiRequest($config['site_url'], $endpoint, 'PUT', [], $configId); +} + +/** + * VPS硬重启 + * @param int $configId 配置ID + * @param int $vpsId VPS ID + * @return array|null 响应数据或null + */ +function mofangHardReboot($configId, $vpsId) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return null; + } + + $endpoint = "/v1/hosts/{$vpsId}/module/hard_reboot"; + return mofangApiRequest($config['site_url'], $endpoint, 'PUT', [], $configId); +} + +/** + * 刷新指定配置的VPS列表并保存到数据库 + * @param int $configId 配置ID + * @return bool 是否成功 + */ +function refreshVpsListForConfig($configId) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return false; + } + + // 获取VPS列表 + $result = mofangGetVpsList($configId); + + if (!$result || !isset($result['status']) || $result['status'] !== 200) { + error_log("获取VPS列表失败: " . ($result['msg'] ?? '未知错误')); + return false; + } + + $hosts = $result['data']['host'] ?? []; + + if (empty($hosts)) { + return true; // 没有VPS也算成功 + } + + // 批量保存到数据库 + $listDb = getVpsListDB(); + + foreach ($hosts as $host) { + // 解析详细信息 + $cpuCores = null; + $memorySize = null; + $diskSize = null; + $bandwidth = null; + $osType = null; + + if (isset($host['config_option']) && is_array($host['config_option'])) { + foreach ($host['config_option'] as $option) { + switch ($option['key']) { + case 'cpu': + if (isset($option['value'])) { + // 提取数字,例如 "16核" -> 16 + preg_match('/(\d+)/', $option['value'], $matches); + if (!empty($matches)) { + $cpuCores = intval($matches[1]); + } + } + break; + case 'memory': + if (isset($option['value'])) { + $memorySize = $option['value']; // 例如 "16G" + } + break; + case 'system_disk_size': + if (isset($option['value'])) { + $diskSize = $option['value']; // 例如 "Lin50G,Win50G" + } + break; + case 'bw': + if (isset($option['value'])) { + $bandwidth = $option['value']; // 例如 "70Mbps" + } + break; + case 'os': + if (isset($option['value'])) { + $osType = $option['value']; // 例如 "Debian-12.0_x64" + } + break; + } + } + } + + // 使用INSERT OR REPLACE防止重复数据 + // 注意:需要保留原有的status字段,避免被覆盖为NULL + $existing = $listDb->queryOne( + 'SELECT status FROM vps_list WHERE config_id = ? AND vps_id = ?', + [$configId, $host['id']] + ); + + $currentStatus = $existing ? $existing['status'] : null; + + $listDb->execute( + 'INSERT OR REPLACE INTO vps_list (config_id, vps_id, domain, ip_address, product_name, cpu_cores, memory_size, disk_size, bandwidth, os_type, status, section, last_check) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)', + [ + $configId, + $host['id'], + $host['domain'] ?? null, + $host['dedicatedip'] ?? null, + $host['product_name'] ?? null, + $cpuCores, + $memorySize, + $diskSize, + $bandwidth, + $osType, + $currentStatus, // 保留原有状态 + $config['auto_monitor'] ? 1 : 0 + ] + ); + } + + return true; +} + +/** + * 刷新所有配置的VPS列表 + * @return int 成功刷新的配置数量 + */ +function refreshAllVpsLists() { + $db = getVpsDB(); + $configs = $db->query('SELECT * FROM configs'); + + $successCount = 0; + + foreach ($configs as $config) { + if (refreshVpsListForConfig($config['id'])) { + $successCount++; + } + } + + return $successCount; +} + +/** + * 获取单个VPS的状态并更新到数据库 + * @param int $configId 配置ID + * @param int $vpsId VPS ID + * @return array|null 状态信息或null + */ +function updateVpsStatusToDb($configId, $vpsId) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + error_log("配置ID {$configId} 不存在"); + return null; + } + + // 调用API获取VPS状态(不自动更新数据库,由本函数统一处理) + $result = mofangGetVpsStatus($configId, $vpsId, false); + + if (!$result || !isset($result['status']) || $result['status'] !== 200) { + error_log("获取VPS {$vpsId} 状态失败: " . ($result['msg'] ?? '未知错误')); + return null; + } + + $statusData = $result['data']; + $status = $statusData['status'] ?? 'unknown'; + $des = $statusData['des'] ?? '未知'; + + // 检查记录是否存在 + $listDb = getVpsListDB(); + $existing = $listDb->queryOne( + 'SELECT id FROM vps_list WHERE config_id = ? AND vps_id = ?', + [$configId, $vpsId] + ); + + if ($existing) { + // 记录存在,执行UPDATE + $listDb->execute( + 'UPDATE vps_list SET status = ?, last_check = CURRENT_TIMESTAMP WHERE config_id = ? AND vps_id = ?', + [$status, $configId, $vpsId] + ); + error_log("[updateVpsStatusToDb] VPS {$vpsId} 状态已更新为: {$status}"); + } else { + // 记录不存在,插入新记录 + $listDb->execute( + 'INSERT INTO vps_list (config_id, vps_id, status, last_check) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', + [$configId, $vpsId, $status] + ); + error_log("[updateVpsStatusToDb] VPS {$vpsId} 新记录已插入,状态: {$status}"); + } + + return [ + 'vps_id' => $vpsId, + 'status' => $status, + 'des' => $des, + 'updated' => true + ]; +} + +/** + * 批量获取VPS状态并更新到数据库 + * @param int $configId 配置ID + * @param array $vpsIds VPS ID数组,为空则更新该配置下所有VPS + * @return array 更新结果统计 + */ +function batchUpdateVpsStatus($configId, $vpsIds = []) { + $db = getVpsDB(); + $config = $db->queryOne('SELECT * FROM configs WHERE id = ?', [$configId]); + + if (!$config) { + return ['success' => 0, 'failed' => 0, 'error' => '配置不存在']; + } + + // 如果未指定VPS IDs,获取该配置下的所有VPS + if (empty($vpsIds)) { + $listDb = getVpsListDB(); + $vpsList = $listDb->query('SELECT vps_id FROM vps_list WHERE config_id = ?', [$configId]); + $vpsIds = array_column($vpsList, 'vps_id'); + } + + if (empty($vpsIds)) { + return ['success' => 0, 'failed' => 0, 'error' => '没有VPS需要更新']; + } + + $successCount = 0; + $failedCount = 0; + $results = []; + + foreach ($vpsIds as $vpsId) { + $result = updateVpsStatusToDb($configId, $vpsId); + + if ($result) { + $successCount++; + $results[] = $result; + } else { + $failedCount++; + $results[] = [ + 'vps_id' => $vpsId, + 'status' => 'error', + 'des' => '获取状态失败', + 'updated' => false + ]; + } + } + + return [ + 'success' => $successCount, + 'failed' => $failedCount, + 'total' => count($vpsIds), + 'results' => $results + ]; +} +?> diff --git a/static/config.js b/static/config.js new file mode 100644 index 0000000..31d4f24 --- /dev/null +++ b/static/config.js @@ -0,0 +1,205 @@ +/** + * VPS Hub 配置管理页面交互脚本 + */ + +/** + * 切换网站URL输入框的提示和默认值 + * @param {string} siteType - 网站类型 + */ +function toggleSiteUrl(siteType) { + const urlGroup = document.getElementById('site_url_group'); + const urlInput = document.getElementById('site_url_input'); + + if (!urlInput) return; + + switch(siteType) { + case 'mofang': + urlInput.placeholder = '留空使用默认值: https://www.heyunidc.cn/v1'; + urlInput.required = false; + break; + case 'aliyun': + urlInput.placeholder = '例如: https://ecs.aliyuncs.com'; + urlInput.required = true; + break; + case 'tencent': + urlInput.value = 'https://cvm.tencentcloudapi.com/'; + urlInput.placeholder = '腾讯云API地址'; + urlInput.required = true; + break; + default: + urlInput.placeholder = '输入API地址'; + urlInput.required = true; + } +} + +/** + * 验证密码格式 + * @returns {boolean} 是否通过验证 + */ +function validatePassword() { + const passwordInput = document.getElementById('api_pass'); + if (!passwordInput) return true; + + const password = passwordInput.value; + + if (password.length < 8) { + alert('密码至少需要8位!'); + return false; + } + + if (!/^[a-zA-Z0-9]+$/.test(password)) { + alert('密码只能包含字母和数字!'); + return false; + } + + return true; +} + +/** + * 确认删除操作 + * @param {string} message - 确认消息 + * @returns {boolean} 是否确认 + */ +function confirmDelete(message) { + return confirm(message || '确认删除此配置?'); +} + +/** + * 显示加载状态 + * @param {HTMLElement} button - 按钮元素 + */ +function showLoading(button) { + if (!button) return; + + const originalText = button.textContent; + button.disabled = true; + button.textContent = '加载中...'; + button.dataset.originalText = originalText; +} + +/** + * 隐藏加载状态 + * @param {HTMLElement} button - 按钮元素 + */ +function hideLoading(button) { + if (!button) return; + + button.disabled = false; + button.textContent = button.dataset.originalText || '提交'; +} + +/** + * AJAX提交表单 + * @param {HTMLFormElement} form - 表单元素 + * @param {Function} successCallback - 成功回调 + * @param {Function} errorCallback - 失败回调 + */ +function submitFormAjax(form, successCallback, errorCallback) { + const formData = new FormData(form); + const submitButton = form.querySelector('button[type="submit"]'); + + showLoading(submitButton); + + fetch(form.action || window.location.href, { + method: form.method || 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + hideLoading(submitButton); + if (data.status === 200 || data.success) { + if (successCallback) successCallback(data); + } else { + if (errorCallback) errorCallback(data); + else alert('操作失败: ' + (data.msg || '未知错误')); + } + }) + .catch(error => { + hideLoading(submitButton); + console.error('请求失败:', error); + if (errorCallback) errorCallback(error); + else alert('网络错误,请稍后重试'); + }); +} + +/** + * 刷新VPS列表 + */ +function refreshVpsList() { + const button = document.querySelector('.btn-refresh'); + if (!button) return; + + showLoading(button); + + // 创建临时表单提交 + const form = document.createElement('form'); + form.method = 'POST'; + form.action = window.location.href; + + const actionInput = document.createElement('input'); + actionInput.type = 'hidden'; + actionInput.name = 'action'; + actionInput.value = 'refresh_vps_list'; + + form.appendChild(actionInput); + document.body.appendChild(form); + form.submit(); +} + +/** + * 初始化页面 + */ +document.addEventListener('DOMContentLoaded', function() { + // 自动聚焦第一个输入框 + const firstInput = document.querySelector('input:not([type="hidden"]):not([type="checkbox"])'); + if (firstInput && !firstInput.value) { + firstInput.focus(); + } + + // 为所有删除按钮添加确认对话框 + const deleteButtons = document.querySelectorAll('.btn-delete'); + deleteButtons.forEach(button => { + button.addEventListener('click', function(e) { + if (!confirmDelete('确认删除此配置?此操作不可恢复!')) { + e.preventDefault(); + } + }); + }); + + // 为所有操作按钮添加确认对话框 + const actionButtons = document.querySelectorAll('.btn-success, .btn-danger, .btn-warning'); + actionButtons.forEach(button => { + if (button.type === 'submit' && button.name === 'action') { + button.addEventListener('click', function(e) { + let message = ''; + switch(this.value) { + case 'on': + message = '确认开机?'; + break; + case 'off': + message = '确认关机?'; + break; + case 'reboot': + message = '确认硬重启?'; + break; + } + + if (message && !confirm(message)) { + e.preventDefault(); + } + }); + } + }); + + console.log('VPS Hub 页面已加载'); +}); + +// 导出函数供全局使用 +window.VPSHub = { + toggleSiteUrl, + validatePassword, + confirmDelete, + refreshVpsList, + showLoading, + hideLoading +}; diff --git a/web/static/favicon.ico b/static/favicon.ico similarity index 100% rename from web/static/favicon.ico rename to static/favicon.ico diff --git a/web/static/initial.css b/static/initial.css similarity index 100% rename from web/static/initial.css rename to static/initial.css diff --git a/web/static/style.css b/static/style.css similarity index 74% rename from web/static/style.css rename to static/style.css index 9e955c0..7157aea 100644 --- a/web/static/style.css +++ b/static/style.css @@ -335,4 +335,125 @@ body { padding: 8px 12px; font-size: 12px; } +} + +/* VPS Hub 新增样式 */ + +.config-container { + max-width: 600px; + margin: 50px auto; + background: white; + padding: 40px; + border-radius: 12px; + box-shadow: 0 4px 16px rgba(0,0,0,0.1); +} + +.config-header { + text-align: center; + margin-bottom: 30px; +} + +.config-header h1 { + color: #667eea; + margin-bottom: 10px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + color: #333; + font-weight: 500; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; +} + +.help-text { + font-size: 12px; + color: #999; + margin-top: 5px; +} + +.btn-submit { + width: 100%; + padding: 12px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + cursor: pointer; + transition: transform 0.2s; +} + +.btn-submit:hover { + transform: translateY(-2px); +} + +.error-message { + background: #fee; + border-left: 4px solid #dc3545; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + color: #dc3545; +} + +.success-message { + background: #efe; + border-left: 4px solid #28a745; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + color: #28a745; +} + +.info-box { + background: #e7f3ff; + border-left: 4px solid #007bff; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + color: #0056b3; + font-size: 14px; +} + +.info-box code { + background: #f0f0f0; + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; +} + +.copyright { + margin-top: 30px; + padding: 20px; + color: #999; + font-size: 14px; +} + +.copyright a { + color: #667eea; + text-decoration: none; +} + +.copyright a:hover { + text-decoration: underline; } \ No newline at end of file diff --git a/web/index.php b/web/index.php deleted file mode 100644 index 88747fa..0000000 --- a/web/index.php +++ /dev/null @@ -1,505 +0,0 @@ - - - - - - - - - 初始配置 - VPS管理面板 - - -
-
-

🔧 初始配置

-

请填写以下信息以开始使用

-
- - -
- ❌ -
- - -
- 💡 提示:这些信息将保存在 config.php 文件中,请妥善保管。
- 若您需要重置或更改配置,请删除 config.php 文件并重新运行。
- 配置完成后请以该格式访问服务:example.com/?pass=API_PASS -
- -
-
- - -
用于保护管理面板的访问权限
可选:字母(a-z,A-Z)和数字(0-9)
-
- -
- - -
您的核云IDC登录账号
-
- -
- - -
在核云IDC控制台获取的API密钥
-
- - -
- -
-
- - - - - ACCOUNT, - 'password' => API_KEY - ]; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode !== 200) { - return null; - } - - $result = json_decode($response, true); - $token = isset($result['jwt']) ? $result['jwt'] : null; - - // 保存新Token到缓存 - if ($token) { - saveTokenToCache($token); - } - - return $token; -} - -// ==================== 功能实现区域 ==================== - -/** - * 发送API请求 - */ -function sendApiRequest($endpoint, $method = 'GET', $data = []) { - $token = getLoginToken(); - if (!$token) { - return ['status' => 500, 'msg' => '获取Token失败,请检查账号和密码配置']; - } - - $url = BASE_URL . $endpoint; - $headers = [ - 'Authorization: JWT ' . $token, - 'Content-Type: application/json' - ]; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - - if ($method === 'POST') { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - } elseif ($method === 'PUT') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - } - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - return json_decode($response, true); -} - -/** - * 获取VPS列表 - */ -function getHostList($page = 1, $limit = 100) { - return sendApiRequest("/hosts?page={$page}&limit={$limit}", 'GET'); -} - -/** - * 获取VPS状态 - */ -function getHostStatus($hostId) { - return sendApiRequest("/hosts/{$hostId}/module/status?type=host", 'GET'); -} - -/** - * 操作VPS - */ -function operateHost($hostId, $operation) { - return sendApiRequest("/hosts/{$hostId}/module/{$operation}", 'PUT'); -} - -// ==================== 主逻辑区域 ==================== - -// 验证pass参数 -$pass = isset($_GET['pass']) ? $_GET['pass'] : ''; - -if ($pass !== API_PASS) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['status' => 403, 'msg' => '访问密码错误或者未输入密码,请拼接?pass=API_PASS后再尝试访问'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); - exit; -} - -// 处理操作请求 -$action = isset($_POST['action']) ? $_POST['action'] : ''; -$hostId = isset($_POST['host_id']) ? intval($_POST['host_id']) : 0; - -$result = null; -if ($action && $hostId > 0) { - if ($action === 'reboot') { - $result = operateHost($hostId, 'hard_reboot'); - } elseif ($action === 'on') { - $result = operateHost($hostId, 'on'); - } elseif ($action === 'off') { - $result = operateHost($hostId, 'off'); - } -} - -// 获取VPS列表和状态 -$hosts = []; -$statusMap = []; -$domainstatus = []; -$totalCount = 0; -$errorMsg = ''; - -$listResult = getHostList(); -if (isset($listResult['status']) && $listResult['status'] === 200) { - $data = $listResult['data']; - $hosts = $data['host']; - $domainstatus = $data['domainstatus']; - $totalCount = $data['total']; - - // 批量获取所有VPS的状态 - foreach ($hosts as $host) { - $statusResult = getHostStatus($host['id']); - if (isset($statusResult['status']) && $statusResult['status'] === 200) { - $statusMap[$host['id']] = $statusResult['data']; - } else { - $statusMap[$host['id']] = ['status' => 'unknown', 'des' => '未知']; - } - } -} else { - $errorMsg = isset($listResult['msg']) ? $listResult['msg'] : '获取VPS列表失败'; -} - -header('Content-Type: text/html; charset=utf-8'); -?> - - - - - - - - VPS管理面板 - - -
-
-

🖥️ 核云IDC VPS管理面板

-

实时查看和管理您的云服务器

-
- -
- -
-

❌ 加载失败

-

-
- - - - -
-

✅ 操作成功

-

VPS # 已成功执行操作

-
- -
-

❌ 操作失败

-

-
- - - - -
- ✅ Token缓存生效中 | 剩余有效期:约 分钟 -
- - -
-
- 总计: 台服务器 -
-
- 运行中: 台 -
-
- 已关机: 台 -
-
- -
- $statusKey, 'color' => '#999']; - $powerStatus = isset($statusMap[$host['id']]) ? $statusMap[$host['id']] : ['status' => 'unknown', 'des' => '未知']; - - $regDate = date('Y-m-d', $host['regdate']); - $nextDueDate = date('Y-m-d', $host['nextduedate']); - - $powerClass = 'power-unknown'; - if ($powerStatus['status'] === 'on') { - $powerClass = 'power-on'; - } elseif ($powerStatus['status'] === 'off') { - $powerClass = 'power-off'; - } - ?> -
-
-
#
- - - -
- -
-
- 域名 - -
-
- IP地址 - -
-
- 产品名称 - -
-
- 注册日期 - -
-
- 到期日期 - -
-
- 金额 - ¥ -
-
- 计费周期 - -
-
- -
- - -
- -
- -
- - - -
-
-
- -
- -
- -
-
- - - \ No newline at end of file diff --git a/新开发文档.md b/新开发文档.md new file mode 100644 index 0000000..3acb3ca --- /dev/null +++ b/新开发文档.md @@ -0,0 +1,69 @@ +### WEB运行 + +#### 配置文件 config_add.php + +用户可访问config_add.php添加VPS配置项 + +初次访问该php页面时,用户需设置网页自定义访问密码:API_PASS(运行输入大小写字母以及数字,最低8位数) + +存在API_PASS时,需跟参数?pass=API_PASS才允许访问 + +用户点击添加配置按钮,新列出一列,分别是: +旧数据也会读取并展示 +序号(自动生成),网站类型(魔方、阿里云、腾讯云等),网站链接(若为魔方则设置为可填,腾讯云为https://cvm.tencentcloudapi.com/,阿里云后面再写),账户(魔方账户:邮箱或手机号,其他云后续添加),密钥(魔方输入API KEY),是否开启自动开机监控(魔方默认开启,其他云默认关闭) + +1. 用户点击添加数据后,在./app/db/文件夹内生成sqlite数据库 vps.db用于存储该类数据 + +2. 首次添加后,自动调用所有API进行VPS列表查询,查询结果记录在./app/db中的vpslist.db中,之后此类查询每天仅需查询一次(是否开启自动开机监控标记为开启时,vps的最后一列section也标记为True) + +3. 进行ping访问测试后,将结果记录于./app/db/中的status.db中,保留最近三十天数据 + + 记录项:目标(IP地址或域名),状态(正常或异常),延时(单位ms 毫秒) + 每天0点时,总结前一天中各VPS的ping响应数据,记录在该数据库中的新表 vps_summary 中。该表需包含以下字段:VPS标识(vps_id)、统计日期(date)、平均延迟(avg_latency_ms)、最大延迟(max_latency_ms)、最小延迟(min_latency_ms)。同时分级记录延迟分布情况:延迟低于100ms的次数(count_under_100)、100ms~300ms的次数(count_100_to_300)、300ms~500ms的次数(count_300_to_500),以及延迟超过500ms或ping异常的丢包/超时次数(count_abnormal)。根据延迟表现计算高可用评分:100ms以内权重最高,300ms以内为良好,500ms以内为可用,超过500ms标记为低可用,异常则标记为不可用。记录为新字段:服务器可用性(优秀/良好/一般/不可用) + +4. 首次使用网页并添加数据后,调用./app/install.sh进行system服务注册 + +#### 首页 index.php + +1. 查询是否存在API_PASS,若存在,则需跟参数?pass=API_PASS才能访问 + +2. 若不存在,则跳转至config_add.php页 + +(获取到的认证token存储于./app/token.php中,记录为两小时,后续调用API时超时或提示失效再重新获取) + +3. 右上角放置“添加配置源”的按钮 + +4. 查询vps.db,并依次获取vps列表并查询其状态信息(使用v1/hosts/:id接口获取VPS的CPU核数,操作系统,内存大小,系统盘大小以及带宽大小),提供开机、关机、硬重启按钮 + +5. 左上角新建一个按钮,用于手动再次获取vps列表以及信息,不再自动获取信息。 + +#### 魔方页 mofangidc.php + +所有智简魔方的操作API以及相关函数存储在此网页下 + +### Python运行 + +#### 安装 + +通过install.sh自动安装 + +功能点: + +1. 探测当前系统是否存在idc_monitor的系统服务,若不存在则进入第二步,若存在则重启一下该服务 + +2. 读取python版本并探测是否存在pip,自动下载python-pip + +3. pip下载所需环境和依赖项 + +4. 新建一个idc_system的系统服务,持续化运行当前路径下的monitor.py脚本 + +#### 读取配置 + +1. 读取vpslist中的数据,则对其进行ping测试 + +2. 测试结果保存至status.db中 + +(获取到的认证token存储于./app/token.php中,记录为两小时) + +3. 若section为True,且ping测试失败,调用API对该机器进行状态查询,若返回状态为关机,则尝试开机,隔60秒后再进行状态查询并再次尝试开机,60秒后查询还是为异常的话,则记录异常,进入下一个循环(多台机器关机时,依次进行状态查询和开机,然后间隔60秒再进行操作,不同的机器不需要间隔) +