Files
drouter/backend/render.py
T
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

1356 lines
56 KiB
Python
Raw 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 -*-
"""
drouter - 配置渲染器
职责:把 Web 层保存在 SQLite 中的结构化配置,渲染成各原生服务的配置文件文本。
本模块为纯逻辑(不碰系统、不需要 root),可独立单元测试。
"""
import re
import ipaddress
# ---------------------------------------------------------------- 校验工具
IPV4_RE = re.compile(r'^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')
IFNAME_RE = re.compile(r'^[a-zA-Z0-9._:-]{1,15}$')
MAC_RE = re.compile(r'^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$')
DOMAIN_RE = re.compile(r'^(?!-)[a-zA-Z0-9-_.]{1,253}$')
class ValidateError(Exception):
"""输入校验失败,附带简体中文提示"""
def __init__(self, msg_cn, field=None):
super().__init__(msg_cn)
self.msg_cn = msg_cn
self.field = field
def v_ipv4(v, field='值', allow_empty=False):
v = (v or '').strip()
if not v:
if allow_empty:
return ''
raise ValidateError(f'{field}不能为空,请填写合法的 IPv4 地址', field)
if not IPV4_RE.match(v):
raise ValidateError(f'{field}不是合法的 IPv4 地址:{v}', field)
return v
def v_ipv6(v, field='值', allow_empty=False, global_only=True):
"""校验 IPv6 地址。默认要求为全球单播(拒绝链路本地 / ULA / 组播)。"""
v = (v or '').strip().split('%')[0]
if not v:
if allow_empty:
return ''
raise ValidateError(f'{field}不能为空,请填写合法的 IPv6 地址', field)
try:
a = ipaddress.IPv6Address(v)
except Exception:
raise ValidateError(f'{field}不是合法的 IPv6 地址:{v}', field)
if global_only and not a.is_global:
raise ValidateError(
f'{field}必须是公网可路由的 IPv6 地址(不能是 fe80::/fc00::/ff00:: 等):{v}', field)
return str(a)
def v_ipv4_list(v, field='值', allow_empty=True):
"""IPv4 列表,用于 DNS 服务器。兼容两种入参:list 或逗号分隔字符串。"""
if isinstance(v, (list, tuple)):
parts = [str(x).strip() for x in v if str(x).strip()]
else:
v = (v or '').strip()
parts = [x.strip() for x in v.split(',') if x.strip()] if v else []
if not parts:
if allow_empty:
return []
raise ValidateError(f'{field}不能为空', field)
out = []
for item in parts:
if not IPV4_RE.match(item):
raise ValidateError(f'{field}包含非法 IPv4 地址:{item}', field)
out.append(item)
if len(out) > 8:
raise ValidateError(f'{field}最多允许 8 个地址', field)
return out
def v_cidr(v, field='值', allow_empty=False):
v = (v or '').strip()
if not v:
if allow_empty:
return ''
raise ValidateError(f'{field}不能为空', field)
try:
net = ipaddress.IPv4Network(v, strict=False)
except Exception:
raise ValidateError(f'{field}不是合法的网段(示例 192.168.7.1/24):{v}', field)
return f'{net.network_address}/{net.prefixlen}'
def v_int(v, field='值', lo=0, hi=65535, default=None, allow_empty=False):
s = str(v if v is not None else '').strip()
if s == '':
if allow_empty:
return None
if default is not None:
return default
raise ValidateError(f'{field}不能为空', field)
if not s.isdigit():
raise ValidateError(f'{field}必须是数字:{s}', field)
n = int(s)
if n < lo or n > hi:
raise ValidateError(f'{field}必须在 {lo} ~ {hi} 之间', field)
return n
def v_ifname(v, field='网卡名', allow_empty=False):
v = (v or '').strip()
if not v:
if allow_empty:
return ''
raise ValidateError(f'{field}不能为空', field)
if not IFNAME_RE.match(v):
raise ValidateError(f'{field}包含非法字符:{v}', field)
return v
def v_mac(v, field='MAC 地址'):
v = (v or '').strip()
if not MAC_RE.match(v):
raise ValidateError(f'{field}格式不正确(示例 bc:24:11:1a:8f:43):{v}', field)
return v.lower()
def v_bool(v):
return bool(v) and str(v).lower() not in ('false', '0', 'no', 'off', '')
def v_choice(v, choices, field='值'):
v = (v or '').strip()
if v not in choices:
raise ValidateError(f'{field}只能是:{", ".join(choices)}', field)
return v
def v_option_code(v):
return v_int(v, field='DHCP Option Code', lo=1, hi=254)
def v_option_value(v, code):
"""按 Option Code 校验值:3/6/44 等为 IP 列表,其他允许字符串(hex 或文本)"""
v = (v or '').strip()
if not v:
raise ValidateError('Option 值不能为空', 'value')
if code in (1, 3, 4, 5, 6, 7, 16, 28, 41, 44, 45, 48, 49, 50, 51, 54, 65, 68, 69, 70, 71, 72, 73, 74, 76):
return ','.join(v_ipv4_list(v, field=f'Option {code} 的值'))
if len(v) > 255:
raise ValidateError(f'Option {code} 的值过长(最多 255 字符)', 'value')
if re.search(r'[\r\n]', v):
raise ValidateError(f'Option {code} 的值不能包含换行', 'value')
return v
# ---------------------------------------------------------------- dnsmasq
def render_dnsmasq(cfg):
"""cfg 结构见 web 配置:lan_iface / pool / options / dns / static_leases"""
lan = v_ifname(cfg.get('lan_iface') or '', field='LAN 接口')
lines = [
'# 由 drouter 自动生成,请勿手工修改',
'# 模块:dnsmasq(DHCPv4 + DNS 转发)',
'',
'# ---- 基本行为 ----',
f'interface={lan}',
'bind-interfaces',
'domain-needed',
'bogus-priv',
'expand-hosts',
f"domain={cfg.get('domain') or 'lan'}",
'local=/lan/',
'',
'# ---- DHCPv4 地址池 ----',
]
enabled = v_bool(cfg.get('dhcp_enabled'))
if enabled:
pool_start = v_ipv4(cfg.get('pool_start'), field='地址池起始')
pool_end = v_ipv4(cfg.get('pool_end'), field='地址池结束')
netmask = v_ipv4(cfg.get('pool_netmask') or '255.255.255.0', field='子网掩码')
lease = v_int(cfg.get('lease_time') or 7200, field='租期', lo=120, hi=604800)
lines.append(f'dhcp-range={pool_start},{pool_end},{netmask},{lease}s')
gw = (cfg.get('option_gateway') or '').strip()
if gw:
lines.append(f'dhcp-option=3,{v_ipv4(gw, field="Option 3 网关")}')
dns = (cfg.get('option_dns') or '').strip()
if dns:
lines.append(f'dhcp-option=6,{",".join(v_ipv4_list(dns, field="Option 6 DNS"))}')
# 自定义任意 option
for opt in cfg.get('options') or []:
if not v_bool(opt.get('enabled')):
continue
code = v_option_code(opt.get('code'))
val = v_option_value(opt.get('value'), code)
force = v_bool(opt.get('force'))
lines.append(f'dhcp-option{"-force" if force else ""}={code},{val}')
# 静态绑定
for sl in cfg.get('static_leases') or []:
if not v_bool(sl.get('enabled')):
continue
mac = v_mac(sl.get('mac'))
ip = v_ipv4(sl.get('ip'), field='静态绑定 IP')
name = (sl.get('name') or '').strip()
name = name if re.match(r'^[a-zA-Z0-9-]{0,63}$', name) else ''
lines.append(f'dhcp-host={mac},{ip}' + (f',{name}' if name else ''))
else:
lines.append('# DHCP 服务已关闭(仅启用 DNS 转发)')
lines.append('no-dhcp-interface=' + lan)
lines += ['', '# ---- DNS 上游 ----']
upstream_mode = v_choice(cfg.get('dns_mode') or 'custom',
['isp', 'custom', 'both'], field='DNS 来源')
servers = []
if upstream_mode in ('custom', 'both'):
servers += v_ipv4_list(cfg.get('dns_custom') or '', field='自定义 DNS')
if upstream_mode in ('isp', 'both'):
servers.append('# 运营商下发 DNS 由 /etc/drouter/generated/isp-dns.conf 动态注入')
lines.append('resolv-file=/etc/drouter/generated/isp-dns.conf')
for s in servers:
if s.startswith('#'):
continue
lines.append(f'server={s}')
if cfg.get('dns_lan_server'):
lines.append(f"server=/{cfg.get('domain') or 'lan'}/{v_ipv4(cfg.get('dns_lan_server'), field='内网 DNS')}")
lines += [
f"cache-size={v_int(cfg.get('dns_cache_size') or 1000, field='DNS 缓存', lo=0, hi=20000)}",
'no-negcache' if not v_bool(cfg.get('dns_negcache', True)) else '',
'log-queries' if v_bool(cfg.get('dns_query_log')) else '',
f"log-facility={cfg.get('log_file') or '/var/log/drouter/dnsmasq.log'}",
'dhcp-leasefile=/var/lib/misc/dnsmasq.leases',
]
return '\n'.join([l for l in lines if l != '']) + '\n'
# ---------------------------------------------------------------- nftables
NFT_HEADER = '# 由 drouter 自动生成 / 可手工编辑\n' \
'# 应用前会执行 nft -c 语法预检,失败将自动回滚\n'
# 防火墙日志开关默认限速(每秒最多打印的包数),避免刷爆内核日志
FW_LOG_RATE = 20
# 日志前缀:/var/log/drouter/fw4.log 的采集脚本据此识别本机防火墙日志。
# 用统一前缀便于后续「连接与流日志」页面把 IPv4/IPv6 防火墙日志聚合到一起。
FW_LOG_PREFIX = 'DROUTER-FW'
def _fw_log_rule(prefix, rate, comment):
"""生成一条带限速的 log 规则文本(log prefix 里带 IPv4/IPv6 标记)。"""
return (' log prefix "%s-%s " limit rate %d/second '
'comment "%s"' % (FW_LOG_PREFIX, prefix, rate, comment))
def _mss_rule(wan, mode, mss, comment):
"""MSS 钳制规则。
clamp —— 按出接口路由的 MTU 自动推算(PPPoE 拨号时最省心,MTU 变了不用改配置);
fixed —— 写死一个值(多拨、隧道、GRE 等 MTU 不一致的场景才需要手工指定)。
"""
if mode == 'fixed':
try:
val = max(576, min(int(mss), 9000))
except Exception:
val = 1452
return (f' oifname "{wan}" tcp flags syn tcp option maxseg size set {val} '
f'comment "{comment}"')
return (f' oifname "{wan}" tcp flags syn tcp option maxseg size set rt mtu '
f'comment "{comment}"')
def _default_v4(wan, lan_ifaces, mss_clamp=True, mss=1452,
log_drop=True, log_accept=False, log_rate=FW_LOG_RATE,
pf_enable=False, pf_rules=None, pf_dmz_enable=False, pf_dmz_host='',
masquerade=True, mss_mode='clamp'):
"""生成 IPv4 默认规则集。
log_drop : 在默认拒绝前记录被丢弃的包(默认开启,方便排查"为什么不通")
log_accept : 是否同时记录放通的包(数据量大,默认关闭)
log_rate : 日志限速(包/秒),防止日志风暴拖垮小内存设备
pf_* : 端口转发 / DMZ 参数(见 _pf_rules)
masquerade : 是否做出向地址伪装(SNAT)。关掉后内网将「只转发不改写源地址」,
只有在上游已有回程路由(静态路由 / 已指派网段)时才通。
mss_mode : clamp=按路由 MTU 自动算 / fixed=写死 mss 值
"""
lan_set = ', '.join(f'"{i}"' for i in lan_ifaces) or '"br0"'
log_drop = bool(log_drop)
log_accept = bool(log_accept)
try:
log_rate = max(1, min(int(log_rate or FW_LOG_RATE), 1000))
except Exception:
log_rate = FW_LOG_RATE
pf_nat, pf_filter = _pf_rules(
{'pf_enable': pf_enable, 'pf_rules': pf_rules,
'pf_dmz_enable': pf_dmz_enable, 'pf_dmz_host': pf_dmz_host,
'wan_iface': wan}, '4', 'ip')
s = [
NFT_HEADER,
'table ip drouter4 {',
' # 局域网接口集合(PPPoe/WAN 口之外的接口)',
' set lanifs {',
' type ifname',
f' elements = {{ {lan_set} }}' if lan_set else ' elements = { "br0" }',
' }',
'',
' chain input {',
' type filter hook input priority filter; policy accept;',
' iif "lo" accept',
' ct state established,related accept',
]
if log_drop:
s += [' ct state invalid '
+ _fw_log_rule('4-IN-INVALID', log_rate, '入站非法状态包(已记录)').strip(),
' ct state invalid drop']
else:
s += [' ct state invalid drop']
s += [
' iifname @lanifs accept comment "允许局域网访问本机管理口"',
]
if log_accept:
s += [_fw_log_rule('4-IN-ACCEPT', log_rate, '入站被放通(已记录)')]
s += [
' # 默认放通 ICMP,便于排障;如需要可在下方自行添加拒绝规则',
' icmp type echo-request accept',
]
if log_drop:
s += [_fw_log_rule('4-IN-REJECT', log_rate, '入站默认拒绝(已记录)')]
s += [
' counter comment "入站总计数"',
' }',
'',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
' ct state established,related accept',
]
if log_drop:
s += [' ct state invalid '
+ _fw_log_rule('4-FWD-INVALID', log_rate, '转发非法状态包(已记录)').strip(),
' ct state invalid drop']
else:
s += [' ct state invalid drop']
s += [' iifname @lanifs accept comment "允许局域网转发上网"']
if log_accept:
s += [_fw_log_rule('4-FWD-ACCEPT', log_rate, '转发被放通(已记录)')]
# 端口转发 / DMZ 的放通规则(需在默认拒绝之前)
if pf_filter:
s += [' # ---- 端口转发 / DMZ 放通(由「端口转发」页面生成)----']
s += pf_filter
if log_drop:
s += [_fw_log_rule('4-FWD-REJECT', log_rate, '转发默认拒绝(已记录)')]
s += [
' counter comment "转发总计数"',
' }',
'',
]
# DNAT 链:端口转发与 DMZ 均在此完成目的地址改写
if pf_nat:
s += [
' # ---- 目的地址转换(DNAT):端口转发 / DMZ ----',
' chain nat_pre {',
' type nat hook prerouting priority dstnat; policy accept;',
]
s += pf_nat
s += [
' }',
'',
]
nat_body = []
if masquerade:
nat_body.append(f' oifname "{wan}" masquerade comment "WAN 出向地址伪装"')
else:
nat_body.append(' # 出向地址伪装已关闭:内网地址原样发出,'
'需要上游路由器已有回程路由才通')
s += [' chain nat_post {',
' type nat hook postrouting priority srcnat; policy accept;'] \
+ nat_body + [' }']
if mss_clamp:
s += [
'',
' chain mangle_forward {',
' type filter hook forward priority mangle; policy accept;',
_mss_rule(wan, mss_mode, mss, 'MSS 钳制(解决部分网站打不开)'),
' }',
]
s += ['}']
return '\n'.join(s) + '\n'
def _default_v6(wan, lan_ifaces,
log_drop=True, log_accept=False, log_rate=FW_LOG_RATE,
pf_enable=False, pf_rules=None, pf_dmz_enable=False, pf_dmz_host='',
mss_clamp=False, mss=1432, mss_mode='clamp', masquerade=False):
"""生成 IPv6 默认规则集(日志语义同 IPv4)。
IPv6 的两项默认与 IPv4 不同,别照抄:
masquerade 默认「关」—— IPv6 地址足够多,正常玩法是端到端直连(每个设备都有
公网 v6),做 NAT66 反而会让 BT / 摄像头 / 远程桌面这类入向应用失效;
只有在「上游只给了一个 /64 且不再下发 PD」时才需要打开。
mss_clamp 默认「关」—— IPv6 依赖 PMTUD 且不允许中间设备分片,
绝大多数场景无需钳制;只有 PPPoE / 隧道把 MTU 压到 1500 以下时才需要。
"""
lan_set = ', '.join(f'"{i}"' for i in lan_ifaces) or '"br0"'
log_drop = bool(log_drop)
log_accept = bool(log_accept)
try:
log_rate = max(1, min(int(log_rate or FW_LOG_RATE), 1000))
except Exception:
log_rate = FW_LOG_RATE
pf_nat, pf_filter = _pf_rules(
{'pf_enable': pf_enable, 'pf_rules': pf_rules,
'pf_dmz_enable': pf_dmz_enable, 'pf_dmz_host': pf_dmz_host,
'wan_iface': wan}, '6', 'ip6')
s = [
NFT_HEADER,
'table ip6 drouter6 {',
' set lanifs {',
' type ifname',
f' elements = {{ {lan_set} }}' if lan_set else ' elements = { "br0" }',
' }',
'',
' chain input {',
' type filter hook input priority filter; policy accept;',
' iif "lo" accept',
' ct state established,related accept',
]
if log_drop:
s += [_fw_log_rule('6-IN-INVALID', log_rate, '入站非法状态包(已记录)'),
' ct state invalid drop']
else:
s += [' ct state invalid drop']
s += [
' # ICMPv6 是 IPv6 正常工作的基础,必须放通',
' icmpv6 type { echo-request, echo-reply, nd-router-advert, nd-router-solicit,',
' nd-neighbor-solicit, nd-neighbor-advert, destination-unreachable,',
' packet-too-big, parameter-problem, time-exceeded } accept',
' iifname @lanifs accept',
]
if log_accept:
s += [_fw_log_rule('6-IN-ACCEPT', log_rate, '入站被放通(已记录)')]
if log_drop:
s += [_fw_log_rule('6-IN-REJECT', log_rate, '入站默认拒绝(已记录)')]
s += [
' }',
'',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
' ct state established,related accept',
]
if log_drop:
s += [_fw_log_rule('6-FWD-INVALID', log_rate, '转发非法状态包(已记录)'),
' ct state invalid drop']
else:
s += [' ct state invalid drop']
s += [
' icmpv6 type { echo-request, destination-unreachable, packet-too-big,',
' parameter-problem, time-exceeded } accept',
' iifname @lanifs accept',
]
if log_accept:
s += [_fw_log_rule('6-FWD-ACCEPT', log_rate, '转发被放通(已记录)')]
# 端口转发 / DMZ 放通(IPv6 直接路由,无需 NAT,仅需放通)
if pf_filter:
s += [' # ---- 端口转发 / DMZ 放通(由「端口转发」页面生成)----']
s += pf_filter
if log_drop:
s += [_fw_log_rule('6-FWD-REJECT', log_rate, '转发默认拒绝(已记录)')]
s += [
' }',
]
if pf_nat:
s += [
'',
' # ---- IPv6 端口转发(DNAT,仅当地址非直连时使用)----',
' chain nat_pre {',
' type nat hook prerouting priority dstnat; policy accept;',
]
s += pf_nat
s += [' }']
if masquerade:
s += [
'',
' # ---- NAT66:上游只给一个 /64 时才需要 ----',
' chain nat_post {',
' type nat hook postrouting priority srcnat; policy accept;',
f' oifname "{wan}" masquerade comment "IPv6 出向地址伪装(NAT66)"',
' }',
]
if mss_clamp:
s += [
'',
' chain mangle_forward {',
' type filter hook forward priority mangle; policy accept;',
_mss_rule(wan, mss_mode, mss, 'IPv6 MSS 钳制'),
' }',
]
s += [
'}',
]
return '\n'.join(s) + '\n'
def render_nft(kind, cfg):
"""
kind: 'v4' | 'v6'
cfg: {wan_iface, lan_ifaces[], mss_clamp, mss, raw,
log_drop, log_accept, log_rate, portfwd}
如果用户提供了 raw 自定义规则,则直接采用 raw(高级模式)
"""
wan = v_ifname(cfg.get('wan_iface') or 'ppp0', field='WAN 接口')
lans = cfg.get('lan_ifaces') or []
lans = [v_ifname(x, field='LAN 接口') for x in lans] or ['br0']
raw = (cfg.get('raw') or '').strip()
log_kw = {
'log_drop': cfg.get('log_drop', True),
'log_accept': cfg.get('log_accept', False),
'log_rate': v_int(cfg.get('log_rate') or FW_LOG_RATE,
field='防火墙日志限速', lo=1, hi=1000),
}
pf = cfg.get('portfwd') or {}
pf_kw = {
'pf_enable': bool(pf.get('enable')),
'pf_rules': pf.get('rules') or [],
'pf_dmz_enable': bool((pf.get('dmz') or {}).get('enable')),
'pf_dmz_host': (pf.get('dmz') or {}).get('host') or '',
}
if raw:
return raw if raw.endswith('\n') else raw + '\n'
mss = v_int(cfg.get('mss') or (1452 if kind == 'v4' else 1432),
field='MSS', lo=576, hi=9000)
mss_mode = str(cfg.get('mss_mode') or 'clamp')
if mss_mode not in ('clamp', 'fixed'):
raise ValidateError('MSS 方式只能是 clamp(按 MTU 自动算)或 fixed(写死数值)')
masq = v_bool(cfg.get('masquerade', True if kind == 'v4' else False))
if kind == 'v4':
return _default_v4(wan, lans, v_bool(cfg.get('mss_clamp', True)), mss,
**log_kw, **pf_kw, masquerade=masq, mss_mode=mss_mode)
return _default_v6(wan, lans, **log_kw, **pf_kw,
mss_clamp=v_bool(cfg.get('mss_clamp', False)),
mss=mss, mss_mode=mss_mode, masquerade=masq)
# ---------------------------------------------------------------- 端口转发 / DMZ(#7)
#
# 目标:用 nftables 的 DNAT(prerouting)实现主流、稳定、好用的端口转发与 DMZ。
#
# 设计要点:
# * 规则进入已有的 drouter4 / drouter6 表,避免多个表互相遮蔽;
# * 采用「转发 + 放通」两段式:nat 链做 DNAT,filter forward 链放通;
# 这样即使 forward 默认策略是 accept,也能显式记录与统计;
# * 同时自动生成内网侧的 hairpin(NAT 回环)规则,使「内网用公网域名访问」也能通;
# * DMZ = 「全部端口转发到某台主机」的快捷方式,等价于 1:1 全端口映射,
# 国内家用最常见的做法;产生一条规则并自动放通。
PF_PROTO_CHOICES = ['tcp', 'udp', 'tcp/udp']
def _pf_rules_v4(cfg):
"""生成 IPv4 端口转发与 DMZ 的 nft 片段。"""
return _pf_rules(cfg, '4', 'ip')
def _pf_rules_v6(cfg):
"""生成 IPv6 端口转发与 DMZ 的 nft 片段(IPv6 不需要 masquerade/DNAT 回环处理一致)。"""
return _pf_rules(cfg, '6', 'ip6')
def _pf_rules(cfg, fam, family_kw):
"""按协议族生成端口转发规则集合。
cfg 内字段:
pf_enable : 总开关
pf_rules : [{name, proto, ext_port, int_ip, int_port, enable, comment}]
pf_dmz_enable : DMZ 开关
pf_dmz_host : DMZ 主机内网地址
返回 (nat_lines, filter_lines)
"""
nat_lines = []
filter_lines = []
# ---- 普通端口转发 ----
if cfg.get('pf_enable'):
for i, r in enumerate(cfg.get('pf_rules') or []):
if not r.get('enable', True):
continue
proto = (r.get('proto') or 'tcp').strip().lower()
if proto not in PF_PROTO_CHOICES:
raise ValidateError('端口转发第 %d 条协议不合法:%s' % (i + 1, proto))
extp = v_int(r.get('ext_port'), field='端口转发第 %d 条外部端口' % (i + 1),
lo=1, hi=65535)
intp = v_int(r.get('int_port') or r.get('ext_port'),
field='端口转发第 %d 条内部端口' % (i + 1), lo=1, hi=65535)
ip = r.get('int_ip')
if fam == '4':
ip = v_ipv4(ip, '端口转发第 %d 条内网 IP' % (i + 1))
else:
ip = v_ipv6(ip, '端口转发第 %d 条内网 IPv6' % (i + 1))
name = (r.get('name') or '').replace('"', '')[:40]
cmt = name or ('端口转发 %d' % (i + 1))
protos = ['tcp', 'udp'] if proto == 'tcp/udp' else [proto]
for pr in protos:
nat_lines.append(
' iifname "%s" %s dport %d dnat to %s%s%s comment "%s"'
% (v_ifname(cfg.get('wan_iface') or 'ppp0', field='WAN 接口'),
('tcp' if pr == 'tcp' else 'udp'), extp,
'[' + ip + ']' if fam == '6' else ip,
(':' + str(intp)) if intp != extp else '',
'', '%s(%s→%s)' % (cmt, pr, extp)))
filter_lines.append(
' %s dport %d %s daddr %s accept comment "放通转发:%s"'
% (('tcp' if pr == 'tcp' else 'udp'), intp,
'ip6' if fam == '6' else 'ip',
ip, cmt))
# ---- DMZ(全端口映射到某台主机)----
if cfg.get('pf_dmz_enable') and (cfg.get('pf_dmz_host') or '').strip():
host = cfg.get('pf_dmz_host')
if fam == '4':
host = v_ipv4(host, 'DMZ 主机 IP')
else:
host = v_ipv6(host, 'DMZ 主机 IPv6')
wan = v_ifname(cfg.get('wan_iface') or 'ppp0', field='WAN 接口')
dest = '[' + host + ']' if fam == '6' else host
# 排除到本机自身的流量,避免把管理口也转走
nat_lines.append(
' iifname "%s" %s daddr != %s dnat to %s comment "DMZ 全端口映射"'
% (wan, family_kw, dest, dest))
filter_lines.append(
' %s daddr %s accept comment "放通 DMZ 主机"' % (family_kw, host))
return nat_lines, filter_lines
# ---------------------------------------------------------------- systemd-networkd
def render_network(cfg):
"""
返回 [{name, content}],写入 /etc/systemd/network/
cfg: {wan: {...}, lan: {...}, bridge: {...}, ifaces: [...]}
"""
out = []
wan = cfg.get('wan') or {}
wif = v_ifname(wan.get('iface') or '', field='WAN 网卡')
wmode = v_choice(wan.get('mode') or 'dhcp', ['dhcp', 'static', 'pppoe'], field='WAN 模式')
# ---- WAN ----
if wmode == 'pppoe':
# PPPoE 的底层网卡保持 up、不配地址,由 pppd 创建 ppp0
out.append({
'name': f'10-drouter-wan-{wif}.network',
'content': '\n'.join([
'# 由 drouter 自动生成:PPPoE 底层网卡(不配置 IP,由 pppd 接管)',
'[Match]',
f'Name={wif}',
'',
'[Link]',
f"MTUBytes={v_int(wan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}",
'RequiredForOnline=no',
'',
'[Network]',
'LinkLocalAddressing=no',
'LLDP=no',
'EmitLLDP=no',
'IPv6AcceptRA=no',
'DHCP=no',
'',
])
})
elif wmode == 'static':
lines = [
'# 由 drouter 自动生成:WAN 静态地址',
'[Match]',
f'Name={wif}',
'',
'[Link]',
f"MTUBytes={v_int(wan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}",
'',
'[Network]',
f"Address={v_cidr(wan.get('address'), field='WAN 地址')}",
]
gw = (wan.get('gateway') or '').strip()
if gw:
lines.append(f'Gateway={v_ipv4(gw, field="WAN 网关")}')
for d in v_ipv4_list(wan.get('dns') or '', field='WAN DNS'):
lines.append(f'DNS={d}')
lines += ['', '[Route]', 'GatewayOnLink=yes', '']
out.append({'name': f'10-drouter-wan-{wif}.network', 'content': '\n'.join(lines)})
else:
out.append({
'name': f'10-drouter-wan-{wif}.network',
'content': '\n'.join([
'# 由 drouter 自动生成:WAN 自动获取(DHCP)',
'[Match]',
f'Name={wif}',
'',
'[Link]',
f"MTUBytes={v_int(wan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}",
'',
'[Network]',
'DHCP=yes',
'IPv6AcceptRA=yes',
'',
'[DHCPv4]',
'UseDNS=yes',
'UseRoutes=yes',
f"RouteMetric={v_int(wan.get('metric') or 100, field='路由优先级', lo=1, hi=9999)}",
'',
])
})
# ---- 桥接 ----
br = cfg.get('bridge') or {}
brname = (br.get('name') or 'br0').strip() or 'br0'
members = [v_ifname(x, field='桥接成员') for x in (br.get('members') or [])]
if v_bool(br.get('enabled')) and members:
out.append({
'name': f'20-drouter-{brname}.netdev',
'content': '\n'.join([
'# 由 drouter 自动生成:局域网网桥',
'[NetDev]',
f'Name={v_ifname(brname, field="网桥名")}',
'Kind=bridge',
'',
'[Bridge]',
'STP=no' if not v_bool(br.get('stp')) else 'STP=yes',
f"ForwardDelaySec={v_int(br.get('fd') or 2, field='转发延迟', lo=0, hi=30)}",
'',
])
})
for m in members:
out.append({
'name': f'21-drouter-{brname}-member-{m}.network',
'content': '\n'.join([
'# 由 drouter 自动生成:桥接成员口',
'[Match]',
f'Name={m}',
'',
'[Network]',
f'Bridge={v_ifname(brname, field="网桥名")}',
'LinkLocalAddressing=no',
'',
])
})
# 桥本身承载 IP
lan = cfg.get('lan') or {}
addr = v_cidr(lan.get('address') or '192.168.7.1/24', field='LAN 地址')
lines = [
'# 由 drouter 自动生成:局域网桥地址',
'[Match]',
f'Name={v_ifname(brname, field="网桥名")}',
'',
'[Network]',
f'Address={addr}',
'DHCP=no',
'LinkLocalAddressing=ipv6',
'IPv6AcceptRA=no',
]
if v_bool(lan.get('ipv6_assign')) and lan.get('ipv6_addr'):
lines.append(f"Address={lan['ipv6_addr'].strip()}")
lines.append('')
out.append({'name': f'30-drouter-{brname}.network', 'content': '\n'.join(lines)})
else:
# 无桥接:LAN 地址直接配在单网卡上
lan = cfg.get('lan') or {}
lif = v_ifname(lan.get('iface') or '', field='LAN 网卡')
addr = v_cidr(lan.get('address') or '192.168.7.1/24', field='LAN 地址')
lines = [
'# 由 drouter 自动生成:局域网地址',
'[Match]',
f'Name={lif}',
'',
'[Link]',
f"MTUBytes={v_int(lan.get('mtu') or 1500, field='MTU', lo=576, hi=9000)}",
'',
'[Network]',
f'Address={addr}',
'DHCP=no',
'LinkLocalAddressing=ipv6',
]
if v_bool(lan.get('ipv6_assign')) and lan.get('ipv6_addr'):
lines.append(f"Address={lan['ipv6_addr'].strip()}")
lines.append('')
out.append({'name': f'30-drouter-lan-{lif}.network', 'content': '\n'.join(lines)})
return out
# ---------------------------------------------------------------- PPPoE
def _sub(cfg, key):
"""兼容两种入参形态:嵌套 {<key>:{...}} 或平铺 {...}。
这样前端、helper、单元测试可以统一按平铺结构传参。"""
if not isinstance(cfg, dict):
return {}
v = cfg.get(key)
if isinstance(v, dict):
merged = dict(v)
# 平铺字段若存在且嵌套中缺失,则以平铺为准补齐
for k, val in cfg.items():
if k == key:
continue
merged.setdefault(k, val)
return merged
return cfg
def render_ppp(cfg):
"""
返回 [(path, content)]:peers 文件 + secrets
兼容两种入参:平铺 {iface, username, ...} 或嵌套 {pppoe:{...}}
"""
p = _sub(cfg, 'pppoe')
iface = v_ifname(p.get('iface') or '', field='PPPoE 网卡')
user = (p.get('username') or '').strip()
passwd = (p.get('password') or '').strip()
if not user:
raise ValidateError('PPPoE 用户名不能为空', 'username')
if re.search(r'[\s"\']', user):
raise ValidateError('PPPoE 用户名不能包含空格或引号', 'username')
if not passwd:
raise ValidateError('PPPoE 密码不能为空(仅保存、暂不拨号)', 'password')
if re.search(r'[\s"\']', passwd):
raise ValidateError('PPPoE 密码不能包含空格或引号', 'password')
mtu = v_int(p.get('mtu') or 1492, field='PPPoE MTU', lo=576, hi=9000)
mru = v_int(p.get('mru') or mtu, field='PPPoE MRU', lo=576, hi=9000)
peers = [
'# 由 drouter 自动生成:PPPoE 拨号配置',
'# 运营商:' + (p.get('isp') or '自动'),
f'user "{user}"',
f'plugin rp-pppoe.so {iface}',
'noipdefault',
'defaultroute',
'replacedefaultroute',
'hide-password',
'noauth',
'persist' if v_bool(p.get('persist', True)) else '',
f'maxfail {v_int(p.get("maxfail") or 0, field="最大失败次数", lo=0, hi=100)}',
f'holdoff {v_int(p.get("holdoff") or 5, field="重拨间隔", lo=1, hi=600)}',
'usepeerdns',
'lcp-echo-interval 20',
'lcp-echo-failure 3',
f'mtu {mtu}',
f'mru {mru}',
'noaccomp',
'nopcomp',
'novj',
'novjccomp',
]
if (p.get('service_name') or '').strip():
peers.append(f"servicename \"{p['service_name'].strip()}\"")
peers += ['', '']
secrets = f'"{user}" * "{passwd}"\n'
return [('/etc/ppp/peers/drouter-wan', '\n'.join([l for l in peers if l != ''])),
('/etc/ppp/chap-secrets', secrets),
('/etc/ppp/pap-secrets', secrets)]
# ---------------------------------------------------------------- radvd (RA)
def render_radvd(cfg):
r = _sub(cfg, 'ra')
iface = v_ifname(r.get('iface') or 'br0', field='RA 接口')
prefix = (r.get('prefix') or '').strip()
lines = [
'# 由 drouter 自动生成:IPv6 路由通告 (RA)',
f'interface {iface} {{',
' AdvSendAdvert on;',
f" AdvManagedFlag {'on' if v_bool(r.get('managed')) else 'off'};",
f" AdvOtherConfigFlag {'on' if v_bool(r.get('other_config')) else 'off'};",
f" MaxRtrAdvInterval {v_int(r.get('max_interval') or 600, field='RA 最大间隔', lo=4, hi=1800)};",
f" MinRtrAdvInterval {v_int(r.get('min_interval') or 200, field='RA 最小间隔', lo=3, hi=1350)};",
f" AdvDefaultLifetime {v_int(r.get('lifetime') or 1800, field='默认生命周期', lo=0, hi=9000)};",
f" AdvCurHopLimit {v_int(r.get('hop_limit') or 64, field='跳数限制', lo=0, hi=255)};",
f" AdvReachableTime {v_int(r.get('reachable') or 0, field='可达时间', lo=0, hi=3600000)};",
f" AdvRetransTimer {v_int(r.get('retrans') or 0, field='重传计时', lo=0, hi=60000)};",
]
if prefix:
try:
ipaddress.IPv6Network(prefix, strict=False)
except Exception:
raise ValidateError(f'IPv6 前缀不合法:{prefix}', 'prefix')
lines += [
f' prefix {prefix} {{',
' AdvOnLink on;',
' AdvAutonomous on;',
f" AdvValidLifetime {v_int(r.get('valid_life') or 86400, field='有效生命期', lo=60, hi=4294967295)};",
f" AdvPreferredLifetime {v_int(r.get('pref_life') or 14400, field='首选生命期', lo=60, hi=4294967295)};",
' };',
]
if r.get('rdnss'):
lines.append(f" RDNSS {r['rdnss'].strip()} {{")
lines.append(f" AdvRDNSSLifetime {v_int(r.get('rdnss_life') or 600, field='RDNSS 生命期', lo=60, hi=9000)};")
lines.append(' };')
if r.get('dnssl'):
dom = r['dnssl'].strip()
if not DOMAIN_RE.match(dom):
raise ValidateError(f'DNSSL 域名不合法:{dom}', 'dnssl')
lines.append(f' DNSSL {dom} {{')
lines.append(f" AdvDNSSLLifetime {v_int(r.get('rdnss_life') or 600, field='DNSSL 生命期', lo=60, hi=9000)};")
lines.append(' };')
lines += ['};', '']
# IPv6 地址池说明块(供人工与后续脚本参考;radvd 本身通过 prefix 段生效)
if r.get('pool_enabled', True) is not False:
pol = r.get('pool_policy') or []
lines += [
'# ---------------- IPv6 地址池 / 池策略(RouterOS 风格) ----------------',
f"# 池名称:{r.get('pool_name') or 'drouter-wan-pool'}",
f"# 地址来源:{r.get('pool_from') or 'pool'}",
f"# 池前缀:{r.get('pool_prefix') or '(跟随运营商 PD)'}",
f"# 子网 ID 长度:{v_int(r.get('pool_subnet_bits') or 8, field='子网 ID 长度', lo=0, hi=64)}",
f"# 分配范围:子网 ID {v_int(r.get('pool_start') or 1, field='起始子网 ID', lo=0, hi=65535)}"
f" - {v_int(r.get('pool_end') or 254, field='结束子网 ID', lo=0, hi=65535)}",
f"# 池策略:{', '.join(pol) if pol else 'recommend'}",
'# ---------------------------------------------------------------------',
'',
]
return '\n'.join(lines)
# ---------------------------------------------------------------- dhcpcd (DHCPv6-PD)
def render_dhcpcd(cfg):
d = _sub(cfg, 'dhcpv6')
wan = v_ifname(d.get('iface') or d.get('wan_iface') or 'ppp0', field='DHCPv6 WAN 接口')
lan = v_ifname(d.get('lan_iface') or 'br0', field='DHCPv6 LAN 接口')
# 前端传来的前缀长度可能是 "/60" 这种带斜杠形式
raw_len = str(d.get('prefix_len') or 64).lstrip('/')
try:
plen = int(raw_len)
except Exception:
plen = 64
enabled = d.get('enabled', True) is not False
lines = [
'# 由 drouter 自动生成:DHCPv6 客户端 / 前缀委派 (PD)',
'# 注意:是否真正拿到 PD 前缀取决于运营商,页面会显示实际获取结果',
'duid',
'noipv6rs',
'waitip 6',
'',
f'interface {wan}',
]
if not enabled:
lines.insert(0, '# DHCPv6 客户端已在页面中关闭,以下为保留配置(未启用)')
if v_bool(d.get('request_address')):
lines.append(' ipv6rs')
if v_bool(d.get('request_pd', True)):
iaid = v_int(d.get('iaid') or 0, field='IAID', lo=0, hi=4294967295)
suffix = (d.get('sla_id') or '::1').strip()
lines.append(f' ia_pd {iaid}/{lan}/0/{plen}/{suffix}')
if d.get('rapid_commit'):
lines.append(' option rapid_commit')
if d.get('slaac') or d.get('method') in ('slaac', 'both'):
lines.append(' ia_na 1')
lines += ['', f'interface {lan}', ' noipv6rs', '']
return '\n'.join(lines)
# ---------------------------------------------------------------- miniupnpd
def render_miniupnpd(cfg):
u = _sub(cfg, 'upnp')
ext = v_ifname(u.get('ext_iface') or 'ppp0', field='UPnP 外网接口')
# 监听网卡名(nftables/新版 miniupnpd 需要网卡名而非 IP:
# 用 IP 会告警 "it is advised to use network interface name instead",
# 且会禁用 IPV6/SSDPv6 支持)。listen_ip 字段仅用于兼容旧配置。
lan_if = u.get('lan_iface') or u.get('listen_ip') or 'ens18'
try:
lan_if = v_ifname(lan_if, field='UPnP 监听接口')
except Exception:
lan_if = 'ens18'
# UPnP 权限规则:仅允许内网网段申请临时端口(>=1024),末尾全拒。
# 网段取自 system.lan_address;取不到时退回 192.168.0.0/16 保守放行。
lan_cidr = '192.168.0.0/16'
sysc = _sub(cfg, 'system')
addr = str(sysc.get('lan_address') or '')
if '/' in addr:
try:
lan_cidr = v_cidr(addr, field='UPnP 允许网段')
except Exception:
lan_cidr = '192.168.0.0/16'
elif addr:
try:
lan_cidr = v_ipv4(addr, field='UPnP 允许网段') + '/24'
except Exception:
lan_cidr = '192.168.0.0/16'
lines = [
'# 由 drouter 自动生成:miniupnpd',
f'ext_ifname={ext}',
f'listening_ip={lan_if}',
# http_port 是 nftables/新版 miniupnpd 的正确选项名;
# 写成 port= 会被忽略(旧版 iptables 变体才用 port=)。
f"http_port={v_int(u.get('port') or 5000, field='UPnP 端口', lo=1024, hi=65535)}",
'enable_upnp=yes' if v_bool(u.get('enable', True)) else 'enable_upnp=no',
'enable_natpmp=yes' if v_bool(u.get('natpmp')) else 'enable_natpmp=no',
f"secure_mode={'yes' if v_bool(u.get('secure_mode', True)) else 'no'}",
# nftables 后端(miniupnpd 2.3.9)支持的 chain 选项。
# 注意:upnp_nat_prerouting_chain 在 2.3.x 中并不存在,
# 写入会触发 "invalid option" 导致 miniupnpd 启动失败(实测 2026-09-28)。
'upnp_forward_chain=forward_miniupnpd',
'upnp_nat_chain=prerouting_miniupnpd',
'upnp_nat_postrouting_chain=postrouting_miniupnpd',
'system_uptime=yes',
f"lease_file={u.get('lease_file') or '/var/run/miniupnpd.leases'}",
f"uuid={u.get('uuid') or 'drouter-0001'}",
'force_igd_desc_v1=yes' if v_bool(u.get('igd_v1')) else '',
'',
'# UPnP 权限规则:仅允许内网申请临时端口,末尾默认拒绝',
f'allow 1024-65535 {lan_cidr} 1024-65535',
'deny 0-65535 0.0.0.0/0 0-65535',
]
return '\n'.join([l for l in lines if l != ''])
# ---------------------------------------------------------------- chrony
def render_chrony(cfg):
n = _sub(cfg, 'ntp')
raw = n.get('servers')
if isinstance(raw, (list, tuple)):
servers = [str(x).strip() for x in raw if str(x).strip()]
else:
servers = [x.strip() for x in (raw or '').split(',') if x.strip()]
if not servers:
servers = ['ntp.aliyun.com', 'time.cloudflare.com']
for s in servers:
if not DOMAIN_RE.match(s) and not IPV4_RE.match(s):
raise ValidateError(f'NTP 服务器地址不合法:{s}', 'servers')
lines = ['# 由 drouter 自动生成:NTP 客户端']
for s in servers:
lines.append(f'server {s} iburst')
lines += [
'driftfile /var/lib/chrony/chrony.drift',
'rtcsync',
'makestep 1.0 3',
'leapsectz right/UTC',
'',
]
if v_bool(n.get('allow_lan')) and n.get('allow_net'):
lines.append(f'allow {v_cidr(n["allow_net"], field="允许同步的网段")}')
return '\n'.join(lines)
# ------------------------------------------------- SMB / NFS 内网文件共享(#9)
# 用户名 / 组名:Samba 与 NFS 共用,限制为 POSIX 可移植字符集
UNAME_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,30}\$?$')
SHARE_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$')
def v_uname(v, field='名称', allow_empty=False):
v = (v or '').strip()
if not v:
if allow_empty:
return ''
raise ValidateError(f'{field}不能为空', field)
if not UNAME_RE.match(v):
raise ValidateError(
f'{field}不合规:{v}(只能用小写字母、数字、下划线、短横线,且以字母或下划线开头)', field)
return v
def v_path(v, field='路径'):
"""共享目录路径:必须是绝对路径,且不含 .. 与 NUL,避免目录穿越。"""
v = (v or '').strip()
if not v:
raise ValidateError(f'{field}不能为空', field)
if not v.startswith('/'):
raise ValidateError(f'{field}必须是绝对路径(以 / 开头):{v}', field)
if '\x00' in v or '..' in v.split('/'):
raise ValidateError(f'{field}不能包含 .. 或空字节:{v}', field)
if not re.match(r'^/[A-Za-z0-9._/\u4e00-\u9fff-]*$', v):
raise ValidateError(f'{field}含不支持的字符:{v}', field)
return v.rstrip('/') or '/'
def listify(v):
"""把 list / 逗号或空白分隔的字符串统一成去空列表。"""
if isinstance(v, (list, tuple)):
return [str(x).strip() for x in v if str(x).strip()]
return [x.strip() for x in re.split(r'[\s,;]+', v or '') if x.strip()]
def render_samba(cfg):
"""渲染 smb.conf(/etc/samba/drouter.conf,由主配置 include)。"""
sb = _sub(cfg, 'samba')
workgroup = (sb.get('workgroup') or 'WORKGROUP').strip().upper()
if not re.match(r'^[A-Z0-9_-]{1,15}$', workgroup):
raise ValidateError('工作组名不合法(1–15 位大写字母/数字/-/_)', 'workgroup')
server_string = (sb.get('server_string') or '').strip()
if server_string and not re.match(r'^[A-Za-z0-9\u4e00-\u9fff ._-]{1,48}$', server_string):
raise ValidateError('服务器描述含不支持的字符(可用中英文、数字、空格、. _ -)', 'server_string')
lines = [
'# 由 drouter 自动生成:SMB / CIFS 文件共享(#9)',
'# 被 /etc/samba/smb.conf 通过 include 引入,请勿手改。',
'',
'[global]',
f' workgroup = {workgroup}',
' server role = standalone server',
' # 关闭 NetBIOS/老协议,只保留 SMB2/3,兼容 Windows 10/11、macOS 与 Linux',
' server min protocol = SMB2',
' client min protocol = SMB2',
' server max protocol = SMB3',
' # 让 macOS 更容易发现(Bonjour / mDNS)',
' multicast dns register = yes',
' # 各平台文件名兼容:UTF-8 + 规范化,避免中文乱码',
' unix charset = UTF-8',
' dos charset = CP936',
' map to guest = bad user',
' log file = /var/log/samba/log.%m',
' max log size = 1000',
' logging = file',
' panic action = /usr/share/samba/panic-action %d',
' server string = %s' % (server_string or 'drouter 文件共享'),
'',
]
if v_bool(sb.get('disable_netbios')):
lines += [' # 关闭 NetBIOS 名称服务(纯本地网段建议开启,减少广播)',
' disable netbios = yes', '']
if v_bool(sb.get('wins_enable')) and sb.get('wins_support'):
lines += [' # 由本机充当 WINS 服务器',
' wins support = yes',
' local master = yes',
' preferred master = yes',
' os level = 65', '']
# 多网卡 / 指定监听
ifaces = listify(sb.get('interfaces'))
if ifaces:
for i in ifaces:
if not IFNAME_RE.match(i):
raise ValidateError(f'监听网卡名不合法:{i}', 'interfaces')
lines += [f' interfaces = {" ".join(ifaces)}',
' bind interfaces only = yes', '']
for extra in listify(sb.get('global_extra')):
lines.append(f' {extra}')
if listify(sb.get('global_extra')):
lines.append('')
shares = sb.get('shares') or []
used = set()
for i, s in enumerate(shares):
if not v_bool(s.get('enable', True)):
continue
name = (s.get('name') or '').strip()
if not name:
raise ValidateError(f'第 {i + 1} 个共享缺少名称', 'name')
if not SHARE_NAME_RE.match(name):
raise ValidateError(
f'共享名「{name}」不合法:只能用字母、数字、. _ -,且不能以符号开头', 'name')
if name.lower() in used:
raise ValidateError(f'共享名重复:{name}', 'name')
used.add(name.lower())
path = v_path(s.get('path'), field=f'共享「{name}」的目录')
mode = (s.get('mode') or 'auth').strip()
comment = (s.get('comment') or '').strip().replace('\n', ' ')
lines.append(f'[{name}]')
lines.append(f' path = {path}')
lines.append(f' comment = {comment or name}')
lines.append(' browseable = yes')
lines.append(' read only = %s' % ('no' if v_bool(s.get('writable')) else 'yes'))
lines.append(' create mask = 0664')
lines.append(' directory mask = 0775')
lines.append(' # 让复制进来的文件继承父目录的组与 setgid 位,跨用户可写')
lines.append(' inherit permissions = yes')
lines.append(' vfs objects = catia fruit streams_xattr')
lines.append(' fruit:metadata = stream')
lines.append(' fruit:model = MacSamba')
lines.append(' # macOS 会写一些以 ._ 开头的元数据文件,隐藏它们')
lines.append(' fruit:veto_appledouble = no')
lines.append(' fruit:posix_rename = yes')
lines.append(' fruit:zero_file_id = yes')
lines.append(' fruit:nfs_aces = no')
if mode == 'public':
lines += [' guest ok = yes', ' guest only = yes',
' force user = nobody', ' force group = nogroup']
elif mode == 'readonly':
lines += [' guest ok = yes', ' read only = yes']
else:
lines += [' guest ok = no', ' valid users = %s' % (
', '.join(v_uname(u, field=f'共享「{name}」的允许用户')
for u in (listify(s.get('users')) or ['drouter'])))]
allowed = listify(s.get('allow_hosts'))
if allowed:
# Samba 用空格分隔,支持 192.168.7.0/24 形式
for a in allowed:
try:
ipaddress.ip_network(a, strict=False)
except Exception:
raise ValidateError(f'共享「{name}」允许网段不合法:{a}', 'allow_hosts')
lines.append(' hosts allow = %s' % ' '.join(allowed))
lines.append(' hosts deny = 0.0.0.0/0 ::/0')
denied = listify(s.get('deny_hosts'))
if denied:
lines.append(' hosts deny = %s' % ' '.join(denied))
for extra in listify(s.get('extra')):
lines.append(f' {extra}')
lines.append('')
return '\n'.join(lines)
# NFS 客户端导出选项:按「平台模板」预置,兼顾兼容性与安全性
NFS_OPTS = {
'linux': 'rw,sync,no_subtree_check,secure,root_squash',
'macos': 'rw,sync,no_subtree_check,secure,all_squash,insecure',
'windows': 'rw,sync,no_subtree_check,secure,all_squash,no_auth_nlm,insecure,anonuid=65534,anongid=65534',
'readonly': 'ro,sync,no_subtree_check,secure,root_squash',
'legacy': 'rw,sync,no_subtree_check,insecure,no_root_squash',
}
def render_nfs(cfg):
"""渲染 /etc/exports(NFSv4 + NFSv3 兼容)。"""
n = _sub(cfg, 'nfs')
lines = [
'# 由 drouter 自动生成:NFS 导出(#9)',
'# 只做内网共享,未列出的网段默认拒绝(由 nftables 放通 2049/111)。',
'',
]
exports = n.get('exports') or []
if not exports or not any(v_bool(x.get('enable', True)) for x in exports):
lines.append('# 未配置任何导出(NFS 已启用但没有共享项)')
return '\n'.join(lines) + '\n'
for i, e in enumerate(exports):
if not v_bool(e.get('enable', True)):
continue
path = v_path(e.get('path'), field=f'第 {i + 1} 个 NFS 导出目录')
clients = e.get('clients') or []
if not clients:
raise ValidateError(f'NFS 导出「{path}」至少需要一个客户端网段', 'clients')
seen = set()
rendered = []
for c in clients:
net = (c.get('net') or '').strip()
preset = (c.get('preset') or 'linux').strip().lower()
if not net:
raise ValidateError(f'NFS 导出「{path}」存在空的客户端网段', 'clients')
# 通配符 * 允许,其余按 CIDR / 单机地址校验
if net != '*':
# 支持 192.168.7.0/24 与 IPv6 前缀
try:
ipaddress.ip_network(net, strict=False)
except Exception:
raise ValidateError(
f'NFS 导出「{path}」的客户端不合法:{net}'
f'(应为 * 或 CIDR,如 192.168.7.0/24)', 'clients')
if net in seen:
continue
seen.add(net)
opts = (c.get('options') or '').strip() or NFS_OPTS.get(preset, NFS_OPTS['linux'])
# 兜底:用户自定义选项里若出现危险组合,给出中文提示
if 'no_root_squash' in opts and preset != 'legacy':
raise ValidateError(
f'NFS 导出「{path}」对 {net} 使用了 no_root_squash,'
f'这会让客户端 root 直接以 root 身份写共享目录,存在安全风险。'
f'如确需如此,请把模板选为「兼容旧设备(不安全)」。', 'options')
rendered.append(f'{net}({opts})')
lines.append(f'# {e.get("comment") or path}')
lines.append(f'{path} {" ".join(rendered)}')
lines.append('')
return '\n'.join(lines)
def render_nfs_conf(cfg):
"""渲染 /etc/nfs.conf.d/drouter.conf:线程数 / 版本 / 端口固定。"""
n = _sub(cfg, 'nfs')
threads = int(n.get('threads') or 8)
if not (1 <= threads <= 128):
raise ValidateError('NFS 服务线程数应在 1–128 之间', 'threads')
lines = [
'# 由 drouter 自动生成:NFS 服务端参数(#9)',
'# 固定端口,便于在 nftables 中精确放通。',
'',
'[nfsd]',
f'threads={threads}',
'vers3=y',
'vers4=y',
'vers4.0=y',
'vers4.1=y',
'vers4.2=y',
'# 固定端口,避免防火墙需要放通随机端口',
'port=2049',
'',
'[mountd]',
'port=20048',
'manage-gids=y',
'',
'[statd]',
'port=32765',
'outgoing-port=32766',
'',
'[lockd]',
'port=32767',
'',
]
return '\n'.join(lines)
# ---------------------------------------------------------------- 汇总渲染入口
RENDERERS = {
'dnsmasq': lambda cfg: [('/etc/dnsmasq.d/drouter.conf', render_dnsmasq(cfg))],
'nft_v4': lambda cfg: [('/etc/nftables.d/drouter-v4.nft', render_nft('v4', cfg))],
'nft_v6': lambda cfg: [('/etc/nftables.d/drouter-v6.nft', render_nft('v6', cfg))],
'network': render_network,
'ppp': render_ppp,
'radvd': lambda cfg: [('/etc/radvd.conf', render_radvd(cfg))],
'dhcpv6': lambda cfg: [('/etc/dhcpcd.conf', render_dhcpcd(cfg))],
'miniupnpd': lambda cfg: [('/etc/miniupnpd/miniupnpd.conf', render_miniupnpd(cfg))],
'chrony': lambda cfg: [('/etc/chrony/chrony.conf', render_chrony(cfg))],
'samba': lambda cfg: [('/etc/samba/drouter.conf', render_samba(cfg))],
'nfs': lambda cfg: [('/etc/exports', render_nfs(cfg)),
('/etc/nfs.conf.d/drouter.conf', render_nfs_conf(cfg))],
}
# 前端/API 使用的模块别名 -> 渲染模块名
ALIASES = {
'upnp': 'miniupnpd',
'ntp': 'chrony',
'dhcpv6': 'dhcpv6',
'nfs': 'nfs',
'smb': 'samba',
# WAN 口页面在前端叫 pppoe,渲染器叫 ppp(render_ppp 用 _sub(cfg,'pppoe')
# 兼容平铺入参,所以别名解析后依然能拿到正确的字段)
'pppoe': 'ppp',
}
def canonical(module):
"""把前端/API 使用的模块名解析成真正的渲染模块名。
helper 的语法预检要按「渲染模块名」分支(chrony / miniupnpd …),
如果直接拿前端传来的别名(ntp / upnp)去比,会静默跳过语法检查。
"""
return ALIASES.get(module, module)
def render(module, cfg):
"""返回 [(path, content)];校验失败抛 ValidateError(含中文提示)"""
module = ALIASES.get(module, module)
if module not in RENDERERS:
raise ValidateError(f'未知的配置模块:{module}')
return RENDERERS[module](cfg)
if __name__ == '__main__':
# 简易自测:渲染默认配置,确认无语法异常
demo = {
'lan_iface': 'br0', 'domain': 'lan',
'dhcp_enabled': True, 'pool_start': '192.168.7.100', 'pool_end': '192.168.7.200',
'pool_netmask': '255.255.255.0', 'lease_time': 7200,
'option_dns': '223.5.5.5,119.29.29.29',
'options': [{'enabled': True, 'code': 3, 'value': '192.168.7.1', 'force': True}],
'dns_mode': 'custom', 'dns_custom': '223.5.5.5',
'wan_iface': 'ppp0', 'lan_ifaces': ['br0'], 'mss_clamp': True,
}
print(render_dnsmasq(demo))
print('---- nft v4 ----')
print(render_nft('v4', demo))
print('SELFTEST OK')