feat: bugfix module - SQL query, log read, and log tail APIs

- execute_sql.dspy: SELECT-only SQL execution with pagination
- read_log.dspy: read last N lines from whitelisted log files
- tail_log.dspy: incremental log monitoring from last position
- RBAC: developer role only
- Security: SQL validation, log file whitelist
This commit is contained in:
yumoqing 2026-06-04 16:35:38 +08:00
parent cd6fe6e194
commit 3406783d13
8 changed files with 334 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.eggs/
*.egg
.venv/
venv/

7
bugfix/__init__.py Normal file
View File

@ -0,0 +1,7 @@
# bugfix/__init__.py
from .init import (
execute_select_sql,
read_log_file,
tail_log_file,
load_bugfix,
)

200
bugfix/init.py Normal file
View File

@ -0,0 +1,200 @@
"""bugfix 模块 - 开发者调试工具"""
import os
import re
from ahserver.serverenv import ServerEnv
from sqlor.dbpools import get_sor_context
from appPublic.log import debug, exception
MODULE_NAME = "bugfix"
# 允许的日志文件白名单
ALLOWED_LOGS = ['sage.log', 'backend_accounting.log']
# 日志监控位置记录 {filename: {'lines': N, 'mtime': timestamp}}
_log_tails = {}
def _validate_select_only(sql):
"""验证 SQL 只能是 SELECT 语句"""
if not sql:
return False, "SQL 不能为空"
# 去除前后空格和注释
cleaned = sql.strip()
# 移除 -- 注释
cleaned = re.sub(r'--.*$', '', cleaned, flags=re.MULTILINE)
# 移除 /* */ 注释
cleaned = re.sub(r'/\*.*?\*/', '', cleaned, flags=re.DOTALL)
cleaned = cleaned.strip()
if not cleaned:
return False, "SQL 不能为空"
# 检查是否以 SELECT 开头
if not cleaned.upper().startswith('SELECT'):
return False, "仅允许执行 SELECT 语句"
# 黑名单检查 - 禁止危险操作
blacklist = [
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE',
'TRUNCATE', 'REPLACE', 'MERGE', 'GRANT', 'REVOKE',
'EXEC', 'EXECUTE', 'CALL', 'INTO', 'LOAD_FILE', 'INTO OUTFILE',
'INTO DUMPFILE'
]
upper = cleaned.upper()
for kw in blacklist:
# 用 \b 匹配完整单词
if re.search(r'\b' + kw + r'\b', upper):
return False, f"禁止使用 {kw} 语句"
return True, "OK"
async def execute_select_sql(sql, page=1, rows=20):
"""执行 SELECT SQL 查询sqlor 标准分页)
Args:
sql: SQL 语句仅允许 SELECT
page: 页码 1 开始
rows: 每页条数默认 20
Returns:
dict: {status, total, rows}
"""
valid, msg = _validate_select_only(sql)
if not valid:
return {'status': 'error', 'error': msg}
try:
env = ServerEnv()
dbname = env.get_module_dbname('bugfix')
async with get_sor_context(env, dbname) as sor:
ns = {'page': page, 'rows': rows}
result = await sor.sqlExe(sql, ns)
return {
'status': 'ok',
'total': result.get('total', 0),
'rows': result.get('rows', [])
}
except Exception as e:
exception(f'execute_select_sql error: {e}')
return {'status': 'error', 'error': str(e)}
async def read_log_file(filename, lines=500):
"""读取日志文件
Args:
filename: 日志文件名必须在白名单中
lines: 读取最后 N 默认 500
Returns:
dict: {status, data, filename}
"""
if filename not in ALLOWED_LOGS:
return {'status': 'error', 'error': f'不允许读取 {filename},仅允许: {", ".join(ALLOWED_LOGS)}'}
try:
env = ServerEnv()
# 日志目录在 sage/logs/ 下
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
# 备用路径
if not os.path.isdir(log_dir):
log_dir = os.path.expanduser('~/repos/sage/logs')
log_path = os.path.join(log_dir, filename)
if not os.path.isfile(log_path):
return {'status': 'error', 'error': f'日志文件不存在: {log_path}'}
# 读取最后 N 行
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
all_lines = f.readlines()
start = max(0, len(all_lines) - lines)
content = ''.join(all_lines[start:])
return {
'status': 'ok',
'filename': filename,
'total_lines': len(all_lines),
'returned_lines': len(all_lines) - start,
'content': content
}
except Exception as e:
exception(f'read_log_file error: {e}')
return {'status': 'error', 'error': str(e)}
async def tail_log_file(filename, reset=False):
"""日志监控 - 从上次读取位置继续读新增内容
Args:
filename: 日志文件名必须在白名单中
reset: True=重置位置到文件末尾下次从末尾开始监控
Returns:
dict: {status, filename, new_lines, content, total_lines}
"""
if filename not in ALLOWED_LOGS:
return {'status': 'error', 'error': f'不允许读取 {filename},仅允许: {", ".join(ALLOWED_LOGS)}'}
try:
env = ServerEnv()
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
if not os.path.isdir(log_dir):
log_dir = os.path.expanduser('~/repos/sage/logs')
log_path = os.path.join(log_dir, filename)
if not os.path.isfile(log_path):
return {'status': 'error', 'error': f'日志文件不存在: {log_path}'}
# 获取文件修改时间,如果文件被替换则重置位置
mtime = os.path.getmtime(log_path)
last = _log_tails.get(filename)
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
all_lines = f.readlines()
total = len(all_lines)
# reset=True 或文件被替换mtime变了且行数变少跳到末尾
if reset or (last and last.get('mtime') != mtime and total < last.get('lines', 0)):
_log_tails[filename] = {'lines': total, 'mtime': mtime}
return {
'status': 'ok',
'filename': filename,
'new_lines': 0,
'content': '',
'total_lines': total,
'reset': True
}
# 从上次位置继续读
start = last.get('lines', 0) if last else max(0, total - 100) # 首次读最后100行
new_content = ''.join(all_lines[start:])
new_count = total - start
# 更新位置
_log_tails[filename] = {'lines': total, 'mtime': mtime}
return {
'status': 'ok',
'filename': filename,
'new_lines': new_count,
'content': new_content,
'total_lines': total
}
except Exception as e:
exception(f'tail_log_file error: {e}')
return {'status': 'error', 'error': str(e)}
def load_bugfix():
"""注册函数到 ServerEnv"""
env = ServerEnv()
env.execute_select_sql = execute_select_sql
env.read_log_file = read_log_file
env.tail_log_file = tail_log_file
debug(f'[bugfix] module loaded')
return True

