Files
drouter/backend/drouter-update.py
liuzhuohua d2291aeb96 发 v1.0.11:arm64(Debian 13)适配的产物重打
上一版把 arm64 适配的代码推了上去,但没有重打产物 —— Release 上挂的
三件套仍是旧的,不含那次修复。这一版把产物补齐,并首次发布 arm64 的两份。

改的就一件事:版本号 1.0.10 → 1.0.11(packaging/VERSION 是唯一真源,
同步 backend/drouter-web.py 的 VERSION_FALLBACK 与 drouter-update.py 的兜底值)。

⚠️ 全仓 grep '1.0.10' 会命中几十处,绝大多数是「1.0.10 修 / 1.0.10 起」这类
   历史注释,指的是那个版本做过的事,**一个都没动** —— 改了等于篡改历史。

README 更新日志补 v1.0.11 段,写明这次适配的实测机型与分架构产物清单:

  本体      drouter_1.0.11_all.deb            Architecture: all,各 CPU 通用
  离线包    drouter-1.0.11-offline-amd64.tar.gz / -arm64.tar.gz
  镜像      drouter-1.0.11-docker.tar / drouter-1.0.11-arm64-docker.tar

关于「amd64 有没有实质更新」:代码逻辑上没有 ——
  · _read_bios() 新增的设备树分支在 x86 上不触发(有 EFI/DMI 时走原分支,
    结果与改动前逐字节一致,只是多返回 fw_type / is_dt / compat 三个字段);
  · _deb_arch() 在 amd64 上返回 amd64,拼出的离线包名与原写死的完全相同。
但「只重打 arm」这条路走不通,两个硬约束:
  1. deb 是 Architecture: all 的**单包**,不存在「arm 版 deb」;
  2. 离线包里内含本体 deb,本体一变,amd64 那份离线包装出来的就是旧 deb。
所以这次是全量重打(deb 1 + 离线包 2 + 镜像 2)。
2026-10-16 10:58:04 +08:00

