Files
drouter/_dev/live-cleanup.py
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

155 lines
6.7 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 -*-
"""真机验收:磁盘与日志清理模块(#3)。
必须 scp 到目标机再跑 —— BASE 是 127.0.0.1:8443,在本机跑会连本机端口。
用法: python3 /tmp/live-cleanup.py
重点验证三件事:
① 扫描结果里没有任何「不该碰」的路径;
② 试运行(dry)不删任何文件;
③ 策略保存后真的落到配置文件里,定时器真的在跑。
"""
import json
import ssl
import sys
import urllib.request
BASE = 'https://127.0.0.1:8443'
CTX = ssl._create_unverified_context()
TOK = None
PASS = FAIL = 0
def call(path, payload=None, timeout=60):
data = json.dumps(payload).encode('utf-8') if payload is not None else None
req = urllib.request.Request(BASE + path, data=data,
method='POST' if data is not None else 'GET')
req.add_header('Content-Type', 'application/json')
if TOK:
req.add_header('X-Token', TOK)
try:
with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
return json.loads(r.read().decode('utf-8'))
except Exception as e:
return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
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))
TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
if not TOK:
print('登录失败,终止')
sys.exit(1)
# ---------------------------------------------------------------- 1. 扫描
print('--- 扫描接口 ---')
r = call('/api/cleanup', {'op': 'status'}, timeout=90)
chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
d = r.get('data') or {}
disk = d.get('disk') or {}
print(' 磁盘:已用 %.0f MB / 共 %.0f MB(%s%%)'
% (disk.get('used_mb') or 0, disk.get('total_mb') or 0, disk.get('percent')))
chk('返回磁盘信息', disk.get('total_mb') and disk.get('percent') is not None)
items = d.get('items') or []
chk('返回清理项', len(items) >= 8, '→ %d 项' % len(items))
print(' 合计可释放 %.1f MB' % ((d.get('reclaimable') or 0) / 1048576.0))
FORBIDDEN = ('/etc/', '/boot/', '/usr/', '/opt/drouter/backend', '/opt/drouter/web',
'/opt/drouter/data', '/opt/drouter/snapshots', '/root/', '/home/')
bad = []
for it in items:
for s in (it.get('samples') or []):
p = s.get('path') or ''
if p.startswith(FORBIDDEN):
bad.append(p)
if not it.get('why') or not it.get('risk'):
bad.append('<%s 缺说明>' % it.get('key'))
chk('样本路径全部在安全范围内', not bad, bad)
kv = {it['key']: it for it in items}
for k in ('ulog', 'drouter_log', 'journal', 'apt', 'tmp', 'trash', 'core', 'snapdl', 'varlog'):
it = kv.get(k) or {}
print(' %-12s 占用 %8.0f KB 可释放 %8.0f KB 命中 %s'
% (k, (it.get('total') or 0) / 1024.0, (it.get('reclaimable') or 0) / 1024.0,
it.get('hit_count') if not it.get('whole') else '全部'))
chk('九项齐全', len(kv) == 9)
# 活跃日志绝不出现在候选里
act = []
for it in items:
for s in (it.get('samples') or []):
p = (s.get('path') or '')
base = p.rsplit('/', 1)[-1]
if base.endswith('.jsonl') or base.endswith('.log'):
act.append(base)
chk('候选里不含正在写入的活跃日志', not act, act)
# ---------------------------------------------------------------- 2. 试运行
print('\n--- 试运行(dry,不删文件)---')
before_total = sum((it.get('total') or 0) for it in items)
r2 = call('/api/cleanup', {'op': 'run', 'dry': True}, timeout=180)
chk('dry 接口可用', r2.get('ok') is True, '→ %s' % r2.get('msg_cn'))
dd = r2.get('data') or {}
chk('标记为试运行', dd.get('dry') is True)
r3 = call('/api/cleanup', {'op': 'status'}, timeout=90)
after_total = sum((it.get('total') or 0) for it in ((r3.get('data') or {}).get('items') or []))
chk('试运行后磁盘占用没有变化', abs(after_total - before_total) < 4096,
'Δ=%d 字节' % (after_total - before_total))
# ---------------------------------------------------------------- 3. 策略保存
print('\n--- 策略保存 ---')
r4 = call('/api/cleanup', {'op': 'save', 'enabled': True, 'trigger': 'both',
'disk_percent': 88, 'schedule': 'daily', 'hour': 3,
'max_mb_per_run': 0,
'items': {'ulog': {'on': True, 'days': 5}}}, timeout=60)
chk('save 接口可用', r4.get('ok') is True, '→ %s' % r4.get('msg_cn'))
r5 = call('/api/cleanup', {'op': 'status'}, timeout=90)
c5 = ((r5.get('data') or {}).get('config') or {})
chk('水位阈值已落库', c5.get('disk_percent') == 88, '→ %s' % c5.get('disk_percent'))
chk('执行时间已落库', c5.get('hour') == 3, '→ %s' % c5.get('hour'))
chk('单项天数已落库', ((c5.get('items') or {}).get('ulog') or {}).get('days') == 5)
chk('其余项未被误改', ((c5.get('items') or {}).get('drouter_log') or {}).get('days') == 7)
chk('定时器处于活动状态', (r5.get('data') or {}).get('timer_active') is True)
# 还原成默认值,别把用户的阈值留在 88
call('/api/cleanup', {'op': 'save', 'enabled': True, 'trigger': 'both',
'disk_percent': 85, 'schedule': 'daily', 'hour': 4,
'max_mb_per_run': 0,
'items': {'ulog': {'on': True, 'days': 3}}}, timeout=60)
print(' (已还原为默认阈值 85% / ulog 3 天)')
# ---------------------------------------------------------------- 4. 自动触发判断
print('\n--- 自动触发判断 ---')
r6 = call('/api/cleanup', {'op': 'auto'}, timeout=180)
chk('auto 接口可用', r6.get('ok') is True, '→ %s' % r6.get('msg_cn'))
d6 = r6.get('data') or {}
if d6.get('skipped'):
print(' 本次跳过(水位 %s%%,未到阈值 / 未到时间)—— 这正是期望行为'
% d6.get('percent'))
chk('跳过时不产生删除', d6.get('freed') is None or d6.get('freed') == 0)
else:
print(' 已执行,释放 %.1f MB' % ((d6.get('freed') or 0) / 1048576.0))
chk('执行后带明细', isinstance(d6.get('items'), list))
# ---------------------------------------------------------------- 5. 关闭后再 auto
print('\n--- 关闭自动清理后 auto 应跳过 ---')
call('/api/cleanup', {'op': 'save', 'enabled': False}, timeout=60)
r7 = call('/api/cleanup', {'op': 'auto'}, timeout=60)
chk('关闭后 auto 跳过', ((r7.get('data') or {}).get('skipped')) is True,
'→ %s' % r7.get('msg_cn'))
call('/api/cleanup', {'op': 'save', 'enabled': True}, timeout=60)
print(' (已重新开启)')
print('\n' + '=' * 60)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
sys.exit(1 if FAIL else 0)