Files
drouter/_dev/t-packaging.py
T
liuzhuohua aab35d48a7 feat(ddns): 真实下发 + 「检测间隔」终于有消费方
原状:DDNS 页面「立即更新」只打印一份更新计划,DNS 记录纹丝不动;
「检测间隔(秒)」字段全仓没有任何定时器消费,纯装饰。

本次做两件事:

1) 真实下发
   - 新增 _http_open:区分「HTTP 层失败」与「业务失败」。
     这类接口的共同点是**用 HTTP 200 回一句 error 表达失败**,
     只判 HTTP 层的话页面显示「成功」而记录没动。
   - 接入 7 家:custom / dnspod / aliyun / cloudflare / noip /
     dyndns / dyndns2。各自的成功判据不同(DNSPod 看 status.code,
     阿里云看 Code,Cloudflare 看 success)。
   - huaweicloud / dnspod_tencent **如实报「暂未接入」**并给替代建议,
     不写「看起来对」的实现 —— 未验证过的签名算法不能假装能用。
   - update 分支三态:全成功 / 全失败(DDNS_UPDATE_FAILED) /
     部分成功(DDNS_UPDATE_PARTIAL)。部分成功不显示成全成功。
   - 下发器内部异常被兜住并如实上报,不把动作打断成 500。

2) 检测间隔有消费方
   - 新增 backend/drouter-ddnsd.py:读 interval → 未启用直接跳过 →
     失败退避 → 探测公网地址 → **只在地址变化时才下发**
     (IP 没变不敲 API,避免触发服务商频率限制)。
   - 失败退避 3 倍递增、上限 1 小时。退避期内秒退,不探测外网
     (一次完整探测 5 秒以上,失败时反复探测是自伤)。
   - _write_ddns_timer 由 save / on / off 三处调用,改了间隔立刻
     重写 timer,不会出现「页面显示已保存、实际还是旧周期」。
   - 守护不依赖 init:容器形态下由常驻守护代跑。

真机验收(192.168.7.3,Debian 13):
  - timer generated/enabled/active,OnUnitActiveSec=120s,跟随用户设定
  - 地址未变 → last_result='unchanged',不下发
  - 篡改 last_ip4 制造「地址变了」→ 真下发 → 失败如实记录并退避
  - 退避内第二次执行 0.136s 返回(首次 4.3s),确认没探测外网
  - HTTP 层失败与业务失败文案可区分
  - 验收后已恢复出厂默认(停用),timer 单元与退避文件已清理

顺带修掉 3 个真缺陷:
  - 阿里云更新参数对新建 dict 做 del up['Action'] → KeyError。
    UpdateDomainRecord 少了 Action/Format/Version 会被阿里云拒。
  - 自定义 URL 失败词表漏 badauth:返回 badauth 被判成功。
  - DNSPod 错误文本在 status.message 而非顶层 message,
    只读顶层时用户只看到「失败」没有原因。

