把全量代码(后端 9 模块 + 前端单文件 SPA,约 4.5 万行)过了一遍,用 AST 抽出 渲染器读取的每个配置键,与页面上输入框绑定的字段做双向差集,专门找两类问题: 前端发了后端从不读(假输入框)、后端读了前端没有入口(隐身字段)。 会写坏系统的: - render_network 违反 render() 契约返回字典,调用方按二元组解包后把字典的键 当成路径和内容 —— 迁移 networkd 会生成文件名叫 name、内容写着 content 的垃圾文件 - DMZ 规则方向写反:注释写「排除到本机自身」,条件写成反面,管理口流量也一并 DNAT 五个「填了等于没填」的输入框: - WAN 的 MTU 与「是否接受上游 DNS」后端零消费,现真正写入 MTUBytes= / UseDNS= - DHCPv6 的「子网 ID」绑的是不存在的键,真生效字段是 sla_id - Samba WINS 要求一个从不回填的字段同时为真,勾了永不生效 - 网桥「启用 STP」未绑定事件,勾完保存不进去 - 主题设计器「按主色生成配色」按钮无 handler 其余: - RA 页补齐 rdnss_life / dnssl_life 输入框(1.0.3 修了错位却没给入口) - 迁移预览只发 lan,必报「WAN 网卡不能为空」且网桥从不出现 → 补发 wan + bridge - dnsmasq / miniupnpd / ppp / dhcpcd IPv6 / RA 地址池 / NFS 等插值零校验 - chrony 服务器校验只认域名和 IPv4,IPv6 NTP 服务器被误拒 - v_int 改用 try/except:上标数字能通过 isdigit() 却让 int() 抛异常导致 500 - ensure_cert 删掉硬编码的开发机 IP,改为本机地址 + 默认路由推导;补 openssl 失败检测 - DDNS 面板 XSS(记录名/服务商未转义);主题 CSS 注释注入(前后端各一处) 测试:render 返回值契约断言、CSS 注释注入回归、模板孤儿控件守卫, _dev/t-*.py 42/42、前端三查 + md 链接检查全绿。
360 lines
18 KiB
Python
360 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""read_render_text 逻辑测试(本地模拟,helper 因 pwd 无法在 Windows 导入)。"""
|
||
import sys, re, io
|
||
sys.path.insert(0, r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend")
|
||
import render
|
||
|
||
src = io.open(r"C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/backend/drouter-helper.py",
|
||
encoding='utf-8').read()
|
||
m = re.search(r'^def read_render_text\(.*?(?=\n\ndef act_apply)', src, re.S | re.M)
|
||
assert m, 'read_render_text 未找到'
|
||
NS = {'render': render, 'ValidateError': render.ValidateError, 'str': str}
|
||
exec('def ok(data=None, msg="ok", code="OK"):\n return {"ok": True, "code": code, "msg_cn": msg, "data": data}\n', NS)
|
||
exec('def fail(msg, code="ERR", data=None):\n return {"ok": False, "code": code, "msg_cn": msg, "data": data}\n', NS)
|
||
exec(m.group(0), NS)
|
||
fn = NS['read_render_text']
|
||
|
||
fails = 0
|
||
def chk(label, cond, extra=''):
|
||
global fails
|
||
if not cond: fails += 1
|
||
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
|
||
|
||
cfg = {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'portfwd': {'enable': True, 'rules': [{'name': 'NAS', 'proto': 'tcp', 'ext_port': 5000,
|
||
'int_ip': '192.168.7.10', 'int_port': 5000, 'enable': True}],
|
||
'dmz': {'enable': True, 'host': '192.168.7.50'}}}
|
||
r = fn({'module': 'nft_v4', 'cfg': cfg})
|
||
chk('nft_v4 预览成功', r['ok'])
|
||
lines = [x for x in r['data']['text'].split('\n') if 'dnat' in x or '放通转发' in x]
|
||
for x in lines: print(' ', x.strip())
|
||
chk('含 dnat 规则', any('dnat to 192.168.7.10' in x for x in lines))
|
||
chk('含 DMZ 规则', any('DMZ' in x for x in lines))
|
||
chk('含放通规则', any('放通转发' in x for x in lines))
|
||
|
||
r2 = fn({'module': 'nft_v4', 'cfg': {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'portfwd': {'enable': True, 'rules': [{'name': 'x', 'proto': 'tcp', 'ext_port': 80,
|
||
'int_ip': '1.2.3.999', 'int_port': 80}]}}})
|
||
chk('非法 IPv4 返回中文错误', (not r2['ok']) and 'IPv4' in r2['msg_cn'], '→ ' + r2['msg_cn'])
|
||
|
||
r3 = fn({'module': 'dnsmasq', 'cfg': {'lan_iface': 'ens18', 'domain': 'lan'}})
|
||
chk('dnsmasq 预览成功', r3['ok'] and len(r3['data']['text']) > 50,
|
||
'长度 %s' % (len(r3['data']['text']) if r3['ok'] else '-'))
|
||
|
||
r4 = fn({'module': 'radvd', 'cfg': {'iface': 'ens18', 'prefix': '2408:8207:1234::/64'}})
|
||
chk('radvd 预览成功', r4['ok'] and r4['data']['text'])
|
||
|
||
r5 = fn({})
|
||
chk('空模块名被拒绝', (not r5['ok']) and r5['code'] == 'NO_MODULE', '→ ' + r5['msg_cn'])
|
||
|
||
r6 = fn({'module': 'nonsense'})
|
||
chk('未知模块返回中文错误', (not r6['ok']) and '未知' in r6['msg_cn'], '→ ' + r6['msg_cn'])
|
||
|
||
# ---------------------------------------------------------------- dnsmasq
|
||
# code 3 / 6 有专属字段,但「自定义 Options」表也能写同一个 code。
|
||
# 1.0.1 及更早会渲染成**重复的 dhcp-option 行**(dnsmasq 对 3/6 是追加语义),
|
||
# 客户端因此收到重复值、DNS 顺序也被搅乱。这里锁死合并后的行为。
|
||
|
||
def opt_lines(text, code):
|
||
"""取某个 code 的**非 force** dhcp-option 行的值"""
|
||
return re.findall(r'^dhcp-option=%s,(.*)$' % code, text, re.M)
|
||
|
||
|
||
def dup_codes(text):
|
||
"""被重复下发的 code(只算非 force 行;force 行与普通行共存是合法的)"""
|
||
codes = re.findall(r'^dhcp-option=(\d+),', text, re.M)
|
||
return sorted({c for c in codes if codes.count(c) > 1})
|
||
|
||
|
||
print('\n--- dnsmasq:code 3 / 6 不得渲染出重复行(1.0.2)---')
|
||
|
||
# 用户真机上那份配置:专属字段与自定义表都写了 3/6
|
||
CFG_DUP = {
|
||
'lan_iface': 'ens18', 'domain': 'lan', 'dhcp_enabled': True,
|
||
'pool_start': '192.168.7.1', 'pool_end': '192.168.7.154',
|
||
'pool_netmask': '255.255.255.0', 'lease_time': 43200,
|
||
'option_gateway': '192.168.7.3',
|
||
'option_dns': '223.5.5.5,119.29.29.29',
|
||
'options': [
|
||
{'enabled': True, 'code': '6', 'value': '192.168.7.3'},
|
||
{'enabled': True, 'code': '3', 'value': '192.168.7.3'},
|
||
],
|
||
'dns_mode': 'custom', 'dns_custom': '223.5.5.5,119.29.29.29',
|
||
}
|
||
t = render.render_dnsmasq(CFG_DUP)
|
||
for _l in t.split('\n'):
|
||
if _l.startswith('dhcp-'):
|
||
print(' ', _l)
|
||
chk('没有任何 code 被重复下发', dup_codes(t) == [], str(dup_codes(t)))
|
||
chk('code 3 只有一行', len(opt_lines(t, 3)) == 1, str(opt_lines(t, 3)))
|
||
chk('code 3 的值已去重', opt_lines(t, 3) == ['192.168.7.3'], str(opt_lines(t, 3)))
|
||
chk('code 6 只有一行', len(opt_lines(t, 6)) == 1, str(opt_lines(t, 6)))
|
||
chk('code 6 合并了自定义值', opt_lines(t, 6) == ['223.5.5.5,119.29.29.29,192.168.7.3'],
|
||
str(opt_lines(t, 6)))
|
||
|
||
# 专属字段留空时,自定义表里的 3/6 不能被吞掉
|
||
t2 = render.render_dnsmasq(dict(CFG_DUP, option_gateway='', option_dns='',
|
||
options=[{'enabled': True, 'code': '3', 'value': '192.168.7.1'}]))
|
||
chk('专属字段为空时自定义 3 仍输出', opt_lines(t2, 3) == ['192.168.7.1'], str(opt_lines(t2, 3)))
|
||
chk('专属字段为空时不下发 code 6', opt_lines(t2, 6) == [], str(opt_lines(t2, 6)))
|
||
|
||
# 去重要保序:先出现的先保留
|
||
t3 = render.render_dnsmasq(dict(CFG_DUP, option_dns='223.5.5.5,1.1.1.1',
|
||
options=[{'enabled': True, 'code': '6', 'value': '1.1.1.1,8.8.8.8'}]))
|
||
chk('顺序去重且保留首次出现顺序', opt_lines(t3, 6) == ['223.5.5.5,1.1.1.1,8.8.8.8'],
|
||
str(opt_lines(t3, 6)))
|
||
|
||
# 带「强制」的 3/6 语义不同(客户端未请求也要下发),不能并进普通行
|
||
t4 = render.render_dnsmasq(dict(CFG_DUP, options=[
|
||
{'enabled': True, 'code': '3', 'value': '192.168.7.9', 'force': True},
|
||
{'enabled': True, 'code': '3', 'value': '192.168.7.9'},
|
||
]))
|
||
chk('force 的 3 走 dhcp-option-force', 'dhcp-option-force=3,192.168.7.9' in t4)
|
||
chk('非 force 的 3 合并成一行', opt_lines(t4, 3) == ['192.168.7.3,192.168.7.9'],
|
||
str(opt_lines(t4, 3)))
|
||
|
||
# 其它 code 必须完全不受影响
|
||
t5 = render.render_dnsmasq(dict(CFG_DUP, options=[
|
||
{'enabled': True, 'code': '43', 'value': 'abc.example.com'},
|
||
{'enabled': True, 'code': '121', 'value': '10.0.0.0/8,192.168.7.3'},
|
||
{'enabled': True, 'code': '121', 'value': '1.2.3.0/24,192.168.7.3', 'force': True},
|
||
]))
|
||
chk('其它 code 原样输出', 'dhcp-option=43,abc.example.com' in t5)
|
||
chk('其它 code 的 force 保留', 'dhcp-option-force=121,1.2.3.0/24,192.168.7.3' in t5)
|
||
chk('121 两行是允许的(值不同)', len(re.findall(r'^dhcp-option(?:-force)?=121,', t5, re.M)) == 2)
|
||
|
||
# 预览接口走的是 helper 的 read_render_text,必须与直接渲染一致
|
||
p = fn({'module': 'dnsmasq', 'cfg': CFG_DUP})
|
||
chk('预览路径同样不重复', p['ok'] and dup_codes(p['data']['text']) == [],
|
||
str(dup_codes(p['data']['text'])) if p['ok'] else p.get('msg_cn'))
|
||
chk('预览里 code 3 也是一行', p['ok'] and opt_lines(p['data']['text'], 3) == ['192.168.7.3'],
|
||
str(opt_lines(p['data']['text'], 3)) if p['ok'] else '-')
|
||
|
||
# 校验不能被放松
|
||
try:
|
||
render.render_dnsmasq(dict(CFG_DUP, option_gateway='1.2.3.999'))
|
||
chk('非法网关仍被拒绝', False, '居然没抛异常')
|
||
except render.ValidateError as ex:
|
||
chk('非法网关仍被拒绝', 'IPv4' in str(ex), str(ex))
|
||
|
||
# ---------------------------------------------------------------- radvd RDNSS
|
||
# RA 通告里根本没有 IPv4 的位置:RDNSS 只能是 IPv6 地址,且多值必须**空格分隔**
|
||
# (逗号形式 radvd 直接 syntax error)。这里原先完全不校验,于是「下发 DNS」
|
||
# 框里顺手填个 223.5.5.5 就会渲染出一份 radvd 读不进去的配置 ——
|
||
# 真机上留下的错误日志就是 "/run/drouter-verify/radvd.conf:18 error: syntax error",
|
||
# 而界面只回一句英文,完全指不到是哪个字段。
|
||
|
||
def ra(extra):
|
||
return render.render_radvd(dict({'iface': 'ens18', 'prefix': 'fd00:7::/64'}, **extra))
|
||
|
||
for _bad in ('223.5.5.5', '223.5.5.5,119.29.29.29', 'dns.alidns.com',
|
||
'fd00:7::/64'):
|
||
try:
|
||
ra({'rdnss': _bad})
|
||
chk('RDNSS 拒绝 %r' % _bad, False, '居然通过了')
|
||
except render.ValidateError as ex:
|
||
chk('RDNSS 拒绝 %r' % _bad, 'IPv6' in str(ex), str(ex))
|
||
|
||
_r = ra({'rdnss': 'fd00:7::1 fd00:7::2'})
|
||
chk('RDNSS 多个 IPv6 合并成一行(空格分隔)',
|
||
re.search(r'^\s*RDNSS fd00:7::1 fd00:7::2 \{$', _r, re.M) is not None)
|
||
_r = ra({'rdnss': 'fd00:7::1,fd00:7::2'})
|
||
chk('RDNSS 逗号列表归一成空格(而不是让 radvd 报 syntax error)',
|
||
re.search(r'^\s*RDNSS fd00:7::1 fd00:7::2 \{$', _r, re.M) is not None)
|
||
|
||
_r = ra({'rdnss': 'fd00:7::1', 'dnssl': 'lan,home.lan'})
|
||
chk('DNSSL 支持多域名(空格分隔)',
|
||
re.search(r'^\s*DNSSL lan home\.lan \{$', _r, re.M) is not None)
|
||
try:
|
||
ra({'rdnss': 'fd00:7::1', 'dnssl': 'ok.lan,bad@lan'})
|
||
chk('DNSSL 非法域名被拒绝', False, '居然通过了')
|
||
except render.ValidateError as ex:
|
||
chk('DNSSL 非法域名被拒绝', 'DNSSL' in str(ex), str(ex))
|
||
|
||
# DNSSL 生命期原来读的是 rdnss_life —— 「DNSSL 生命期」这个 label 指向别人的值
|
||
chk('DNSSL 生命期优先取 dnssl_life',
|
||
'AdvDNSSLLifetime 1200;' in ra({'rdnss': 'fd00:7::1', 'dnssl': 'lan',
|
||
'dnssl_life': 1200}))
|
||
chk('没有 dnssl_life 时退回 rdnss_life',
|
||
'AdvDNSSLLifetime 900;' in ra({'rdnss': 'fd00:7::1', 'dnssl': 'lan',
|
||
'rdnss_life': 900}))
|
||
|
||
# ------------------------------------------------- 1.0.4:未落地校验的补齐
|
||
# 以下字段原先都是「用户在页面上填什么就原样拼进配置行」,同段的其它字段
|
||
# 都有校验、唯独它们漏了。风险不只是填错:配置是一行一条指令,值里带一个
|
||
# 换行就能凭空造出一条新指令。这里统一按 UI 上真实的输入框逐项锁死。
|
||
|
||
def rej(label, fn_call):
|
||
try:
|
||
fn_call()
|
||
chk(label, False, '居然通过了')
|
||
except render.ValidateError as ex:
|
||
chk(label, True, str(ex))
|
||
|
||
|
||
print('\n--- dnsmasq:domain / log_file ---')
|
||
rej('域名含换行被拒绝',
|
||
lambda: render.render_dnsmasq({'lan_iface': 'ens18',
|
||
'domain': 'lan\ndhcp-range=1.1.1.1,2.2.2.2'}))
|
||
rej('域名含空格被拒绝',
|
||
lambda: render.render_dnsmasq({'lan_iface': 'ens18', 'domain': 'my lan'}))
|
||
rej('日志文件含换行被拒绝',
|
||
lambda: render.render_dnsmasq({'lan_iface': 'ens18',
|
||
'log_file': '/var/log/x\nsecurity=off'}))
|
||
_d = render.render_dnsmasq({'lan_iface': 'ens18', 'domain': 'home.lan'})
|
||
chk('合法域名正常渲染', 'domain=home.lan' in _d)
|
||
chk('默认域名仍是 lan',
|
||
'domain=lan' in render.render_dnsmasq({'lan_iface': 'ens18'}))
|
||
chk('默认日志路径未被改动',
|
||
'log-facility=/var/log/drouter/dnsmasq.log'
|
||
in render.render_dnsmasq({'lan_iface': 'ens18'}))
|
||
|
||
print('\n--- ppp:运营商 / 服务名 ---')
|
||
_PPPOE = {'iface': 'ens19', 'username': 'u1', 'password': 'p1'}
|
||
|
||
|
||
def ppp(extra):
|
||
return render.render_ppp(dict(_PPPOE, **extra))
|
||
|
||
|
||
rej('PPPoE 服务名含引号被拒绝',
|
||
lambda: ppp({'service_name': 'cmcc"\nnoauth'}))
|
||
rej('PPPoE 运营商含换行被拒绝',
|
||
lambda: ppp({'isp': '江苏电信\nnoauth'}))
|
||
_peers = ppp({'service_name': 'cmcc', 'isp': '江苏电信'})[0][1]
|
||
chk('合法服务名正常写出', 'servicename "cmcc"' in _peers, )
|
||
chk('运营商写进注释行', '# 运营商:江苏电信' in _peers)
|
||
|
||
print('\n--- dhcpv6:SLA-ID 后缀 ---')
|
||
rej('SLA-ID 含换行被拒绝',
|
||
lambda: render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '::1\nia_na 1'}}))
|
||
# 注意别拿 abc 当非法值:那是一串合法的十六进制(::abc 也是合法 IPv6)
|
||
rej('SLA-ID 非 IPv6 被拒绝',
|
||
lambda: render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '1.2.3.4'}}))
|
||
rej('SLA-ID 含非十六进制字符被拒绝',
|
||
lambda: render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '::zzz'}}))
|
||
_cd = render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '::1'}})
|
||
chk('合法 SLA-ID 正常渲染', 'ia_pd 0/br0/0/64/::1' in _cd, _cd.split('\n')[-2] if _cd else '')
|
||
# 页面默认值 ::1,也允许用户偷懒只写 1
|
||
chk('SLA-ID 支持简写(1 → ::1)',
|
||
'::1' in render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '1'}}))
|
||
|
||
print('\n--- miniupnpd:lease_file / uuid ---')
|
||
rej('UPnP 租约文件路径含注入被拒绝',
|
||
lambda: render.render_miniupnpd({'upnp': {'lease_file': '/tmp/x\next_ifname=lo'}}))
|
||
rej('UPnP uuid 含换行被拒绝',
|
||
lambda: render.render_miniupnpd({'upnp': {'uuid': 'x\nenable_upnp=no'}}))
|
||
_u = render.render_miniupnpd({'upnp': {}})
|
||
chk('UPnP 默认租约文件/UUID 未变',
|
||
'lease_file=/var/run/miniupnpd.leases' in _u and 'uuid=drouter-0001' in _u)
|
||
|
||
print('\n--- radvd:IPv6 地址池块 ---')
|
||
rej('池名称含换行被拒绝',
|
||
lambda: render.render_radvd({'iface': 'ens18', 'pool_name': 'x\nprefix 2001:db8::/64 {};'}))
|
||
rej('池前缀非法被拒绝',
|
||
lambda: render.render_radvd({'iface': 'ens18', 'pool_prefix': '2408::/999'}))
|
||
rej('池策略含换行被拒绝',
|
||
lambda: render.render_radvd({'iface': 'ens18', 'pool_policy': ['a\nb']}))
|
||
rej('起始大于结束被拒绝',
|
||
lambda: render.render_radvd({'iface': 'ens18', 'pool_start': 10, 'pool_end': 5}))
|
||
# 0 是合法起始子网 ID:别写成 pool_start or 1(同 hour=0 的老毛病)
|
||
_r0 = render.render_radvd({'iface': 'ens18', 'pool_start': 0, 'pool_end': 254})
|
||
chk('起始子网 ID 0 不会被吞成 1', '子网 ID 0 - 254' in _r0, _r0.strip().split('\n')[-2])
|
||
|
||
print('\n--- chrony:IPv6 NTP 服务器 ---')
|
||
_n = render.render_chrony({'ntp': {'servers': 'ntp.aliyun.com,2400:3200::1'}})
|
||
chk('IPv6 的 NTP 服务器被接受', 'server 2400:3200::1 iburst' in _n, )
|
||
rej('NTP 服务器里的换行仍被拒绝',
|
||
lambda: render.render_chrony({'ntp': {'servers': 'x.com\nserver 1.2.3.4'}}))
|
||
|
||
print('\n--- nfs:备注 / 选项 / 模板 ---')
|
||
_NFS = {'nfs': {'exports': [{'path': '/srv/share',
|
||
'clients': [{'net': '192.168.7.0/24', 'preset': 'macos'}]}]}}
|
||
_nexp = render.render_nfs(_NFS)
|
||
chk('NFS 正常导出', '/srv/share 192.168.7.0/24(' in _nexp)
|
||
rej('NFS 备注含换行被拒绝',
|
||
lambda: render.render_nfs({'nfs': {'exports': [
|
||
{'path': '/srv/share', 'comment': 'ok\n/srv 127.0.0.1(rw)',
|
||
'clients': [{'net': '192.168.7.0/24'}]}]}}))
|
||
rej('NFS 自定义选项含空格/换行被拒绝',
|
||
lambda: render.render_nfs({'nfs': {'exports': [
|
||
{'path': '/srv/share', 'clients': [
|
||
{'net': '192.168.7.0/24', 'options': 'rw\n/srv 0.0.0.0/0(rw)'}]}]}}))
|
||
rej('未知 NFS 模板不再静默退回 linux',
|
||
lambda: render.render_nfs({'nfs': {'exports': [
|
||
{'path': '/srv/share', 'clients': [{'net': '192.168.7.0/24', 'preset': 'mac'}]}]}}))
|
||
rej('NFS 线程数非数字给中文提示',
|
||
lambda: render.render_nfs_conf({'nfs': {'threads': 'abc'}}))
|
||
|
||
print('\n--- v_int:Unicode 数字不能绕成 500 ---')
|
||
for _bad in ('²', '٣'):
|
||
try:
|
||
render.v_int(_bad, field='测试值', lo=0, hi=65535)
|
||
chk('v_int 拒绝 %r' % _bad, False, '居然通过了')
|
||
except render.ValidateError as ex:
|
||
chk('v_int 拒绝 %r' % _bad, True, str(ex))
|
||
|
||
print('\n--- DMZ:不能再把本机自己的流量转走 ---')
|
||
_dmz = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'portfwd': {'dmz': {'enable': True, 'host': '192.168.7.50'}}})
|
||
chk('DMZ 前先放行到本机自身的流量',
|
||
'fib daddr type local accept' in _dmz)
|
||
chk('DMZ 规则不再用 daddr != <DMZ 主机> 的反向条件',
|
||
'daddr != 192.168.7.50' not in _dmz)
|
||
chk('DMZ 主机仍能收到转发', 'dnat to 192.168.7.50' in _dmz)
|
||
|
||
print('\n--- 端口转发备注:换行不得注入 nft ---')
|
||
_pf = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'],
|
||
'portfwd': {'enable': True, 'rules': [
|
||
{'name': 'a\nmasquerade', 'proto': 'tcp',
|
||
'ext_port': 80, 'int_ip': '192.168.7.10', 'int_port': 80}]}})
|
||
chk('备注里的换行被去掉', '\nmasquerade' not in _pf and 'masquerade' in _pf)
|
||
|
||
_NET = {'wan': {'iface': 'ens19', 'mode': 'dhcp', 'mtu': 1500},
|
||
'lan': {'iface': 'ens18', 'address': '192.168.7.3/24', 'mtu': 1500},
|
||
'bridge': {'enabled': True, 'name': 'br0', 'members': ['ens18'], 'stp': True}}
|
||
_net = render.render('network', _NET)
|
||
chk('render("network") 按契约返回 (路径, 内容) 二元组',
|
||
all(isinstance(f, tuple) and len(f) == 2 for f in _net))
|
||
# 曾经的 bug:这里返回的是 {'name','content'} 字典,helper 里
|
||
# `for path, content in files` 解包 dict 只会拿到它的两个**键名**,
|
||
# 于是把字面量 'content' 写进一个叫 'name' 的文件。
|
||
chk('路径是 /etc/systemd/network/ 下的绝对路径',
|
||
all(f[0].startswith('/etc/systemd/network/') for f in _net))
|
||
chk('内容是文本而不是 dict 的键名',
|
||
all(isinstance(f[1], str) and f[1] != 'content' for f in _net))
|
||
_net_names = [f[0].split('/')[-1] for f in _net]
|
||
chk('停用式迁移会产出 WAN 单元', any(n.startswith('10-drouter-wan-') for n in _net_names))
|
||
chk('网桥 netdev 单元存在', '20-drouter-br0.netdev' in _net_names)
|
||
chk('桥接成员单元存在', '21-drouter-br0-member-ens18.network' in _net_names)
|
||
chk('STP 打开时写 STP=yes',
|
||
'STP=yes' in dict((f[0].split('/')[-1], f[1]) for f in _net).get('20-drouter-br0.netdev', ''))
|
||
_net_off = render.render('network', dict(_NET, bridge={'enabled': False, 'members': ['ens18']}))
|
||
chk('网桥关闭时不产出任何 bridge 单元',
|
||
not any('/br0' in f[0] for f in _net_off))
|
||
chk('网桥关闭时 LAN 地址直接落在物理口上',
|
||
any('30-drouter-lan-ens18.network' in f[0] for f in _net_off))
|
||
rej('缺 WAN 网卡时给出中文字段级提示',
|
||
lambda: render.render('network', {'lan': {'iface': 'ens18'}}))
|
||
|
||
print('\n--- network:WAN 页 DHCP 模式的 MTU / DNS 开关不再是假控件 ---')
|
||
|
||
|
||
def _wan_unit(wan):
|
||
fs = render.render('network', {'lan': {'iface': 'ens18', 'address': '192.168.7.3/24'},
|
||
'bridge': {'enabled': False}, 'wan': wan})
|
||
return fs[0][1]
|
||
|
||
|
||
chk('未传 use_dns 时沿用「使用上级 DNS」',
|
||
'UseDNS=yes' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp'}))
|
||
chk('用户在 WAN 页关掉后能真的关掉',
|
||
'UseDNS=no' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp', 'use_dns': False}))
|
||
chk('用户在 WAN 页打开后仍是 yes',
|
||
'UseDNS=yes' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp', 'use_dns': True}))
|
||
chk('DHCP 模式填的 MTU 能落进配置文件',
|
||
'MTUBytes=1400' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp', 'mtu': 1400}))
|
||
|
||
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
|
||
sys.exit(1 if fails else 0)
|