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

View File

@@ -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 <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():
cmd_name = os.path.basename(sys.argv[0])
if cmd_name in ("oss", "oss.exe"):

View File

@@ -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",
# 数据目录

View File

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

View File

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

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
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):

View File

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

View File

@@ -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}',

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