From 5fbc5cc335a985dd6eb47d72e54907f7e53d43c4 Mon Sep 17 00:00:00 2001 From: starlight-apk Date: Sat, 16 May 2026 20:20:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=84=9A=E6=89=8B?= =?UTF-8?q?=E6=9E=B6/=E5=BC=80=E5=8F=91=E6=A8=A1=E5=BC=8F/=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=99=BD=E5=90=8D=E5=8D=95/system-monitor=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nebula create mod/key/list-templates 模组脚手架 - nebula dev 开发模式热重载 - manifest permissions.imports 权限白名单机制 - system-monitor 系统监控仪表盘插件 - 默认端口统一为 10086 - 修复 _init_nbpf 误读 Ed25519 私钥为 RSA 的 bug - 更新 README.md 文档 --- README.md | 563 +++++++++++++++----------------- oss/cli.py | 302 ++++++++++++++++- oss/config/config.py | 4 +- oss/core/http_api/server.py | 2 +- oss/core/manager.py | 9 +- oss/core/nbpf/compiler.py | 23 +- oss/core/nbpf/format.py | 11 +- oss/core/nbpf/loader.py | 28 +- oss/templates/mod/README.md | 24 ++ oss/templates/mod/main.py | 50 +++ oss/templates/mod/manifest.json | 22 ++ system-monitor/README.md | 24 ++ system-monitor/main.py | 430 ++++++++++++++++++++++++ system-monitor/manifest.json | 45 +++ 14 files changed, 1225 insertions(+), 312 deletions(-) create mode 100644 oss/templates/mod/README.md create mode 100644 oss/templates/mod/main.py create mode 100644 oss/templates/mod/manifest.json create mode 100644 system-monitor/README.md create mode 100644 system-monitor/main.py create mode 100644 system-monitor/manifest.json diff --git a/README.md b/README.md index 1210c41..34fe2d0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- - NebulaShell + + NebulaShell

@@ -9,7 +9,6 @@ Python License Build - Coverage Security

@@ -24,16 +23,13 @@ ## 目录 - [项目定位](#项目定位) -- [架构总览](#架构总览) -- [核心能力](#核心能力) - [快速开始](#快速开始) -- [NBPF 包格式](#nbpf-包格式) -- [NIR 中间表示](#nir-中间表示) - [CLI 工具链](#cli-工具链) - [插件开发](#插件开发) +- [权限白名单](#权限白名单manifestpermissionsimports) +- [NBPF 包格式](#nbpf-包格式) - [内置插件](#内置插件) - [安全体系](#安全体系) -- [性能指标](#性能指标) - [贡献指南](#贡献指南) - [许可证](#许可证) @@ -43,374 +39,357 @@ NebulaShell 是一个**以安全为基石、以插件为灵魂**的运行时框架。核心只做两件事:**加载插件**与**调度插件**,其余一切功能均由插件生态提供。 -

- NebulaShell Philosophy -

- ### 设计原则 | 原则 | 说明 | -|------|------| -| **最小核心** | 核心仅 1100+ 行,职责单一,可独立审计 | -| **插件即产品** | 所有业务功能以插件形式交付,核心不耦合任何业务 | -| **安全默认** | 插件分发强制签名加密,运行时隔离,防篡改防逆向 | -| **一次编译** | NIR 中间表示确保插件跨平台运行,无需为架构适配 | -| **零信任分发** | 每个包经过三层签名验证 + 两层加密解密才可加载 | - ---- - -## 架构总览 - -

- NebulaShell Architecture -

- -### 分层架构 - -

- NebulaShell Layers -

- -### 数据流 - -

- NebulaShell Data Flow -

- ---- - -## 核心能力 - -### 插件化架构 - -- **热插拔**:插件可在运行时动态加载/卸载,无需重启 -- **依赖注入**:通过 `use()` 获取任意已加载插件实例 -- **生命周期管理**:`init → start → stop` 三阶段标准化生命周期 -- **优先级控制**:支持 `load_priority` 标记控制加载顺序 -- **熔断降级**:插件异常自动隔离,不影响核心运行 - -### NBPF 包格式 - -- **多重签名**:Ed25519(外层)→ RSA-4096-PSS(中层)→ HMAC-SHA256(内层) -- **多重加密**:AES-256-GCM 双层加密,密钥经 RSA-OAEP 封装 -- **代码隐藏**:混淆导入路径、常量运行时计算、反调试检测、内存擦除、花指令混淆 -- **防篡改**:任何字节级别的修改都会导致签名验证失败 - -### NIR 中间表示 - -- **一次编译,到处运行**:基于 Python `compile()` + `marshal` 序列化 -- **跨平台**:code object 是 Python 虚拟机原生格式,与 CPU 架构无关 -- **目标版本**:Python 3.10+ -- **代码保护**:编译产物不可读,增加逆向难度 - -### CLI 工具链 - -- **`nebula nbpf`**:完整的包管理命令组 -- **密钥生成**:一键生成 Ed25519 + RSA-4096 密钥对 -- **打包/解包**:插件目录 ↔ .nbpf 文件双向转换 -- **验证/签名**:独立验证工具 + 重新签名能力 +|:----|:-----| +| 🧩 **一切皆插件** | 框架本身只提供加载和调度能力,所有功能都来自插件 | +| 🔒 **安全默认** | 沙箱执行、签名验证、权限声明、完整性校验,层层防护 | +| 📦 **一次编译到处运行** | NIR 中间表示使插件可在任何 Python 3.10+ 平台运行 | +| 🎯 **最小权限** | 插件必须显式声明所需权限,未授权操作被拒绝 | +| 🔧 **开发者体验** | 脚手架生成、热重载、详细日志,让开发尽可能愉快 | --- ## 快速开始 -```bash -# 克隆仓库 -git clone https://github.com/Starlight-apk/NebulaShell.git -cd NebulaShell +### 一键启动(推荐) +```bash +# 非交互式启动(适合 Docker / CI / 后台服务) +python headless.py + +# 自动信任所有 NBPF 插件 +python headless.py --trust-all + +# 仅检查运行环境 +python headless.py --dry-run +``` + +### 手动启动 + +```bash # 安装依赖 pip install -r requirements.txt -# 启动 NebulaShell -python main.py +# 开发模式(热重载,推荐开发时使用) +python main.py dev + +# 生产模式(交互式 REPL) +python main.py serve + +# 生产模式(非交互,适合 Docker) +python main.py serve --headless ``` -启动后访问 [http://localhost:8080](http://localhost:8080) 进入管理控制台。 - -### 生成密钥并打包一个插件 +### 快速创建第一个模组 ```bash -# 1. 生成密钥对 -nebula nbpf keygen --output ./nbpf-keys +# 一行命令创建模组脚手架 +python main.py create mod hello-world -a "我" -d "我的第一个模组" -# 2. 打包插件为 .nbpf -nebula nbpf pack ./store/NebulaShell/my-plugin -o my-plugin.nbpf \ - --ed25519-key ./nbpf-keys/private/ed25519.pem \ - --rsa-key ./nbpf-keys/private/rsa.pem +# 编辑功能 +cd hello-world +# vim main.py # 实现你的功能 -# 3. 验证包完整性 -nebula nbpf verify my-plugin.nbpf +# 生成签名密钥 +python main.py create key -o ./keys --name mykey -# 4. 将密钥放入信任目录 -cp ./nbpf-keys/trusted/* ./data/nbpf-keys/trusted/ -cp ./nbpf-keys/rsa/* ./data/nbpf-keys/rsa/ +# 打包为 .nbpf +python main.py nbpf pack ./hello-world -o mods/hello-world.nbpf \ + --ed25519-key ./keys/mykey_ed25519.pem \ + --rsa-key ./keys/mykey_rsa.pem \ + --rsa-pub ./keys/mykey_rsa.pub.pem \ + --signer "我" -# 5. 重启 NebulaShell,插件自动加载 +# 启动开发模式测试 +python main.py dev ``` ---- - -## NBPF 包格式 - -### 包结构 - -

