Files
liuzhuohua 64df3cf331 feat: Drouter 1.0.0 —— Debian 13 软路由管理系统
一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的
Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、
访问控制、DDNS、文件共享、Docker 管理等。

核心设计原则:不重新实现任何协议
  PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。
  Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。
  面板删掉后,系统仍是一台配置完好的 Debian 路由器。

安全默认值
  · 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务
  · 所有改网络的动作都需要用户在面板上亲手点「应用」
  · 避开 5900 端口,不触碰 VNC / X 会话
  · 配置写入前先做语法预检,失败则回滚

架构
  · backend/  Python 3 标准库,零第三方依赖(9 个模块)
  · web/      原生 JS/CSS,无构建步骤
  · 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess
  · 鉴权走 X-Token 请求头

打包
  · packaging/build-deb.sh            → drouter_1.0.0_all.deb(含 6 个 systemd 单元)
  · scripts/make-offline-deps.sh      → 236 个依赖 .deb + 本地仓库索引
  · packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装
  · packaging/build-docker.sh         → 容器镜像(体验用)

测试
  40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配),
  全部挂在 devtools/sync.sh 的预检链里。

许可:MIT
2026-09-30 19:26:24 +08:00

171 lines
7.8 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""真·公网 IP 判定:静态契约 + 判定逻辑单测。
盯两件事:
① 判定分级是真的按「入向实测」拍板,不是只看地址段 ——
用户的 101.70.131.227 就栽在这:地址是公网段,但从 VPS ping 不通。
② 后端动作 / 路由 / 前端三处接线齐全。
"""
import ast
import io
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
APP = os.path.join(ROOT, 'web', 'app.js')
PASS = FAIL = 0
def chk(name, cond, extra=''):
global PASS, FAIL
if cond:
PASS += 1
print('[OK] %s %s' % (name, extra))
else:
FAIL += 1
print('[FAIL] %s %s' % (name, extra))
HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
WEB_SRC = io.open(WEB, encoding='utf-8').read()
APP_SRC = io.open(APP, encoding='utf-8').read()
def main():
print('--- 后端动作 ---')
chk('定义了 act_pubip', 'def act_pubip(' in HELPER_SRC)
chk('注册到 ACTIONS', re.search(r"^\s*'pubip':\s*act_pubip,", HELPER_SRC, re.M) is not None)
for op in ('check', 'probe_start', 'probe_status', 'probe_stop'):
chk('支持 op=%s' % op, ("op == '%s'" % op) in HELPER_SRC
or ("op = (p.get('op') or 'check')" in HELPER_SRC and op == 'check'))
print('\n--- 判定分级 ---')
tree = ast.parse(HELPER_SRC)
verdicts = None
for n in tree.body:
if isinstance(n, ast.Assign):
for t in n.targets:
if isinstance(t, ast.Name) and t.id == 'PUBIP_VERDICT':
ns = {}
mod = ast.Module(body=[ast.Assign(
targets=[ast.Name('PUBIP_VERDICT', ast.Store())],
value=n.value)], type_ignores=[])
ast.fix_missing_locations(mod)
exec(compile(mod, '<V>', 'exec'), ns)
verdicts = ns['PUBIP_VERDICT']
chk('PUBIP_VERDICT 可解析', verdicts is not None)
if verdicts:
for k in ('real', 'likely', 'blocked', 'cgnat', 'private', 'unknown'):
v = verdicts.get(k) or {}
chk('分级 %-8s 有 title/level/conclusion/advice' % k,
bool(v.get('title')) and bool(v.get('level'))
and bool(v.get('conclusion')) and isinstance(v.get('advice'), list))
# 用户遇到的正是这种:公网地址 + 入向不通
blk = verdicts['blocked']['conclusion']
chk('blocked 的结论点明「连不上」', ('连不' in blk) or ('ping 不通' in blk),
'→ %s…' % blk[:30])
chk('blocked 给出内网穿透建议',
any('穿透' in a for a in verdicts['blocked']['advice']))
print('\n--- 判定逻辑:入向实测才是拍板依据 ---')
# 关键分支:地址是公网段时,结论必须由 inbound 决定,不能停在 likely
chk('公网地址 + 入向可达 → real',
re.search(r"inbound is True:\s*\n\s*verdict = 'real'", HELPER_SRC) is not None)
chk('公网地址 + 入向实测失败 → blocked',
re.search(r"inbound is False:\s*\n\s*verdict = 'blocked'", HELPER_SRC) is not None)
chk('未做入向实测 → likely(不是直接判公网)',
re.search(r"else:\s*\n\s*verdict = 'likely'", HELPER_SRC) is not None)
chk('CGNAT 地址段直接判 cgnat',
re.search(r"cls == 'cgnat':\s*\n\s*verdict = 'cgnat'", HELPER_SRC) is not None)
chk('外部回显全失败 → unknown(不乱下结论)',
"verdict = 'unknown'" in HELPER_SRC)
print('\n--- 入向实测的实现安全性 ---')
chk('优先用 tcpdump 抓 ICMP(零监听端口)', 'icmp[icmptype] = 8' in HELPER_SRC)
# 局域网内的 ping 不算数,否则隔壁电脑一 ping 就误判成公网可达
chk('排除局域网来源(避免误判)', 'not src net' in HELPER_SRC
and 'def _local_subnets' in HELPER_SRC)
chk('备用 TCP 端口只用高位端口', '_PROBE_PORTS = (41' in HELPER_SRC)
chk('TCP 退路不碰 5900 / 8443',
'5900' not in HELPER_SRC.split('_PROBE_PORTS')[1][:200])
chk('实测有超时上限', '_PROBE_TIMEOUT =' in HELPER_SRC)
print('\n--- HTTP 路由 ---')
chk('注册 /api/pubip', "'/api/pubip'" in WEB_SRC)
chk('需要登录', re.search(r"p in \('/api/pubip'.*?if not self\.auth\(\)", WEB_SRC, re.S) is not None)
chk('check 用长超时(要打多个外部服务)',
re.search(r"to = 90 if op == 'check'", WEB_SRC) is not None)
chk('probe_* 用短超时(轮询要秒回)',
re.search(r"to = 90 if op == 'check' else 15", WEB_SRC) is not None)
print('\n--- 前端接线 ---')
chk('定义了 viewPubip', 'function viewPubip(' in APP_SRC)
chk('注册到 VIEWS', re.search(r"^\s*pubip: viewPubip,", APP_SRC, re.M) is not None)
chk('菜单里有 pubip 项', "k: 'pubip'" in APP_SRC)
chk('菜单文案点明入向', '入向可达性实测' in APP_SRC or '连进来' in APP_SRC)
chk('有开始/停止实测按钮', "id=\"pi-start\"" in APP_SRC and "id=\"pi-stop\"" in APP_SRC)
chk('轮询状态', "pubipProbePoll" in APP_SRC)
chk('离开页面停掉轮询(否则一直打接口)',
# 现在统一在 stopPageTimers() 里收口,go() 第一件事就是调它。
# 这样比原来「go() 里内联一行」更不容易漏 —— 新增页面定时器只要
# 登记进 stopPageTimers 即可,不用再去改 go()。
'function stopPageTimers(' in APP_SRC
and 'pubipProbeStopPoll' in APP_SRC
and re.search(r"function go\(k\) \{\s*\n?\s*(//[^\n]*\n\s*)*stopPageTimers\(\);", APP_SRC) is not None)
chk('DDNS 页有跳转入口', "dd-gopubip" in APP_SRC)
chk('DDNS 页提示「地址是公网段 ≠ 能连进来」',
'不能说明外网能不能连进来' in APP_SRC)
print('\n--- 四条证据都渲染 ---')
for k in ('class', 'echo', 'path', 'inbound'):
chk('证据 key=%s' % k, ("'key': '%s'" % k) in HELPER_SRC)
print('\n--- 地址分类逻辑单测(真实执行,不是正则匹配)---')
# 只抽 _is_private_v4 与 _addr_class_of 两个纯函数到隔离命名空间执行
ns = {'re': re}
tree2 = ast.parse(HELPER_SRC)
want = ('_is_private_v4', '_addr_class_of')
body = [n for n in tree2.body
if isinstance(n, ast.FunctionDef) and n.name in want]
mod2 = ast.Module(body=body, type_ignores=[])
ast.fix_missing_locations(mod2)
exec(compile(mod2, '<pubip-logic>', 'exec'), ns)
classify = ns['_addr_class_of']
cases = [
('101.70.131.227', 'public', '用户实际遇到的联通地址:公网段'),
('8.8.8.8', 'public', 'Google DNS'),
('100.64.0.1', 'cgnat', 'CGNAT 段下边界'),
('100.127.255.255', 'cgnat', 'CGNAT 段上边界'),
('100.63.255.255', 'public', '紧邻 CGNAT 之外,仍是公网'),
('100.128.0.1', 'public', '紧邻 CGNAT 之上,仍是公网'),
('192.168.7.3', 'private', '局域网'),
('10.0.0.1', 'private', 'RFC1918 A 类'),
('172.16.0.1', 'private', 'RFC1918 B 类下边界'),
('172.31.255.255', 'private', 'RFC1918 B 类上边界'),
('172.32.0.1', 'public', '172.32 已超出私有段'),
('169.254.1.1', 'reserved', '链路本地'),
('224.0.0.1', 'reserved', '组播'),
('127.0.0.1', 'private', '回环'),
('', 'empty', '空地址'),
]
for ip, expect, why in cases:
got = classify(ip)
chk('%-18s → %-8s (%s)' % (ip or '(空)', got, why), got == expect,
'' if got == expect else '期望 %s' % expect)
print('\n' + '=' * 60)
if FAIL:
print('失败 %d 项' % FAIL)
sys.exit(1)
print('结果: 全部通过(通过 %d 项)' % PASS)
sys.exit(0)
if __name__ == '__main__':
main()