一套跑在 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
362 lines
17 KiB
Python
362 lines
17 KiB
Python
#!/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)
|