判据:
  - 新增 _dev/t-111-ddns-push.py(44 项,12 组),全绿。
    真实验签不靠网络:_http_open 换成假实现按剧本返回不同正文,
    并在启动时掐死 socket.connect —— 判据测试打真实网络既慢又不确定,
    还会污染对方日志,且失败时看起来像「产品有 bug」。
  - t-packaging 补 2 条:deploy.sh 里每个后端模块都要出现。
    本次就踩到 —— build-deb 用 backend/*.py 通配复制(ddnsd 自动进包、
    判据全绿),可 deploy.sh 是**逐个 install 硬名单**,ddnsd 漏在里面,
    表现为「本地全绿、真机没有文件」,timer 起来报 203/EXEC。
    部署才是真正生效的那一步,两份清单都得查。已反向验证会红。

全量静态回归 43 个检查器 0 失败。
2026-10-03 21:45:43 +08:00

245 lines
11 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 -*-
"""打包产物静态自检:deb 元数据 / 维护脚本 / Dockerfile。
真机构建由 packaging/build-deb.sh 负责(需要 dpkg-deb),本脚本负责在任何机器上
都能跑的「结构正确性」检查,把「打出来的包不能用」挡在构建之前。
"""
import io
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, '..'))
fails = []
def ck(name, cond, extra=''):
# extra 只在失败时打。通过的时候打出来是纯噪声("OK → 只有 12 个" 这种
# 自相矛盾的话最糟,会让人以为真有问题),久了就没人看这输出了。
print(' %-46s %s %s' % (name, 'OK ' if cond else 'FAIL',
'' if cond else extra))
if not cond:
fails.append(name)
def read(rel):
with open(os.path.join(ROOT, rel), encoding='utf-8') as f:
return f.read()
def no_crlf(rel):
with open(os.path.join(ROOT, rel), 'rb') as f:
return b'\r' not in f.read()
def bash_ok(rel):
"""能用 bash 就做真语法检查;Windows 上没有 bash 就跳过(不算失败)。"""
exe = subprocess.run(['bash', '-n', os.path.join(ROOT, rel)],
capture_output=True)
if exe.returncode == 127 or (b'not found' in exe.stderr.lower()):
return None
return exe.returncode == 0
print('一、deb 元数据(packaging/deb/control)')
ctl = read('packaging/deb/control')
ck('含 Package', 'Package: drouter' in ctl)
ck('Version 是占位符(构建时替换)', '__VERSION__' in ctl)
ck('Architecture: all', 'Architecture: all' in ctl)
ck('Depends 非空', 'Depends:' in ctl and len(ctl.split('Depends:')[1].split('\n\n')) > 0)
for pkg in ('python3', 'nftables', 'dnsmasq', 'radvd', 'ppp', 'systemd', 'sudo'):
ck('Depends 含 %s' % pkg, pkg in ctl.split('Recommends:')[0])
# smartmontools 装完会自动起 smartd,不该出现在 apt 默认安装的 Recommends 里
rec = ctl.split('Recommends:')[1].split('\n')[0] if 'Recommends:' in ctl else ''
ck('Recommends 不含 smartmontools(会自启守护)', 'smartmontools' not in rec, rec.strip()[:50])
ck('smartmontools 放进了 Suggests',
'smartmontools' in (ctl.split('Suggests:')[1] if 'Suggests:' in ctl else ''))
ck('control 无 CRLF', no_crlf('packaging/deb/control'))
# DEBIAN/control 是 deb822 格式,官方并不保证支持 "#" 注释 —— 别往里写注释
ck('control 不含 # 注释行(deb822 不保证支持)',
not [l for l in ctl.split('\n') if l.lstrip().startswith('#')])
# 每一行必须是「空行」或「字段名: 值」或「以空格/制表符开头的续行」
bad_cont = []
for l in ctl.split('\n'):
if not l.strip():
continue
if l[0] in (' ', '\t'):
continue
if ':' not in l:
bad_cont.append(l)
ck('每行都是「字段: 值」或续行', not bad_cont, str(bad_cont[:2]))
# 依赖策略的理由写在 build-deb.sh 里(control 不能写注释)
ck('依赖策略说明在 build-deb.sh 里',
'smartmontools' in read('packaging/build-deb.sh'))
print('\n二、维护脚本(postinst / prerm / postrm)')
for m in ('postinst', 'prerm', 'postrm'):
rel = 'packaging/deb/' + m
src = read(rel)
ck('%s 存在' % m, bool(src))
ck('%s 无 CRLF' % m, no_crlf(rel))
ck('%s 有 shebang' % m, src.startswith('#!/bin/bash'))
ck('%s 处理了所有 dpkg 动作' % m, 'case "$1" in' in src and '*)' in src)
ck('%s 以 exit 0 收尾' % m, 'exit 0' in src)
r = bash_ok(rel)
if r is not None:
ck('%s bash 语法' % m, r)
# postinst 的安全红线:绝不能 enable/start DHCP、DNS、UPnP
pi = read('packaging/deb/postinst')
for svc in ('dnsmasq', 'radvd', 'miniupnpd'):
ck('postinst 不启动 %s' % svc,
('systemctl enable %s' % svc) not in pi and ('systemctl start %s' % svc) not in pi)
ck('postinst 用构建保护模式', 'BUILD_MODE' in pi)
ck('postinst 会装 drouter-ctl', 'drouter-ctl' in pi)
ck('postinst 会跑 deploy.sh', 'deploy.sh' in pi)
ck('postinst 用 DROUTER_SRC 指向 /opt/drouter', 'DROUTER_SRC=/opt/drouter' in pi)
prm = read('packaging/deb/postrm')
ck('postrm purge 清 /etc/drouter', 'rm -rf /etc/drouter' in prm)
ck('postrm purge 清 /var/lib/drouter(累计流量)', '/var/lib/drouter' in prm)
ck('postrm remove 保留配置', 'remove)' in prm)
print('\n三、Dockerfile')
df = read('packaging/docker/Dockerfile')
ck('无 CRLF', no_crlf('packaging/docker/Dockerfile'))
INSTR = ('FROM', 'LABEL', 'ENV', 'RUN', 'COPY', 'EXPOSE', 'VOLUME',
'HEALTHCHECK', 'ENTRYPOINT', 'CMD', 'WORKDIR', 'ARG', 'USER', 'SHELL')
lines = df.split('\n')
bad = []
for i, l in enumerate(lines):
s = l.strip()
if not s or s.startswith('#'):
continue
if s.split()[0].startswith(INSTR):
continue
# 上一行以 \ 结尾则是续行,合法
if i > 0 and lines[i - 1].rstrip().endswith('\\'):
continue
bad.append((i + 1, s))
ck('所有指令合法', not bad, str(bad[:2]))
ck('有 FROM', df.startswith('FROM ') or '\nFROM ' in df)
ck('有 ENTRYPOINT', 'ENTRYPOINT' in df)
ck('有 CMD', 'CMD' in df)
ck('有 HEALTHCHECK', 'HEALTHCHECK' in df)
# COPY 的源必须真实存在,否则 docker build 才失败就太晚了
missing = []
for l in lines:
if l.strip().startswith('COPY'):
parts = l.split()
if len(parts) >= 2:
src = parts[1]
if '*' not in src and not os.path.exists(os.path.join(ROOT, src.rstrip('/'))):
missing.append(src)
ck('COPY 源路径都存在', not missing, str(missing))
# HEALTHCHECK / 端口不应写死,否则用户改端口后永远 unhealthy
hc = [l for l in lines if 'HEALTHCHECK' in l or 'curl -sk' in l]
ck('健康检查读取实际端口(不写死 8443)',
any('web-port' in x for x in hc), ''.join(hc)[:80])
ini = read('packaging/docker/docker-init.sh')
ck('docker-init 无 CRLF', no_crlf('packaging/docker/docker-init.sh'))
ck('docker-init 以 exec 结尾(信号可传递)', 'exec "$@"' in ini)
ck('docker-init 播种端口后 unset(避免压住界面改端口)',
'unset DROUTER_WEB_PORT' in ini)
r = bash_ok('packaging/docker/docker-init.sh')
if r is not None:
ck('docker-init bash 语法', r)
print('\n四、构建脚本')
for rel in ('packaging/build-deb.sh', 'packaging/build-docker.sh',
'scripts/drouter-ctl.sh', 'scripts/install-base.sh'):
ck('%s 存在' % rel, os.path.exists(os.path.join(ROOT, rel)))
ck('%s 无 CRLF' % rel, no_crlf(rel))
r = bash_ok(rel)
if r is not None:
ck('%s bash 语法' % rel, r)
bd = read('packaging/build-deb.sh')
ck('build-deb 先校验后端再打包', 'ast.parse' in bd)
ck('build-deb 生成 md5sums', 'md5sums' in bd)
# 逐个点名,别再用「数 .py 出现次数」这种会被注释里的文件名骗过的写法。
# drouter-shelld.py 是 Web 终端的 PTY 守护,漏了它终端就打不开。
#
# ⚠️ 清单必须**从 build-deb.sh 的 for 循环里抽**,不能在测试里再抄一份。
# 早先这里硬编码了 9 个,而 build-deb.sh 实际校验 12 个 —— 两份清单各自
# 独立维护,结果是 backupd / alertd / quotad 三个新守护「测试说齐了、
# 打包脚本说齐了」,但谁都没真去对过对方。测试抄实现的清单,等于没测。
# ⚠️ 必须 findall 再挑「.py 最多」的那个,不能用 search 取第一个 ——
# build-deb.sh 里有好几个 for 循环(复制文件、算权限…),第一个匹配到的
# 根本不是后端校验那个。
_cands = [re.findall(r'[\w.-]+\.py', g)
for g in re.findall(r'for\s+\w+\s+in\s+(.*?);\s*do', bd, re.S)]
if not _cands:
ck('build-deb.sh 能抽出后端模块 for 循环', False,
'→ 找不到 `for f in ... ; do` 形式的校验循环')
BACKEND_MODULES = []
else:
BACKEND_MODULES = max(_cands, key=len)
ck('build-deb.sh 抽出 %d 个后端模块' % len(BACKEND_MODULES),
len(BACKEND_MODULES) >= 12,
'→ 只有 %d 个,backupd/alertd/quotad 这类新守护容易漏' %
len(BACKEND_MODULES))
miss_mod = [x for x in BACKEND_MODULES if x not in bd]
ck('build-deb 逐个校验 %d 个后端模块' % len(BACKEND_MODULES), not miss_mod,
'→ 缺 %s' % miss_mod if miss_mod else '')
# 清单里的每个文件都必须真的存在于 backend/ 下。
# 上面那条只验「文件名出现在脚本里」,写错一个字(比如 drouter-backupd.py
# 拼成 backup.py)照样能过。这条才是「打包不会漏文件」的真正保证。
miss_real = [x for x in BACKEND_MODULES
if not os.path.exists(os.path.join(ROOT, 'backend', x))]
ck('build-deb 清单里的每个模块在 backend/ 下真实存在', not miss_real,
'→ 不存在 %s' % miss_real if miss_real else '')
# 反向:新加一个后端 .py 就必须进清单。漏了的话 dpkg 包里没有它,
# 线上表现是「某个页面 404 / 某个守护根本不存在」,极难定位。
have = {f for f in os.listdir(os.path.join(ROOT, 'backend'))
if f.endswith('.py') and not f.startswith('__')}
notlisted = sorted(have - set(BACKEND_MODULES))
ck('backend/ 下没有游离于清单之外的模块', not notlisted,
'→ 未进清单:%s' % notlisted if not notlisted else '')
# ⚠️ 光查 build-deb.sh **不够** —— 打包对、真机不对,一样是功能开不起来。
# 1.0.8 加 drouter-ddnsd.py 时就踩到过:build-deb 用 `install backend/*.py`
# 通配复制,ddnsd 自动进了包,t-packaging 全绿;可 scripts/deploy.sh
# 是**逐个 install 硬名单**,ddnsd 漏在里面 —— 于是「本地全绿、真机没有文件」,
# 表现为 timer 起来报 203/EXEC,极难定位。
# 部署才是真正生效的那一步,所以**两份清单都要查**。
DP = os.path.join(ROOT, 'scripts', 'deploy.sh')
dp_src = io.open(DP, encoding='utf-8').read()
# deploy.sh 里那两段:编译校验的 for 清单 + 逐个 install 段落
dp_for = ''
m = re.search(r'for f in render\.py.*?; do', dp_src, re.S)
if m:
dp_for = m.group(0)
dp_inst = '\n'.join(l for l in dp_src.splitlines()
if re.search(r'install -m 0644 \$SRC/backend/', l))
dp_all = dp_for + '\n' + dp_inst
dp_miss = sorted(x for x in have
if not re.search(r'(?<![A-Za-z0-9_.-])%s(?![A-Za-z0-9_.-])'
% re.escape(x), dp_all))
ck('deploy.sh 里每个后端模块都出现(for 校验或逐个 install)',
not dp_miss, '→ deploy.sh 漏了:%s' % dp_miss if dp_miss else '')
# 反向:deploy.sh 的 for 清单也必须与 backend/ 对得上(抄一份就会漂移)
dp_named = set(re.findall(r'(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]+\.py)(?![A-Za-z0-9_.-])',
dp_for))
ck('deploy.sh 的 for 清单不是脱离 backend/ 的另一份',
set(BACKEND_MODULES) - dp_named == set(),
'→ build-deb 有而 deploy.sh 无:%s'
% sorted(set(BACKEND_MODULES) - dp_named))
print('\n结果:失败 %d 项' % len(fails))
for f in fails:
print(' ✘ ' + f)
sys.exit(1 if fails else 0)