Initial commit: SenSu Alpha 0.2.0
- 13-service async plugin framework - Textual TUI with CLI fallback - Plugin hot-reload + permission system - Web management panel (aiohttp) - Bridge-based inter-module communication - 10 regression tests Fixes applied: - PBKDF2-SHA256 auth (was plain SHA256) - Auth bypass removed (was allow-all on fail) - Bare excepts replaced with logged errors - CatFramework/DreamSu -> SenSu naming unified - ServiceManager: health checks + startup_order - Env var credentials (SENSU_ADMIN_PASSWORD etc)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
from .manager import WebPanelManager
|
||||
__all__ = ["WebPanelManager"]
|
||||
@@ -0,0 +1,31 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import functools
|
||||
from aiohttp import web
|
||||
|
||||
def panel_auth(handler):
|
||||
"""面板专用鉴权装饰器(替代子应用中间件)"""
|
||||
@functools.wraps(handler)
|
||||
async def wrapper(request, *args, **kwargs):
|
||||
token = request.cookies.get("panel_token")
|
||||
if not token and request.headers.get("Authorization", "").startswith("Bearer "):
|
||||
token = request.headers["Authorization"].split(" ", 1)[1]
|
||||
|
||||
auth_svc = request.app.get('auth_service')
|
||||
is_valid = False
|
||||
|
||||
if token and auth_svc:
|
||||
try:
|
||||
v = await auth_svc.validate_token(token)
|
||||
is_valid = bool(v)
|
||||
except: pass
|
||||
elif not auth_svc:
|
||||
is_valid = False # 认证不可用时拒绝
|
||||
|
||||
if not is_valid:
|
||||
return web.json_response({"error": "未认证或会话过期"}, status=401)
|
||||
|
||||
return await handler(request, *args, **kwargs)
|
||||
return wrapper
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
from .routes import auth, status, plugins, commands, logs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WebPanelManager:
|
||||
def __init__(self, config: dict, service_manager):
|
||||
panel_cfg = config.get('panel', {}).get('entrance', {})
|
||||
self.base_path = panel_cfg.get('path', '/panel')
|
||||
self.panel_user = os.environ.get('SENSU_PANEL_USER', panel_cfg.get('username', 'admin'))
|
||||
self.panel_pass = os.environ.get('SENSU_PANEL_PASS', panel_cfg.get('password', 'admin'))
|
||||
|
||||
self.base_path = f"/{self.base_path.strip('/')}"
|
||||
self.sm = service_manager
|
||||
self.project_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
async def start(self):
|
||||
internet = self.sm.get_service("internet")
|
||||
if not internet or not internet.http_app:
|
||||
logger.error("❌ 网络服务未就绪,无法注册面板路由")
|
||||
return False
|
||||
|
||||
app = internet.http_app
|
||||
logger.info(f"🌐 向网络服务注册面板路由 (前缀: {self.base_path})...")
|
||||
|
||||
# 依赖注入
|
||||
app['service_manager'] = self.sm
|
||||
app['auth_service'] = self.sm.get_service("auth")
|
||||
app['log_service'] = self.sm.get_service("log")
|
||||
app['panel_config'] = {
|
||||
'username': self.panel_user,
|
||||
'password': self.panel_pass,
|
||||
'index_path': self.project_root / "static" / "web_panel" / "index.html",
|
||||
'home_path': self.project_root / "static" / "web_panel" / "home.html" # 🟢 新增
|
||||
}
|
||||
|
||||
# 注册静态文件
|
||||
# URL 前缀: /SenSu/static/ -> 物理路径: .../static/web_panel/
|
||||
static_dir = self.project_root / "static" / "web_panel"
|
||||
if static_dir.exists():
|
||||
app.router.add_static(f'{self.base_path}/static/', path=str(static_dir))
|
||||
logger.info(f"📂 静态资源已挂载: {self.base_path}/static/")
|
||||
else:
|
||||
logger.warning(f"⚠️ 静态目录缺失: {static_dir}")
|
||||
|
||||
# 注册首页 (登录页)
|
||||
app.router.add_get(self.base_path, self._redirect_slash)
|
||||
app.router.add_get(f'{self.base_path}/', self._serve_index)
|
||||
|
||||
# 🟢 新增: 注册面板主页 (/SenSu/home.html -> home.html)
|
||||
app.router.add_get(f'{self.base_path}/home.html', self._serve_home)
|
||||
|
||||
# 注册 API 路由
|
||||
auth.setup_routes(app, self.base_path)
|
||||
status.setup_routes(app, self.base_path)
|
||||
plugins.setup_routes(app, self.base_path)
|
||||
commands.setup_routes(app, self.base_path)
|
||||
logs.setup_routes(app, self.base_path)
|
||||
|
||||
# 注册日志广播
|
||||
ls = self.sm.get_service("log")
|
||||
if ls and hasattr(ls, 'add_log_consumer'):
|
||||
ls.add_log_consumer(logs.broadcast_log)
|
||||
logger.info("📡 日志广播已连接")
|
||||
|
||||
logger.info(f"✅ 面板路由注册完成 (复用原有网络服务路由器)")
|
||||
return True
|
||||
|
||||
async def _redirect_slash(self, req):
|
||||
return web.HTTPFound(f'{self.base_path}/')
|
||||
|
||||
async def _serve_index(self, req):
|
||||
"""提供登录页"""
|
||||
path = req.app['panel_config']['index_path']
|
||||
if path.exists(): return web.FileResponse(path)
|
||||
return web.Response(text=f"❌ 找不到 index.html\n路径: {path}", status=404)
|
||||
|
||||
async def _serve_home(self, req):
|
||||
"""提供面板主页"""
|
||||
path = req.app['panel_config']['home_path']
|
||||
if path.exists(): return web.FileResponse(path)
|
||||
return web.Response(text=f"❌ 找不到 home.html\n路径: {path}", status=404)
|
||||
@@ -0,0 +1,39 @@
|
||||
from aiohttp import web
|
||||
from .utils.response import json_res
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 白名单 (相对于子应用的路径)
|
||||
WHITE_LIST = {
|
||||
"/api/login",
|
||||
"/api/auth/status",
|
||||
"/",
|
||||
"/static/"
|
||||
}
|
||||
|
||||
async def auth_middleware(app, handler):
|
||||
async def mid(req):
|
||||
path = req.path
|
||||
|
||||
# 检查白名单
|
||||
if any(path.startswith(w) for w in WHITE_LIST):
|
||||
return await handler(req)
|
||||
|
||||
# 提取 Token
|
||||
token = req.cookies.get("panel_token")
|
||||
if not token and req.headers.get("Authorization", "").startswith("Bearer "):
|
||||
token = req.headers["Authorization"].split(" ", 1)[1]
|
||||
|
||||
valid, info = False, {}
|
||||
|
||||
# 验证 Token (简单内存验证,后期可接 Redis/DB)
|
||||
session_store = app.get('session_store', {})
|
||||
if token and token in session_store:
|
||||
valid, info = True, session_store[token]
|
||||
|
||||
if valid:
|
||||
req['user'] = info
|
||||
return await handler(req)
|
||||
return json_res({"error": "未认证"}, 401)
|
||||
return mid
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import secrets
|
||||
import logging
|
||||
from aiohttp import web
|
||||
from ..utils.auth import panel_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局 Session 存储 (内存型)
|
||||
# 格式: { "token_string": { "username": "...", "perms": [...] } }
|
||||
PANEL_SESSION_STORE = {}
|
||||
|
||||
def setup_routes(app, prefix=''):
|
||||
"""注册面板认证路由"""
|
||||
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
|
||||
app['panel_session_store'] = PANEL_SESSION_STORE
|
||||
|
||||
# 路由注册
|
||||
app.router.add_post(f'{prefix}/api/login', handle_login)
|
||||
# 退出和状态检查都需要拦截
|
||||
app.router.add_post(f'{prefix}/api/logout', panel_auth(handle_logout))
|
||||
app.router.add_get(f'{prefix}/api/auth/status', panel_auth(handle_auth_status))
|
||||
|
||||
async def handle_login(req):
|
||||
"""处理面板登录"""
|
||||
try:
|
||||
data = await req.json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
cfg = req.app.get('panel_config', {})
|
||||
cfg_user = cfg.get('username', 'admin')
|
||||
cfg_pass = cfg.get('password', 'admin')
|
||||
|
||||
# 校验配置中的账号密码
|
||||
if username == cfg_user and password == cfg_pass:
|
||||
# 登录成功:生成 Token
|
||||
token = secrets.token_hex(16)
|
||||
|
||||
# 写入 Session Store
|
||||
user_info = {
|
||||
"username": username,
|
||||
"perms": ["admin"],
|
||||
"login_time": __import__('time').time()
|
||||
}
|
||||
PANEL_SESSION_STORE[token] = user_info
|
||||
|
||||
logger.info(f"✅ 面板登录成功: {username} (Session: {token[:4]}...)")
|
||||
|
||||
resp = web.json_response({"success": True, "username": username})
|
||||
# 设置 Cookie
|
||||
resp.set_cookie("panel_token", token, max_age=259200, httponly=True, samesite="Lax")
|
||||
return resp
|
||||
else:
|
||||
logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误")
|
||||
return web.json_response({"success": False, "msg": "用户名或密码错误"}, status=401)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"登录异常: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def handle_logout(req):
|
||||
"""处理退出登录"""
|
||||
token = req.cookies.get("panel_token")
|
||||
if token and token in PANEL_SESSION_STORE:
|
||||
del PANEL_SESSION_STORE[token]
|
||||
logger.info(f"👋 用户退出登录")
|
||||
|
||||
resp = web.json_response({"success": True})
|
||||
resp.del_cookie("panel_token")
|
||||
return resp
|
||||
|
||||
async def handle_auth_status(req):
|
||||
"""获取当前认证状态 (被 panel_auth 拦截,能进来说明已认证)"""
|
||||
user = req.get('user', {})
|
||||
return web.json_response({
|
||||
"authenticated": True,
|
||||
"username": user.get("username", "Unknown"),
|
||||
"perms": user.get("perms", [])
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
from aiohttp import web
|
||||
from ..utils.auth import panel_auth
|
||||
|
||||
def setup_routes(app, prefix=''):
|
||||
app.router.add_post(f'{prefix}/api/command', panel_auth(exec_cmd))
|
||||
|
||||
async def exec_cmd(req):
|
||||
d = await req.json()
|
||||
cs = req.app.get('service_manager').get_service("command")
|
||||
if not cs: return web.json_response({"error": "Missing"}, 503)
|
||||
try:
|
||||
res = await cs.execute_command(d.get('command',''))
|
||||
return web.json_response({"success": True, "output": str(res)})
|
||||
except Exception as e:
|
||||
return web.json_response({"success": False, "error": str(e)})
|
||||
@@ -0,0 +1,30 @@
|
||||
import json, asyncio
|
||||
from aiohttp import web
|
||||
from ..utils.auth import panel_auth
|
||||
|
||||
active_ws = set()
|
||||
|
||||
def setup_routes(app, prefix=''):
|
||||
app.router.add_get(f'{prefix}/api/logs/ws', panel_auth(ws_handler))
|
||||
|
||||
async def ws_handler(req):
|
||||
ws = web.WebSocketResponse(heartbeat=30.0)
|
||||
await ws.prepare(req)
|
||||
active_ws.add(ws)
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
d = json.loads(msg.data)
|
||||
if d.get('action') == 'set_level':
|
||||
ls = req.app.get('log_service')
|
||||
if ls: ls.set_level(d.get('level','INFO'))
|
||||
finally: active_ws.discard(ws)
|
||||
return ws
|
||||
|
||||
def broadcast_log(log_record):
|
||||
if not active_ws: return
|
||||
payload = json.dumps({"type":"log", "level":log_record.get('level','INFO'),
|
||||
"message":log_record.get('simple_message',''), "timestamp":log_record.get('timestamp',0)})
|
||||
for ws in list(active_ws):
|
||||
if not ws.closed: asyncio.ensure_future(ws.send_str(payload))
|
||||
else: active_ws.discard(ws)
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from aiohttp import web
|
||||
from ..utils.auth import panel_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def setup_routes(app, prefix=''):
|
||||
app.router.add_get(f'{prefix}/api/plugins', panel_auth(list_plugins))
|
||||
app.router.add_post(f'{prefix}/api/plugins/{{name}}/{{action}}', panel_auth(manage_plugin))
|
||||
app.router.add_get(f'{prefix}/api/plugins/{{name}}/perms', panel_auth(get_perms))
|
||||
app.router.add_post(f'{prefix}/api/plugins/{{name}}/perms', panel_auth(set_perms))
|
||||
|
||||
async def list_plugins(req):
|
||||
sm = req.app.get('service_manager')
|
||||
if not sm:
|
||||
return web.json_response({"error": "Service Manager 未初始化"}, status=503)
|
||||
|
||||
ps = sm.get_service("plugin")
|
||||
if not ps:
|
||||
return web.json_response({"plugins": []})
|
||||
|
||||
data = []
|
||||
for name, info in ps.plugin_info.items():
|
||||
data.append({
|
||||
"name": name,
|
||||
"version": getattr(info, 'version', '?'),
|
||||
"running": name in ps.plugins,
|
||||
"enabled": True
|
||||
})
|
||||
return web.json_response({"plugins": data})
|
||||
|
||||
async def manage_plugin(req):
|
||||
sm = req.app.get('service_manager')
|
||||
if not sm: return web.json_response({"error": "SM Missing"}, 503)
|
||||
|
||||
name = req.match_info['name']
|
||||
action = req.match_info['action']
|
||||
ps = sm.get_service("plugin")
|
||||
|
||||
if not ps: return web.json_response({"error": "Plugin Service Missing"}, 503)
|
||||
|
||||
try:
|
||||
if action in ('disable', 'unload'):
|
||||
await ps.unload_plugin(name)
|
||||
elif action == 'enable':
|
||||
await ps.load_plugin(name)
|
||||
elif action == 'reload':
|
||||
await ps.unload_plugin(name)
|
||||
await ps.load_plugin(name)
|
||||
return web.json_response({"success": True, "msg": "操作成功"})
|
||||
except Exception as e:
|
||||
logger.error(f"插件操作失败: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)})
|
||||
|
||||
async def get_perms(req):
|
||||
return web.json_response({"plugin": req.match_info['name'], "permissions": ["read", "write"]})
|
||||
|
||||
async def set_perms(req):
|
||||
return web.json_response({"success": True})
|
||||
@@ -0,0 +1,27 @@
|
||||
import time
|
||||
from aiohttp import web
|
||||
from ..utils.auth import panel_auth
|
||||
from ..utils.system_info import SystemInfoCollector
|
||||
|
||||
collector = SystemInfoCollector()
|
||||
|
||||
def setup_routes(app, prefix=''):
|
||||
app.router.add_get(f'{prefix}/api/framework', panel_auth(get_framework))
|
||||
app.router.add_get(f'{prefix}/api/system', panel_auth(get_system))
|
||||
|
||||
async def get_framework(req):
|
||||
sm = req.app.get('service_manager')
|
||||
if not sm: return web.json_response({"error": "Missing"}, 500)
|
||||
|
||||
ps = sm.get_service("plugin")
|
||||
# 🟢 修复:使用 sm.start_time 属性
|
||||
uptime = time.time() - getattr(sm, 'start_time', time.time())
|
||||
|
||||
return web.json_response({
|
||||
"version": "Alpha_0.2.0",
|
||||
"uptime": int(uptime), # 取整秒
|
||||
"plugins": len(ps.plugins) if ps else 0
|
||||
})
|
||||
|
||||
async def get_system(req):
|
||||
return web.json_response(collector.get_all())
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import functools
|
||||
from aiohttp import web
|
||||
|
||||
def panel_auth(handler):
|
||||
"""面板专用鉴权装饰器:基于面板自有的 Session Store 验证"""
|
||||
@functools.wraps(handler)
|
||||
async def wrapper(request, *args, **kwargs):
|
||||
# 1. 获取 Token
|
||||
token = request.cookies.get("panel_token")
|
||||
if not token and request.headers.get("Authorization", "").startswith("Bearer "):
|
||||
token = request.headers["Authorization"].split(" ", 1)[1]
|
||||
|
||||
is_valid = False
|
||||
|
||||
# 2. 从面板 Session Store 验证
|
||||
session_store = request.app.get('panel_session_store', {})
|
||||
if token and token in session_store:
|
||||
is_valid = True
|
||||
# 验证通过,将用户信息注入 request 供后续使用
|
||||
request['user'] = session_store[token]
|
||||
|
||||
# 3. 拦截逻辑 (不再依赖外部 AuthService,确保安全隔离)
|
||||
if not is_valid:
|
||||
# 返回 401 并附带提示,前端可据此判断状态
|
||||
return web.json_response({
|
||||
"error": "未认证或会话已过期",
|
||||
"status": 401
|
||||
}, status=401)
|
||||
|
||||
return await handler(request, *args, **kwargs)
|
||||
return wrapper
|
||||
@@ -0,0 +1,7 @@
|
||||
from aiohttp import web
|
||||
def json_res(data, status=200, cookie=None):
|
||||
resp = web.json_response(data, status=status)
|
||||
if cookie: resp.set_cookie(cookie["n"], cookie["v"], max_age=cookie.get("m", 86400), httponly=True)
|
||||
return resp
|
||||
def get_user(req):
|
||||
return req.get('user')
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import time
|
||||
import platform
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _is_android() -> bool:
|
||||
"""检测是否为 Android 环境 (Termux 等)"""
|
||||
return (
|
||||
'ANDROID_ROOT' in os.environ or
|
||||
os.path.exists('/system/bin/getprop') or
|
||||
platform.release().lower().find('android') != -1
|
||||
)
|
||||
|
||||
|
||||
class SystemInfoCollector:
|
||||
def __init__(self):
|
||||
self.is_android = 'ANDROID_ROOT' in os.environ or os.path.exists('/system/bin/getprop')
|
||||
self.psutil = None
|
||||
if not self.is_android:
|
||||
try:
|
||||
import psutil
|
||||
self.psutil = psutil
|
||||
except ImportError as e:
|
||||
logger.debug(f"psutil not available: {e}")
|
||||
else:
|
||||
logger.info("🤖 Android 平台识别,启用原生采集")
|
||||
|
||||
def get_all(self):
|
||||
return {
|
||||
"platform": {
|
||||
"system": platform.system(),
|
||||
"machine": platform.machine(),
|
||||
"python": platform.python_version()
|
||||
},
|
||||
"cpu": self._get_cpu(),
|
||||
"memory": self._get_memory(),
|
||||
"network": self._get_network()
|
||||
}
|
||||
|
||||
def _get_cpu(self):
|
||||
if self.psutil:
|
||||
return {
|
||||
"percent": self.psutil.cpu_percent(interval=0.1),
|
||||
"cores": self.psutil.cpu_count(),
|
||||
"load_avg": os.getloadavg() if hasattr(os, 'getloadavg') else [0,0,0]
|
||||
}
|
||||
# Android 估算:负载率 = (1分钟负载 / 核心数) * 100
|
||||
try:
|
||||
load = os.getloadavg()
|
||||
cores = os.cpu_count() or 1
|
||||
percent = min(100.0, (load[0] / cores) * 100)
|
||||
return {"percent": round(percent, 1), "cores": cores, "load_avg": load}
|
||||
except Exception as e:
|
||||
logger.error(f"System info collection error: {e}", exc_info=True)
|
||||
return {"percent": 0, "cores": 0, "load_avg": [0,0,0]}
|
||||
|
||||
def _get_memory(self):
|
||||
if self.psutil:
|
||||
m = self.psutil.virtual_memory()
|
||||
return {"total_gb": round(m.total/1073741824, 1), "used_gb": round(m.used/1073741824, 1), "percent": m.percent}
|
||||
try:
|
||||
mem = {}
|
||||
with open('/proc/meminfo') as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2: mem[parts[0].rstrip(':')] = int(parts[1]) * 1024
|
||||
t, a = mem.get('MemTotal', 1), mem.get('MemAvailable', mem.get('MemFree', 0))
|
||||
return {"total_gb": round(t/1073741824, 1), "used_gb": round((t-a)/1073741824, 1), "percent": round(((t-a)/t)*100, 1)}
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory info failed: {e}")
|
||||
return {"total_gb": 0, "used_gb": 0, "percent": 0}
|
||||
|
||||
def _get_network(self):
|
||||
if self.psutil:
|
||||
io = self.psutil.net_io_counters()
|
||||
return {"rx": round(io.bytes_recv/1048576, 1), "tx": round(io.bytes_sent/1048576, 1)}
|
||||
# Android 解析 /proc/net/dev
|
||||
try:
|
||||
rx = 0
|
||||
with open('/proc/net/dev', 'r') as f:
|
||||
for line in f:
|
||||
if ':' in line and 'lo' not in line: # 排除 lo 回环
|
||||
parts = line.split(':')[1].split()
|
||||
rx += int(parts[0]) # RX bytes
|
||||
return {"rx": round(rx/1048576, 1), "tx": 0}
|
||||
except Exception as e:
|
||||
logger.warning(f"Network info failed: {e}")
|
||||
return {"rx": 0, "tx": 0}
|
||||
Reference in New Issue
Block a user