原状: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 失败。
245 lines
11 KiB
Python
245 lines
11 KiB
Python
#!/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)
|