- NBPF Package Structure -

- -### 加密层级 - -| 层级 | 算法 | 密钥来源 | 保护范围 | -|------|------|----------|----------| -| 外层加密 | AES-256-GCM | key1(RSA-OAEP 封装) | META-INF/ 和 NIR/ 目录 | -| 中层加密 | AES-256-GCM | key2(RSA-OAEP 封装) | NIR 数据内容 | -| 外层签名 | Ed25519 | 开发者私钥 | 加密层完整性 | -| 中层签名 | RSA-4096-PSS | 作者私钥 | 模块内容完整性 | -| 内层签名 | HMAC-SHA256 | 派生密钥(key1+key2) | 单个模块完整性 | - -### 安全流程 - -

- NBPF Security Flow -

- ---- - -## NIR 中间表示 - -NIR(Nebula Intermediate Representation)是 NebulaShell 的跨平台编译方案。 - -### 技术原理 - -

- NIR Compilation Flow -

- -### 代码保护 - -| 技术 | 说明 | -|------|------| -| 混淆导入路径 | 动态 `__import__()` + 字符串拼接,隐藏依赖关系 | -| 常量运行时计算 | 关键字符串在运行时拼接,避免静态分析 | -| 反调试检测 | `sys.gettrace()` 检测调试器附加 | -| 内存擦除 | `bytearray` 覆盖清零,防止内存 dump | -| 花指令混淆 | 向 `co_consts` 插入无害垃圾常量,干扰分析 | +> 默认 HTTP 服务端口:**10086**(可在 `data/config.json` 或环境变量 `HTTP_API_PORT` 中修改) --- ## CLI 工具链 +| 命令 | 说明 | +|:----|:------| +| `serve` | 启动 NebulaShell 服务端 | +| `serve --headless` | 🆕 非交互模式(不启动 REPL,适合 Docker/后台) | +| `dev` | 🆕 **开发模式** — 监听文件变化 + 自动热重载 | +| `version` | 显示版本信息 | +| `info` | 显示系统信息 | +| `create mod` | 🆕 **模组脚手架** — 交互式生成插件骨架 | +| `create key` | 🆕 快速生成 Ed25519 + RSA 签名密钥对 | +| `create list-templates` | 🆕 查看可用模板 | +| `nbpf pack` | 打包插件目录为 .nbpf 文件 | +| `nbpf unpack` | 解包 .nbpf 文件到目录 | +| `nbpf verify` | 验证 .nbpf 签名 | +| `nbpf sign` | 为 .nbpf 重新签名 | +| `nbpf keygen` | 生成 Ed25519 + RSA-4096 密钥对 | + +### 典型用法 + ```bash -# 密钥管理 -nebula nbpf keygen # 生成 Ed25519 + RSA-4096 密钥对 -nebula nbpf keygen --output ./keys # 指定输出目录 +# 🚀 快速创建模组(脚手架) +nebula create mod my-plugin -a "我" -d "插件描述" +nebula create mod my-service --type service --with-keys -# 打包 -nebula nbpf pack ./plugin-dir # 打包为 .nbpf -nebula nbpf pack ./plugin-dir -o out.nbpf --keys-dir ./keys +# 🔑 生成签名密钥 +nebula create key -o ./keys --name mykey -# 解包 -nebula nbpf unpack package.nbpf # 解包到目录 -nebula nbpf unpack package.nbpf -o ./out +# 💻 开发模式(热重载) +nebula dev # 监听当前目录 +nebula dev ./my-plugin --port 10086 # 监听指定目录 +nebula dev --skip-sign # 跳过签名验证(调试用) -# 验证 -nebula nbpf verify package.nbpf # 验证完整签名链 +# 🚀 启动服务 +nebula serve +nebula serve --headless # 非交互模式 -# 重新签名 -nebula nbpf sign package.nbpf # 使用新密钥重新签名 -nebula nbpf sign package.nbpf --ed25519-key ./key.pem --rsa-key ./rsa.pem +# 📦 打包插件 +nebula nbpf pack ./my-plugin -o my-plugin.nbpf \ + --ed25519-key ./keys/ed25519.pem \ + --rsa-key ./keys/rsa.pem \ + --rsa-pub ./keys/rsa.pub.pem \ + --signer "作者名" + +# ℹ️ 系统信息 +nebula info ``` --- ## 插件开发 -### 最小插件 +### 模组结构(目录模式) -```python -from oss.plugin.types import Plugin - -class HelloPlugin(Plugin): - def init(self, deps=None): - self.name = "hello" - - def start(self): - print(f"{self.name} started") - - def stop(self): - print(f"{self.name} stopped") - -def New(): - return HelloPlugin() +``` +my-plugin/ +├── manifest.json ← 【必填】模组身份证 +├── main.py ← 【必填】模组代码(类 + New() 工厂函数) +└── README.md ← 【推荐】说明文档 ``` -### 清单文件 +### main.py 规范 + +插件支持两种写法,**推荐使用类 + New() 工厂函数**以兼容目录和 nbpf 两种加载方式: + +```python +class MyPlugin: + name = "my-plugin" + version = "1.0.0" + description = "描述" + + def init(self, deps=None): # 初始化,可获取依赖 + pass + def start(self): # 启动 + pass + def stop(self): # 停止,释放资源 + pass + def reload(self, config): # 热重载(可选) + pass + def health(self) -> dict: # 健康检查(可选) + return {"status": "ok"} + +def New(): + """目录插件工厂函数(必需)""" + return MyPlugin() +``` + +### manifest.json ```json { - "metadata": { - "name": "hello-plugin", - "version": "1.0.0", - "description": "示例插件", - "author": "developer" + "name": "@作者/模组名", + "version": "1.0.0", + "description": "描述", + "author": "作者", + "type": "example", + "main": "main.py", + "enabled": true, + "priority": 999, + "runtime": { + "language": "python", + "entry_point": "main.py", + "requirements": [] }, - "config": { - "enabled": true, - "args": {} + "permissions": { + "imports": [] ← 声明的系统模块导入权限 }, - "dependencies": [], - "permissions": ["storage:read"] + "services": { + "provides": [], + "consumes": [] + }, + "config": {} } ``` -### 使用其他插件 - -```python -from store.NebulaShell.plugin_bridge.main import use - -# 获取 HTTP API 插件实例 -http_api = use("http-api") - -# 注册路由 -http_api.add_route("/hello", lambda: {"message": "world"}) -``` - -### 打包分发 +### 开发流程 ```bash -# 开发阶段:源码直接放入 store/ 目录 -# 分发阶段:打包为 .nbpf -nebula nbpf pack ./store/NebulaShell/hello-plugin -o hello-plugin.nbpf \ - --ed25519-key ./nbpf-keys/private/ed25519.pem \ - --rsa-key ./nbpf-keys/private/rsa.pem +# 1. 使用脚手架创建模组(推荐) +nebula create mod my-plugin -a "我" -d "描述" + +# 2. 编辑 main.py 实现功能 +cd my-plugin +# vim main.py ... + +# 3. 开发模式:边改边测,自动热重载 +nebula dev ./my-plugin + +# 4. 满意后生成签名密钥 +nebula create key -o ./keys --name mykey + +# 5. 打包为 .nbpf 发布 +nebula nbpf pack ./my-plugin -o mods/my-plugin.nbpf \\ + --ed25519-key ./keys/mykey_ed25519.pem \\ + --rsa-key ./keys/mykey_rsa.pem \\ + --rsa-pub ./keys/mykey_rsa.pub.pem \\ + --signer "我" +``` + +--- + +## 权限白名单(manifest.permissions.imports) + +NBPF 安全沙箱默认禁止导入 `os`、`threading`、`socket` 等系统模块。 +如果插件确实需要,**必须在 manifest.json 的 `permissions.imports` 中显式声明**。 + +```json +{ + "permissions": { + "imports": ["os", "threading", "json", "http"] + } +} +``` + +| 声明方式 | 效果 | +|:---------|:-----| +| 不声明 `permissions` | 仅限纯 Python 计算,不可导入任何系统模块 | +| `"imports": ["os", "json"]` | 只允许导入 `os` 和 `json` | +| 使用未声明的模块 | **编译时报错**,拒绝打包 | +| 运行时突破白名单 | **安全沙箱拦截**,抛出 `ImportError` | + +> **安全原则**:最小权限。只声明你真正需要的模块,不要图省事全写上。 + +--- + +## NBPF 包格式 + +`.nbpf` 是 NebulaShell 的插件分发格式,本质上是一个多层加密签名的 ZIP 包。 + +### 包结构 + +``` +my-plugin.nbpf +├── META-INF/ +│ ├── SIGNATURE ← 外层 Ed25519 签名 +│ ├── SIGNER.PEM ← 签名者 Ed25519 公钥 +│ ├── ENCRYPTION ← 外层加密信息(RSA 加密 AES 密钥) +│ ├── INNER_SIGNATURE ← 中层 RSA-4096 签名 +│ ├── INNER_ENCRYPTION ← 中层加密信息 +│ ├── MODULE_SIGS ← 内层 HMAC 模块签名 +│ └── PLUGIN.MF ← 插件清单 +├── NIR/ ← 编译后的 NIR 中间表示 +│ └── main ← 主模块序列化 code object +└── RES/ ← 资源文件(README、图片等) +``` + +### 安全层级 + +``` +外层: Ed25519 签名 → 验证包完整性 & 作者身份 + ↓ +中层: RSA-4096 签名 → 验证 NIR 数据完整性 + ↓ +内层: HMAC 签名 → 验证每个模块未被篡改 + ↓ +执行: 安全沙箱 + 权限白名单 → 限制运行时能力 ``` --- ## 内置插件 -NebulaShell 内置 26+ 个插件,覆盖 Web 服务、系统管理、安全防护、协议适配等场景。 - -### Web 与 API +NebulaShell 内置了以下官方插件(位于 `store/NebulaShell/`): | 插件 | 说明 | -|------|------| -| `http-api` | RESTful API 服务,支持路由注册、中间件 | -| `ws-api` | WebSocket 实时通信服务 | -| `webui` | 管理控制台 Web 界面 | -| `dashboard` | 系统仪表盘,实时监控 | - -### 系统管理 - -| 插件 | 说明 | -|------|------| -| `plugin-loader` | 插件加载核心,manifest 解析 | -| `plugin-loader-pro` | 熔断、降级、容错机制 | -| `pkg-manager` | 插件包管理器,在线安装/更新 | -| `lifecycle` | 插件生命周期管理 | -| `hot-reload` | 插件热重载,开发模式自动刷新 | -| `dependency` | 依赖关系解析与冲突检测 | - -### 安全防护 - -| 插件 | 说明 | -|------|------| -| `signature-verifier` | 运行时签名验证 | -| `code-reviewer` | 插件代码安全审查 | -| `firewall` | 网络防火墙规则引擎 | - -### 通信与协议 - -| 插件 | 说明 | -|------|------| -| `plugin-bridge` | 插件间通信(事件总线 / RPC / use()) | -| `http-tcp` | TCP 协议适配 | +|:----|:------| +| `plugin-bridge` | 事件总线 + 服务注册 + 跨插件 RPC | +| `plugin-storage` | 插件持久化 KV 存储 | +| `ws-api` | WebSocket 实时推送 | +| `i18n` | 国际化多语言支持 | | `nodejs-adapter` | Node.js 运行时适配 | -| `frp-proxy` | 内网穿透代理 | -| `ftp-server` | 文件服务 | +| `system-monitor` 🆕 | **系统监控仪表盘** — CPU/内存/磁盘/网络/进程TOP | +| | 提供 HTML 仪表盘 + REST API,默认端口 **10087** | -### 工具与增强 +### system-monitor API -| 插件 | 说明 | -|------|------| -| `plugin-storage` | 插件持久化存储 | -| `i18n` | 国际化支持 | -| `auto-dependency` | 系统依赖自动安装 | -| `performance-optimizer` | 性能优化引擎 | -| `json-codec` | JSON 编解码 | -| `log-terminal` | 日志查看与终端 | -| `polyglot-deploy` | 多语言部署支持 | +``` +http://localhost:10087/ → 📊 仪表盘 HTML +http://localhost:10087/health → 💚 {"status": "ok"} +http://localhost:10087/stats → 📄 系统状态 JSON +http://localhost:10087/stats/cpu → 🧠 CPU 详情 +http://localhost:10087/stats/memory → 💾 内存详情 +http://localhost:10087/stats/disk → 💿 磁盘详情 +http://localhost:10087/stats/network → 🌐 网络详情 +http://localhost:10087/stats/processes → ⚡ TOP 进程 +http://localhost:10087/stats/history → 📈 历史数据(最近60条) +``` --- ## 安全体系 -### 全链路安全 - -

