一套跑在 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
254 lines
12 KiB
Python
254 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""全项目静态审计(#6):专抓「编译得过、跑起来才炸」的低级错误。
|
||
|
||
四类检查,全部基于源码静态分析,不需要目标机:
|
||
1. web.py 里 self.helper('xxx') 的动作名必须都在 helper 的 ACTIONS 里
|
||
—— 写错一个字母就是「接口返回 ok 但实际报未知操作」;
|
||
2. app.js 里 api('/api/xxx') 的路径必须在 web.py 的路由里有分支
|
||
—— 漏了就是 404,前端拿到 HTML 报错页,表现为「点了没反应」;
|
||
3. 同一个顶层名字(函数 / 常量)不能重复定义
|
||
—— 后定义的静默覆盖前面的,最难查的一类 bug;
|
||
4. 前端引用的 CSS 类必须在 app.css 里有定义(只查我们自己新引入的类)。
|
||
"""
|
||
import ast
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.join(HERE, '..')
|
||
|
||
fails = 0
|
||
|
||
|
||
def chk(label, cond, extra=''):
|
||
global fails
|
||
if not cond:
|
||
fails += 1
|
||
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
|
||
|
||
|
||
def read(rel):
|
||
with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
|
||
return f.read()
|
||
|
||
|
||
HELPER = read('backend/drouter-helper.py')
|
||
WEB = read('backend/drouter-web.py')
|
||
APP = read('web/app.js')
|
||
CSS = read('web/app.css')
|
||
|
||
# ---------------------------------------------------------------- 1. 动作名
|
||
h_tree = ast.parse(HELPER)
|
||
ACTIONS = set()
|
||
for node in ast.walk(h_tree):
|
||
if isinstance(node, ast.Assign):
|
||
for t in node.targets:
|
||
if isinstance(t, ast.Name) and t.id == 'ACTIONS' and isinstance(node.value, ast.Dict):
|
||
for k in node.value.keys:
|
||
if isinstance(k, ast.Constant) and isinstance(k.value, str):
|
||
ACTIONS.add(k.value)
|
||
chk('helper 至少注册了 60 个动作', len(ACTIONS) >= 60, '实际 %d 个' % len(ACTIONS))
|
||
|
||
used = set(re.findall(r"self\.helper\(\s*'([^']+)'", WEB))
|
||
used |= set(re.findall(r'helper\(\s*"([^"]+)"', WEB))
|
||
bad = sorted(a for a in used if a not in ACTIONS)
|
||
chk('web.py 调用的动作全部已注册', not bad, '→ 未注册:%s' % bad)
|
||
|
||
# 反向:注册了但从来没人调(不算错,只是提示,避免误当失败)
|
||
unused = sorted(a for a in ACTIONS if a not in used)
|
||
print(' 提示:已注册但 web.py 未直接调用的动作 %d 个(可能由其它入口使用)'
|
||
% len(unused))
|
||
|
||
# ---------------------------------------------------------------- 2. 前端路径
|
||
# web.py 的路由写法有三种:p == '/api/x'、p in ('/api/x','/api/y')、
|
||
# p.startswith('/api/x'),还有一张 readings 字典把路径映射到 action。
|
||
# 只用正则会漏掉字典键(/api/ifaces 等 13 个路径就是这么被误报的),
|
||
# 所以这里改用 AST 精确收集。
|
||
routes, prefixes = set(), set()
|
||
w_tree = ast.parse(WEB)
|
||
for node in ast.walk(w_tree):
|
||
# 任何字典里以 /api 开头的键(readings 路由表等)
|
||
if isinstance(node, ast.Dict):
|
||
for k in node.keys:
|
||
if isinstance(k, ast.Constant) and isinstance(k.value, str) \
|
||
and k.value.startswith('/api'):
|
||
routes.add(k.value)
|
||
# 元组/列表里的路径字面量(p in ('/api/a','/api/b'))
|
||
if isinstance(node, (ast.Tuple, ast.List)):
|
||
for e in node.elts:
|
||
if isinstance(e, ast.Constant) and isinstance(e.value, str) \
|
||
and e.value.startswith('/api') and '*' not in e.value:
|
||
routes.add(e.value)
|
||
# p == '/api/x' / p in ... 的比较
|
||
if isinstance(node, ast.Compare) and isinstance(node.left, ast.Name) \
|
||
and node.left.id == 'p':
|
||
for c in node.comparators:
|
||
if isinstance(c, ast.Constant) and isinstance(c.value, str) \
|
||
and c.value.startswith('/api'):
|
||
routes.add(c.value)
|
||
# p.startswith('/api/x')
|
||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \
|
||
and node.func.attr == 'startswith':
|
||
for a in node.args:
|
||
if isinstance(a, ast.Constant) and isinstance(a.value, str) \
|
||
and a.value.startswith('/api'):
|
||
prefixes.add(a.value)
|
||
# 前端实际请求
|
||
paths = set(re.findall(r"api\(\s*'([^']+)'", APP))
|
||
paths |= set(re.findall(r'api\(\s*`([^`]+)`', APP))
|
||
paths = set(p for p in paths if p.startswith('/api'))
|
||
|
||
missing = []
|
||
for p in sorted(paths):
|
||
if p in routes:
|
||
continue
|
||
# 前缀匹配(如 /api/webshell/connect 由 startswith('/api/webshell') 处理)
|
||
if any(p.startswith(pre) for pre in prefixes if pre.startswith('/api')):
|
||
continue
|
||
# 形如 '/api/xxx?op=yyy' 或带模板变量的,只看主干
|
||
base = p.split('?')[0]
|
||
if base in routes:
|
||
continue
|
||
if any(base.startswith(pre) for pre in prefixes if pre.startswith('/api')):
|
||
continue
|
||
missing.append(p)
|
||
chk('前端请求的接口路径都有后端路由', not missing, '→ 缺少路由:%s' % missing)
|
||
|
||
# ---------------------------------------------------------------- 3. 重复定义
|
||
def top_names(src, lang):
|
||
out = {}
|
||
if lang == 'py':
|
||
tree = ast.parse(src)
|
||
for node in tree.body:
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||
out.setdefault(node.name, []).append(node.lineno)
|
||
elif isinstance(node, ast.Assign):
|
||
for t in node.targets:
|
||
if isinstance(t, ast.Name):
|
||
out.setdefault(t.id, []).append(node.lineno)
|
||
else:
|
||
# JS:顶层 function 声明 + const/let/var
|
||
for m in re.finditer(r'^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(',
|
||
src, re.M):
|
||
out.setdefault(m.group(1), []).append(src[:m.start()].count('\n') + 1)
|
||
for m in re.finditer(r'^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=',
|
||
src, re.M):
|
||
out.setdefault(m.group(1), []).append(src[:m.start()].count('\n') + 1)
|
||
return out
|
||
|
||
|
||
for rel, src, lang in (('backend/drouter-helper.py', HELPER, 'py'),
|
||
('backend/drouter-web.py', WEB, 'py'),
|
||
('web/app.js', APP, 'js')):
|
||
names = top_names(src, lang)
|
||
dup = {k: v for k, v in names.items() if len(v) > 1}
|
||
# 白名单:JS 里允许用 let 再赋值覆盖;Python 里 __main__ 守卫下的同名不算
|
||
chk('%s 无重复顶层定义' % rel, not dup,
|
||
'→ %s' % dict(list(dup.items())[:5]))
|
||
|
||
# ---------------------------------------------------------------- 4. CSS 类
|
||
# 只查我们新引入的、带 t- 前缀的终端样式类
|
||
for cls in ('t-b', 't-dim', 't-i', 't-u', 'tcur', 'tl', 'ws-capture',
|
||
'term-screen', 'ws-probe'):
|
||
chk('app.css 定义了 .%s' % cls, ('.' + cls + '{') in CSS or ('.' + cls + ' ') in CSS)
|
||
|
||
# ---------------------------------------------------------------- 5. 外部命令 ↔ 依赖清单
|
||
# helper 里 sh(['xxx', ...]) 调的每个外部命令,都应该在 DEPS 里有对应条目。
|
||
# 漏了的表现最阴:依赖自检页一片绿,但某功能因为命令不存在静默失败。
|
||
BASE_CMDS = {
|
||
# coreutils / util-linux / systemd / 账号管理:任何 Linux 都自带,不必登记
|
||
'sh', 'bash', 'cat', 'ls', 'rm', 'cp', 'mv', 'date', 'hostname', 'uname',
|
||
'mount', 'umount', 'df', 'du', 'tar', 'which', 'chown', 'chmod', 'mkdir',
|
||
'sleep', 'echo', 'env', 'timeout', 'dpkg-query', 'apt', 'apt-get',
|
||
'apt-cache', 'apt-mark', 'systemctl', 'systemd-detect-virt', 'modinfo',
|
||
'modprobe', 'ss', 'pgrep', 'useradd', 'userdel', 'usermod', 'chpasswd',
|
||
'kill', 'ip', 'getent', 'sed', 'grep', 'awk', 'head', 'tail', 'wc', 'sort',
|
||
'id', 'stat', 'readlink', 'python3', 'find', 'touch', 'tr', 'cut', 'sync',
|
||
'blockdev', 'partprobe', 'dmesg', 'free',
|
||
}
|
||
# 同一软件包提供的兄弟命令:DEPS 用其中一个登记即可
|
||
# 同包不同命令:DEPS 里登记的是该包的一个代表命令(如 procps 用 ps),
|
||
# 代码里却可能调用同包的另一个命令(pkill)。按命令名比对会误报,这里做映射。
|
||
PKG_ALIAS = {
|
||
'chronyc': 'chronyd', 'docker-compose': 'docker', 'smbpasswd': 'smbd',
|
||
'pkill': 'ps', 'pgrep': 'ps', 'free': 'ps', 'top': 'ps',
|
||
'ss': 'ip', 'tc': 'ip', 'devlink': 'ip',
|
||
'mount': 'lsblk', 'umount': 'lsblk', 'blkid': 'lsblk', 'findmnt': 'lsblk',
|
||
'modinfo': 'modprobe', 'lsmod': 'modprobe', 'depmod': 'modprobe',
|
||
'du': 'df', 'ls': 'df', 'stat': 'df',
|
||
'fsck.exfat': 'mkfs.exfat', 'fsck.f2fs': 'mkfs.f2fs', 'fsck.vfat': 'mkfs.vfat',
|
||
'xfs_repair': 'mkfs.xfs', 'btrfs': 'mkfs.btrfs', 'resize2fs': 'mkfs.ext4',
|
||
'snmpwalk': 'snmpd', 'snmpget': 'snmpd',
|
||
# 「内核转发与加速」用它即时生效 IP 转发 / BBR(同包不同命令)
|
||
'sysctl': 'ps',
|
||
# 打印服务:这几个命令全由 cups / cups-client 提供,DEPS 里以 cupsd 为代表登记
|
||
'lpadmin': 'cupsd', 'lpstat': 'cupsd', 'lpinfo': 'cupsd',
|
||
'lp': 'cupsd', 'cancel': 'cupsd', 'cupsctl': 'cupsd',
|
||
'cupsaccept': 'cupsd', 'cupsreject': 'cupsd',
|
||
}
|
||
called = {}
|
||
for node in ast.walk(h_tree):
|
||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
|
||
and node.func.id in ('sh', 'sh_out', 'run'):
|
||
if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
|
||
elts = node.args[0].elts
|
||
if elts and isinstance(elts[0], ast.Constant) \
|
||
and isinstance(elts[0].value, str):
|
||
c = elts[0].value
|
||
called[c] = called.get(c, 0) + 1
|
||
deps_node = None
|
||
for node in h_tree.body:
|
||
if isinstance(node, ast.Assign) and node.value.__class__.__name__ == 'List':
|
||
for t in node.targets:
|
||
if isinstance(t, ast.Name) and t.id == 'DEPS':
|
||
deps_node = node.value
|
||
dep_targets = set()
|
||
for row in deps_node.elts:
|
||
if isinstance(row, (ast.Tuple, ast.List)) and len(row.elts) >= 4:
|
||
kind = row.elts[2]
|
||
tgt = row.elts[3]
|
||
if isinstance(kind, ast.Constant) and kind.value == 'cmd' \
|
||
and isinstance(tgt, ast.Constant) and isinstance(tgt.value, str):
|
||
for c in tgt.value.split('|'):
|
||
dep_targets.add(c.strip())
|
||
unknown = sorted(c for c in called
|
||
if c not in dep_targets and c not in BASE_CMDS
|
||
and PKG_ALIAS.get(c) not in dep_targets)
|
||
chk('helper 调用的外部命令都已登记到依赖清单', not unknown,
|
||
'→ 未登记:%s' % [(c, called[c]) for c in unknown])
|
||
|
||
# ---------------------------------------------------------------- 6. 已知易错点
|
||
chk('helper 仍保留 PATH 归一化(/usr/sbin)', '_ensure_sbin_path' in HELPER)
|
||
DEPLOY = read('scripts/deploy.sh')
|
||
chk('Web 终端守护有独立 systemd 单元', 'drouter-shelld' in DEPLOY)
|
||
chk('常驻执行守护有独立 systemd 单元', 'drouter-helpd' in DEPLOY)
|
||
chk('部署脚本会校验 9 个后端模块',
|
||
all(m in DEPLOY for m in ('render.py', 'drouter-helper.py', 'drouter-web.py',
|
||
'drouter-logd.py', 'drouter-snapshotd.py',
|
||
'drouter-rescue.py', 'drouter-shelld.py',
|
||
'drouter-helpd.py', 'theme.py')))
|
||
# 守护把 helper 常驻在内存:只 enable --now 的话跑的还是上一版代码
|
||
chk('部署时会 restart 常驻执行守护(否则旧代码不生效)',
|
||
'systemctl restart drouter-helpd' in DEPLOY)
|
||
# 两个守护共用 /run/drouter。用 RuntimeDirectory 的话,systemd 会在最后一个
|
||
# 声明它的单元停止时把整个目录重建,顺手删掉另一个守护的 socket ——
|
||
# shelld 依旧 active 但 socket 没了,终端直接废。所以必须各自 mkdir。
|
||
# 只匹配真正的配置行:单元注释里也会提到 RuntimeDirectory 这个词
|
||
chk('两个守护都不再用 RuntimeDirectory(否则互相删 socket)',
|
||
not re.search(r'^RuntimeDirectory=', DEPLOY, re.M))
|
||
chk('部署后会校验终端 socket 真的存在',
|
||
'-S /run/drouter/shell.sock' in DEPLOY)
|
||
SHELLD = read('backend/drouter-shelld.py')
|
||
chk('shelld 自己创建 /run/drouter 并设 0755',
|
||
'os.makedirs(d, exist_ok=True)' in SHELLD and 'os.chmod(d, 0o755)' in SHELLD)
|
||
chk('helper 暴露了 run_action(供守护复用同一套分发)',
|
||
'def run_action(' in HELPER)
|
||
chk('前端入口破缓存版本号仍在', '?v=' in APP or 'cachebust' in APP.lower()
|
||
or '?v=' in read('web/index.html'))
|
||
|
||
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
|
||
sys.exit(1 if fails else 0)
|