一套跑在 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
1356 lines
56 KiB
Python
1356 lines
56 KiB
Python
#!/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')
|