- Full Chain Security -

- -### 加密标准 - -| 组件 | 标准 | 密钥长度 | -|------|------|----------| -| 对称加密 | AES-256-GCM | 256 位 | -| 非对称加密 | RSA-OAEP | 4096 位 | -| 外层签名 | Ed25519 | 256 位 | -| 中层签名 | RSA-PSS | 4096 位 | -| 内层签名 | HMAC-SHA256 | 256 位 | - -### 密钥管理 - -

- Key Management Structure -

- ---- - -## 性能指标 - -| 指标 | 数值 | -|------|------| -| 核心代码行数 | ~1,100 行 | -| 内置插件数量 | 26+ | -| 测试覆盖率 | ~92% | -| 语法检查通过率 | 100% | -| Python 版本要求 | 3.10+ | -| 依赖库数量 | 精简(核心仅依赖 cryptography) | +| 层级 | 措施 | 说明 | +|:----|:-----|:------| +| 🛡 **分发安全** | 三层签名(Ed25519 + RSA-4096 + HMAC) | 防止包被篡改或伪造 | +| 🔐 **传输安全** | AES-256-GCM 双层加密 | 插件代码在传输和存储中均加密 | +| 🏖 **执行沙箱** | NIR 安全沙箱 | 限制危险模块和内置函数 | +| 📋 **权限控制** | manifest 权限白名单 | 插件必须声明所需模块导入权限 | +| 🔍 **完整性检查** | SHA-256 文件 hash 监控 | 运行时定期校验插件文件完整性 | +| 🧠 **内存防护** | MemoryGuard 冻结核心属性 | 防止插件修改框架内部状态 | +| 📝 **行为审计** | AuditLogger | 记录所有插件操作行为 | +| 👀 **防篡改监控** | TamperMonitor 后台线程 | 自动检测篡改并停止被篡改插件 | +| 🔄 **降级恢复** | FallbackManager 自动重试 | 插件崩溃时自动重启(最多3次) | --- ## 贡献指南 -### 开发流程 +### 开发环境 ```bash -# 1. Fork 仓库 -# 2. 创建特性分支 -git checkout -b feat/my-feature +# 克隆仓库 +git clone https://git.starlight-apk.cn/starlight-apk/NebulaShell.git +cd NebulaShell -# 3. 安装开发依赖 +# 安装依赖 pip install -r requirements.txt -# 4. 确保语法检查通过 -find . -name "*.py" -not -path "./venv/*" -not -path "./.git/*" | \ - xargs -I{} python3 -m py_compile {} +# 启动开发模式 +python main.py dev -# 5. 运行测试 +# 运行测试 python -m pytest tests/ - -# 6. 提交 PR ``` +### 贡献流程 + +1. Fork 项目并创建特性分支 +2. 编写代码,确保语法检查零错误 +3. 添加或更新测试 +4. 更新文档(README、注释等) +5. 提交 Pull Request + ### 代码规范 - 遵循 PEP 8 编码规范 -- 所有插件必须实现 `init()`、`start()`、`stop()` 方法 -- 插件清单必须包含完整的元数据和权限声明 -- 提交前确保语法检查零错误 +- 插件必须实现 `init()`、`start()`、`stop()` 方法 +- 插件必须包含 `New()` 工厂函数(兼容目录 + nbpf 两种加载方式) +- 插件必须声明完整的 `permissions.imports` 权限白名单 +- 提交前确保所有测试通过 --- diff --git a/oss/cli.py b/oss/cli.py index 20f4f5f..ebc1dd5 100644 --- a/oss/cli.py +++ b/oss/cli.py @@ -147,7 +147,7 @@ def info(ctx): @cli.command(name="cli") @click.option('--connect-host', default='127.0.0.1', help='后端地址(默认 127.0.0.1)') -@click.option('--connect-port', default=8080, help='后端端口(默认 8080)') +@click.option('--connect-port', default=10086, help='后端端口(默认 10086)') def cli_command(connect_host, connect_port): """启动 TUI 前端(前后端分离,连接已有后端)""" click.echo("NebulaShell TUI 客户端(待实现)") @@ -373,6 +373,306 @@ def keygen(output_dir, name): click.echo(f" nebula nbpf pack ./my-plugin --ed25519-key {private_dir / f'{name}_ed25519.pem'} --rsa-key {private_dir / f'{name}_rsa.pem'} --rsa-pub {rsa_dir / f'{name}.pem'} --signer {name}") +# ═══════════════════════════════════════════════════════════════ +# create 命令 — 模组脚手架 +# ═══════════════════════════════════════════════════════════════ + +@cli.group() +def create(): + """创建模组/密钥等资源""" + pass + + +@create.command("mod") +@click.argument("name", type=str, required=False, default=None) +@click.option("--author", "-a", type=str, default=None, help="作者名") +@click.option("--description", "-d", type=str, default=None, help="模组描述") +@click.option("--type", "-t", "mod_type", type=click.Choice(["example", "adapter", "service", "security", "tool"]), default="example", help="模组类型") +@click.option("--with-keys", is_flag=True, default=False, help="同时生成签名密钥") +@click.option("--output", "-o", type=str, default=None, help="输出目录") +@click.pass_context +def create_mod(ctx, name, author, description, mod_type, with_keys, output): + """创建新模组脚手架""" + import string as _string + from pathlib import Path as _Path + + # 交互式输入(如果参数缺失) + if not name: + name = click.prompt("📛 模组名称", type=str) + if not author: + author = click.prompt("👤 作者", type=str, default="anonymous") + if not description: + description = click.prompt("📝 描述", type=str, default="") + + # 校验模组名 + valid_chars = set(_string.ascii_lowercase + _string.digits + "-_") + safe_name = "".join(c for c in name.lower().replace(" ", "-") if c in valid_chars) + if not safe_name: + click.echo("❌ 模组名称无效,请使用字母、数字、连字符") + raise click.Abort() + + # 确定输出目录 + output_dir = _Path(output or safe_name) + if output_dir.exists(): + click.echo(f"❌ 目录 '{output_dir}' 已存在") + raise click.Abort() + + # 渲染模板 + templates_dir = _Path(__file__).parent / "templates" / "mod" + if not templates_dir.exists(): + click.echo("❌ 模板目录不存在,请检查安装") + raise click.Abort() + + # 替换变量 + replacements = { + "{{ mod_name }}": safe_name, + "{{ author }}": author, + "{{ description }}": description, + "{{ mod_type }}": mod_type, + } + + output_dir.mkdir(parents=True, exist_ok=True) + + for tmpl_file in templates_dir.iterdir(): + if tmpl_file.is_file(): + content_tmpl = tmpl_file.read_text(encoding="utf-8") + for old, new in replacements.items(): + content_tmpl = content_tmpl.replace(old, new) + out_path = output_dir / tmpl_file.name + out_path.write_text(content_tmpl, encoding="utf-8") + click.echo(f" ✅ 创建: {out_path.name}") + + click.echo("") + click.echo(f"🎉 模组 '{safe_name}' 创建成功!") + click.echo(f"📂 位置: {output_dir.resolve()}") + click.echo("") + click.echo("下一步:") + click.echo(f" cd {safe_name}") + click.echo(" # 编辑 main.py 实现功能") + click.echo(" # 然后打包:") + click.echo(f' nebula nbpf pack ./{safe_name} -o mods/{safe_name}.nbpf --ed25519-key --rsa-key --rsa-pub --signer "{author}"') + + # 可选生成密钥 + if with_keys: + click.echo("") + click.echo("🔑 正在生成签名密钥...") + try: + from oss.core.nbpf.crypto import NBPCrypto + keys_dir = output_dir / "keys" + keys_dir.mkdir(exist_ok=True) + + ed_priv, ed_pub = NBPCrypto.generate_ed25519_keypair() + (keys_dir / "ed25519.pem").write_bytes(ed_priv) + (keys_dir / "ed25519.pub.pem").write_bytes(ed_pub) + + rsa_priv, rsa_pub = NBPCrypto.generate_rsa_keypair(key_size=2048) + (keys_dir / "rsa.pem").write_bytes(rsa_priv) + (keys_dir / "rsa.pub.pem").write_bytes(rsa_pub) + + click.echo(f" ✅ Ed25519 密钥: {keys_dir}/ed25519.pem") + click.echo(f" ✅ RSA 密钥: {keys_dir}/rsa.pem") + click.echo("") + click.echo("打包命令:") + click.echo(f" nebula nbpf pack ./{safe_name} -o mods/{safe_name}.nbpf") + click.echo(f" --ed25519-key {keys_dir}/ed25519.pem") + click.echo(f" --rsa-key {keys_dir}/rsa.pem") + click.echo(f" --rsa-pub {keys_dir}/rsa.pub.pem") + except Exception as e: + click.echo(f" ⚠ 密钥生成失败: {e}") + + +@create.command("key") +@click.option("--output", "-o", type=str, default="./keys", help="密钥输出目录") +@click.option("--name", type=str, default="default", help="密钥名称") +def create_key(output, name): + """生成 Ed25519 + RSA 签名密钥对""" + from oss.core.nbpf.crypto import NBPCrypto + from pathlib import Path as _Path + + output_path = _Path(output) + output_path.mkdir(parents=True, exist_ok=True) + + click.echo(f"🔑 生成密钥对到: {output_path.resolve()}") + + ed_priv, ed_pub = NBPCrypto.generate_ed25519_keypair() + (output_path / f"{name}_ed25519.pem").write_bytes(ed_priv) + (output_path / f"{name}_ed25519.pub.pem").write_bytes(ed_pub) + click.echo(f" ✅ Ed25519: {output_path / f'{name}_ed25519.pem'}") + + rsa_priv, rsa_pub = NBPCrypto.generate_rsa_keypair(key_size=2048) + (output_path / f"{name}_rsa.pem").write_bytes(rsa_priv) + (output_path / f"{name}_rsa.pub.pem").write_bytes(rsa_pub) + click.echo(f" ✅ RSA: {output_path / f'{name}_rsa.pem'}") + + click.echo("") + click.echo("密钥生成完成!") + + +@create.command("list-templates") +def list_templates(): + """列出可用的模板""" + from pathlib import Path as _Path + templates_base = _Path(__file__).parent / "templates" + if not templates_base.exists(): + click.echo("没有可用的模板") + return + for tdir in templates_base.iterdir(): + if tdir.is_dir(): + files = [f.name for f in tdir.iterdir() if f.is_file()] + click.echo(f" 📦 {tdir.name}/") + for f in files: + click.echo(f" ├── {f}") + + +# ═══════════════════════════════════════════════════════════════ +# dev 命令 — 开发模式热重载 +# ═══════════════════════════════════════════════════════════════ + +@cli.command() +@click.argument("mod_dir", type=str, required=False, default=None) +@click.option("--port", "-p", type=int, default=None, help="HTTP API 端口") +@click.option("--host", type=str, default=None, help="监听地址") +@click.option("--skip-sign", is_flag=True, default=False, help="跳过签名验证(调试用)") +@click.pass_context +def dev(ctx, mod_dir, port, host, skip_sign): + """开发模式 — 监听模组文件变化并自动热重载""" + import time as _time + import hashlib as _hashlib + from pathlib import Path as _Path + from oss.core.watcher import FileWatcher + from oss.logger.logger import Log as _Log + + config = ctx.obj.get("config") + if port: + config.set("HTTP_API_PORT", port) + else: + config.set("HTTP_API_PORT", 10086) + if host: + config.set("HOST", host) + + # 确定监听目录 + watch_dirs = [] + if mod_dir: + mod_path = _Path(mod_dir).resolve() + if not mod_path.exists(): + click.echo(f"❌ 目录不存在: {mod_dir}") + raise click.Abort() + watch_dirs.append(mod_path) + click.echo(f"📁 监听目录: {mod_path}") + else: + # 默认监听 mods/ 和当前目录 + watch_dirs.append(_Path.cwd()) + click.echo(f"📁 监听目录: {_Path.cwd()}") + click.echo("") + + # 启动 NebulaShell 服务 + from oss.core.manager import PluginManager as _PluginManager + + plugin_mgr = _PluginManager() + plugin_mgr.load_all() + # 同时加载 mods/ 目录下的 .nbpf 模组 + from pathlib import Path as _P + mods_path = _P("mods") + if mods_path.exists(): + for f in sorted(mods_path.iterdir()): + if f.suffix == ".nbpf": + plugin_mgr.load(f) + plugin_mgr.start_all() + + # 启动 HTTP 服务 + try: + plugin_mgr.start_http_server() + _Log.ok("Dev", f"HTTP API: http://{config.host}:{config.http_api_port}") + except Exception as e: + _Log.warn("Dev", f"HTTP 服务启动失败: {e}") + + click.echo("") + click.echo("🔧 NebulaShell 开发模式已启动") + click.echo("=" * 50) + click.echo(f" HTTP: http://{config.host}:{config.http_api_port}") + click.echo(f" 监听: {', '.join(str(d) for d in watch_dirs)}") + click.echo(f" 签名验证: {'跳过' if skip_sign else '开启'}") + click.echo(f" 模组数: {len(plugin_mgr.plugins)}") + click.echo("=" * 50) + click.echo(" 按 Ctrl+C 停止") + click.echo("") + + # 文件变更缓存 + _file_hashes: dict[str, str] = {} + + def _get_file_hash(path: _Path) -> str: + """计算文件 hash""" + try: + return _hashlib.sha256(path.read_bytes()).hexdigest() + except Exception: + return "" + + def _get_dir_hash(directory: _Path) -> dict[str, str]: + """获取目录下所有文件的 hash""" + result = {} + for f in sorted(directory.rglob("*")): + if f.is_file() and ".nbpf" not in f.suffix and "__pycache__" not in str(f): + h = _get_file_hash(f) + if h: + result[str(f)] = h + return result + + # 初始化 hash + for wd in watch_dirs: + if wd.is_dir(): + _file_hashes.update(_get_dir_hash(wd)) + + # 主循环 + try: + while True: + _time.sleep(1) + changed = False + + for wd in watch_dirs: + if not wd.exists(): + continue + current = _get_dir_hash(wd) + # 检查新增/修改 + for fpath, h in current.items(): + old_h = _file_hashes.get(fpath) + if old_h is None: + _Log.info("Dev", f"🆕 新增文件: {_Path(fpath).name}") + changed = True + elif old_h != h: + _Log.info("Dev", f"📝 文件变更: {_Path(fpath).name}") + changed = True + _file_hashes[fpath] = h + # 检查删除 + for fpath in list(_file_hashes.keys()): + if fpath not in current: + _Log.info("Dev", f"🗑 文件删除: {_Path(fpath).name}") + _file_hashes.pop(fpath) + changed = True + + if changed: + _Log.info("Dev", "检测到变更,尝试热重载...") + try: + # 重新加载所有模组 + plugin_mgr.stop_all() + # 清空并重新加载 + plugin_mgr.plugins.clear() + plugin_mgr._plugin_dirs.clear() + plugin_mgr.load_all() + from pathlib import Path as _P2 + for f in sorted(_P2("mods").iterdir()): + if f.suffix == ".nbpf": + plugin_mgr.load(f) + plugin_mgr.start_all() + _Log.ok("Dev", f"热重载完成!当前模组数: {len(plugin_mgr.plugins)}") + except Exception as e: + _Log.error("Dev", f"热重载失败: {e}") + except KeyboardInterrupt: + click.echo("") + _Log.info("Dev", "正在停止开发模式...") + plugin_mgr.stop_all() + _Log.info("Dev", "开发模式已停止") + + def main(): cmd_name = os.path.basename(sys.argv[0]) if cmd_name in ("oss", "oss.exe"): diff --git a/oss/config/config.py b/oss/config/config.py index 771c935..fc2a50d 100644 --- a/oss/config/config.py +++ b/oss/config/config.py @@ -16,8 +16,8 @@ class Config: DEFAULTS = { # 服务器配置 - "HTTP_API_PORT": 8080, - "HTTP_TCP_PORT": 8082, + "HTTP_API_PORT": 10086, + "HTTP_TCP_PORT": 10086, "HOST": "127.0.0.1", # 数据目录 diff --git a/oss/core/http_api/server.py b/oss/core/http_api/server.py index a59563f..6cbeb2f 100644 --- a/oss/core/http_api/server.py +++ b/oss/core/http_api/server.py @@ -29,7 +29,7 @@ class HttpServer: def __init__(self, router, middleware, host=None, port=None): config = get_config() self.host = host or config.get("HOST", "127.0.0.1") - self.port = port or config.get("HTTP_API_PORT", 8080) + self.port = port or config.get("HTTP_API_PORT", 10086) self.router = router self.middleware = middleware self._server = None diff --git a/oss/core/manager.py b/oss/core/manager.py index 13c2d15..035817e 100644 --- a/oss/core/manager.py +++ b/oss/core/manager.py @@ -154,11 +154,16 @@ class PluginManager: name = kf.stem trusted_rsa[name] = kf.read_bytes() - # 加载 RSA 私钥 + # 加载 RSA 私钥(只匹配名称包含 rsa 的文件,避免误读 Ed25519 私钥) rsa_private = None private_dir = Path(config.get("NBPF_KEYS_DIR", "./data/nbpf-keys")) / "private" if private_dir.exists(): - pk_files = list(private_dir.glob("*.pem")) + pk_files = [f for f in private_dir.glob("*.pem") if "rsa" in f.name.lower()] + if not pk_files: + # 回退:匹配任意私钥(警告日志) + pk_files = list(private_dir.glob("*.pem")) + if pk_files: + Log.warn("Core", "未找到名称包含 'rsa' 的私钥文件,尝试加载第一个 .pem 文件(可能导致类型错误)") if pk_files: rsa_private = pk_files[0].read_bytes() diff --git a/oss/core/nbpf/compiler.py b/oss/core/nbpf/compiler.py index 855e5f7..ba6bb7c 100644 --- a/oss/core/nbpf/compiler.py +++ b/oss/core/nbpf/compiler.py @@ -46,7 +46,7 @@ class NIRCompiler: # ── 编译 ── - def compile_source(self, source: str, filename: str = "") -> bytes: + def compile_source(self, source: str, filename: str = "", allowed_imports: list[str] = None) -> bytes: """将 Python 源码编译为序列化的 code object Args: @@ -61,7 +61,7 @@ class NIRCompiler: """ try: # 静态安全检查 - self._static_check(source, filename) + self._static_check(source, filename, allowed_imports or []) # 编译为 code object code = compile(source, filename, 'exec') @@ -79,11 +79,12 @@ class NIRCompiler: except Exception as e: raise NIRCompileError(f"编译失败: {type(e).__name__}: {e}") from e - def compile_plugin(self, plugin_dir: Path) -> dict[str, bytes]: + def compile_plugin(self, plugin_dir: Path, allowed_imports: list[str] = None) -> dict[str, bytes]: """编译整个插件目录为 NIR Args: plugin_dir: 插件目录路径 + allowed_imports: 允许导入的系统模块白名单(来自 manifest permissions.imports) Returns: {module_name: nir_bytes} 字典 @@ -105,7 +106,7 @@ class NIRCompiler: module_name = rel_path.replace(".py", "").replace("/", ".") if module_name.endswith(".__init__"): module_name = module_name[:-9] # 去掉 .__init__ - nir_data[module_name] = self.compile_source(source, str(plugin_dir / rel_path)) + nir_data[module_name] = self.compile_source(source, str(plugin_dir / rel_path), allowed_imports) return nir_data @@ -163,7 +164,7 @@ class NIRCompiler: # ── 静态安全检查 ── - def _static_check(self, source: str, filename: str): + def _static_check(self, source: str, filename: str, allowed_imports: list[str] = None): """静态源码安全检查""" try: tree = ast.parse(source, filename=filename) @@ -174,12 +175,12 @@ class NIRCompiler: # 检查 import 语句 if isinstance(node, ast.Import): for alias in node.names: - self._check_module(alias.name, node.lineno) + self._check_module(alias.name, node.lineno, allowed_imports) # 检查 from ... import 语句 elif isinstance(node, ast.ImportFrom): if node.module: - self._check_module(node.module, node.lineno) + self._check_module(node.module, node.lineno, allowed_imports) # 检查 __import__ 调用 elif isinstance(node, ast.Call): @@ -196,12 +197,16 @@ class NIRCompiler: f"{filename}:{node.lineno} - 禁止使用 {node.func.id}()" ) - def _check_module(self, module_name: str, lineno: int): - """检查模块是否被禁止""" + def _check_module(self, module_name: str, lineno: int, allowed_imports: list[str] = None): + """检查模块是否被禁止(支持白名单豁免)""" base = module_name.split(".")[0] if base in self.FORBIDDEN_MODULES: + # 检查是否在白名单中 + if allowed_imports and base in allowed_imports: + return # 白名单放行 raise NIRCompileError( f"第 {lineno} 行 - 禁止导入系统模块: '{module_name}'" + f"(如需使用请在 manifest.json 的 permissions.imports 中声明)" ) def _reject_c_extensions(self, plugin_dir: Path): diff --git a/oss/core/nbpf/format.py b/oss/core/nbpf/format.py index 789569d..4a72ae6 100644 --- a/oss/core/nbpf/format.py +++ b/oss/core/nbpf/format.py @@ -114,9 +114,16 @@ class NBPFPacker: # 1. 读取 manifest manifest = self._read_manifest(plugin_dir) - # 2. 编译所有 .py 文件为 NIR + # 2. 编译所有 .py 文件为 NIR(传入 manifest 权限白名单) Log.info("NBPF", f"编译插件: {plugin_dir.name}") - nir_data = self.compiler.compile_plugin(plugin_dir) + perms = manifest.get("permissions", {}) + if isinstance(perms, dict): + allowed_imports = perms.get("imports", []) + else: + allowed_imports = [] # 旧的数组格式,不开放系统模块 + if allowed_imports: + Log.info("NBPF", f"已授权导入: {allowed_imports}") + nir_data = self.compiler.compile_plugin(plugin_dir, allowed_imports=allowed_imports) # 3. 收集资源文件 res_files = self._collect_resources(plugin_dir) diff --git a/oss/core/nbpf/loader.py b/oss/core/nbpf/loader.py index 4007c52..a29ae5c 100644 --- a/oss/core/nbpf/loader.py +++ b/oss/core/nbpf/loader.py @@ -55,6 +55,7 @@ class NBPFLoader: self.trusted_ed25519_keys = trusted_ed25519_keys or {} self.trusted_rsa_keys = trusted_rsa_keys or {} self.rsa_private_key = rsa_private_key + self._current_allowed_imports: list[str] = [] def load( self, @@ -112,7 +113,12 @@ class NBPFLoader: meta = manifest.get("metadata", {}) name = plugin_name or meta.get("name", nbpf_path.stem) - # 9. 反序列化并执行 + # 9. 反序列化并执行(传入 imports 白名单) + perms = manifest.get("permissions", {}) + if isinstance(perms, dict): + self._current_allowed_imports = perms.get("imports", []) + else: + self._current_allowed_imports = [] instance, module = self._deserialize_and_exec(nir_data, name) # 10. 构建插件信息 @@ -326,7 +332,7 @@ class NBPFLoader: """反序列化 NIR 并执行,返回 (instance, module)""" # 构建安全的全局命名空间 - safe_globals = self._build_safe_globals(plugin_name) + safe_globals = self._build_safe_globals(plugin_name, self._current_allowed_imports) # 按依赖顺序执行模块 main_module = None @@ -365,9 +371,12 @@ class NBPFLoader: return instance, main_module - def _build_safe_globals(self, plugin_name: str) -> dict: + def _build_safe_globals(self, plugin_name: str, allowed_imports: list[str] = None) -> dict: """构建安全的全局命名空间 + 如果插件在 manifest 中声明了 imports 权限,将 `__import__` 加回内置函数, + 并用白名单包装器限制只能导入声明的模块。 + 注意:Python 沙箱无法完全阻止通过 ()__class__.__bases__[0].__subclasses__() 等反射方式逃逸。本沙箱仅用于防止意外访问危险模块,真正的安全隔离 需要 OS 级容器化。 @@ -390,6 +399,19 @@ class NBPFLoader: 'KeyError': KeyError, 'IndexError': IndexError, 'Exception': Exception, 'BaseException': BaseException, } + + # 如果插件声明了 imports 权限,添加白名单 __import__ + if allowed_imports: + _allowed_set = set(allowed_imports) + def _safe_import(name, *args, **kwargs): + base = name.split(".")[0] + if base not in _allowed_set: + raise ImportError( + f"模块 '{name}' 不在权限白名单中。" + f"请在 manifest.json 的 permissions.imports 中声明: {sorted(_allowed_set)}" + ) + return __import__(name, *args, **kwargs) + safe_builtins['__import__'] = _safe_import return { '__builtins__': safe_builtins, '__name__': f'nbpf.{plugin_name}', diff --git a/oss/templates/mod/README.md b/oss/templates/mod/README.md new file mode 100644 index 0000000..5c39212 --- /dev/null +++ b/oss/templates/mod/README.md @@ -0,0 +1,24 @@ +# @{{ author }}/{{ mod_name }} + +{{ description }} + +## 安装 + +将 `{{ mod_name }}.nbpf` 放入 NebulaShell 的 `mods/` 目录即可。 + +## 开发 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 打包 +nebula nbpf pack ./{{ mod_name }} -o {{ mod_name }}.nbpf \ + --ed25519-key ./keys/ed25519.pem \ + --rsa-key ./keys/rsa.pem \ + --signer "{{ author }}" +``` + +## 许可证 + +MIT diff --git a/oss/templates/mod/main.py b/oss/templates/mod/main.py new file mode 100644 index 0000000..65e266f --- /dev/null +++ b/oss/templates/mod/main.py @@ -0,0 +1,50 @@ +""" +@{{ author }}/{{ mod_name }} +{{ description }} +""" + +import os +from pathlib import Path + + +# 模组信息(可选,用于动态获取) +NAME = "{{ mod_name }}" +VERSION = "0.1.0" + + +def init(deps): + """ + 模组初始化。 + deps 包含: + - deps["services"] — 其他模组注册的服务 + - deps["config"] — 当前模组的配置 + - deps["logger"] — 日志工具 + """ + logger = deps.get("logger") + if logger: + logger.info(f"{NAME} v{VERSION} 初始化完成") + + +def start(): + """模组启动。init 成功后调用。""" + pass + + +def stop(): + """模组停止。框架关闭时调用,释放资源。""" + pass + + +def reload(config: dict): + """热重载配置(可选)""" + pass + + +def health() -> dict: + """健康检查(可选)""" + return {"status": "ok", "version": VERSION} + + +def stats() -> dict: + """统计信息(可选)""" + return {} diff --git a/oss/templates/mod/manifest.json b/oss/templates/mod/manifest.json new file mode 100644 index 0000000..6a6a00d --- /dev/null +++ b/oss/templates/mod/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "@{{ author }}/{{ mod_name }}", + "version": "0.1.0", + "description": "{{ description }}", + "author": "{{ author }}", + "license": "MIT", + "type": "{{ mod_type }}", + "main": "main.py", + "enabled": true, + "priority": 999, + "runtime": { + "language": "python", + "entry_point": "main.py", + "requirements": [] + }, + "capabilities": [], + "services": { + "provides": [], + "consumes": [] + }, + "config": {} +} diff --git a/system-monitor/README.md b/system-monitor/README.md new file mode 100644 index 0000000..5c60cc4 --- /dev/null +++ b/system-monitor/README.md @@ -0,0 +1,24 @@ +# @NebulaShell/system-monitor + +实时系统监控:CPU/内存/磁盘/网络/进程TOP + +## 安装 + +将 `system-monitor.nbpf` 放入 NebulaShell 的 `mods/` 目录即可。 + +## 开发 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 打包 +nebula nbpf pack ./system-monitor -o system-monitor.nbpf \ + --ed25519-key ./keys/ed25519.pem \ + --rsa-key ./keys/rsa.pem \ + --signer "NebulaShell" +``` + +## 许可证 + +MIT diff --git a/system-monitor/main.py b/system-monitor/main.py new file mode 100644 index 0000000..7b293c8 --- /dev/null +++ b/system-monitor/main.py @@ -0,0 +1,430 @@ +""" +@NebulaShell/system-monitor +实时系统监控模组 - CPU/内存/磁盘/网络/进程TOP + +提供 HTTP REST API,默认端口 10087。 +可在 manifest.json 的 config.args 中自定义端口。 +""" + +import json +import os +import threading +import time +from collections import deque +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse + +try: + import psutil + HAS_PSUTIL = True +except ImportError: + HAS_PSUTIL = False + +NAME = "system-monitor" +VERSION = "0.1.0" + +# ── 历史数据存储 ── +MAX_HISTORY = 60 # 保留最近60条(每分钟一条,即1小时) +_history: deque = deque(maxlen=MAX_HISTORY) +_collector_thread = None +_collector_running = False + + +def _collect_stats() -> dict: + """采集一次系统状态快照""" + if not HAS_PSUTIL: + return {"error": "psutil not installed"} + + now = time.time() + cpu_percent = psutil.cpu_percent(interval=0.1) + cpu_count = psutil.cpu_count() + + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + + disk = psutil.disk_usage("/") + + net = psutil.net_io_counters() + net_conns = len(psutil.net_connections()) + + # TOP 10 进程(按CPU排序) + top_processes = [] + for proc in sorted(psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent", "status"]), + key=lambda p: p.info.get("cpu_percent", 0) or 0, reverse=True)[:10]: + try: + top_processes.append({ + "pid": proc.info["pid"], + "name": proc.info["name"] or "?", + "cpu": round(proc.info["cpu_percent"] or 0, 1), + "mem": round(proc.info["memory_percent"] or 0, 1), + "status": proc.info["status"] or "?", + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + # 开机时间 + boot_time = psutil.boot_time() + + return { + "timestamp": now, + "datetime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)), + "uptime": round(now - boot_time), + "cpu": { + "percent": round(cpu_percent, 1), + "count": cpu_count, + "load_avg": [round(x, 2) for x in os.getloadavg()] if hasattr(os, "getloadavg") else None, + }, + "memory": { + "total": mem.total, + "available": mem.available, + "used": mem.used, + "percent": round(mem.percent, 1), + "swap_total": swap.total, + "swap_used": swap.used, + "swap_percent": round(swap.percent, 1), + }, + "disk": { + "total": disk.total, + "used": disk.used, + "free": disk.free, + "percent": round(disk.percent, 1), + }, + "network": { + "bytes_sent": net.bytes_sent, + "bytes_recv": net.bytes_recv, + "packets_sent": net.packets_sent, + "packets_recv": net.packets_recv, + "connections": net_conns, + }, + "processes": { + "total": len(psutil.pids()), + "running": sum(1 for p in psutil.process_iter(["status"]) + if p.info.get("status") == "running"), + "top": top_processes, + }, + } + + +def _collector_loop(interval: float = 5.0): + """后台采集线程""" + global _collector_running + _collector_running = True + while _collector_running: + try: + stats = _collect_stats() + _history.append(stats) + except Exception: + pass + time.sleep(interval) + + +# ── HTTP 服务 ── + +class MonitorHandler(BaseHTTPRequestHandler): + """HTTP 请求处理器""" + + def _json_response(self, data: dict, status: int = 200): + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")) + + def _html_response(self, html: str): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(html.encode("utf-8")) + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + + if path == "/" or path == "": + self._html_response(_render_dashboard()) + elif path == "/health": + self._json_response({"status": "ok", "version": VERSION, "uptime": _get_uptime()}) + elif path == "/stats": + stats = _collect_stats() if not _history else _history[-1] + self._json_response(stats) + elif path == "/stats/current": + self._json_response(_collect_stats()) + elif path == "/stats/history": + self._json_response({ + "count": len(_history), + "max": MAX_HISTORY, + "data": list(_history), + }) + elif path == "/stats/cpu": + s = _collect_stats() + self._json_response(s.get("cpu", {})) + elif path == "/stats/memory": + s = _collect_stats() + self._json_response(s.get("memory", {})) + elif path == "/stats/disk": + s = _collect_stats() + self._json_response(s.get("disk", {})) + elif path == "/stats/network": + s = _collect_stats() + self._json_response(s.get("network", {})) + elif path == "/stats/processes": + s = _collect_stats() + self._json_response(s.get("processes", {})) + else: + self._json_response({"error": "Not Found", "path": path}, 404) + + def log_message(self, format, *args): + """静默日志,避免刷屏""" + pass + + +def _get_uptime() -> float: + try: + return round(time.time() - psutil.boot_time()) + except Exception: + return 0 + + +def _render_dashboard() -> str: + """渲染简易仪表盘 HTML""" + stats = _collect_stats() if not _history else _history[-1] + if not stats or "error" in stats: + return "

