Files
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

362 lines
17 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 -*-
"""真机验收:打印服务 CUPS / USB RAW 直通(#7)。
必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443),且必须用 root(要写
/etc/cups/cupsd.conf 和启停服务)。用法: python3 /tmp/live-print.py
会真的走一遍「保存并生效」,所以开头先把现场拍下来,结尾逐项还原:
* cupsd.conf —— 开头按字节存一份,结尾原样写回;
* cups / cups.socket / cups-browsed 的 active + enabled —— 按原样恢复;
* /etc/drouter/print.conf —— 原本没有就删掉;
* 本次产生的备份、RAW 直通的 unit 与 wrapper —— 全部清理。
安全边界:不碰 5900(VNC)、不动网卡与路由、不启停任何网络/DHCP 服务。
"""
import json
import os
import re
import ssl
import sys
import time
import subprocess
import urllib.request
BASE = 'https://127.0.0.1:8443'
CTX = ssl._create_unverified_context()
TOK = None
PASS = FAIL = 0
PRINT_CONF = '/etc/drouter/print.conf'
CUPSD = '/etc/cups/cupsd.conf'
BAK_DIR = '/var/lib/drouter/print-backup'
RAW_SERVICE = 'drouter-printer-raw.service'
RAW_UNIT = '/etc/systemd/system/' + RAW_SERVICE
RAW_WRAPPER = '/opt/drouter/scripts/printer-raw.sh'
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))
def sh(argv, timeout=60):
# 超时绝不能把还原流程打断:systemctl 偶发卡住是很常见的,
# 一次卡住就抛异常退出 = 把机器留在半成品状态。
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
return p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired:
return 124, '', '命令超时(%ds):%s' % (timeout, ' '.join(argv))
except Exception as e:
return 1, '', '执行异常:%s' % e
def svc(name):
rc1, a, _ = sh(['systemctl', 'is-active', name])
rc2, e, _ = sh(['systemctl', 'is-enabled', name])
return (a.strip() if rc1 == 0 else 'inactive',
e.strip() if rc2 == 0 else 'disabled')
def listen_of(port):
rc, out, _ = sh(['ss', '-lnt'])
if rc != 0:
return []
hits = []
for ln in out.splitlines():
if (':%d' % port) not in ln:
continue
m = re.search(r'(\S+):%d\b' % port, ln)
if m and m.group(1) not in hits:
hits.append(m.group(1))
return hits
TOK = call('/api/login', {'username': 'admin',
'password': 'admin123'}).get('data', {}).get('token')
if not TOK:
print('登录失败,终止')
sys.exit(1)
# ------------------------------------------------------------ 0. 拍下现场
had_conf = os.path.isfile(PRINT_CONF)
if had_conf:
os.rename(PRINT_CONF, PRINT_CONF + '.livetest-bak')
print('开关状态文件已临时挪开(验收完还原)')
ORIG_CUPSD = ''
if os.path.isfile(CUPSD):
ORIG_CUPSD = open(CUPSD, encoding='utf-8', errors='replace').read()
ORIG_SVC = {n: svc(n) for n in ('cups.service', 'cups.socket',
'cups-browsed.service')}
ORIG_RAW_UNIT = os.path.isfile(RAW_UNIT)
ORIG_RAW_WRAPPER = os.path.isfile(RAW_WRAPPER)
BAK_BEFORE = set(os.listdir(BAK_DIR)) if os.path.isdir(BAK_DIR) else set()
# 现场先落盘一份:万一脚本中途崩了,也能照这份 JSON 手工还原
with open('/tmp/print-livetest-orig.json', 'w', encoding='utf-8') as f:
json.dump({'cupsd': ORIG_CUPSD, 'svc': ORIG_SVC, 'had_conf': had_conf,
'raw_unit': ORIG_RAW_UNIT, 'raw_wrapper': ORIG_RAW_WRAPPER},
f, ensure_ascii=False)
print('保存前:cups=%s cups.socket=%s cups-browsed=%s 631监听=%s'
% (ORIG_SVC['cups.service'], ORIG_SVC['cups.socket'],
ORIG_SVC['cups-browsed.service'], listen_of(631) or '无'))
# ------------------------------------------------------------ 1. 状态读取
print('\n--- 状态读取 ---')
r = call('/api/print', {'op': 'status'}, timeout=60)
chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn'))
d = r.get('data') or {}
c0 = (d.get('cfg') or {})
print(' CUPS 已装:%s 版本:%s' % (d.get('installed'), d.get('cups_version')))
print(' CUPS:%s / cups-browsed:%s / RAW:%s'
% (d.get('cups'), d.get('browsed'), d.get('raw')))
print(' 631 实际监听:%s' % (d.get('listen') or '无'))
print(' 队列:%s' % (', '.join(q['name'] for q in (d.get('queues') or [])) or '无'))
print(' USB 打印机:%s 设备节点:%s'
% (d.get('usb_printers') or '无', d.get('usb_nodes') or '无'))
chk('返回 CUPS 是否安装', 'installed' in d)
chk('返回三个服务状态', all(k in d for k in ('cups', 'browsed', 'raw')))
chk('返回 631 实际监听', 'listen' in d)
chk('返回队列列表', 'queues' in d)
chk('返回 USB 打印机与设备节点', 'usb_printers' in d and 'usb_nodes' in d)
chk('返回驱动清单', len(d.get('drivers') or []) == 2)
chk('返回六个开关的说明', len(d.get('items') or []) == 6)
chk('返回 cupsd.conf 路径', d.get('cupsd_file') == CUPSD)
chk('返回 socat 是否可用', 'socat' in d)
chk('返回 avahi 状态', 'avahi_active' in d)
chk('出厂默认 mode=cups', c0.get('mode') == 'cups')
chk('出厂默认允许局域网访问', (c0.get('cups') or {}).get('listen') == 'lan')
chk('出厂默认共享打印机', (c0.get('cups') or {}).get('share') is True)
chk('出厂默认禁止远程管理', (c0.get('cups') or {}).get('remote_admin') is False)
chk('出厂默认开 cups-browsed', (c0.get('cups') or {}).get('browsed') is True)
chk('出厂默认 RAW 端口 9100', (c0.get('raw') or {}).get('port') == 9100)
if d.get('installed'):
chk('队列能被解析出来(不再靠本地化的 lpstat)',
isinstance(d.get('queues'), list))
for q in (d.get('queues') or []):
print(' %-40s shared=%s accepting=%s uri=%s'
% (q.get('name'), q.get('shared'), q.get('accepting'), q.get('uri')))
# ------------------------------------------------------------ 2. 队列参数校验
print('\n--- 队列参数校验(这些都不该碰到 CUPS)---')
bad = call('/api/print', {'op': 'queue_add', 'name': '-bad name',
'uri': 'ipp://x/'}, timeout=40)
chk('非法队列名被拒', bad.get('ok') is False, '→ %s' % bad.get('msg_cn'))
bad2 = call('/api/print', {'op': 'queue_add', 'name': 'ok-name',
'uri': 'ftp://x/'}, timeout=40)
chk('不支持的 URI 方案被拒', bad2.get('ok') is False, '→ %s' % bad2.get('msg_cn'))
bad3 = call('/api/print', {'op': 'queue_add', 'name': 'ok-name',
'uri': 'ipp://x; rm -rf /'}, timeout=40)
chk('带 shell 元字符的 URI 被拒', bad3.get('ok') is False, '→ %s' % bad3.get('msg_cn'))
bad4 = call('/api/print', {'op': 'job_cancel', 'id': '../../etc/passwd'}, timeout=40)
chk('非法任务号被拒', bad4.get('ok') is False, '→ %s' % bad4.get('msg_cn'))
bad5 = call('/api/print', {'op': 'queue_del', 'name': 'ok-name',
'confirm': 'wrong'}, timeout=40)
chk('删除队列要二次确认', bad5.get('ok') is False, '→ %s' % bad5.get('msg_cn'))
bad6 = call('/api/print', {'op': 'bogus_op'}, timeout=30)
chk('未知 op 被拒', bad6.get('ok') is False, '→ %s' % bad6.get('msg_cn'))
print('\n--- 打印任务查询 ---')
j = call('/api/print', {'op': 'jobs'}, timeout=40)
chk('jobs 接口可用', j.get('ok') is True, '→ %s' % j.get('msg_cn'))
chk('返回任务数与列表', 'count' in (j.get('data') or {})
and 'jobs' in (j.get('data') or {}))
try:
# ------------------------------------------------------------ 3. 关闭模式
print('\n--- 关闭模式:所有打印服务都停掉 ---')
s = call('/api/print', {'op': 'save', 'mode': 'off'}, timeout=150)
chk('off 保存成功', s.get('ok') is True, '→ %s' % (s.get('msg_cn') or '')[:120])
time.sleep(2)
chk('CUPS 已停', svc('cups.service')[0] != 'active', '→ %s' % (svc('cups.service'),))
chk('631 不再监听', listen_of(631) == [], '→ %s' % (listen_of(631),))
chk('9100 没有监听', listen_of(9100) == [])
# ------------------------------------------------------------ 4. CUPS 模式
print('\n--- CUPS 模式:真正生效到 631 ---')
s = call('/api/print', {'op': 'save', 'mode': 'cups',
'cups': {'listen': 'lan', 'share': True,
'web_iface': True, 'remote_admin': False,
'browsed': True}}, timeout=150)
chk('cups 保存成功', s.get('ok') is True, '→ %s' % (s.get('msg_cn') or '')[:160])
time.sleep(3)
now = listen_of(631)
print(' 631 监听:%s' % (now or '无'))
chk('631 已经不是只听 127.0.0.1', bool(now) and '127.0.0.1' not in now,
'→ %s' % now)
chk('CUPS 在运行', svc('cups.service')[0] == 'active')
txt = open(CUPSD, encoding='utf-8', errors='replace').read()
root_blk = ''
if '<Location />' in txt:
root_blk = txt.split('<Location />', 1)[1].split('</Location>', 1)[0]
chk('根 Location 段收窄成 @LOCAL', 'Allow @LOCAL' in root_blk, repr(root_blk[:120]))
chk('没有残留 Allow all(cupsctl 会写成放行一切)', 'Allow all' not in root_blk)
rc, _o, e = sh(['cupsd', '-t'], timeout=20)
chk('cupsd -t 配置校验通过', rc == 0, (e or '')[:120])
st = call('/api/print', {'op': 'status'}, timeout=60)
chk('状态里读到新监听', (st.get('data') or {}).get('listen') != '')
# ------------------------------------------------------------ 6. 队列增删(真做再删干净)
print('\n--- 队列增删(做完立刻删掉)---')
TESTQ = 'drouter-livetest'
qa = call('/api/print', {'op': 'queue_add', 'name': TESTQ,
'uri': 'socket://127.0.0.1:9/', 'driver': 'raw',
'info': '验收临时队列', 'share': False}, timeout=100)
chk('添加队列接口可用', qa.get('ok') is True, '→ %s' % (qa.get('msg_cn') or '')[:140])
st2 = call('/api/print', {'op': 'status'}, timeout=60)
names = [q['name'] for q in ((st2.get('data') or {}).get('queues') or [])]
chk('新队列出现在列表里', TESTQ in names, '→ %s' % names)
if TESTQ in names:
qs = call('/api/print', {'op': 'queue_set', 'name': TESTQ,
'share': True}, timeout=60)
chk('设置共享成功', qs.get('ok') is True, '→ %s' % (qs.get('msg_cn') or '')[:120])
qd = call('/api/print', {'op': 'queue_del', 'name': TESTQ,
'confirm': TESTQ}, timeout=60)
chk('删除队列成功', qd.get('ok') is True, '→ %s' % (qd.get('msg_cn') or '')[:120])
st3 = call('/api/print', {'op': 'status'}, timeout=60)
chk('队列确实被删掉了',
TESTQ not in [q['name'] for q in ((st3.get('data') or {}).get('queues') or [])])
# ------------------------------------------------------------ 5. RAW 模式(这台机器没插打印机)
print('\n--- RAW 模式:互斥 + 没设备时如实报错 ---')
s = call('/api/print', {'op': 'save', 'mode': 'raw',
'raw': {'device': '/dev/usb/lp0', 'port': 9100,
'bind': '0.0.0.0'}}, timeout=150)
print(' 返回:%s' % (s.get('msg_cn') or '')[:200])
time.sleep(2)
chk('切到 RAW 后 CUPS 被停掉(互斥生效)',
svc('cups.service')[0] != 'active', '→ %s' % (svc('cups.service'),))
chk('CUPS 也被 disable 掉', svc('cups.service')[1] != 'enabled')
chk('631 不再被 CUPS 占用', listen_of(631) == [])
chk('RAW 的 wrapper 已生成', os.path.isfile(RAW_WRAPPER))
if os.path.isfile(RAW_WRAPPER):
w = open(RAW_WRAPPER, encoding='utf-8').read()
print(' wrapper:%s' % w.strip().splitlines()[-1])
chk('wrapper 用 GOPEN 开字符设备', 'GOPEN:/dev/usb/lp0' in w)
chk('wrapper 监听 9100 且带 fork', 'TCP-LISTEN:9100' in w and 'fork' in w)
chk('RAW 的 systemd 单元已生成', os.path.isfile(RAW_UNIT))
if os.path.isfile(RAW_UNIT):
u = open(RAW_UNIT, encoding='utf-8').read()
chk('单元里写全了 PATH', 'Environment=PATH=' in u and '/usr/sbin' in u)
rawst = call('/api/print', {'op': 'status'}, timeout=60)
rd = rawst.get('data') or {}
print(' RAW 服务:%s USB 打印机:%s 设备节点:%s'
% (rd.get('raw'), rd.get('usb_printers'), rd.get('usb_nodes')))
if not rd.get('usb_nodes'):
chk('没有 USB 打印机时给出明确提示(不是静默失败)',
'找不到' in (s.get('msg_cn') or '')
or any('找不到' in (x or '') for x in ((s.get('data') or {}).get('errors') or [])),
'→ %s' % ((s.get('data') or {}).get('errors') or []))
# ------------------------------------------------------------ 7. 备份与还原
print('\n--- cupsd.conf 备份与还原 ---')
st4 = call('/api/print', {'op': 'status'}, timeout=60)
bks = (st4.get('data') or {}).get('backups') or []
chk('产生了备份', len(bks) >= 1, '→ %d 份' % len(bks))
bad7 = call('/api/print', {'op': 'restore', 'file': '../../etc/shadow'}, timeout=40)
chk('还原路径穿越被拒', bad7.get('ok') is False, '→ %s' % bad7.get('msg_cn'))
if bks:
rr = call('/api/print', {'op': 'restore', 'file': bks[0]['file']}, timeout=100)
chk('还原接口可用', rr.get('ok') is True, '→ %s' % (rr.get('msg_cn') or '')[:120])
except Exception as e:
chk("验收流程自身未崩溃", False, "→ %s" % e)
print(" (异常已吞掉,继续走还原流程)")
# ------------------------------------------------------------ 8. 还原现场
print('\n--- 还原现场 ---')
call('/api/print', {'op': 'save', 'mode': 'cups',
'cups': {'listen': 'lan', 'share': True, 'web_iface': True,
'remote_admin': False, 'browsed': True}}, timeout=150)
sh(['systemctl', 'stop', RAW_SERVICE], timeout=60)
sh(['systemctl', 'disable', RAW_SERVICE], timeout=20)
# 先把打印服务全部停干净再改配置:一边 restart 一边写 cupsd.conf 会让
# systemd 的 job 排队卡住(实测 systemctl restart cups.socket 卡过 60 秒)。
for name in ('cups-browsed.service', 'cups.service', 'cups.socket'):
sh(['systemctl', 'stop', name], timeout=60)
sh(['systemctl', 'reset-failed'], timeout=30)
# cupsd.conf 按字节写回
with open(CUPSD, 'w', encoding='utf-8') as f:
f.write(ORIG_CUPSD)
chk('cupsd.conf 已按原样写回',
open(CUPSD, encoding='utf-8', errors='replace').read() == ORIG_CUPSD)
# 服务状态按原样恢复
for name, (act, en) in ORIG_SVC.items():
if en == 'enabled':
sh(['systemctl', 'enable', name], timeout=30)
else:
sh(['systemctl', 'disable', name], timeout=30)
for name, (act, en) in ORIG_SVC.items():
if act == 'active':
sh(['systemctl', 'start', name], timeout=90)
else:
sh(['systemctl', 'stop', name], timeout=60)
time.sleep(4)
for name, (act, en) in ORIG_SVC.items():
cur = svc(name)
chk('%s 恢复如初(原 %s/%s)' % (name, act, en), cur == (act, en), '→ %s' % (cur,))
print(' 631 监听:%s(原:%s)'
% (listen_of(631) or '无', ['127.0.0.1'] if 'Listen' in ORIG_CUPSD else '?'))
chk('9100 未被占用', listen_of(9100) == [])
# 清理本次产生的东西
if not ORIG_RAW_UNIT and os.path.isfile(RAW_UNIT):
os.remove(RAW_UNIT)
if not ORIG_RAW_WRAPPER and os.path.isfile(RAW_WRAPPER):
os.remove(RAW_WRAPPER)
sh(['systemctl', 'daemon-reload'], timeout=30)
if os.path.isdir(BAK_DIR):
new = set(os.listdir(BAK_DIR)) - BAK_BEFORE
for f in new:
try:
os.remove(os.path.join(BAK_DIR, f))
except Exception:
pass
print(' 本次产生的 %d 份备份已清理' % len(new))
if had_conf:
os.rename(PRINT_CONF + '.livetest-bak', PRINT_CONF)
print(' 开关状态文件已还原')
else:
try:
os.remove(PRINT_CONF)
print(' 本次生成的开关状态文件已清理')
except Exception:
pass
# ------------------------------------------------------------ 9. 安全红线复核
print('\n--- 安全红线复核 ---')
rc, vnc, _ = sh(['ss', '-lnt'])
chk('VNC 5900 仍在监听(绝不能碰)',
any(':5900' in ln for ln in vnc.splitlines()))
rc, ipa, _ = sh(['ip', '-o', '-4', 'addr', 'show', 'ens18'])
chk('ens18 仍是 192.168.7.3', '192.168.7.3' in ipa, ipa.strip()[:80])
rc, rt, _ = sh(['ip', 'route', 'show', 'default'])
chk('默认网关仍是 192.168.7.2(没有接管路由)', '192.168.7.2' in rt, rt.strip()[:80])
for s_ in ('dnsmasq', 'kea-dhcp4-server'):
chk('%s 没有被启动' % s_, svc(s_)[0] != 'active')
chk('drouter-web 仍在运行', svc('drouter-web')[0] == 'active')
chk('救援通道未被打开', svc('drouter-rescue')[0] != 'active')
print('\n' + '=' * 60)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
sys.exit(1 if FAIL else 0)