16
pyproject.toml Normal file
View File

@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "bugfix"
version = "1.0.0"
description = "Developer debugging tools module for Sage platform"
requires-python = ">=3.8"
dependencies = [
"sqlor",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["bugfix*"]

76
scripts/load_path.py Normal file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
bugfix 模块 RBAC 权限管理脚本
仅限 developer 角色使用
使用方法:
cd ~/repos/sage
./py3/bin/python ~/test/bugfix/scripts/load_path.py
"""
import subprocess
import os
import sys
def find_sage_root():
candidates = [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
]
for c in candidates:
if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")):
return c
return None
SAGE_ROOT = find_sage_root()
if not SAGE_ROOT:
print("ERROR: Cannot find Sage root directory")
sys.exit(1)
PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python")
SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py")
MOD = "bugfix"
# ============================================================
# 权限路径定义 — 仅 developer 角色可访问
# ============================================================
# developer — 仅开发者
PATHS_DEVELOPER = [
f"/{MOD}",
f"/{MOD}/index.ui",
f"/{MOD}/api/execute_sql.dspy",
f"/{MOD}/api/read_log.dspy",
f"/{MOD}/api/tail_log.dspy",
]
def run_set_perm(role, path):
cmd = [PYTHON, SET_PERM_SCRIPT, role, path]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def register_role_paths(role, paths):
count = 0
for p in paths:
if run_set_perm(role, p):
count += 1
print(f" {role}: {count}/{len(paths)} paths registered")
return count
def main():
print(f"Sage root: {SAGE_ROOT}")
print("Registering bugfix module RBAC permissions (developer only)...")
total = 0
total += register_role_paths("developer", PATHS_DEVELOPER)
print(f"\nDone. Total {total} permission entries registered.")
print("NOTE: Restart Sage after permission changes to reload RBAC cache.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,9 @@
sql = params_kw.get('sql') or ''
page = int(params_kw.get('page') or 1)
rows = int(params_kw.get('rows') or 20)
if not sql:
return {'status': 'error', 'error': '缺少 sql 参数'}
result = await execute_select_sql(sql, page, rows)
return result

View File

@ -0,0 +1,8 @@
filename = params_kw.get('filename') or ''
lines = int(params_kw.get('lines') or 500)
if not filename:
return {'status': 'error', 'error': '缺少 filename 参数'}
result = await read_log_file(filename, lines)
return result

View File

@ -0,0 +1,8 @@
filename = params_kw.get('filename') or ''
reset = params_kw.get('reset', '').lower() in ('1', 'true', 'yes')
if not filename:
return {'status': 'error', 'error': '缺少 filename 参数'}
result = await tail_log_file(filename, reset)
return result