System Monitor

psutil not available

" + + cpu = stats.get("cpu", {}) + mem = stats.get("memory", {}) + disk = stats.get("disk", {}) + net = stats.get("network", {}) + procs = stats.get("processes", {}) + uptime = stats.get("uptime", 0) + + # 格式化时间 + days, rem = divmod(uptime, 86400) + hours, rem = divmod(rem, 3600) + mins = rem // 60 + uptime_str = f"{int(days)}天 {int(hours)}时 {int(mins)}分" + + def bar(pct, color="primary"): + color_map = { + "primary": "#0d6efd", "success": "#198754", + "warning": "#ffc107", "danger": "#dc3545", + } + c = color_map.get(color, color_map["primary"]) + return f'
' \ + f'
' + + def mem_fmt(b): + for unit in ["B", "KB", "MB", "GB", "TB"]: + if b < 1024: + return f"{b:.1f} {unit}" + b /= 1024 + return f"{b:.1f} PB" + + # 进程TOP表格行 + proc_rows = "" + for p in procs.get("top", []): + proc_rows += f"{p['pid']}{p['name']}" \ + f"{p['cpu']}%{p['mem']}%{p['status']}" + + html = f""" + + +System Monitor + + +
+

📊 System Monitor

+

v{VERSION} · 运行时间 {uptime_str} · 进程 {procs.get('total', '?')} 个

