Files
liuzhuohua 7b321bfdce fix: 第二轮深度审计 —— 堵提权/注入、收口资源生命周期、低配置性能水位,版本号 1.0.5
安全:
- chpasswd 换行注入可改 root 密码:用户名/密码拒绝换行与控制字符
- 删用户保护从写死两个名字改为 UID<1000 系统账号
- WOL 接口名白名单 + 目标地址 IPv4 校验,去 sh -c 管道
- ACL hosts 改 ipaddress 严格校验;存储挂载点限定 /mnt|/media|/srv
- 文件管理器拒绝清单扩到目录级 + 按文件名拦截(authorized_keys/shadow 等)
- 前端 201 处 value="${...}" 补 esc();href 只放行 http/https;
  主题导出注释同时剥 /* 与 */

丢数据/假功能:
- ACL/文件共享/DDNS 三页切走再回来草稿被静默覆盖:新增 PAGE_DIRTY 脏页登记
- LAN 页网关/域名写进 system 死键,改绑 dnsmasq 同源
- UPnP 映射表按钮不再顺手起服务,走只读接口 read:upnpmap

资源:
- PTY 缓冲 8MB 封顶 + 会话信号量 + 后台空闲回收 + 离开页面主动断开
- Web 工作线程 48 封顶、请求体 16MB 封顶、会话 500 封顶改单调时钟、
  审计 2 万行裁剪、helpd 并发 6 封顶
- helper 日志 32MB 轮转;快照前查磁盘余量;fs 下载 512MB→20MB
  (通道单条响应 32MB,超限只会静默失败)
- 11 处配置写入改原子写;rescue/snapshot 单元补 MemoryMax(三处同步)

性能:
- /api/config 16 次开库 → 1 次 IN 查询
- cleanup 状态扫描 30s 缓存;gzip 缓存加 24MB 字节闸
- 后台标签页心跳暂停、终端轮询降频

杂项:DHCPv6 停用只留注释;租约回收用 dhcp_release(DEPS 可选登记);
VLAN 只删自建接口;公网探测清残留结果文件;快照根白名单;
心跳定时器防重复;keep_days/keep_rows 不吞 0
2026-10-02 10:25:40 +08:00

