feat: 新增脚手架/开发模式/权限白名单/system-monitor插件
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled

- nebula create mod/key/list-templates 模组脚手架
- nebula dev 开发模式热重载
- manifest permissions.imports 权限白名单机制
- system-monitor 系统监控仪表盘插件
- 默认端口统一为 10086
- 修复 _init_nbpf 误读 Ed25519 私钥为 RSA 的 bug
- 更新 README.md 文档
This commit is contained in:
starlight-apk
2026-05-16 20:20:43 +08:00
parent bce27db4ac
commit 5fbc5cc335
14 changed files with 1225 additions and 312 deletions

563
README.md
View File

@@ -1,7 +1,7 @@
<p align="center"> <p align="center">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/NebulaShell-v2.0-6C47FF?style=for-the-badge&logo=python&logoColor=white&labelColor=1a1a2e"> <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/NebulaShell-v1.2.0-6C47FF?style=for-the-badge&logo=python&logoColor=white&labelColor=1a1a2e">
<img alt="NebulaShell" src="https://img.shields.io/badge/NebulaShell-v2.0-6C47FF?style=for-the-badge&logo=python&logoColor=white&labelColor=f0f0ff"> <img alt="NebulaShell" src="https://img.shields.io/badge/NebulaShell-v1.2.0-6C47FF?style=for-the-badge&logo=python&logoColor=white&labelColor=f0f0ff">
</picture> </picture>
</p> </p>
@@ -9,7 +9,6 @@
<a href="https://python.org"><img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python"></a> <a href="https://python.org"><img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache--2.0-6C47FF" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache--2.0-6C47FF" alt="License"></a>
<a href=""><img src="https://img.shields.io/badge/Build-Passing-22C55E" alt="Build"></a> <a href=""><img src="https://img.shields.io/badge/Build-Passing-22C55E" alt="Build"></a>
<a href=""><img src="https://img.shields.io/badge/Coverage-92%25-22C55E" alt="Coverage"></a>
<a href=""><img src="https://img.shields.io/badge/Security-AES--256--GCM%20%7C%20Ed25519%20%7C%20RSA--4096-EF4444" alt="Security"></a> <a href=""><img src="https://img.shields.io/badge/Security-AES--256--GCM%20%7C%20Ed25519%20%7C%20RSA--4096-EF4444" alt="Security"></a>
</p> </p>
@@ -24,16 +23,13 @@
## 目录 ## 目录
- [项目定位](#项目定位) - [项目定位](#项目定位)
- [架构总览](#架构总览)
- [核心能力](#核心能力)
- [快速开始](#快速开始) - [快速开始](#快速开始)
- [NBPF 包格式](#nbpf-包格式)
- [NIR 中间表示](#nir-中间表示)
- [CLI 工具链](#cli-工具链) - [CLI 工具链](#cli-工具链)
- [插件开发](#插件开发) - [插件开发](#插件开发)
- [权限白名单](#权限白名单manifestpermissionsimports)
- [NBPF 包格式](#nbpf-包格式)
- [内置插件](#内置插件) - [内置插件](#内置插件)
- [安全体系](#安全体系) - [安全体系](#安全体系)
- [性能指标](#性能指标)
- [贡献指南](#贡献指南) - [贡献指南](#贡献指南)
- [许可证](#许可证) - [许可证](#许可证)
@@ -43,374 +39,357 @@
NebulaShell 是一个**以安全为基石、以插件为灵魂**的运行时框架。核心只做两件事:**加载插件**与**调度插件**,其余一切功能均由插件生态提供。 NebulaShell 是一个**以安全为基石、以插件为灵魂**的运行时框架。核心只做两件事:**加载插件**与**调度插件**,其余一切功能均由插件生态提供。
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/philosophy.svg" alt="NebulaShell Philosophy" width="600">
</p>
### 设计原则 ### 设计原则
| 原则 | 说明 | | 原则 | 说明 |
|------|------| |:----|:-----|
| **最小核心** | 核心仅 1100+ 行,职责单一,可独立审计 | | 🧩 **一切皆插件** | 框架本身只提供加载和调度能力,所有功能都来自插件 |
| **插件即产品** | 所有业务功能以插件形式交付,核心不耦合任何业务 | | 🔒 **安全默认** | 沙箱执行、签名验证、权限声明、完整性校验,层层防护 |
| **安全默认** | 插件分发强制签名加密,运行时隔离,防篡改防逆向 | | 📦 **一次编译到处运行** | NIR 中间表示使插件可在任何 Python 3.10+ 平台运行 |
| **一次编译** | NIR 中间表示确保插件跨平台运行,无需为架构适配 | | 🎯 **最小权限** | 插件必须显式声明所需权限,未授权操作被拒绝 |
| **零信任分发** | 每个包经过三层签名验证 + 两层加密解密才可加载 | | 🔧 **开发者体验** | 脚手架生成、热重载、详细日志,让开发尽可能愉快 |
---
## 架构总览
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/architecture.svg" alt="NebulaShell Architecture" width="800">
</p>
### 分层架构
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/layers.svg" alt="NebulaShell Layers" width="800">
</p>
### 数据流
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/dataflow.svg" alt="NebulaShell Data Flow" width="800">
</p>
---
## 核心能力
### 插件化架构
- **热插拔**:插件可在运行时动态加载/卸载,无需重启
- **依赖注入**:通过 `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 文件双向转换
- **验证/签名**:独立验证工具 + 重新签名能力
--- ---
## 快速开始 ## 快速开始
```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 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 ```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 \ cd hello-world
--ed25519-key ./nbpf-keys/private/ed25519.pem \ # vim main.py # 实现你的功能
--rsa-key ./nbpf-keys/private/rsa.pem
# 3. 验证包完整性 # 生成签名密钥
nebula nbpf verify my-plugin.nbpf python main.py create key -o ./keys --name mykey
# 4. 将密钥放入信任目录 # 打包为 .nbpf
cp ./nbpf-keys/trusted/* ./data/nbpf-keys/trusted/ python main.py nbpf pack ./hello-world -o mods/hello-world.nbpf \
cp ./nbpf-keys/rsa/* ./data/nbpf-keys/rsa/ --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
``` ```
--- > 默认 HTTP 服务端口:**10086**(可在 `data/config.json` 或环境变量 `HTTP_API_PORT` 中修改)
## NBPF 包格式
### 包结构
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/package-structure.svg" alt="NBPF Package Structure" width="500">
</p>
### 加密层级
| 层级 | 算法 | 密钥来源 | 保护范围 |
|------|------|----------|----------|
| 外层加密 | AES-256-GCM | key1RSA-OAEP 封装) | META-INF/ 和 NIR/ 目录 |
| 中层加密 | AES-256-GCM | key2RSA-OAEP 封装) | NIR 数据内容 |
| 外层签名 | Ed25519 | 开发者私钥 | 加密层完整性 |
| 中层签名 | RSA-4096-PSS | 作者私钥 | 模块内容完整性 |
| 内层签名 | HMAC-SHA256 | 派生密钥key1+key2 | 单个模块完整性 |
### 安全流程
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/security-flow.svg" alt="NBPF Security Flow" width="700">
</p>
---
## NIR 中间表示
NIRNebula Intermediate Representation是 NebulaShell 的跨平台编译方案。
### 技术原理
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/nir-flow.svg" alt="NIR Compilation Flow" width="600">
</p>
### 代码保护
| 技术 | 说明 |
|------|------|
| 混淆导入路径 | 动态 `__import__()` + 字符串拼接,隐藏依赖关系 |
| 常量运行时计算 | 关键字符串在运行时拼接,避免静态分析 |
| 反调试检测 | `sys.gettrace()` 检测调试器附加 |
| 内存擦除 | `bytearray` 覆盖清零,防止内存 dump |
| 花指令混淆 | 向 `co_consts` 插入无害垃圾常量,干扰分析 |
--- ---
## CLI 工具链 ## 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 ```bash
# 密钥管理 # 🚀 快速创建模组(脚手架)
nebula nbpf keygen # 生成 Ed25519 + RSA-4096 密钥对 nebula create mod my-plugin -a "我" -d "插件描述"
nebula nbpf keygen --output ./keys # 指定输出目录 nebula create mod my-service --type service --with-keys
# 打包 # 🔑 生成签名密钥
nebula nbpf pack ./plugin-dir # 打包为 .nbpf nebula create key -o ./keys --name mykey
nebula nbpf pack ./plugin-dir -o out.nbpf --keys-dir ./keys
# 解包 # 💻 开发模式(热重载)
nebula nbpf unpack package.nbpf # 解包到目录 nebula dev # 监听当前目录
nebula nbpf unpack package.nbpf -o ./out 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 pack ./my-plugin -o my-plugin.nbpf \
nebula nbpf sign package.nbpf --ed25519-key ./key.pem --rsa-key ./rsa.pem --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 my-plugin/
├── manifest.json ← 【必填】模组身份证
class HelloPlugin(Plugin): ├── main.py ← 【必填】模组代码(类 + New() 工厂函数)
def init(self, deps=None): └── README.md ← 【推荐】说明文档
self.name = "hello"
def start(self):
print(f"{self.name} started")
def stop(self):
print(f"{self.name} stopped")
def New():
return HelloPlugin()
``` ```
### 清单文件 ### 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 ```json
{ {
"metadata": { "name": "@作者/模组名",
"name": "hello-plugin", "version": "1.0.0",
"version": "1.0.0", "description": "描述",
"description": "示例插件", "author": "作者",
"author": "developer" "type": "example",
"main": "main.py",
"enabled": true,
"priority": 999,
"runtime": {
"language": "python",
"entry_point": "main.py",
"requirements": []
}, },
"config": { "permissions": {
"enabled": true, "imports": []
"args": {}
}, },
"dependencies": [], "services": {
"permissions": ["storage:read"] "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 ```bash
# 开发阶段:源码直接放入 store/ 目录 # 1. 使用脚手架创建模组(推荐)
# 分发阶段:打包为 .nbpf nebula create mod my-plugin -a "我" -d "描述"
nebula nbpf pack ./store/NebulaShell/hello-plugin -o hello-plugin.nbpf \
--ed25519-key ./nbpf-keys/private/ed25519.pem \ # 2. 编辑 main.py 实现功能
--rsa-key ./nbpf-keys/private/rsa.pem 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 服务、系统管理、安全防护、协议适配等场景。 NebulaShell 内置了以下官方插件(位于 `store/NebulaShell/`
### Web 与 API
| 插件 | 说明 | | 插件 | 说明 |
|------|------| |:----|:------|
| `http-api` | RESTful API 服务,支持路由注册、中间件 | | `plugin-bridge` | 事件总线 + 服务注册 + 跨插件 RPC |
| `ws-api` | WebSocket 实时通信服务 | | `plugin-storage` | 插件持久化 KV 存储 |
| `webui` | 管理控制台 Web 界面 | | `ws-api` | WebSocket 实时推送 |
| `dashboard` | 系统仪表盘,实时监控 | | `i18n` | 国际化多语言支持 |
### 系统管理
| 插件 | 说明 |
|------|------|
| `plugin-loader` | 插件加载核心manifest 解析 |
| `plugin-loader-pro` | 熔断、降级、容错机制 |
| `pkg-manager` | 插件包管理器,在线安装/更新 |
| `lifecycle` | 插件生命周期管理 |
| `hot-reload` | 插件热重载,开发模式自动刷新 |
| `dependency` | 依赖关系解析与冲突检测 |
### 安全防护
| 插件 | 说明 |
|------|------|
| `signature-verifier` | 运行时签名验证 |
| `code-reviewer` | 插件代码安全审查 |
| `firewall` | 网络防火墙规则引擎 |
### 通信与协议
| 插件 | 说明 |
|------|------|
| `plugin-bridge` | 插件间通信(事件总线 / RPC / use() |
| `http-tcp` | TCP 协议适配 |
| `nodejs-adapter` | Node.js 运行时适配 | | `nodejs-adapter` | Node.js 运行时适配 |
| `frp-proxy` | 内网穿透代理 | | `system-monitor` 🆕 | **系统监控仪表盘** — CPU/内存/磁盘/网络/进程TOP |
| `ftp-server` | 文件服务 | | | 提供 HTML 仪表盘 + REST API默认端口 **10087** |
### 工具与增强 ### system-monitor API
| 插件 | 说明 | ```
|------|------| http://localhost:10087/ → 📊 仪表盘 HTML
| `plugin-storage` | 插件持久化存储 | http://localhost:10087/health → 💚 {"status": "ok"}
| `i18n` | 国际化支持 | http://localhost:10087/stats → 📄 系统状态 JSON
| `auto-dependency` | 系统依赖自动安装 | http://localhost:10087/stats/cpu → 🧠 CPU 详情
| `performance-optimizer` | 性能优化引擎 | http://localhost:10087/stats/memory → 💾 内存详情
| `json-codec` | JSON 编解码 | http://localhost:10087/stats/disk → 💿 磁盘详情
| `log-terminal` | 日志查看与终端 | http://localhost:10087/stats/network → 🌐 网络详情
| `polyglot-deploy` | 多语言部署支持 | http://localhost:10087/stats/processes → ⚡ TOP 进程
http://localhost:10087/stats/history → 📈 历史数据最近60条
```
--- ---
## 安全体系 ## 安全体系
### 全链路安全 | 层级 | 措施 | 说明 |
|:----|:-----|:------|
<p align="center"> | 🛡 **分发安全** | 三层签名Ed25519 + RSA-4096 + HMAC | 防止包被篡改或伪造 |
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/security-chain.svg" alt="Full Chain Security" width="800"> | 🔐 **传输安全** | AES-256-GCM 双层加密 | 插件代码在传输和存储中均加密 |
</p> | 🏖 **执行沙箱** | NIR 安全沙箱 | 限制危险模块和内置函数 |
| 📋 **权限控制** | manifest 权限白名单 | 插件必须声明所需模块导入权限 |
### 加密标准 | 🔍 **完整性检查** | SHA-256 文件 hash 监控 | 运行时定期校验插件文件完整性 |
| 🧠 **内存防护** | MemoryGuard 冻结核心属性 | 防止插件修改框架内部状态 |
| 组件 | 标准 | 密钥长度 | | 📝 **行为审计** | AuditLogger | 记录所有插件操作行为 |
|------|------|----------| | 👀 **防篡改监控** | TamperMonitor 后台线程 | 自动检测篡改并停止被篡改插件 |
| 对称加密 | AES-256-GCM | 256 位 | | 🔄 **降级恢复** | FallbackManager 自动重试 | 插件崩溃时自动重启最多3次 |
| 非对称加密 | RSA-OAEP | 4096 位 |
| 外层签名 | Ed25519 | 256 位 |
| 中层签名 | RSA-PSS | 4096 位 |
| 内层签名 | HMAC-SHA256 | 256 位 |
### 密钥管理
<p align="center">
<img src="https://raw.githubusercontent.com/Starlight-apk/NebulaShell/main/docs/key-structure.svg" alt="Key Management Structure" width="500">
</p>
---
## 性能指标
| 指标 | 数值 |
|------|------|
| 核心代码行数 | ~1,100 行 |
| 内置插件数量 | 26+ |
| 测试覆盖率 | ~92% |
| 语法检查通过率 | 100% |
| Python 版本要求 | 3.10+ |
| 依赖库数量 | 精简(核心仅依赖 cryptography |
--- ---
## 贡献指南 ## 贡献指南
### 开发流程 ### 开发环境
```bash ```bash
# 1. Fork 仓库 # 克隆仓库
# 2. 创建特性分支 git clone https://git.starlight-apk.cn/starlight-apk/NebulaShell.git
git checkout -b feat/my-feature cd NebulaShell
# 3. 安装开发依赖 # 安装依赖
pip install -r requirements.txt pip install -r requirements.txt
# 4. 确保语法检查通过 # 启动开发模式
find . -name "*.py" -not -path "./venv/*" -not -path "./.git/*" | \ python main.py dev
xargs -I{} python3 -m py_compile {}
# 5. 运行测试 # 运行测试
python -m pytest tests/ python -m pytest tests/
# 6. 提交 PR
``` ```
### 贡献流程
1. Fork 项目并创建特性分支
2. 编写代码,确保语法检查零错误
3. 添加或更新测试
4. 更新文档README、注释等
5. 提交 Pull Request
### 代码规范 ### 代码规范
- 遵循 PEP 8 编码规范 - 遵循 PEP 8 编码规范
- 所有插件必须实现 `init()``start()``stop()` 方法 - 插件必须实现 `init()``start()``stop()` 方法
- 插件清单必须包含完整的元数据和权限声明 - 插件必须包含 `New()` 工厂函数(兼容目录 + nbpf 两种加载方式)
- 提交前确保语法检查零错误 - 插件必须声明完整的 `permissions.imports` 权限白名单
- 提交前确保所有测试通过
--- ---

View File

@@ -147,7 +147,7 @@ def info(ctx):
@cli.command(name="cli") @cli.command(name="cli")
@click.option('--connect-host', default='127.0.0.1', help='后端地址(默认 127.0.0.1') @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): def cli_command(connect_host, connect_port):
"""启动 TUI 前端(前后端分离,连接已有后端)""" """启动 TUI 前端(前后端分离,连接已有后端)"""
click.echo("NebulaShell 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}") 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 <key> --rsa-key <key> --rsa-pub <key> --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(): def main():
cmd_name = os.path.basename(sys.argv[0]) cmd_name = os.path.basename(sys.argv[0])
if cmd_name in ("oss", "oss.exe"): if cmd_name in ("oss", "oss.exe"):

View File

@@ -16,8 +16,8 @@ class Config:
DEFAULTS = { DEFAULTS = {
# 服务器配置 # 服务器配置
"HTTP_API_PORT": 8080, "HTTP_API_PORT": 10086,
"HTTP_TCP_PORT": 8082, "HTTP_TCP_PORT": 10086,
"HOST": "127.0.0.1", "HOST": "127.0.0.1",
# 数据目录 # 数据目录

View File

@@ -29,7 +29,7 @@ class HttpServer:
def __init__(self, router, middleware, host=None, port=None): def __init__(self, router, middleware, host=None, port=None):
config = get_config() config = get_config()
self.host = host or config.get("HOST", "127.0.0.1") 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.router = router
self.middleware = middleware self.middleware = middleware
self._server = None self._server = None

View File

@@ -154,11 +154,16 @@ class PluginManager:
name = kf.stem name = kf.stem
trusted_rsa[name] = kf.read_bytes() trusted_rsa[name] = kf.read_bytes()
# 加载 RSA 私钥 # 加载 RSA 私钥(只匹配名称包含 rsa 的文件,避免误读 Ed25519 私钥)
rsa_private = None rsa_private = None
private_dir = Path(config.get("NBPF_KEYS_DIR", "./data/nbpf-keys")) / "private" private_dir = Path(config.get("NBPF_KEYS_DIR", "./data/nbpf-keys")) / "private"
if private_dir.exists(): 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: if pk_files:
rsa_private = pk_files[0].read_bytes() rsa_private = pk_files[0].read_bytes()

View File

@@ -46,7 +46,7 @@ class NIRCompiler:
# ── 编译 ── # ── 编译 ──
def compile_source(self, source: str, filename: str = "<nbpf>") -> bytes: def compile_source(self, source: str, filename: str = "<nbpf>", allowed_imports: list[str] = None) -> bytes:
"""将 Python 源码编译为序列化的 code object """将 Python 源码编译为序列化的 code object
Args: Args:
@@ -61,7 +61,7 @@ class NIRCompiler:
""" """
try: try:
# 静态安全检查 # 静态安全检查
self._static_check(source, filename) self._static_check(source, filename, allowed_imports or [])
# 编译为 code object # 编译为 code object
code = compile(source, filename, 'exec') code = compile(source, filename, 'exec')
@@ -79,11 +79,12 @@ class NIRCompiler:
except Exception as e: except Exception as e:
raise NIRCompileError(f"编译失败: {type(e).__name__}: {e}") from 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 """编译整个插件目录为 NIR
Args: Args:
plugin_dir: 插件目录路径 plugin_dir: 插件目录路径
allowed_imports: 允许导入的系统模块白名单(来自 manifest permissions.imports
Returns: Returns:
{module_name: nir_bytes} 字典 {module_name: nir_bytes} 字典
@@ -105,7 +106,7 @@ class NIRCompiler:
module_name = rel_path.replace(".py", "").replace("/", ".") module_name = rel_path.replace(".py", "").replace("/", ".")
if module_name.endswith(".__init__"): if module_name.endswith(".__init__"):
module_name = module_name[:-9] # 去掉 .__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 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: try:
tree = ast.parse(source, filename=filename) tree = ast.parse(source, filename=filename)
@@ -174,12 +175,12 @@ class NIRCompiler:
# 检查 import 语句 # 检查 import 语句
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
for alias in node.names: for alias in node.names:
self._check_module(alias.name, node.lineno) self._check_module(alias.name, node.lineno, allowed_imports)
# 检查 from ... import 语句 # 检查 from ... import 语句
elif isinstance(node, ast.ImportFrom): elif isinstance(node, ast.ImportFrom):
if node.module: if node.module:
self._check_module(node.module, node.lineno) self._check_module(node.module, node.lineno, allowed_imports)
# 检查 __import__ 调用 # 检查 __import__ 调用
elif isinstance(node, ast.Call): elif isinstance(node, ast.Call):
@@ -196,12 +197,16 @@ class NIRCompiler:
f"{filename}:{node.lineno} - 禁止使用 {node.func.id}()" 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] base = module_name.split(".")[0]
if base in self.FORBIDDEN_MODULES: if base in self.FORBIDDEN_MODULES:
# 检查是否在白名单中
if allowed_imports and base in allowed_imports:
return # 白名单放行
raise NIRCompileError( raise NIRCompileError(
f"{lineno} 行 - 禁止导入系统模块: '{module_name}'" f"{lineno} 行 - 禁止导入系统模块: '{module_name}'"
f"(如需使用请在 manifest.json 的 permissions.imports 中声明)"
) )
def _reject_c_extensions(self, plugin_dir: Path): def _reject_c_extensions(self, plugin_dir: Path):

View File

@@ -114,9 +114,16 @@ class NBPFPacker:
# 1. 读取 manifest # 1. 读取 manifest
manifest = self._read_manifest(plugin_dir) manifest = self._read_manifest(plugin_dir)
# 2. 编译所有 .py 文件为 NIR # 2. 编译所有 .py 文件为 NIR(传入 manifest 权限白名单)
Log.info("NBPF", f"编译插件: {plugin_dir.name}") 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. 收集资源文件 # 3. 收集资源文件
res_files = self._collect_resources(plugin_dir) res_files = self._collect_resources(plugin_dir)

View File

@@ -55,6 +55,7 @@ class NBPFLoader:
self.trusted_ed25519_keys = trusted_ed25519_keys or {} self.trusted_ed25519_keys = trusted_ed25519_keys or {}
self.trusted_rsa_keys = trusted_rsa_keys or {} self.trusted_rsa_keys = trusted_rsa_keys or {}
self.rsa_private_key = rsa_private_key self.rsa_private_key = rsa_private_key
self._current_allowed_imports: list[str] = []
def load( def load(
self, self,
@@ -112,7 +113,12 @@ class NBPFLoader:
meta = manifest.get("metadata", {}) meta = manifest.get("metadata", {})
name = plugin_name or meta.get("name", nbpf_path.stem) 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) instance, module = self._deserialize_and_exec(nir_data, name)
# 10. 构建插件信息 # 10. 构建插件信息
@@ -326,7 +332,7 @@ class NBPFLoader:
"""反序列化 NIR 并执行,返回 (instance, module)""" """反序列化 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 main_module = None
@@ -365,9 +371,12 @@ class NBPFLoader:
return instance, main_module 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__() 注意Python 沙箱无法完全阻止通过 ()__class__.__bases__[0].__subclasses__()
等反射方式逃逸。本沙箱仅用于防止意外访问危险模块,真正的安全隔离 等反射方式逃逸。本沙箱仅用于防止意外访问危险模块,真正的安全隔离
需要 OS 级容器化。 需要 OS 级容器化。
@@ -390,6 +399,19 @@ class NBPFLoader:
'KeyError': KeyError, 'IndexError': IndexError, 'KeyError': KeyError, 'IndexError': IndexError,
'Exception': Exception, 'BaseException': BaseException, '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 { return {
'__builtins__': safe_builtins, '__builtins__': safe_builtins,
'__name__': f'nbpf.{plugin_name}', '__name__': f'nbpf.{plugin_name}',

View File

@@ -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

50
oss/templates/mod/main.py Normal file
View File

@@ -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 {}

View File

@@ -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": {}
}

24
system-monitor/README.md Normal file
View File

@@ -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

430
system-monitor/main.py Normal file
View File

@@ -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 "<h1>System Monitor</h1><p>psutil not available</p>"
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'<div style="height:20px;background:#e9ecef;border-radius:10px;overflow:hidden">' \
f'<div style="height:100%;width:{pct}%;background:{c};transition:width 0.5s"></div></div>'
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"<tr><td>{p['pid']}</td><td>{p['name']}</td>" \
f"<td>{p['cpu']}%</td><td>{p['mem']}%</td><td>{p['status']}</td></tr>"
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>System Monitor</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; background:#f8f9fa; color:#333; padding:20px; }}
.container {{ max-width:900px; margin:0 auto; }}
h1 {{ font-size:24px; margin-bottom:5px; }}
.sub {{ color:#666; font-size:14px; margin-bottom:20px; }}
.grid {{ display:grid; grid-template-columns:1fr 1fr; gap:15px; margin-bottom:20px; }}
.card {{ background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.1); }}
.card h3 {{ font-size:14px; color:#666; margin-bottom:8px; }}
.card .value {{ font-size:28px; font-weight:700; }}
.card .subtext {{ font-size:12px; color:#999; margin-top:4px; }}
.full {{ grid-column:1/-1; }}
table {{ width:100%; border-collapse:collapse; font-size:13px; }}
th, td {{ padding:6px 8px; text-align:left; border-bottom:1px solid #eee; }}
th {{ color:#666; font-weight:600; }}
a {{ color:#0d6efd; text-decoration:none; }}
a:hover {{ text-decoration:underline; }}
.links {{ margin-bottom:15px; font-size:13px; }}
.links a {{ margin-right:12px; }}
</style>
</head><body>
<div class="container">
<h1>📊 System Monitor</h1>
<p class="sub">v{VERSION} · 运行时间 {uptime_str} · 进程 {procs.get('total', '?')} 个</p>
<div class="links">
<a href="/stats">📄 JSON</a>
<a href="/stats/current">🔄 实时刷新</a>
<a href="/stats/history">📈 历史数据</a>
<a href="/health">💚 健康检查</a>
</div>
<div class="grid">
<div class="card">
<h3>🧠 CPU</h3>
<div class="value">{cpu.get('percent', '?')}%</div>
<div class="subtext">{cpu.get('count', '?')} 核心 · 负载 {cpu.get('load_avg', ['?','?','?'])}</div>
{bar(cpu.get('percent', 0), 'danger' if cpu.get('percent', 0) > 80 else 'warning' if cpu.get('percent', 0) > 60 else 'primary')}
</div>
<div class="card">
<h3>💾 内存</h3>
<div class="value">{mem.get('percent', '?')}%</div>
<div class="subtext">{mem_fmt(mem.get('used', 0))} / {mem_fmt(mem.get('total', 0))}</div>
{bar(mem.get('percent', 0), 'danger' if mem.get('percent', 0) > 80 else 'warning' if mem.get('percent', 0) > 60 else 'success')}
</div>
<div class="card">
<h3>💿 磁盘 /</h3>
<div class="value">{disk.get('percent', '?')}%</div>
<div class="subtext">{mem_fmt(disk.get('used', 0))} / {mem_fmt(disk.get('total', 0))}</div>
{bar(disk.get('percent', 0), 'danger' if disk.get('percent', 0) > 85 else 'warning')}
</div>
<div class="card">
<h3>🌐 网络</h3>
<div class="value">{net.get('connections', '?')}</div>
<div class="subtext">连接数 · ↓ {mem_fmt(net.get('bytes_recv', 0))}{mem_fmt(net.get('bytes_sent', 0))}</div>
</div>
</div>
<div class="card full">
<h3>⚡ TOP 10 进程 (CPU)</h3>
<table>
<thead><tr><th>PID</th><th>名称</th><th>CPU</th><th>内存</th><th>状态</th></tr></thead>
<tbody>{proc_rows}</tbody>
</table>
</div>
<p class="sub" style="text-align:center;margin-top:20px">
数据采集间隔 5秒 · 保留最近 {MAX_HISTORY}
</p>
</div>
</body></html>"""
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()

View File

@@ -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"
]
}
}