+ + + +
+
+

🧠 CPU

+
{cpu.get('percent', '?')}%
+
{cpu.get('count', '?')} 核心 · 负载 {cpu.get('load_avg', ['?','?','?'])}
+ {bar(cpu.get('percent', 0), 'danger' if cpu.get('percent', 0) > 80 else 'warning' if cpu.get('percent', 0) > 60 else 'primary')} +
+
+

💾 内存

+
{mem.get('percent', '?')}%
+
{mem_fmt(mem.get('used', 0))} / {mem_fmt(mem.get('total', 0))}
+ {bar(mem.get('percent', 0), 'danger' if mem.get('percent', 0) > 80 else 'warning' if mem.get('percent', 0) > 60 else 'success')} +
+
+

💿 磁盘 /

+
{disk.get('percent', '?')}%
+
{mem_fmt(disk.get('used', 0))} / {mem_fmt(disk.get('total', 0))}
+ {bar(disk.get('percent', 0), 'danger' if disk.get('percent', 0) > 85 else 'warning')} +
+
+

🌐 网络

+
{net.get('connections', '?')}
+
连接数 · ↓ {mem_fmt(net.get('bytes_recv', 0))} ↑ {mem_fmt(net.get('bytes_sent', 0))}
+
+
+ +
+

⚡ TOP 10 进程 (CPU)