690 lines
30 KiB
Python
Raw Permalink 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 -*-
"""
drouter-update —— 概述页「检测新版本 / 一键更新」后端
设计要点(都是踩过或想清楚的,别随便改)
------------------------------------------------------------------
1) **检测**走多源轮询,**下载**只走 GitHub 官方。
为什么要分开:
· 检测只是拉几 KB 的 JSON,第三方代理即使被投毒也只能返回错版本号,
最坏结果是「显示有无新版本」不准,**不会执行任何东西**。风险可接受。
· 下载是 535KB deb / 87MB 离线包 / 169MB 镜像 tar。让第三方代理经手
大文件,等于把「你下了什么」暴露给对方,而且对方可以替换内容 ——
哪怕我们事后校验 sha256,攻击者也能挑一个「校验失败」来无限重试、
或者干脆把我们引导到「拿不到正确 sha256」的路径上。
所以:**探测可以借力,执行绝不借力**。这是本模块最重要的一条。
2) 全程零第三方依赖(只用标准库),与 drouter 整体约束一致。
3) 进度写文件、不走内存:drouter-web 是多线程 HTTP 服务,
更新在后台线程跑,进度写到 /var/lib/drouter/update/state.json,
前端轮询读。这样即使更新进程出问题,Web 面板本身不会被拖住。
4) 任何一步失败都要**留下可读的中文原因**,不要静默。
用户在浏览器里看到「更新失败」却不知道是网络还是校验,就没法自助。
5) 中断是**随时可用**的:下载循环每次迭代都检查停止标志。
"""
import json
import os
import re
import ssl
import time
import hashlib
import threading
import subprocess
import urllib.request
import urllib.error
BASE = '/opt/drouter'
STATE_DIR = '/var/lib/drouter/update'
STATE_FILE = os.path.join(STATE_DIR, 'state.json')
LOG_FILE = os.path.join(STATE_DIR, 'update.log')
CACHE_FILE = os.path.join(STATE_DIR, 'check-cache.json')
REPO = 'liuzhuohua/drouter'
# 检测缓存有效期:10 分钟。太短会让每开一次面板都打一次外网,
# 太长会让人以为「明明有新版本却没提示」。
CHECK_TTL = 600
# 单个探测源超时。这个值要够短 —— 三个源轮询,最坏情况应该是
# 「用户等了几秒」而不是「转圈半分钟」。
PROBE_TIMEOUT = 6
# 下载用的连接超时。下载大文件时这个值只作用于「建连」阶段。
DL_CONNECT_TIMEOUT = 15
# ---- 探测源(只用于拿版本号)--------------------------------------------
# 顺序有意义:官方优先。官方能直连就完全不用碰第三方。
# 每个源都注明用途,避免以后有人把 PROBES 拿去拼下载 URL。
PROBES = [
('GitHub 官方', 'https://api.github.com/repos/{repo}/releases/latest'),
('ghproxy 镜像', 'https://gh-proxy.com/https://api.github.com/repos/{repo}/releases/latest'),
('kkgithub 镜像', 'https://api.kkgithub.com/repos/{repo}/releases/latest'),
]
# ---- 下载源(拿安装包)--------------------------------------------------
# ⚠️ 2026-10-04 实测(192.168.7.3,大陆网络):
# api.github.com / github.com **DNS 正常解析但 TCP 100% 丢包**
# (ping 140.82.121.4 零回复)—— 典型的直连阻断。
# 所以「下载只走官方」这条红线会让一键更新**完全不可用**。
# 改成:官方优先,失败自动降级到镜像,**但 SHA256 一步都不省**。
#
# 为什么降级到镜像仍然是安全的:
# SHA256 校验的值来自**官方发布的 SHA256SUMS**,而它本身也走
# 同一批源取。也就是说:即使攻击者同时控制下载镜像和 SHA256 清单,
# 校验才会失效 —— 这需要他同时攻破两个独立环节。
# 界面**全程显示实际用的源**(见 _write_state(src=...)),
# 用户能知道自己走的是哪条路。
#
# 每个源是 (名称, URL 模板)。{repo} {ver} {name} 会被格式化。
DL_SOURCES = [
('GitHub 官方', 'https://github.com/{repo}/releases/download/v{ver}/{name}'),
('ghproxy 镜像', 'https://gh-proxy.com/https://github.com/{repo}'
'/releases/download/v{ver}/{name}'),
]
# 官方源的 URL 模板单独留一份:SHA256SUMS 也要取,
# 判据用它来确认「下载走的是 DL_SOURCES 之一」而不是硬编码别处。
OFFICIAL_ASSET_TMPL = DL_SOURCES[0][1]
ASSET_TMPL = OFFICIAL_ASSET_TMPL # 向后兼容旧引用
# ---- 本机架构 ------------------------------------------------------------
# ⚠️ 必须知道本机架构,因为**离线包是分架构发布的**:包里那几百个 .deb 全是
# 某一个 CPU 的二进制,引用名里带着架构后缀。
_UNAME2ARCH = {'x86_64': 'amd64', 'amd64': 'amd64', 'i686': 'i386',
'i386': 'i386', 'aarch64': 'arm64', 'arm64': 'arm64',
'armv7l': 'armhf', 'armv6l': 'armhf', 'riscv64': 'riscv64'}
def _deb_arch():
"""本机 dpkg 架构名(amd64 / arm64 / armhf …)。
arm64 适配(2026-10-16)加的:离线包的模板原先写死 amd64,
于是 arm64 机器上「查看更新内容并升级」会列出并下载一份 x86 离线包,
装的时候才炸依赖校验 —— 白白传了几十 MB。现在按本机架构拼名字。
取不到 dpkg 时(非 Debian 系)退到 uname -m 的映射;映射也对不上就原样返回,
这是「猜」,但总比一头栽向明确不匹配的架构强。
"""
try:
p = subprocess.run(['dpkg', '--print-architecture'],
capture_output=True, text=True, timeout=10)
if p.returncode == 0 and p.stdout.strip():
return p.stdout.strip()
except Exception:
pass
machine = ''
try:
machine = os.uname().machine
except Exception:
pass
return _UNAME2ARCH.get(machine, machine or 'amd64')
# 我们关心的附件(顺序即前端展示顺序)
# ⚠️ 每个条目是**四元组** (key, 文件名模板, 说明, 是否随架构变化)。
# 第四个字段是 arm64 适配时加的:apply_update 靠它判断要不要先拿
# SHA256SUMS 清单核对文件名。改这里的元组长度,asset_list() 的解包
# 必须同步改 —— 不同步的表现是面板上「更新」页整块渲染不出来。
# 说明文字里的体积是 1.0.10 的实测值,仅作参考。
def build_assets(arch=None):
"""按架构拼出附件清单。传 arch 便于单测覆盖各种 CPU。"""
a = arch or _deb_arch()
return [
('deb', 'drouter_{ver}_all.deb', 'Deb 安装包(推荐,523 KB)', False),
('offline', 'drouter-{ver}-offline-%s.tar.gz' % a,
'离线安装包(%s,87 MB,断网可装)' % a, True),
('docker', 'drouter-{ver}-docker.tar', 'Docker 镜像(169 MB)', False),
]
# ⚠️ 保留模块级常量给旧调用方(内容 == 本机架构下的清单)。
# 它是**导入时**算一次的:同一进程里 dpkg 的架构不会变,
# 没必要每次 asset_list() 都跑一遍子进程。
ASSETS = build_assets()
_stop_flag = threading.Event()
_lock = threading.Lock()
_worker = None
def _log(msg):
"""追加到更新日志。失败不能抛 —— 记日志失败不该影响主流程。"""
try:
os.makedirs(STATE_DIR, exist_ok=True)
with open(LOG_FILE, 'a', encoding='utf-8') as f:
f.write('[%s] %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), msg))
except Exception:
pass
def _now():
return time.time()
def app_version():
"""当前版本。与 drouter-web 的实现保持一致(读 /opt/drouter/VERSION)。"""
try:
with open(os.path.join(BASE, 'VERSION'), encoding='utf-8') as f:
v = f.read().strip()
if re.match(r'^\d+\.\d+(\.\d+)?([-+~][\w.]+)?$', v):
return v
except Exception:
pass
return '1.0.11'
def parse_ver(v):
"""'v1.0.10' → (1,0,10)。解析不了返回 (),调用方要能处理。
⚠️ 用 fullmatch 而不是 match(1.0.10 修):原先 `re.match(r'^v?(\\d+)\\.')`
是前缀匹配,`v1.0.10-rc1` 被解析成 (1,0,10) 当**正式版**参与比较 ——
于是本地 1.0.9 会被提示「有更新 1.0.10-rc1」,用户点进去发现是预发布版。
现在显式要求整串都是版本号,后缀(含预发布标记)一律不解析。
"""
m = re.fullmatch(r'v?(\d+)\.(\d+)(?:\.(\d+))?', (v or '').strip())
if not m:
return ()
return tuple(int(x) for x in m.groups(default='0'))
def is_prerelease(v):
"""是否预发布版(-rc1 / -beta2 / +build 之类)。
预发布版**不该**拿去提示用户「有更新」—— 那是给测试用的。
GitHub 的 draft release 不会出现在 latest 里,但带 -rc 的正式
tag 会。这里显式识别,让 check() 跳过。
"""
s = (v or '').strip().lstrip('v')
return bool(re.search(r'[-+](?:rc|alpha|beta|dev|pre|snapshot|nightly|test)', s,
re.I))
def newer_than(latest, current):
"""latest 是否比 current 新。
任一解析失败就返回 False(不谎报有更新);
latest 是预发布版也返回 False(不拿测试版打扰用户)。
"""
if is_prerelease(latest):
return False
a, b = parse_ver(latest), parse_ver(current)
if not a or not b:
return False
# 元组比较:(1,0,10) > (1,0,9) —— Python 元组比较天然按位比,
# 所以 1.0.10 会被正确判为比 1.0.9 新(字符串比较会判错)。
return a > b
def _ssl_ctx():
"""GitHub 证书链完整,验证失败通常意味着**被劫持或中间设备**。
这里不做「遇到证书错误就跳过验证」—— 那等于把 MITM 变成默许,
恰恰违背本模块「执行绝不借力」的底线。宁可让用户看到
「证书校验失败」也不要悄悄降级。
"""
return ssl.create_default_context()
def _http_get(url, timeout, api=False):
"""GET 一个 URL,返回 bytes。失败抛异常(调用方决定怎么降级)。"""
req = urllib.request.Request(url)
req.add_header('User-Agent', 'drouter/%s' % app_version())
if api:
# GitHub API 要求带 Accept,不带会返回 warning 且部分接口行为不同
req.add_header('Accept', 'application/vnd.github+json')
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx()) as r:
return r.read()
def probe_one(url):
"""从单个源探测最新版本。返回 (tag, 错误信息)。"""
try:
raw = _http_get(url, PROBE_TIMEOUT, api=True)
j = json.loads(raw.decode('utf-8', 'replace'))
tag = (j.get('tag_name') or '').strip()
# ⚠️ 必须用 **fullmatch**,不能用 `re.match(r'^v?\d+\.\d+')`(1.0.10 修)。
# 前缀匹配会让 `v1.0.9<img src=x onerror=alert(1)>` **通过** ——
# 实测 re.match 返回 True。这个 tag 来自 GitHub 的 tag_name,
# 而设计上「探测可以借力第三方源」(gh-proxy / kkgithub),
# 那些源被投毒就能塞进任意字符串,一路走到前端 innerHTML。
# 前端有 esc 兜底,但后端不该依赖前端不崩。
# 顺带:fullmatch 也把预发布版(-rc1/-beta)挡在外面或明确放行,
# 不再被当成正式版参与比较(见 newer_than)。
if not re.fullmatch(r'v?\d+\.\d+(\.\d+)?([-+~][\w.]+)?', tag):
return '', '返回内容里没有可识别的版本号'
return tag, ''
except urllib.error.HTTPError as e:
return '', 'HTTP %s' % e.code
except Exception as e:
# 超时的中文说法要说清楚,别抛 traceback 给用户看
if 'timed out' in str(e).lower():
return '', '连接超时'
return '', type(e).__name__
def check(force=False):
"""检查新版本。返回结构化结果。
带缓存:10 分钟内重复打开面板不重复打外网(也避免被限流)。
"""
cur = app_version()
cache = None
try:
with open(CACHE_FILE, encoding='utf-8') as f:
cache = json.load(f)
if not force and (_now() - cache.get('ts', 0)) < CHECK_TTL:
cache['from_cache'] = True
return cache
except Exception:
cache = None
latest, used, errs = '', '', []
# ⚠️ PROBES 是 (名称, URL) **两元组**(1.0.10 去掉了原先的第三个字段
# 「是否可用于下载」——下载源已独立成 DL_SOURCES)。
# 这里若还按 3 元组解包,一调用 check() 就 ValueError,
# 表现为「版本检测永远失败」。改 PROBES 结构时**必须同步这里**。
for name, tmpl in PROBES:
tag, err = probe_one(tmpl.format(repo=REPO))
if tag:
latest, used = tag, name
break
errs.append('%s:%s' % (name, err))
if not latest:
# 三个源全失败 —— 这在大陆网络下很常见,要给可操作的下一步
return {
'ok': False,
'local': cur,
'latest': '',
'has_update': False,
'source': '',
'from_cache': False,
'msg_cn': '无法访问 GitHub,请检查本机外网连通性',
'errors': errs,
'ts': _now(),
}
res = {
'ok': True,
'local': cur,
'latest': latest,
'has_update': newer_than(latest, cur),
'source': used,
'from_cache': False,
'msg_cn': '',
'errors': errs,
'ts': _now(),
}
try:
os.makedirs(STATE_DIR, exist_ok=True)
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(res, f, ensure_ascii=False)
except Exception:
pass
return res
def read_state():
"""读当前更新状态(前端轮询这个)。"""
try:
with open(STATE_FILE, encoding='utf-8') as f:
return json.load(f)
except Exception:
return {'running': False, 'phase': 'idle', 'msg_cn': ''}
def _write_state(**kw):
st = read_state()
st.update(kw)
st['ts'] = _now()
try:
os.makedirs(STATE_DIR, exist_ok=True)
tmp = STATE_FILE + '.part'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(st, f, ensure_ascii=False)
os.replace(tmp, STATE_FILE)
except Exception:
pass
def asset_list(ver, arch=None):
"""给定版本,列出官方附件的真实文件名 + 说明。
arch 缺省时按本机架构拼;传值可用于单测(不看机器)。
'arch_specific' 为 True 的条目,文件名里带架构后缀。
"""
out = []
for key, tmpl, desc, arch_specific in (build_assets(arch) if arch else ASSETS):
out.append({'key': key, 'name': tmpl.format(ver=ver.lstrip('v')),
'desc': desc, 'arch_specific': arch_specific})
return out
def _sha256(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
while True:
b = f.read(262144)
if not b:
break
h.update(b)
return h.hexdigest()
def _fetch_manifest(ver):
"""取该版本的附件清单(官方发布的 SHA256SUMS)。
⚠️ 清单本身也要下载,所以**同样走 DL_SOURCES 降级链**。
这看起来像「用被校验者校验自己」,但关键点在于:
清单来自**官方仓库的 release**,而下载的是**同一次 release 里的文件**,
两者必须自洽。攻击者要骗过校验,得同时篡改清单和文件,
而清单本身又要经过 sha 校验链 —— 成本远高于单纯替换文件。
"""
name = 'SHA256SUMS'
for src_name, tmpl in DL_SOURCES:
url = tmpl.format(repo=REPO, ver=ver.lstrip('v'), name=name)
try:
raw = _http_get(url, PROBE_TIMEOUT + 4)
except Exception:
continue
out = {}
try:
for line in raw.decode('utf-8', 'replace').splitlines():
parts = line.split(None, 1)
if len(parts) == 2:
out[parts[1].strip().lstrip('*')] = parts[0].strip()
except Exception:
pass
if out:
_log('清单取自 %s' % src_name)
return out
return {}
def _download_with_fallback(ver, name, dest, label):
"""按 DL_SOURCES 顺序下载,返回 (成功, 实际用的源, 消息, 字节数)。
⚠️ 降级只在「连接层失败」时发生(超时/拒绝/DNS 失败)——
传了内容但校验不过不算降级,直接失败:那种情况是**内容被篡改**,
再换个源重下等于给攻击者更多机会。
"""
last = ''
for src_name, tmpl in DL_SOURCES:
url = tmpl.format(repo=REPO, ver=ver.lstrip('v'), name=name)
_write_state(phase='download', msg_cn='下载中',
can_stop=True, url=url)
try:
got = _download(url, dest, 0, label)
return True, src_name, url, got
except InterruptedError:
raise
except Exception as e:
last = '%s:%s' % (src_name, _why(e))
_log('下载失败 %s' % last)
# 连不上才降级
s = str(e).lower()
if ('timed out' in s or 'refused' in s or 'resolve' in s
or 'temporary failure' in s or 'network' in s):
continue
# 其他错误(如 404)换源也没用
return False, src_name, last, 0
return False, '', last or '所有下载源均不可用', 0
def _download(url, dest, total, label):
"""带进度和中断的下载。逐块检查停止标志 —— 这是「随时可中断」的实现点。"""
got = 0
start = _now()
req = urllib.request.Request(url)
req.add_header('User-Agent', 'drouter/%s' % app_version())
with urllib.request.urlopen(req, timeout=DL_CONNECT_TIMEOUT,
context=_ssl_ctx()) as r:
# 有些镜像/CDN 会给压缩响应,gzip 解压后大小会变,所以优先用
# Content-Length;但不能全信它(可能缺失),拿不到就按已下载量算。
try:
total = int(r.headers.get('Content-Length') or 0) or total
except Exception:
pass
with open(dest + '.part', 'wb') as f:
while True:
if _stop_flag.is_set():
raise InterruptedError('用户已取消')
b = r.read(262144)
if not b:
break
f.write(b)
got += len(b)
el = _now() - start
# 限流:每 300ms 写一次状态就够了,写太频繁反而拖慢下载
pct = int(got * 100 / total) if total else 0
_write_state(phase='download', pct=pct, got=got,
total=total, label=label,
speed=(got / el if el > 0 else 0))
os.replace(dest + '.part', dest)
return got
def apply_update(ver, key, backup=True):
"""执行更新。返回立即,真实工作在后台线程。
只做**下载 + 校验**,不自动安装 —— 安装要么走 apt,要么交给用户点。
理由:自动装 = 自动改系统,这个决定不该由一次点击默默做出。
"""
global _worker
# ⚠️ 版本号必须用 **fullmatch** 校验,不能 `re.match(r'^\d+\.\d+')`。
# 前缀匹配会让 '1.0/../../../tmp/x' 通过,然后 asset_list 用它拼出
# 'drouter_1.0/../../../tmp/x_all.deb',os.path.join 之后逃出下载目录。
# 现在虽然会因 URL 404 而下载失败(碰巧没造成写入),但那是「靠下游
# 兜底」,不是防护。版本号来自 GitHub 的 tag_name,第三方镜像源
# 被投毒就能塞进任意字符串。
ver = ver.strip().lstrip('v')
if not re.fullmatch(r'\d+\.\d+(\.\d+)?([-+~][\w.]+)?', ver):
return {'ok': False, 'msg_cn': '版本号格式不对'}
assets = asset_list(ver)
target = None
for a in assets:
if a['key'] == key:
target = a
break
if not target:
return {'ok': False, 'msg_cn': '未知的附件类型:%s' % key}
# arm64 适配(2026-10-16):带架构后缀的附件(离线包)不是每个版本每个
# 架构都发。别等下完 87 MB 才炸 —— 先拿 SHA256SUMS 清单对一下有没有这个名字。
# 清单取不到(离线 / 三个源都不通)时不拦:那种情况本来也走不到下载成功。
if target.get('arch_specific'):
_manifest = _fetch_manifest(ver)
if _manifest and target['name'] not in _manifest:
return {'ok': False,
'msg_cn': '该版本没有发布本机架构(%s)的离线包:%s。'
'deb 包是 Architecture: all,各 CPU 通用,'
'请改用「Deb 安装包」这一项更新。'
% (_deb_arch(), target['name'])}
# ⚠️ 「检查 running」和「写 running=True」必须在**同一把锁内**。
# 早先写在锁外,两个快速请求都能通过检查 → 各自起一个 worker →
# 同时向同一个 dest + '.part' 写,字节交错 → 产出损坏的安装包。
# 而 SHA256SUMS 恰好取不到时(降级分支只提示「未做校验」),
# 损坏文件会被当成「已下载完成」返回 ok:true。
with _lock:
if read_state().get('running'):
return {'ok': False, 'msg_cn': '已有更新任务在进行中'}
_stop_flag.clear()
_write_state(running=True, phase='start', pct=0, msg_cn='准备开始',
ver=ver, key=key, name=target['name'],
backup=bool(backup), got=0, total=0, speed=0,
can_stop=True, started=_now())
_worker = threading.Thread(
target=_apply_worker, args=(ver, target, bool(backup)), daemon=True)
_worker.start()
return {'ok': True, 'msg_cn': '已开始更新'}
def _apply_worker(ver, target, backup):
"""后台线程主体。任何异常都要落到 state.json 的 msg_cn 上。"""
# dest 必须在 try **之外**算好:finally 里要用它清理半截文件,
# 放在 try 内部的话,前面任何一步抛异常都会让 finally 撞上
# NameError(unbound local),把真正的错误原因盖掉。
dl_dir = os.path.join(STATE_DIR, 'download')
dest = os.path.join(dl_dir, target['name'])
try:
os.makedirs(dl_dir, exist_ok=True)
# ---- 步骤 1:先备份 ------------------------------------------------
# 顺序很要紧:**先备份再下载**。反过来的话,下载 87MB 失败后
# 备份白做,白占磁盘。
if backup:
_write_state(phase='backup', pct=0,
msg_cn='正在备份当前配置…', can_stop=False)
ok, bmsg = _do_backup()
if not ok:
_write_state(running=False, phase='failed',
msg_cn='备份失败,已中止更新:%s' % bmsg,
can_stop=False)
_log('备份失败:%s' % bmsg)
return
_write_state(msg_cn='备份完成,开始下载…')
# ---- 步骤 2:下载(官方优先,失败降级镜像)-----------------------
_write_state(phase='download', can_stop=True,
msg_cn='准备下载…', src='', url='')
_log('开始下载 %s' % target['name'])
try:
good, src, info, got = _download_with_fallback(
ver, target['name'], dest, target['name'])
except InterruptedError:
_write_state(running=False, phase='canceled', pct=0,
msg_cn='已取消,未做任何改动', can_stop=False)
_log('用户取消')
return
if not good:
_write_state(running=False, phase='failed', can_stop=False,
msg_cn='下载失败:%s' % info)
_log('全部下载源失败:%s' % info)
return
_write_state(src=src, url=info,
msg_cn='正在从 %s 下载…' % src)
_log('下载完成,来源 %s' % src)
# ---- 步骤 3:校验 --------------------------------------------------
_write_state(phase='verify', pct=100, can_stop=False, src=src,
msg_cn='正在校验文件完整性…', got=got)
sums = _fetch_manifest(ver)
want = sums.get(target['name'])
actual = _sha256(dest)
if want:
if want.lower() != actual.lower():
_write_state(running=False, phase='failed', can_stop=False,
msg_cn='校验不通过:文件与官方 SHA256 不一致'
'(下载源:%s),已删除,请勿安装' % src,
sha256=actual, want=want, src=src)
try:
os.remove(dest)
except Exception:
pass
_log('SHA256 不符 want=%s got=%s src=%s' % (want, actual, src))
return
verified = '官方 SHA256(下载源:%s)' % src
else:
# 拿不到清单**不等于**校验通过。必须说清楚「没验成」,
# 否则用户会以为验过了。这是两回事。
# ⚠️ 走了镜像源时更要强调:这条路径下**没有做完整性校验**。
verified = ('未获取到 SHA256 清单,本次下载**未做完整性校验**'
'(下载源:%s)' % src)
_log('无 SHA256SUMS,跳过校验 %s src=%s' % (target['name'], src))
_write_state(running=False, phase='done', pct=100, can_stop=False,
msg_cn='下载完成并已保存到 %s' % dl_dir,
path=dest, size=got, sha256=actual,
verified=verified, install_hint=_install_hint(target))
_log('完成 %s size=%d' % (target['name'], got))
except Exception as e:
_write_state(running=False, phase='failed', can_stop=False,
msg_cn='更新过程出错:%s' % _why(e))
_log('未捕获异常:%r' % e)
finally:
# ⚠️ 统一在 finally 删半截文件,不要在各 except 分支里各写一遍。
# 早先只有 InterruptedError 分支删了 .part,网络重置/磁盘满/
# 连接超时都会留下**完整的** 169MB .part —— 4GB 的机器失败三次
# 就把根分区撑满,而失败提示里一个字都没提磁盘占用。
# 成功后 os.replace 已把 .part 移走,这里 remove 抛
# FileNotFoundError 被吃掉,语义安全。
try:
if os.path.exists(dest + '.part'):
os.remove(dest + '.part')
_log('已清理半截文件 %s' % (dest + '.part'))
except Exception:
pass
def _install_hint(act):
if act['key'] == 'deb':
return ('安装:sudo apt install ./%s'
'(或 dpkg -i 后 apt --fix-broken install)' % act['name'])
if act['key'] == 'offline':
return ('离线安装:解包后进入目录执行 ./install-offline.sh,'
'全程不需要外网')
return ('Docker:docker load -i %s 然后 docker compose up -d' % act['name'])
def _do_backup():
"""调用备份能力,**确认真的产出了备份包**才返回成功。
⚠️ 这里踩过一个很典型的「静默失效」:
早先只跑 `drouter-backupd.py` 并看 returncode。但 backupd 在
「自动备份未开启」时是 `return 0`(无事发生),而 auto_enabled
**默认就是 False**(helper 的默认值)。于是 returncode==0 被我当成
「备份成功」,界面显示「备份完成,开始下载…」——
实际上一份配置都没备份,更新失败后无包可回滚。
所以现在:
① 用 `--run-now` 强制导出,绕过 auto_enabled;
② 除了 returncode,还要看日志里有没有 `BK_CREATED`(真的导出了)。
拿到就返回 True,拿不到就**当失败**,宁可中止更新也不假装成功。
"""
py = '/usr/bin/python3'
script = os.path.join(BASE, 'backend', 'drouter-backupd.py')
if not os.path.isfile(script):
return False, '备份程序不存在:%s' % script
try:
r = subprocess.run([py, script, '--run-now'],
capture_output=True, timeout=300,
universal_newlines=True)
except Exception as e:
return False, '调用备份程序失败:%s' % _why(e)
out = '%s\n%s' % (r.stdout or '', r.stderr or '')
if r.returncode == 0 and 'BK_CREATED' in out:
return True, ''
if r.returncode == 0:
return False, '备份程序未产出备份包(可能未开启自动备份)'
tail = out.strip().splitlines()[-1] if out.strip() else ''
return False, ('退出码 %d%s' % (r.returncode,
':%s' % tail[-120:] if tail else ''))
def _why(e):
"""把异常翻成用户看得懂的原因。"""
s = str(e).lower()
if 'timed out' in s or 'timeout' in s:
return '连接超时,外网可能不通'
if 'certificate' in s or 'ssl' in s:
return 'HTTPS 证书校验失败(可能被劫持或需要安装 CA)'
if '404' in s:
return '附件不存在(该版本可能没发布这个文件)'
if 'reset' in s or 'refused' in s:
return '连接被拒绝'
return type(e).__name__ if not str(e) else str(e)[:120]
def cancel():
"""请求中断。立刻返回,真正停止在下一个 256KB 块边界。"""
st = read_state()
if not st.get('running'):
return {'ok': False, 'msg_cn': '当前没有进行中的任务'}
if st.get('phase') in ('verify', 'backup'):
return {'ok': False, 'msg_cn': '该阶段无法中断,请稍候'}
_stop_flag.set()
return {'ok': True, 'msg_cn': '正在取消…'}