254 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
drouter-helpd —— 特权动作的常驻执行守护(#7 性能优化)
为什么要有它:
原来每来一个 Web 请求,drouter-web 都要
sudo -n /usr/bin/python3 /opt/drouter/backend/drouter-helper.py <action> <json>
也就是「起一个 Python 解释器 + 过一遍 sudo + 重新 import 一万行代码」,
实测固定开销约 236ms,而动作本身的计算往往只有几十毫秒。
一个面板页面点一下要调 1~3 个接口,光启动就花掉半秒多。
改成常驻后:helper 的代码只 import 一次,请求通过 Unix socket 进来,
直接调 run_action(),省掉解释器启动与 sudo 开销。
安全边界(与原来的 sudo 白名单等价,不放宽):
* 原来:drouter 用户被 sudoers 允许无密码执行
/usr/bin/python3 /opt/drouter/backend/drouter-helper.py *
* 现在:socket 权限 0660、属主 root:drouter,只有 drouter 用户/组能连上;
其它本地用户连都连不上。能达到的权限完全一样。
* 分发的仍然是同一张 ACTIONS 白名单,构建保护模式熔断也照样生效
(复用 helper.run_action,不另写一套)。
* 守护以 root 运行,但它不监听任何 TCP 端口,只暴露 Unix socket。
降级:
drouter-web 优先连 socket;连不上(守护没起/崩了)就自动回退到原来的
subprocess 方式。所以这个守护挂掉不会让面板不可用,只是变慢。
"""
import json
import os
import socket
import socketserver
import sys
import threading
import time
from datetime import datetime
_SBIN_DIRS = ('/usr/local/sbin', '/usr/sbin', '/sbin')
def _ensure_sbin_path():
cur = os.environ.get('PATH') or ''
parts = [p for p in cur.split(os.pathsep) if p]
for d in _SBIN_DIRS:
if d not in parts and os.path.isdir(d):
parts.insert(0, d)
os.environ['PATH'] = os.pathsep.join(parts)
_ensure_sbin_path()
# 让 import 能找到同目录的 drouter-helper
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
SOCK_PATH = '/run/drouter/helper.sock'
SOCK_DIR = '/run/drouter'
SOCK_GROUP = 'drouter'
LOG_DIR = '/var/log/drouter'
# 单行请求的最大长度。配置类 payload 可能不小(防火墙规则、compose 文件),
# 给到 8MB;再大就拒绝而不是无限扩张内存。
MAX_LINE = 8 * 1024 * 1024
_started = time.time()
_stats_lock = threading.Lock()
STATS = {'requests': 0, 'errors': 0, 'by_action': {}}
# 同时执行的特权动作上限。这些动作里有格式化磁盘、apt 安装、重启服务这类
# 重活:客户端并发点几下就会同时跑好几个,2 vCPU 机器上直接把负载打满,
# 还可能互相抢系统资源(apt 锁、nft 表)。
MAX_ACTIONS = 6
_action_sem = threading.BoundedSemaphore(MAX_ACTIONS)
def log(level, code, msg_cn, detail=None):
try:
os.makedirs(LOG_DIR, exist_ok=True)
rec = {'ts': datetime.now().isoformat(timespec='seconds'),
'level': level, 'module': 'helpd', 'code': code,
'msg_cn': msg_cn, 'detail': detail or ''}
line = json.dumps(rec, ensure_ascii=False)
for fn in ('all.jsonl', 'helpd.jsonl'):
with open(os.path.join(LOG_DIR, fn), 'a', encoding='utf-8') as f:
f.write(line + '\n')
except Exception:
pass
class Handler(socketserver.StreamRequestHandler):
"""一连接可以多请求:每行一个 JSON 请求,回一行 JSON 响应。"""
timeout = 900 # 格式化大硬盘这类动作本身就要跑很久
def handle(self):
peer = '-'
try:
# SO_PEERCRED 拿到对端 uid/gid,写进日志便于审计
raw = self.connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
_pid, uid, gid = __import__('struct').unpack('3i', raw)
peer = 'uid=%d gid=%d' % (uid, gid)
except Exception:
pass
try:
while True:
line = self.rfile.readline(MAX_LINE + 1)
if not line:
return
if len(line) > MAX_LINE:
self._write({'ok': False, 'code': 'TOO_LARGE',
'msg_cn': '请求体过大'})
return
try:
req = json.loads(line.decode('utf-8'))
except Exception:
self._write({'ok': False, 'code': 'BAD_JSON',
'msg_cn': '请求不是合法 JSON'})
return
action = str(req.get('action') or '')
payload = req.get('payload') or {}
# 名额用完就明确告诉调用方「忙」,而不是把动作堆在线程池里
if not _action_sem.acquire(blocking=False):
self._write({'ok': False, 'code': 'BUSY',
'msg_cn': '已有 %d 个特权操作在执行,请稍后再试'
% MAX_ACTIONS})
return
t0 = time.time()
try:
res = HELPER.run_action(action, payload)
finally:
_action_sem.release()
dt = (time.time() - t0) * 1000.0
with _stats_lock:
STATS['requests'] += 1
if not res.get('ok'):
STATS['errors'] += 1
a = STATS['by_action']
# 键数上限:本地 drouter 用户可以发任意非法 action 字符串,
# 每个都会在这里留下一条永久记录。
if len(a) > 200 and action not in a:
a.clear()
rec = a.get(action) or {'n': 0, 'ms': 0.0, 'max': 0.0}
rec['n'] += 1
rec['ms'] += dt
rec['max'] = max(rec['max'], dt)
a[action] = rec
if not self._write(res):
return
except Exception as e:
log('warn', 'CONN_ERR', '连接处理异常(%s):%s' % (peer, e))
def _write(self, obj):
"""写回一行 JSON。客户端已断开时安静返回 False,不能让线程崩掉。"""
try:
data = (json.dumps(obj, ensure_ascii=False) + '\n').encode('utf-8')
self.wfile.write(data)
self.wfile.flush()
return True
except Exception:
return False
class Server(socketserver.ThreadingUnixStreamServer):
daemon_threads = True # 主线程退出时不要被工作线程拖住
request_queue_size = 128
allow_reuse_address = True
def handle_error(self, request, client_address):
log('error', 'HANDLER_ERR', '处理请求时抛出异常', str(sys.exc_info()[1])[:500])
def _bind():
"""建好 socket 并收紧权限。返回 server 对象。"""
os.makedirs(SOCK_DIR, exist_ok=True)
os.chmod(SOCK_DIR, 0o755) # drouter 要能进目录
if os.path.exists(SOCK_PATH):
# 上次异常退出留下的陈旧 socket:连上去会直接被拒,必须先清掉
try:
os.unlink(SOCK_PATH)
except Exception:
pass
srv = Server(SOCK_PATH, Handler)
os.chmod(SOCK_PATH, 0o660)
try:
import grp
gid = grp.getgrnam(SOCK_GROUP).gr_gid
os.chown(SOCK_PATH, 0, gid)
except Exception as e:
# 属组设不成就退回 0600:那样只有 root 能连,drouter-web 会走
# subprocess 回退路径,功能不受影响,只是没有加速。
os.chmod(SOCK_PATH, 0o600)
log('warn', 'CHOWN_FAIL', '无法把 socket 属组改为 drouter,已退回 0600:%s' % e)
return srv
def _load_helper():
"""加载 drouter-helper 模块。
文件名是 drouter-helper.py(带连字符),不能用普通 import,
只能用 importlib 从文件路径加载。
"""
import importlib.util
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'drouter-helper.py')
spec = importlib.util.spec_from_file_location('drouter_helper', path)
mod = importlib.util.module_from_spec(spec)
sys.modules['drouter_helper'] = mod
spec.loader.exec_module(mod)
return mod
def main():
global HELPER
HELPER = _load_helper()
srv = _bind()
log('info', 'STARTED', '常驻执行守护已启动(socket=%s,动作 %d 个)'
% (SOCK_PATH, len(HELPER.ACTIONS)))
print('helpd listening on %s' % SOCK_PATH, flush=True)
def _on_stop(*_a):
try:
srv.shutdown()
except Exception:
pass
try:
import signal
signal.signal(signal.SIGTERM, _on_stop)
signal.signal(signal.SIGINT, _on_stop)
except Exception:
pass
try:
srv.serve_forever(poll_interval=0.5)
except KeyboardInterrupt:
pass
finally:
try:
srv.server_close()
except Exception:
pass
try:
if os.path.exists(SOCK_PATH):
os.unlink(SOCK_PATH)
except Exception:
pass
log('info', 'STOPPED', '常驻执行守护已停止')
return 0
HELPER = None
if __name__ == '__main__':
sys.exit(main())