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

428 lines
21 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 -*-
"""真机验收:新手向导(上网四步走)。
必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443),必须用 root:
python3 /tmp/live-wizard.py
这个脚本会真的改 dnsmasq / pppoe / radvd 的配置库,还会真的重启 dnsmasq。
所以开头先把这几个配置存一份到 /tmp/wizard-live-backup/,
无论中途发生什么,结尾都会原样装回去并复核红线。
**刻意不做的事**:不真正发起 PPPoE 拨号。这台机器与远端 CHR 共用局域网,
真拨号会改动默认路由与出口,属于「会切换网络」的动作 —— 那是红线。
拨号路径只做参数校验(缺账号 / 缺密码 / 缺确认),不执行。
会真跑的环节:
· probe:体检能不能读出上网方式 / 地址池 / DNS / IPv6 四块状态
· 第 2 步:填一个「把本机自己圈进地址池」的错值 → 必须被拒(最重要的一条)
· 第 2 步:填跨网段的地址池 → 必须被拒
· 第 2 步:正常地址池 → 保存并生效,dnsmasq 真的重启、租约文件仍在
· 第 3 步:填非法 DNS → 被拒;填 223.5.5.5 → 生效并真的能解析
· 第 4 步:开启 IPv6 → radvd 配置落盘、转发打开(前缀留空则不动 RADVD 前缀)
· 第 4 步:关闭 IPv6 → 转发关掉,但已填前缀不删
· 构建保护模式:只写盘不重启
结尾逐项还原并复核红线。
安全边界:不碰 5900(VNC)、不动网卡与默认路由、不启 DHCP 服务给别的网段、
不真正拨号、不启 dnsmasq 之前先确认它本来就是启着的(否则只写盘不启)。
"""
import os
import re
import ssl
import sys
import json
import time
import shutil
import subprocess
import urllib.request
BASE = 'https://127.0.0.1:8443'
CTX = ssl._create_unverified_context()
TOK = None
PASS = FAIL = 0
BUILD_FLAG = '/etc/drouter/BUILD_MODE'
DB = '/opt/drouter/data/drouter.db'
SAFE = '/tmp/wizard-live-backup'
DNSMASQ_CONF = '/etc/dnsmasq.d/drouter.conf'
RADVD_CONF = '/etc/radvd.conf'
def call(path, payload=None, timeout=90):
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, 'code': 'HTTP_ERR'}
def login():
global TOK
r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
if r.get('ok'):
TOK = (r.get('data') or {}).get('token')
return bool(TOK)
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=90):
try:
p = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
return p.returncode, p.stdout.decode('utf-8', 'replace')
except Exception as e:
return 1, str(e)
def svc(name):
rc, out = sh(['systemctl', 'is-active', name])
return (out or '').strip()
def sqlite_get(key):
"""直接读配置库,用来核对接口说的和库里存的是不是一回事"""
rc, out = sh(['python3', '-c', '''
import sqlite3, json
c = sqlite3.connect(%r)
r = c.execute("SELECT value FROM settings WHERE key=?", (%r,)).fetchone()
print(r[0] if r else "")
c.close()
''' % (DB, key)])
if rc != 0 or not out.strip():
return {}
try:
return json.loads(out.strip().splitlines()[-1])
except Exception:
return {}
def main():
global PASS, FAIL
print('===== 真机验收:新手向导 =====\n')
# ---------- 备份 ----------
os.makedirs(SAFE, exist_ok=True)
for k in ('dnsmasq', 'pppoe', 'radvd', 'system', 'nft_v4', 'nft_v6'):
rc, out = sh(['python3', '-c', '''
import sqlite3, json
c = sqlite3.connect(%r)
r = c.execute("SELECT value FROM settings WHERE key=?", (%r,)).fetchone()
open(%r, "w").write(r[0] if r else "")
c.close()
''' % (DB, k, os.path.join(SAFE, k + '.json'))])
for f in (DNSMASQ_CONF, RADVD_CONF):
if os.path.isfile(f):
shutil.copy2(f, os.path.join(SAFE, os.path.basename(f)))
has_build = os.path.isfile(BUILD_FLAG)
dnsmasq_was = svc('dnsmasq')
radvd_was = svc('radvd')
print(' dnsmasq 原本:%s' % dnsmasq_was)
print(' radvd 原本:%s' % radvd_was)
print(' 保护模式原本:%s\n' % ('开' if has_build else '关'))
# 红线快照
rc, gw0 = sh(['sh', '-c', "ip route | grep default"])
rc, ip0 = sh(['sh', '-c', "ip -4 addr show ens18 | grep inet"])
try:
chk('登录成功', login())
# ---------- 1. 体检 ----------
print('\n--- 1. 体检(probe)---')
r = call('/api/wizard', {'op': 'probe'})
d = r.get('data') or {}
chk('probe 成功', r.get('ok'), str(r.get('msg_cn'))[:60])
chk('返回四个步骤', len(d.get('steps') or []) == 4, str(len(d.get('steps') or [])))
chk('步骤顺序正确',
[s['k'] for s in (d.get('steps') or [])] == ['wan', 'lan', 'dns', 'v6'])
w = d.get('wan') or {}
l = d.get('lan') or {}
n = d.get('dns') or {}
v = d.get('v6') or {}
chk('有上网方式段落', bool(w), '方式=%s' % w.get('mode_cn'))
chk('有内网段落', bool(l), 'LAN=%s IP=%s' % (l.get('iface'), l.get('lan_ip')))
chk('有 DNS 段落', bool(n), '来源=%s' % n.get('mode_cn'))
chk('有 IPv6 段落', bool(v))
chk('有 internet_ok 结论', 'internet_ok' in d, str(d.get('internet_ok')))
chk('有 todo 列表', isinstance(d.get('todo'), list))
chk('下发三种上网方式', len(d.get('wan_modes') or []) == 3)
chk('下发 DNS 服务商范例',
len(d.get('dns_presets') or []) >= 5, str(len(d.get('dns_presets') or [])))
chk('下发地址池范例提示', len((d.get('pool_hint') or {}).get('rules') or []) >= 3)
chk('下发网卡下拉', len(d.get('wan_ifaces') or []) >= 1,
str([x.get('name') for x in (d.get('wan_ifaces') or [])]))
chk('体检结果与配置库一致(LAN 接口)',
(l.get('iface') or '') == (sqlite_get('dnsmasq') or {}).get('lan_iface', ''),
'接口 %s / 库 %s' % (l.get('iface'), (sqlite_get('dnsmasq') or {}).get('lan_iface')))
# ---------- 2. 第 2 步:地址池校验(最重要) ----------
print('\n--- 2. 第 2 步:地址池校验 ---')
lan_ip = l.get('lan_ip') or ''
chk('本机 LAN 地址读到了', bool(lan_ip), lan_ip)
base = lan_ip.rsplit('.', 1)[0] if lan_ip else '192.168.7'
# ① 把本机自己圈进池子 —— 必须拒
r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
'iface': l.get('iface'), 'pool_start': base + '.2',
'pool_end': base + '.200',
'pool_netmask': '255.255.255.0'})
chk('地址池圈进本机自己 → 被拒', not r.get('ok'),
str(r.get('msg_cn'))[:80])
chk('拒绝理由点明了「抢 IP」',
'抢 IP' in (r.get('msg_cn') or '') or '本机自己' in (r.get('msg_cn') or ''))
# ② 跨网段 —— 必须拒
r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
'iface': l.get('iface'), 'pool_start': '10.9.9.100',
'pool_end': '10.9.9.200',
'pool_netmask': '255.255.255.0'})
chk('跨网段地址池 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
chk('拒绝理由点明了「同一个网段」', '同一个网段' in (r.get('msg_cn') or ''))
# ③ 非法地址 —— 必须拒
r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
'iface': l.get('iface'), 'pool_start': 'abc',
'pool_end': base + '.200'})
chk('非法地址 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
# ④ 正常地址池 —— 通过并真的生效
r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
'iface': l.get('iface'),
'pool_start': base + '.100', 'pool_end': base + '.200',
'pool_netmask': '255.255.255.0', 'lease_time': '7200',
'option_gateway': lan_ip, 'option_dns': lan_ip})
chk('正常地址池 → 保存成功', r.get('ok'), str(r.get('msg_cn'))[:80])
db_lan = sqlite_get('dnsmasq') or {}
chk('配置库写入了起始地址', db_lan.get('pool_start') == base + '.100',
str(db_lan.get('pool_start')))
chk('配置库写入了结束地址', db_lan.get('pool_end') == base + '.200',
str(db_lan.get('pool_end')))
chk('DHCP 开关被打开', db_lan.get('dhcp_enabled') is True)
chk('网关自动填成本机', db_lan.get('option_gateway') == lan_ip,
str(db_lan.get('option_gateway')))
if os.path.isfile(DNSMASQ_CONF):
with open(DNSMASQ_CONF, encoding='utf-8') as f:
conf = f.read()
chk('渲染产物里有 dhcp-range',
('dhcp-range=%s.100,%s.200' % (base, base)) in conf,
[x for x in conf.splitlines() if x.startswith('dhcp-range')][:1])
chk('渲染产物里有网关 option 3', 'dhcp-option=3,%s' % lan_ip in conf)
# dnsmasq 原本就是 inactive(这台机器还没接管路由)→ 不能因为向导就把它启起来
now_dnsmasq = svc('dnsmasq')
if dnsmasq_was == 'active':
chk('dnsmasq 仍在运行', now_dnsmasq == 'active', now_dnsmasq)
else:
chk('原本没跑 dnsmasq,向导没有把它擅自启起来',
now_dnsmasq != 'active', '当前 %s' % now_dnsmasq)
# ---------- 3. 第 3 步:DNS ----------
print('\n--- 3. 第 3 步:DNS ---')
r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
'mode': 'custom', 'custom': 'not-an-ip'})
chk('非法 DNS → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
'mode': 'custom', 'custom': ''})
chk('选了自定义却不填 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
'mode': 'bogus', 'custom': '223.5.5.5'})
chk('非法 mode → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'dns',
'mode': 'custom', 'custom': '223.5.5.5,119.29.29.29'})
chk('正常 DNS → 保存成功', r.get('ok'), str(r.get('msg_cn'))[:80])
db_dns = sqlite_get('dnsmasq') or {}
chk('配置库写入了 DNS 来源', db_dns.get('dns_mode') == 'custom',
str(db_dns.get('dns_mode')))
chk('配置库写入了 DNS 地址',
'223.5.5.5' in (db_dns.get('dns_custom') or ''),
str(db_dns.get('dns_custom')))
if os.path.isfile(DNSMASQ_CONF):
with open(DNSMASQ_CONF, encoding='utf-8') as f:
conf = f.read()
chk('渲染产物里有 server=223.5.5.5', 'server=223.5.5.5' in conf)
# 真解析一次,确认这台机器自己能用这个 DNS
rc, out = sh(['sh', '-c', 'timeout 8 dig +short @223.5.5.5 www.baidu.com 2>/dev/null | head -1'])
chk('223.5.5.5 真的能解析(体检验证的同一路径)',
bool((out or '').strip()), (out or '').strip()[:40])
# ---------- 4. 第 4 步:IPv6 ----------
print('\n--- 4. 第 4 步:IPv6 ---')
r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6', 'enable': True,
'lan_iface': l.get('iface'), 'prefix': '2408:8207:1234:5678::/80',
'rdnss': '2400:3200::1'})
chk('开启 IPv6 → 成功', r.get('ok'), str(r.get('msg_cn'))[:100])
chk('位数不是 /64 时给出说明',
any('/64' in x for x in (r.get('data') or {}).get('notes') or []),
str((r.get('data') or {}).get('notes'))[:90])
db_ra = sqlite_get('radvd') or {}
chk('前缀被规范成 /64', str(db_ra.get('prefix') or '').endswith('/64'),
str(db_ra.get('prefix')))
chk('RDNSS 落库', db_ra.get('rdnss') == '2400:3200::1', str(db_ra.get('rdnss')))
rc, fwd = sh(['sysctl', '-n', 'net.ipv6.conf.all.forwarding'])
chk('IPv6 转发已打开', (fwd or '').strip() == '1', (fwd or '').strip())
if os.path.isfile(RADVD_CONF):
with open(RADVD_CONF, encoding='utf-8') as f:
ra = f.read()
chk('radvd 配置里有前缀', '2408:8207:1234:5678::/64' in ra)
chk('radvd 配置里有 RDNSS', '2400:3200::1' in ra)
r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6',
'enable': True, 'prefix': 'not-a-prefix'})
chk('非法 IPv6 前缀 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6',
'enable': True, 'rdnss': '223.5.5.5'})
chk('把 IPv4 当 RDNSS → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'v6', 'enable': False})
chk('关闭 IPv6 → 成功', r.get('ok'), str(r.get('msg_cn'))[:80])
rc, fwd = sh(['sysctl', '-n', 'net.ipv6.conf.all.forwarding'])
chk('IPv6 转发已关闭', (fwd or '').strip() == '0', (fwd or '').strip())
db_ra2 = sqlite_get('radvd') or {}
chk('关闭时保留已填前缀(不抹掉用户的东西)',
str(db_ra2.get('prefix') or '').endswith('/64'),
str(db_ra2.get('prefix')))
# ---------- 5. 第 1 步:上网方式(只做校验,不真拨号) ----------
print('\n--- 5. 第 1 步:上网方式(不真拨号)---')
r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan', 'mode': 'bogus'})
chk('非法上网方式 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
'mode': 'pppoe', 'iface': 'ens19', 'username': ''})
chk('PPPoE 缺账号 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
'mode': 'pppoe', 'iface': 'ens19', 'username': 'testuser'})
chk('PPPoE 缺密码 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
'mode': 'static', 'iface': 'ens19', 'static_address': ''})
chk('静态模式缺地址 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'wan',
'mode': 'static', 'iface': 'ens19',
'static_address': '192.168.1.2/24', 'static_gateway': 'xx'})
chk('静态模式网关非法 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'apply_step', 'step': 'bogus'})
chk('未知步骤 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
# 拨号接口:不肯确认必须被拒(这样才不会误触断网)
r = call('/api/wizard', {'op': 'dial', 'action': 'connect'})
chk('拨号不带确认 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
chk('拒绝理由说明了会断网',
'中断' in (r.get('msg_cn') or ''), str(r.get('msg_cn'))[:80])
r = call('/api/wizard', {'op': 'dial', 'action': 'reboot', 'confirm': True})
chk('非法拨号动作 → 被拒', not r.get('ok'), str(r.get('msg_cn'))[:80])
print('\n--- 6. 默认路由没有被向导动过(关键红线)---')
rc, gw1 = sh(['sh', '-c', "ip route | grep default"])
chk('默认路由未变', (gw0 or '').strip() == (gw1 or '').strip(),
(gw1 or '').strip()[:80])
rc, ip1 = sh(['sh', '-c', "ip -4 addr show ens18 | grep inet"])
chk('ens18 地址未变', (ip0 or '').strip() == (ip1 or '').strip(),
(ip1 or '').strip()[:60])
# ---------- 7. 构建保护模式 ----------
print('\n--- 7. 构建保护模式:只写盘不重启 ---')
open(BUILD_FLAG, 'w').write('live-wizard-test\n')
before = ''
if os.path.isfile(DNSMASQ_CONF):
with open(DNSMASQ_CONF, encoding='utf-8') as f:
before = f.read()
r = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
'iface': l.get('iface'),
'pool_start': base + '.101', 'pool_end': base + '.201',
'pool_netmask': '255.255.255.0'})
chk('保护模式下接口仍返回成功', r.get('ok'), str(r.get('msg_cn'))[:80])
chk('保护模式标记能读到', (r.get('data') or {}).get('build_mode') is True)
db_lan3 = sqlite_get('dnsmasq') or {}
chk('保护模式下配置仍写进了库(不是丢掉)',
db_lan3.get('pool_start') == base + '.101', str(db_lan3.get('pool_start')))
chk('保护模式下没有重启服务(dnsmasq 状态不变)',
svc('dnsmasq') == now_dnsmasq, '%s → %s' % (now_dnsmasq, svc('dnsmasq')))
os.remove(BUILD_FLAG)
chk('保护模式已关闭', not os.path.isfile(BUILD_FLAG))
finally:
# ---------- 还原 ----------
print('\n--- 8. 还原现场 ---')
for k in ('dnsmasq', 'pppoe', 'radvd', 'system', 'nft_v4', 'nft_v6'):
p = os.path.join(SAFE, k + '.json')
if not os.path.isfile(p):
continue
raw = open(p, encoding='utf-8').read()
if not raw.strip():
sh(['python3', '-c', '''
import sqlite3
c = sqlite3.connect(%r)
c.execute("DELETE FROM settings WHERE key=?", (%r,))
c.commit(); c.close()
''' % (DB, k)])
else:
sh(['python3', '-c', '''
import sqlite3
c = sqlite3.connect(%r)
c.execute("INSERT INTO settings(key,value) VALUES(?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (%r, %r))
c.commit(); c.close()
''' % (DB, k, raw)])
chk('配置库已还原',
(sqlite_get('dnsmasq') or {}).get('pool_start')
== json.loads(open(os.path.join(SAFE, 'dnsmasq.json'), encoding='utf-8').read()
or '{}').get('pool_start'),
str((sqlite_get('dnsmasq') or {}).get('pool_start')))
if os.path.isfile(os.path.join(SAFE, os.path.basename(RADVD_CONF))):
shutil.copy2(os.path.join(SAFE, os.path.basename(RADVD_CONF)), RADVD_CONF)
elif os.path.isfile(RADVD_CONF):
os.remove(RADVD_CONF)
if has_build:
open(BUILD_FLAG, 'w').write('restored\n')
chk('保护模式按原样还原', os.path.isfile(BUILD_FLAG) == has_build)
# 把配置重新渲染回去,让磁盘上的文件和还原后的配置库一致
for mod in ('dnsmasq', 'radvd', 'pppoe'):
call('/api/apply', {'module': mod, 'cfg': sqlite_get(mod), 'live': True})
print('\n--- 9. 红线复核 ---')
rc, out = sh(['sh', '-c', 'ss -lnt 2>/dev/null | grep -c 5900'])
chk('VNC 5900 仍在监听', (out or '').strip() not in ('', '0'), (out or '').strip())
rc, out = sh(['sh', '-c', "ip -4 addr show ens18 | grep inet"])
chk('ens18 仍是 192.168.7.3', '192.168.7.3' in (out or ''), (out or '').strip()[:60])
rc, out = sh(['sh', '-c', "ip route | grep default"])
chk('默认网关仍是 192.168.7.2', '192.168.7.2' in (out or ''), (out or '').strip()[:60])
chk('drouter-web 在跑', svc('drouter-web') == 'active', svc('drouter-web'))
chk('救援通道仍是 inactive', svc('drouter-rescue') != 'active', svc('drouter-rescue'))
if dnsmasq_was != 'active':
chk('dnsmasq 按原样保持未启动', svc('dnsmasq') != 'active', svc('dnsmasq'))
if radvd_was != 'active':
chk('radvd 按原样保持未启动', svc('radvd') != 'active', svc('radvd'))
rc, out = sh(['sh', '-c', 'nft list ruleset 2>/dev/null | wc -l'])
chk('nft 规则集未被向导改动', (out or '').strip() == '0', (out or '').strip())
print('\n' + '=' * 60)
print('通过 %d / 失败 %d' % (PASS, FAIL))
return 1 if FAIL else 0
if __name__ == '__main__':
sys.exit(main())