+ + +{proc_rows} +
PID名称CPU内存状态
+
+ +

+ 数据采集间隔 5秒 · 保留最近 {MAX_HISTORY} 条 +

+
+""" + return html + + +# ── HTTP 服务器 ── + +_http_server = None +_server_thread = None + + +def _run_http_server(host: str, port: int): + global _http_server + server = HTTPServer((host, port), MonitorHandler) + _http_server = server + try: + server.serve_forever() + except Exception: + pass + + +# ── 生命周期 ── + +def init(deps): + """模组初始化""" + logger = deps.get("logger") + if logger: + logger.info(f"System Monitor v{VERSION} 初始化") + + +def start(): + """启动 HTTP 服务 + 数据采集""" + global _http_server, _server_thread, _collector_thread + + if not HAS_PSUTIL: + print("[system-monitor] ⚠ psutil 未安装,系统监控不可用") + print("[system-monitor] 💡 请执行: pip install psutil") + return + + # 启动数据采集(后台线程,5秒间隔) + _collector_thread = threading.Thread( + target=_collector_loop, args=(5.0,), daemon=True + ) + _collector_thread.start() + + # 启动 HTTP 服务 + port = 10087 + host = "0.0.0.0" + + _server_thread = threading.Thread( + target=_run_http_server, args=(host, port), daemon=True + ) + _server_thread.start() + + print(f"[system-monitor] ✅ 系统监控已启动") + print(f"[system-monitor] 🌐 仪表盘: http://localhost:{port}") + print(f"[system-monitor] 📄 API: http://localhost:{port}/stats") + print(f"[system-monitor] 💚 健康: http://localhost:{port}/health") + + +def stop(): + """停止服务,释放资源""" + global _http_server, _collector_running + + _collector_running = False + + if _http_server: + try: + _http_server.shutdown() + except Exception: + pass + _http_server = None + + _history.clear() + print("[system-monitor] 已停止") + + +def health() -> dict: + """健康检查""" + return { + "status": "ok" if HAS_PSUTIL else "degraded", + "version": VERSION, + "uptime": _get_uptime(), + "data_points": len(_history), + } + + +def stats() -> dict: + """统计信息""" + return { + "version": VERSION, + "psutil_available": HAS_PSUTIL, + "history_count": len(_history), + "history_max": MAX_HISTORY, + "collector_running": _collector_running, + } + + +# ── 目录插件兼容(类 + New() 工厂函数) ── + +class SystemMonitor: + """系统监控插件类封装""" + name = NAME + version = VERSION + description = "实时系统监控:CPU/内存/磁盘/网络/进程TOP" + + def __init__(self): + self._http_server = None + self._server_thread = None + + def init(self, deps=None): + return init(deps) + + def start(self): + return start() + + def stop(self): + return stop() + + def health(self) -> dict: + return health() + + def stats(self) -> dict: + return stats() + + +def New(): + """目录插件工厂函数""" + return SystemMonitor() diff --git a/system-monitor/manifest.json b/system-monitor/manifest.json new file mode 100644 index 0000000..2cb0ef8 --- /dev/null +++ b/system-monitor/manifest.json @@ -0,0 +1,45 @@ +{ + "name": "@NebulaShell/system-monitor", + "version": "0.1.0", + "description": "实时系统监控:CPU/内存/磁盘/网络/进程TOP,提供HTTP仪表盘和REST API", + "author": "NebulaShell", + "license": "MIT", + "type": "tool", + "main": "main.py", + "enabled": true, + "priority": 999, + "runtime": { + "language": "python", + "entry_point": "main.py", + "requirements": [ + "psutil>=5.8.0" + ] + }, + "capabilities": [ + "monitoring" + ], + "services": { + "provides": [ + "system-monitor" + ], + "consumes": [] + }, + "config": { + "port": 10087, + "host": "0.0.0.0", + "interval": 5, + "max_history": 60 + }, + "permissions": { + "imports": [ + "os", + "threading", + "json", + "time", + "collections", + "http", + "urllib", + "psutil" + ] + } +} \ No newline at end of file