Files
drouter/_dev/t-cleanup.py
T
liuzhuohua 64df3cf331 feat: Drouter 1.0.0 —— Debian 13 软路由管理系统
一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的
Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、
访问控制、DDNS、文件共享、Docker 管理等。

核心设计原则:不重新实现任何协议
  PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。
  Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。
  面板删掉后,系统仍是一台配置完好的 Debian 路由器。

安全默认值
  · 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务
  · 所有改网络的动作都需要用户在面板上亲手点「应用」
  · 避开 5900 端口,不触碰 VNC / X 会话
  · 配置写入前先做语法预检,失败则回滚

架构
  · backend/  Python 3 标准库,零第三方依赖(9 个模块)
  · web/      原生 JS/CSS,无构建步骤
  · 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess
  · 鉴权走 X-Token 请求头

打包
  · packaging/build-deb.sh            → drouter_1.0.0_all.deb(含 6 个 systemd 单元)
  · scripts/make-offline-deps.sh      → 236 个依赖 .deb + 本地仓库索引
  · packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装
  · packaging/build-docker.sh         → 容器镜像(体验用)

测试
  40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配),
  全部挂在 devtools/sync.sh 的预检链里。

许可:MIT
2026-09-30 19:26:24 +08:00

300 lines
13 KiB
Python
Raw 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 -*-
"""磁盘与日志清理(#3):静态契约 + 清理逻辑单测。
这个模块是「会真删文件」的,测试重点必须放在安全边界上:
① 禁用前缀表能挡住一切配置库 / 代码 / 快照路径;
② 每一项的 glob 之间不重叠,否则同一个文件被两个项目重复计数(页面会虚报可释放量);
③ 活跃日志(*.jsonl / *.log 无轮转后缀)永不进入候选集;
④ 后端动作 / 路由 / 前端三处接线齐全。
"""
import ast
import io
import os
import re
import shutil
import sys
import tempfile
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
APP = os.path.join(ROOT, 'web', 'app.js')
PASS = FAIL = 0
def chk(name, cond, extra=''):
global PASS, FAIL
if cond:
PASS += 1
print('[OK] %s %s' % (name, extra))
else:
FAIL += 1
print('[FAIL] %s %s' % (name, extra))
HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
WEB_SRC = io.open(WEB, encoding='utf-8').read()
APP_SRC = io.open(APP, encoding='utf-8').read()
TREE = ast.parse(HELPER_SRC)
def node(name):
"""按名字取顶层节点(Assign 或 FunctionDef)。"""
for n in TREE.body:
if isinstance(n, ast.Assign):
for t in n.targets:
if isinstance(t, ast.Name) and t.id == name:
return n
elif isinstance(n, ast.FunctionDef) and n.name == name:
return n
return None
def build(names, ns=None):
"""把若干顶层节点抽到隔离命名空间里 exec。"""
ns = ns if ns is not None else {}
body = []
for nm in names:
n = node(nm)
if n is None:
raise AssertionError('源码里找不到 %s' % nm)
body.append(n)
mod = ast.Module(body=body, type_ignores=[])
ast.fix_missing_locations(mod)
exec(compile(mod, '<cleanup>', 'exec'), ns)
return ns
def main():
print('--- 后端动作与路由 ---')
chk('定义了 act_cleanup', 'def act_cleanup(' in HELPER_SRC)
chk('注册到 ACTIONS', re.search(r"^\s*'cleanup':\s*act_cleanup,", HELPER_SRC, re.M) is not None)
chk('Web 路由 /api/cleanup', "'/api/cleanup'" in WEB_SRC)
chk('路由里区分 run 的超时', re.search(r"to = 180 if op == 'run' else 60", WEB_SRC) is not None)
for op in ('status', 'save', 'run', 'auto'):
chk('支持 op=%s' % op, ("op == '%s'" % op) in HELPER_SRC)
chk('未知 op 返回 fail', "未知的清理操作" in HELPER_SRC)
print('\n--- 清理项清单结构 ---')
ns = build(['CLEAN_ITEMS', 'CLEAN_ITEM_KEYS', 'CLEAN_FORBIDDEN'])
items = ns['CLEAN_ITEMS']
keys = ns['CLEAN_ITEM_KEYS']
forbidden = ns['CLEAN_FORBIDDEN']
chk('清理项非空', len(items) >= 5, '%d 项' % len(items))
chk('key 无重复', len(set(it['key'] for it in items)) == len(items))
chk('CLEAN_ITEM_KEYS 与清单一致', keys == [it['key'] for it in items])
for it in items:
k = it['key']
chk('%-12s 字段齐全' % k,
all(x in it for x in ('name', 'kind', 'days', 'on', 'why', 'risk'))
and it['kind'] in ('age', 'cmd'))
chk('%-12s 有通俗说明与安全边界' % k,
len(it.get('why') or '') >= 10 and len(it.get('risk') or '') >= 10)
if it['kind'] == 'cmd':
chk('%-12s cmd 项有 size_dirs 与 cmd' % k,
bool(it.get('size_dirs')) and bool(it.get('cmd')))
chk('%-12s cmd 项不带 peek(无可枚举的活跃文件)' % k, not it.get('peek'))
else:
chk('%-12s age 项有 paths' % k, bool(it.get('paths')))
# peek 只统计不清理:必须和 paths 严格互斥,否则活跃文件会被计入可释放量
if it.get('peek'):
chk('%-12s peek 只出现在 age 项' % k, it['kind'] == 'age')
print('\n--- 安全边界(最关键)---')
# 本测试在 Windows 上跑,os.path.realpath 会给路径加盘符前缀,
# 前缀比对必然失配。这里注入一个 posix 语义的替身,让判定逻辑仍可被验证。
posixpath = __import__('posixpath')
class _PosixOS(object):
sep = '/'
path = posixpath
realpath = staticmethod(posixpath.normpath)
ns2 = build(['CLEAN_FORBIDDEN', '_clean_safe'], {'os': _PosixOS})
safe = ns2['_clean_safe']
must_block = ['/etc/drouter/drouter.db', '/etc/chrony.conf', '/boot/vmlinuz',
'/usr/bin/python3', '/opt/drouter/backend/drouter-helper.py',
'/opt/drouter/web/app.js', '/opt/drouter/data/drouter.db',
'/opt/drouter/snapshots/20260928-154705/_meta.json',
'/root/.bashrc', '/home/ajeef/x', '/var/lib/dpkg/status',
'/var/cache/apt/archives/x.deb']
for p in must_block:
chk('拒绝删除 %s' % p, safe(p) is False)
must_allow = ['/var/log/drouter/all.jsonl.1', '/var/log/drouter/ulog.jsonl.2.gz',
'/tmp/drouter-test/x', '/var/tmp/old', '/var/lib/drouter/trash/a/b',
'/var/lib/systemd/coredump/core.x', '/var/log/cups/access_log.2.gz']
for p in must_allow:
chk('允许清理 %s' % p, safe(p) is True)
# 清理项自己的路径不能被禁用表挡住,否则那一项永远清不动
print('\n--- 清理项目录不得落在禁用表内 ---')
# 只校验 age 项的 paths —— cmd 项的删除走系统命令(journalctl / apt-get / find),
# 不经过 Python 删除路径,它的 size_dirs 只是用来统计占用,故不受禁用表约束。
for it in items:
if it['kind'] != 'age':
chk('%-12s cmd 项不带 paths(删除交给系统命令)' % it['key'],
not (it.get('paths') or []))
continue
dirs = [sp['dir'] for sp in (it.get('paths') or [])]
bad = [d for d in dirs if d and not safe(d)]
chk('%-12s 目录均可用' % it['key'], not bad, bad)
print('\n--- glob 之间不得重叠(防重复计数)---')
ns3 = build(['CLEAN_ITEMS', '_clean_files_of', '_clean_excluded'],
{'os': os, 'fnmatch': __import__('fnmatch'), 'stat': __import__('stat')})
files_of = ns3['_clean_files_of']
items2 = ns3['CLEAN_ITEMS']
tmp = tempfile.mkdtemp(prefix='drover-clean-')
try:
logd = os.path.join(tmp, 'logd')
os.makedirs(logd)
names = ['ulog.jsonl', 'ulog.jsonl.1', 'ulog.jsonl.1.gz',
'all.jsonl', 'all.jsonl.1', 'system.jsonl.2',
'dnsmasq.log', 'dnsmasq.log.1']
for n in names:
with open(os.path.join(logd, n), 'w') as f:
f.write('x' * 100)
def hits(key):
it = [x for x in items2 if x['key'] == key][0]
out = set()
for spec in (it.get('paths') or []):
spec = dict(spec, dir=logd)
for f in files_of(spec):
out.add(os.path.basename(f['path']))
return out
ulog_h = hits('ulog')
dlog_h = hits('drouter_log')
chk('ulog 项命中轮转后的流日志',
{'ulog.jsonl.1', 'ulog.jsonl.1.gz'} <= ulog_h, sorted(ulog_h))
chk('ulog 项不含活跃文件 ulog.jsonl', 'ulog.jsonl' not in ulog_h)
chk('面板日志项不含 ulog 系列',
not any(n.startswith('ulog') for n in dlog_h), sorted(dlog_h))
chk('面板日志项命中自己的轮转件',
{'all.jsonl.1', 'system.jsonl.2', 'dnsmasq.log.1'} <= dlog_h, sorted(dlog_h))
chk('面板日志项不含活跃文件',
not any(n in dlog_h for n in ('all.jsonl', 'dnsmasq.log')), sorted(dlog_h))
chk('两项不重叠', not (ulog_h & dlog_h), sorted(ulog_h & dlog_h))
def peek(key):
it2 = [x for x in items2 if x['key'] == key][0]
out = set()
for spec in (it2.get('peek') or []):
spec = dict(spec, dir=logd)
for f in files_of(spec):
out.add(os.path.basename(f['path']))
return out
for k in ('ulog', 'drouter_log'):
ph = peek(k)
hh = hits(k)
chk('%-12s peek 覆盖活跃文件' % k,
any(('.' not in n.replace('.jsonl', '').replace('.log', '')) for n in ph),
sorted(ph))
chk('%-12s peek 与可清理集不重叠' % k, not (ph & hh), sorted(ph & hh))
chk('两项 peek 不重叠', not (peek('ulog') & peek('drouter_log')),
sorted(peek('ulog') & peek('drouter_log')))
finally:
shutil.rmtree(tmp, ignore_errors=True)
print('\n--- exclude 语义 ---')
exc = ns3['_clean_excluded']
chk("'drouter/*' 命中目录 drouter 本身", exc('drouter', ['drouter/*']) is True)
chk("'drouter/*' 命中 drouter/a", exc('drouter/a', ['drouter/*']) is True)
chk("'drouter/*' 不命中 drx", exc('drx', ['drouter/*']) is False)
chk("'ulog*' 命中 ulog.jsonl.1", exc('ulog.jsonl.1', ['ulog*']) is True)
chk("'ulog*' 不命中 system.jsonl.1", exc('system.jsonl.1', ['ulog*']) is False)
print('\n--- 按天筛选 ---')
ns4 = build(['CLEAN_ITEMS', '_clean_item_stat', '_clean_files_of', '_clean_excluded',
'_clean_dir_bytes'],
{'os': os, 'fnmatch': __import__('fnmatch'), 'stat': __import__('stat'),
'time': time})
item_stat = ns4['_clean_item_stat']
tmp2 = tempfile.mkdtemp(prefix='drover-age-')
try:
d = os.path.join(tmp2, 'x')
os.makedirs(d)
new_f = os.path.join(d, 'new.log.1')
old_f = os.path.join(d, 'old.log.1')
for p in (new_f, old_f):
with open(p, 'w') as f:
f.write('x' * 1000)
os.utime(old_f, (time.time() - 20 * 86400, time.time() - 20 * 86400))
fake = {'key': 't', 'name': 't', 'kind': 'age',
'paths': [{'dir': d, 'glob': '*.log.1'}]}
st10 = item_stat(fake, 10)
st30 = item_stat(fake, 30)
chk('保留 10 天时只回收过期那个', st10['hit_count'] == 1 and st10['reclaimable'] == 1000)
chk('保留 30 天时无可回收', st30['hit_count'] == 0 and st30['reclaimable'] == 0)
chk('总占用与可释放分开统计', st10['total'] == 2000 and st10['files'] == 2)
chk('days=0 表示全部回收', item_stat(fake, 0)['hit_count'] == 2)
chk('无 peek 时 active 为 0', item_stat(fake, 10).get('active') == 0)
fake2 = dict(fake, peek=[{'dir': d, 'glob': '*.log'}])
with open(os.path.join(d, 'live.log'), 'w') as f:
f.write('y' * 4096)
st_p = item_stat(fake2, 10)
chk('peek 只计入 active,不进可释放量',
st_p.get('active') == 4096 and st_p['reclaimable'] == 1000,
'active=%s reclaimable=%s' % (st_p.get('active'), st_p['reclaimable']))
finally:
shutil.rmtree(tmp2, ignore_errors=True)
print('\n--- 默认策略 ---')
ns5 = build(['_clean_defaults', 'CLEAN_ITEMS'])
dft = ns5['_clean_defaults']()
chk('默认开启自动清理', dft['enabled'] is True)
chk('默认触发方式为 water+timer', dft['trigger'] == 'both')
chk('默认水位阈值 85%', dft['disk_percent'] == 85)
chk('默认单次不限量', dft['max_mb_per_run'] == 0)
chk('默认每项都开启', all(v['on'] for v in dft['items'].values()))
chk('默认天数取自清单',
all(dft['items'][it['key']]['days'] == it['days'] for it in ns5['CLEAN_ITEMS']))
chk('默认执行时间在 0-23', 0 <= dft['hour'] <= 23)
print('\n--- 定时器接线 ---')
chk('定义了 wrapper 脚本路径', "CLEAN_WRAPPER = '/opt/drouter/scripts/cleanup-auto.sh'" in HELPER_SRC)
chk('wrapper 用单引号包住 JSON(绕开 systemd 引号解析)',
"""cleanup '{\\"op\\":\\"auto\\"}'""" in HELPER_SRC)
chk('service 单元 ExecStart 指向 wrapper',
"ExecStart=/bin/sh %s" in HELPER_SRC)
chk('timer 每小时检查一次', 'OnUnitActiveSec=1h' in HELPER_SRC)
chk('auto 分支会先判断水位与时间',
"if trig == 'disk':" in HELPER_SRC and "go = watermark or due" in HELPER_SRC)
print('\n--- 前端接线 ---')
chk('菜单里有 cleanup', re.search(r"k: 'cleanup'", APP_SRC) is not None)
chk('菜单文案含「磁盘与日志清理」', '磁盘与日志清理' in APP_SRC)
chk('VIEWS 注册 cleanup', re.search(r"^\s*cleanup: viewCleanup,", APP_SRC, re.M) is not None)
chk('定义了 viewCleanup', 'async function viewCleanup(' in APP_SRC)
for fn in ('cleanupLoad', 'cleanupRenderDisk', 'cleanupRenderPolicy',
'cleanupRenderItems', 'cleanupSave', 'cleanupRun', 'cleanupSelectAll'):
chk('前端函数 %s' % fn, ('function %s(' % fn) in APP_SRC)
_seg = APP_SRC[APP_SRC.index('async function viewCleanup'):
APP_SRC.index('async function cleanupRun')]
chk('清理前有二次确认', 'confirm(' in _seg)
chk('提供试运行入口', "op: 'run', dry: !!dry" in APP_SRC)
print('\n--- 依赖登记 ---')
chk('coreutils(df/du)已登记',
re.search(r"\('coreutils'.*?'coreutils'", HELPER_SRC, re.S) is not None)
print('\n--- 结果文案 ---')
chk('小数量改用 KB 而不是 0.0 MB',
"('%d KB' % max(1, round(freed / 1024.0)))" in HELPER_SRC)
chk('试运行文案区别于真删', '试运行,未真正删除' in HELPER_SRC)
print('\n' + '=' * 60)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
return 1 if FAIL else 0
if __name__ == '__main__':
sys.exit(main())