Repository navigation
Home
ISO Hub 是把上游 Sowevo/iso_download(纯 CLI 脚本)封装成带网页界面的 Docker 服务:网页上勾选要下载的 Linux 发行版/版本,后台实时下载并做 SHA256 校验,一键抓取镜像站目录自动刷新"最新版本"元数据,过期旧 ISO 可视化清理。
项目源码 / 完整 README:https://github.com/DC1024/iso-hub Docker Hub 镜像:https://hub.docker.com/r/dcchendockeruser/iso-hub 阿里云 ACR 镜像(国内推荐):
registry.cn-hangzhou.aliyuncs.com/dcchen/isohub(公开仓库,无需登录)
- 部署
- 使用
- 运维支持
上游 iso_download 是一个命令行脚本,需要手动编辑配置、在终端敲命令、等待下载。ISO Hub 把它变成:
- 一个 Web 界面:勾选、下载、看进度、删旧版,全程可视化。
- 一个 Docker 服务:一条
docker compose up -d --build起 iso-hub 主服务 + socket-proxy 两个容器即可开箱使用;SMB / WebDAV 共享挂在shareprofile、种子下载 qBittorrent 挂在btprofile,默认不部署,需要时--profile share/--profile bt单独启用(见 快速部署 的「按需启用 sidecar 容器」)。 - 一个 自动更新器:订阅同步定时抓镜像站最新版本、自动下载、自动清理过期版本。
docker compose up -d --build
→ 打开 http://<IP>:8899
→ 首次登录(公网先在 .env 播种 ISO_HUB_ADMIN_USER/PASS 建管理员,否则需 SSH 隧道从本机建号)
→ 点右上角「抓取最新版本元数据」
→ 勾选要下载的发行版/版本
→ 点「下载」,看实时进度
标题右上角的绿色徽章(如 1.2.1)是前端版本号,刷新后显示。用它判断你加载的是不是最新界面:
版本体系:
1.x是已发 release 的稳定线;beta 2.x是开发线(代码在beta分支、镜像:beta),同样累计在此里程碑。徽章显示的版本号即为当前代码对应版本。
- 1.0.1 — 修复 tqdm 阻塞日志(日志只在停止任务后刷出的问题)
- 1.0.2 — 修复进度条卡 0% + 日志不刷新的变量遮蔽根因(彻底修复)
- 1.0.3 — 移除重复的「网络共享」顶部标签页,统一并入「设置 → 共享设置」
- 1.0.4 — 完善 docker-compose.yml 环境变量 + 补充 README 网络共享 compose 示例
- 1.1.4 — webdav 配置自愈 + qBittorrent 前端输入框 bug 修复
- 1.1.5 — webdav.yml 空文件自愈(判断"存在且非空"再重写)
- 1.1.6 — 根治全站卡死:/api/distros 请求线程只读缓存、自定义源改后台子进程手动/定时刷新、移除前端 30s 轮询
- 1.1.7 — 三个稳定性修复:① 停止下载卡死(RLock 可重入锁替换不可重入 Lock)② 手动勾选下载进度条不动(#TARGET 解析移出锁外)③ 订阅同步进度条不显示(订阅路径补打 #TARGET 标记 + running_task 从 targets 派生 downloads)
-
1.2.0 — 安全加固版,两大特性:① GPG 签名验证:checksum 文件验签,公钥从官网 HTTPS 获取并与硬编码指纹比对(消除 TOFU 风险),覆盖 Ubuntu detached / Arch 只签 ISO / Fedora 内嵌 clearsigned 三种机制;修掉"持久卷配置永久遮蔽代码内置配置"的迁移 Bug。② Docker 权限收窄:主容器不再挂载裸
/var/run/docker.sock,改经 socket-proxy 白名单转发(仅放行/containers/*,禁镜像/卷/网络/exec/系统);socket-proxy 镜像锁定 digest。③ sidecar 按需启动(compose profiles)+ 服务开关四态显示(运行/已停止/未部署/未知) -
1.3.19 — 种子下载勾选体验对齐镜像列表:① 「下载列表」勾选后点「下载到本机」,成功发起下载即自动清空勾选(此前勾选跨轮询一直保留,用户误以为没点上而重复操作);② 「种子源」列表每行新增勾选框,支持批量添加下载——分类头带半选态全选框,工具条按钮在种子源子页自动变为「⬇ 下载所选」,批量时逐项提交
/api/torrent/add(后端落盘目录按首个 URL 推断发行版,逐项提交保证混选的不同发行版各自落对目录),完成后同样清空勾选。回归:test_local_download.py新增 10 个契约用例(清空时机、逐项提交、混选目录、按钮联动、stopPropagation 等),590 用例全绿。 -
1.3.18 — 种子面板「下载到本机」体验修复(用户在 Z4Pro 外部 qB 部署上实测踩到):① 子标签「⏳ 下载中」更名「⏳ 下载列表」(该栏实际含全部/下载中/已完成/做种筛选,旧名误导);② 两个同名「下载到本机」按钮合并为一个——删除种子面板内按钮,仅保留工具条按钮,
downloadLocalSel()按当前面板自动分发(种子面板走勾选种子的{hash}形态,其余面板走清单勾选三元组);③ 外部 qB 场景修复:iso-hub 容器只挂/data,外部 qB 的下载目录(如/downloads)在容器内不可见时,旧代码静默跳过并笼统报「没有已完成的可下载文件」。现在:目录已按原路径挂进容器 → 新增_abs票据寻址(绝对路径,下载时必须与 qB 此刻报告的已完成文件逐字符串相等,客户端无法伪造路径,防任意文件读);未挂卷 → 改为可行动提示「N 个已完成文件不在 iso-hub 数据目录(外部 qB 需把下载目录按原路径挂载进容器)」。回归:test_local_download.py新增外部 qB abs 下载/半成品拒绝/未挂卷提示/伪造_abs票据被 qB 复核拒绝等 6 类用例,前端契约改为断言全局唯一下载按钮 + 面板分发,580 用例全绿。 -
1.3.17 — 种子面板补全:① 已完成/做种列表可筛选,② 真正能勾选文件下载到本机。修的是用户实际踩到的两个缺口:种子下载过去只有「下载中」一栏、看不到已下完的文件,且面板上的「下载到本机」只对目录清单里的发行版生效(靠
downloadable文件名精确匹配),而种子文件几乎永远对不上号 → 想从服务器把种子的 ISO 拉回本机无从下手。做法:① 后端新增「相对路径」(_rel) 与「按哈希」(hash) 两套下载寻址——_rel用DATA_DIR相对路径(复用_safe_join的穿越防护、拒绝.part/.aria2/.!qB/.tmp半成品),hash走 qBittorrenttorrent_files(hash)取进度≥1 或is_seed的已完成文件、把save_path归一到DATA_DIR相对路径;两者都经现有_issue_dl_ticket走短票据下载(浏览器顶层导航带不上X-Auth-Token,票据自证身份,10 分钟可复用以支持续传)。② 前端种子「下载中」页新增状态筛选 chips(全部/下载中/已完成/做种)与批量勾选——每行一个 checkbox、勾选态存进TORR_CHECKEDSet,2 秒轮询重渲染时保留勾选(自动剔除已消失的种子哈希),点「⬇ 下载到本机」把所选哈希发给/api/files/ticket、逐张票据触发下载(400ms 错峰,失败项 toast 汇报)。回归测试:tests/test_local_download.py新增 rel 路径穿越拒绝、哈希命中已完成文件、跳过未完成文件三类用例(64/64 全绿);test_sync_progress的 2 个错误为既有跨测试全局态污染(隔离运行即通过,与本改动无关)。 -
1.3.15 — 质量版:共享凭据明文存储给出明确提示 + 钉死
.part跨模块契约。两项均不改变运行时行为的加固:-
共享凭据明文提示:SMB / WebDAV 的凭据以明文写入
./data/settings.json(同一文件还保存用户、会话、受保护清单与定时任务),任何能读到 data 卷的进程或用户都能拿到共享密码。本轮不改存储方式——Docker secrets 是只读挂载、应用无法回写,而当前设计恰恰依赖「在网页上改共享密码 → 覆盖 compose env」,换成 secrets 会直接废掉这个功能。因此在「设置 → 共享设置」页加一条用户可见提示,点明凭据的落盘位置与影响面(把 data 目录交给不受信任的一方 = 交出共享密码)。提示带data-i18n挂钩、中英双语齐全,切语言同步。
演进:beta 2.4 之后新增可选静态加密——设置
ISO_HUB_SECRET_KEY(或ISO_HUB_SECRET_KEY_FILE)后,settings.json 整文件改以 AES-GCM 密文落盘(ISOHUB-SETTINGS-ENC-v1),不设则保持明文(向后兼容)。密钥仅用于磁盘加解密,与登录密码无关,用户侧零感知。详见「环境变量与路径」的 settings.json 静态加密 一节。-
.part跨模块契约上锁:_entry_status判「下载中」靠的是把DATA_DIR/<type>/<name>/<文件名>.part拼成字符串、去比对运行中任务上报的#TARGET路径;而PART_SUFFIX在iso_download/download_linux.py、web/app.py、web/iso_runner.py三处各写了一遍,只靠注释要求人工保持一致。历史上正是这个契约被改坏,才出过「下载中误报成下载停止」。本轮不动运行时(把 app.py 改成import download_linux看似干净,但该模块在导入期就from tqdm import tqdm+import requests,只装web/requirements.txt的轻装环境会 ImportError → 整个服务起不来;旁证:web/sync_subscriptions.py在 import 它之前专门 mock 掉了 tqdm)。改为新增契约测试把三处常量与目录布局钉在一起,且路径由 runner 自己的_safe_dist_dir()推导、不在测试里重抄一遍布局——重抄的测试挡不住布局改动。附 6 个变异体验证该测试确实会红:改任一侧的后缀、删掉目录层级、把 i18n 挂钩改名、或把提示挪出共享卡片,测试均立即失败。
-
共享凭据明文提示:SMB / WebDAV 的凭据以明文写入
-
1.3.16 — 数据文件加固版:settings.json 加锁 + 原子写 + 损坏隔离。修的是外部评审里的两条硬伤:#3 JSON 文件写竞争(waitress 8 线程 + 1 调度线程共享同一个
settings.json,外加distro_torrents.py同进程第二写方、sync_subscriptions.py被 Popen 出的独立进程第三写方;两条写线程交错时后写的整份覆盖先写的,丢掉「改共享密码 / 加保护项」这类刚点过保存的东西且毫无报错)与 #4 错误被except Exception: log(...)静默吞(过去load_*解析失败直接data = {},排障时毫无线索)。做法:新增web/config_files.py作为 settings.json 这一类共享 JSON 的唯一读写通道——① 一把独立的可重入锁(app._lock保护内存状态,复用它曾死锁过一次,故绝不混用;且锁绝不包含容器操作 stop→rm→create→start samba 最长约 50 秒,否则设置页被整体串行化);② 先写临时文件再os.replace(写一半被打断不再留残缺文件,Windows 上还会重试吸收「目标被读者占住」的瞬时 WinError 5);③ 内容损坏时备份到settings.json.corrupt并写日志点名备份路径,不再静默当空配置。三个写方全部改走该通道,并配源码扫描 + mock 双重断言挡住「以后又图省事写回write_text」;另附 反向对照测试证明这些断言有牙——把锁拆掉后并发增量必然丢失。顺带把 CItest.yml补上pyyaml,让此前因缺依赖被静默跳过的TestComposeConfig(8 个用例)真正跑起来。 -
1.3.14 — 下载到本机:服务器上已下载的 ISO 可直接拉到打开面板的电脑。
- 解决什么:以前想把服务器上的 ISO 拿到本机,唯一办法是额外启用 SMB/WebDAV sidecar(还得配一套凭据、放行端口),否则无从下手。现在面板上直接点 ⬇ 即可,不需要任何额外容器或开关。
-
为什么用「票据」而不是直接给文件链接:会话 token 走
X-Auth-Token请求头,而浏览器点链接下载是顶层导航、带不上自定义头,直接指过去会 401;把 token 塞进 URL 更糟——它是 7 天有效的长效凭据,会留在浏览器历史与代理日志里。所以改成:先用带头的请求换一张短时票据(随机、默认 10 分钟、只对应单个文件),再用票据 URL 触发下载,由票据自证身份。 -
断点续传:
send_file(conditional=True)提供 HTTP Range 支持(实测Range: bytes=100-199返回206与正确的Content-Range),大 ISO 可暂停/续传。为此/api/files/get从「所有 API 一律禁缓存」的规则里豁免——no-store会让浏览器丢弃已下分片、续传从头开始。票据在有效期内可重复使用(续传与连接重试会对同一 URL 再发请求,一次性票据会直接掐断续传)。 -
覆盖范围:发行版列表的每一行、以及「过期文件」行都会出现 ⬇ 按钮,是否显示由后端下发的
downloadable决定(不靠前端从status/local_size猜——完整文件与.part半成品可能并存,status显示「下载停止」时完整文件依然可下)。种子下载的文件落在同一批目录中,因此同样可下。支持多选后点工具栏「⬇ 下载到本机」批量下载。 -
安全边界:路径穿越防护复用
_safe_join(type 白名单 + 拒绝分隔符与..),并二次校验resolve()后仍在目标目录内(兜住 symlink 指向外部);只下发完整文件(最终名),拒绝.part/.aria2/.!qB/.tmp等半成品名;票据无效、过期或缺失一律 403。下载能力不要求文件属于当前清单(种子下载/手动放入的 ISO 也应能拉回本机),所以这里不做清单成员校验——那与删除接口的语义不同。
-
1.3.13 — 种子列表按发行版分类浏览 + CVE 增量基线门禁。两大特性:
-
种子分类(新增「种子源 → 🏷️ 分类管理」子标签):官方种子列表过去是一长条平铺的 DistroWatch 条目,想找某个发行版只能靠肉眼扫。现在按分类折叠展示,内置 6 个预置分类(Ubuntu / Debian / Arch / Fedora 系 / Proxmox / 工具盘),未命中任何分类的进「未分类」并固定排在最后。分类规则可改:预置分类只能禁用不能删除(防误删后规则静默失效),可另行新增自定义分类(最多 50 个,关键词/排除词各 50 条)。匹配语义刻意区分——关键词按「词首」匹配(填
ubuntu即可覆盖ubuntukylin/ubuntucinnamon/ubuntustudio等派生版),排除词按「完整词」匹配(arch不会误伤search)。另附:列表上方搜索框实时过滤;「未分类」分组提供导出前 20 条(复制到剪贴板,失败降级为下载 txt)——它是后续做标题归一化调优的唯一数据来源。 -
折叠状态用后端下发的 key:首页那套折叠
key是从 DOM 可见文本(chip|gname)拼出来的,计数一变或切一下语言键就变、状态随之丢失。种子分类改为后端下发fold_key,前端只读dataset.foldKey,本地化文本与计数都不参与拼键——切语言重渲染后折叠状态原样保留;并使用独立的 localStorage 键,不与首页折叠互相踩。 -
CVE 增量基线门禁:把当前镜像的 101 条基础镜像 CVE 固化成基线快照(
scripts/cve_baseline.json,经分诊确认零个真实可达、且 Debian 对全部条目均无修复版本,apt upgrade一条都修不掉)。新增scripts/cve_check.py做增量比对:只对基线之外新增的 High/Critical 给出阻断退出码(2),新增 Medium/Low/Unknown 给警告码(1),干净为0,配置/输入异常为3(明确区分于"健康")。配套 GitHub Actions 每周一 04:43 自动跑(也可手动触发,支持传镜像引用与「更新基线」);基线更新只上传产物不自动提交——基线变动必须人工签字。不要用 CVE 绝对数量做门禁,否则 101 条噪音会让流水线永远红。
-
种子分类(新增「种子源 → 🏷️ 分类管理」子标签):官方种子列表过去是一长条平铺的 DistroWatch 条目,想找某个发行版只能靠肉眼扫。现在按分类折叠展示,内置 6 个预置分类(Ubuntu / Debian / Arch / Fedora 系 / Proxmox / 工具盘),未命中任何分类的进「未分类」并固定排在最后。分类规则可改:预置分类只能禁用不能删除(防误删后规则静默失效),可另行新增自定义分类(最多 50 个,关键词/排除词各 50 条)。匹配语义刻意区分——关键词按「词首」匹配(填
-
1.3.12 — ① 外部 qBittorrent 显示真实连接状态,② 镜像瘦身并消除 pip 相关 CVE。
- 外部 QB 连接状态:此前使用外部 qBittorrent 时,状态栏显示的是「未知 / 无法查询容器状态,请检查 socket-proxy 是否运行」——那是查 iso-hub 配套 sidecar 容器的结果,对外部 QB 毫无意义且严重误导(外部 QB 不需要 socket-proxy)。现在改为真实探测该外部实例能否连上并登录,直接显示:● 已连接到外部 qBittorrent / ✗ 用户名或密码错误(并提示核对凭据)/ ✗ 无法连接(并提示检查地址端口与跨主机网络)。配套容器的四态显示保持不变。
-
镜像瘦身 + 安全:构建结束后卸载运行期用不到的
pip(依赖在构建阶段已固定安装,运行期从不执行pip install)。这直接消掉了 6 个python-pipCVE(2025-8869 / 2026-13346 / 2026-6357 / 2026-3219 / 2026-8643 / 2026-1703,Debian 对这些均标注<no-dsa>次要问题),并减小镜像体积。已实测验证:卸载后 flask / waitress / requests / defusedxml / tqdm 全部正常 import,服务健康检查照常通过。
-
1.3.11 — 修复:填写外部 qBittorrent 地址并开启时,误报「容器状态未知(请检查 socket-proxy 是否运行)」。外部 qBittorrent 是用户自行部署的容器,不需要 socket-proxy —— iso-hub 只通过 Web API 用你填的用户名/密码登录连接它。此前保存时若「启用开关」状态发生变化,后端仍会去启停 iso-hub 配套的
iso-hub-qbittorrent容器,而该容器在外部场景下并不存在,于是失败并弹出这条与预期完全不符的错误。现已修正:外部 QB 的启用/停用只保存开关状态,完全不碰任何容器;配套 sidecar 的启停行为保持不变(失败仍正常提示)。 -
1.3.10 — 外部 qBittorrent 凭据可在面板填写(仅用于登录,不改动容器)。延续 v1.3.9 的「配套/外部」区分:当 qBittorrent 填的是外部地址(用户自行部署)时,面板上的用户名/密码保持可填,只用于登录连接那个外部 qBittorrent,保存时只落盘、不写 sidecar 配置、也不重启容器——因为根本没有 iso-hub 配套的 qB 容器可重启。提示文案也改清楚:「当前使用外部 qBittorrent(自行部署),此处用户名/密码用于登录连接它,不会修改其本身设置」。配套 sidecar 的 qB 仍走「改凭据 → 写 conf + 重启」的老路径。回归测试:外部 QB 保存不调用
_set_qb_password/docker restart;配套 QB 保存仍调用,两者分支不再混淆(修掉保存外部 QB 凭据时误入重启分支返回 500 的 bug)。 - 1.3.9 — 区分配套/外部容器,面板更诚实。设置面板现在能区分「iso-hub 配套的 sidecar 容器」与「用户自行部署的外部容器」:SMB/WebDAV 检测不到配套容器、或 qBittorrent 填了外部地址时,面板会提示「当前使用非配套容器(自行部署的外部实例),本面板无法管理其用户名/密码」,并禁用对应的凭据输入与保存按钮;配套容器则照常可改。这样用户不会误以为改面板凭据就能改外部容器的密码。
-
1.3.8 — 设置面板改进:SMB/WebDAV 地址动态端口 + qBittorrent 可填外部地址。① SMB 与 WebDAV 地址端口不再写死:显示为
smb://<服务器IP>:<端口号>/iso、http://<服务器IP>:<端口号>/dav,端口号跟随 docker-compose 配置动态展示,改端口后界面地址自动同步。② qBittorrent 地址可自行填写:默认空输入框(placeholder 提示http://<服务器IP>:<端口号>),部署了外部 qBittorrent 的镜像可直接填完整地址让 iso-hub 主动连接;仅改动地址不会触发 qb 容器重启(凭据变更才重启)。 - 1.3.7 — 配置加载更稳健 + 健康监控更灵敏。① 预设配置在任何启动位置都能加载:修复了从不同工作目录启动时默认配置可能加载不到的问题,现在无论用哪种方式部署,预设的配置文件都能被正确读取。② 健康检查阈值优化:当镜像站/关键端点失效比例达到 30% 时,系统会将状态升级为「严重(error)」级别告警,让你更快发现大规模异常。③ 服务降级保护:数据目录创建遇到问题时,服务不再直接崩溃,而是降级为警告继续运行,尽量不影响已下载文件的使用。
-
1.3.6 — 修复 sidecar 启停"部分成功 / 完全失败"状态表达不清 + 自愈策略查询失败被误判为已一致。① 启停失败文案区分"完全失败"与"部分成功":
set_share/set_qb在一步成功、另一步失败(如docker start成功但RestartPolicy=unless-stopped没补上)时,不再笼统报"未完全成功",而是明确写出——完全失败:"SMB 启动失败(步骤1: HTTP 500),配置未变更,当前不可用";部分成功:"SMB 已启动,但自愈策略未生效(步骤2: HTTP 500)。当前可用,重启后不会自动恢复,将在下次 iso-hub 启动时自动修复"。关键是把"当下能不能用"与"重启后会不会失效"分开说,消除用户最怕的状态不明。② 自愈策略查询失败不再误判:_heal_enabled_sidecar原先policy is None(代理暂不可达 / Docker API 抖动)会return False当作"已一致"跳过,漏掉真正需要补自愈策略的容器;改为return True(留待下一轮重试)并打告警日志。③ 启动顺序微调:_bootstrap_webdav_conf()提到start_sidecar_convergence()之前,先按当前设置生成 webdav.yml 再启动后台收敛线程。 -
1.3.5 — 为 socket-proxy 加健康检查门禁,堵住"代理就绪时序"缺口。面板经 socket-proxy(白名单转发 Docker API)访问 Docker,但此前 iso-hub 与代理无
depends_on关系,iso-hub 可能在代理尚未就绪时就启动并尝试 Docker 操作。修复:① 给 socket-proxy 容器加healthcheck——用代理镜像自带的wget探/_ping(白名单放行),不可达则unhealthy;② iso-hub 的depends_on改为condition: service_healthy,启动前先等代理健康。三份 compose(docker-compose.yml / .acr.yml / .dockerhub.yml)同步修改。生产实测:代理重建后约 8 秒即报healthy,iso-hub 不再先于代理启动。 -
1.3.4 — 修复 sidecar(SMB / WebDAV / qBittorrent)自愈能力静默丢失。面板启用时会同时做两件事:
docker start与RestartPolicy=unless-stopped(前者管当下,后者管持久化)。但有两条路径让"自愈"悄悄丢失:① 运维重建容器——compose 里 sidecar 写死restart: no,任何 recreate(compose up -d遇镜像更新 /--force-recreate/down+up)都会让它重新生效,而容器仍在运行、service_state()只回报State.Status,面板依旧绿灯,直到宿主机重启才暴露为"共享消失";② 启停返回值校验不严——set_share用and判断两个请求,start成功而update失败时误报成功,set_qb更是完全不检查update返回值,于是留下 running + 旧策略。而启动收敛_converge_disabled_sidecars只处理"配置为禁用但容器在跑"的单向情况,对启用的 sidecar 直接continue,从不纠正。修复:新增container_restart_policy()读取容器真实重启策略;新增_heal_enabled_sidecar()做反向收敛——运行中但策略 ≠unless-stopped时只补update(不碰运行状态,故不违背"启用容器不得被停止"),容器存在但未运行时按配置拉起(配置 enabled 即表达"期望在运行"),状态或策略查不到时保守跳过;set_share/set_qb改为任一步失败即返回 False。测试 290 passed(+11),5/5 变异均被捕获。 -
1.3.2 — 修复订阅同步进度条卡在 50% 不动。现象:手动下载完 09.01 后订阅 Arch 组执行「订阅同步」,进度条恒定在 50%,且表头一直显示那个已下载完成的文件(1.5 GB),而不是真正在下载的文件。两个缺陷叠加:① 上报路径用错名字(
sync_subscriptions.py)——订阅同步打印#TARGET <最终名> <大小>,但下载器下载期间把字节写在<最终名>.part上、完成后才os.replace成最终名;后端running_task()对 targets 里的路径 stat(),于是"文件还没开始下 → 最终名不存在 → size=0(进度恒 0%)"或"该文件已下载完成 → 读到一个静止的完整大小 → 分子被垫高后不再变化"。实测 1.5GB 已完成 + 1.5GB 下载中 → 恰好 50%(已用真实数值复现)。修复:上报.part路径;已存在完整文件时把目标大小夹到本地实际大小,让该条在聚合里天然 100%(避免"本地 1.5GB / 远端 1.5GB"的下载幻象)。② 聚合把已完成文件也算进来(前端poll)——分子分母都含已完成的 1.5GB,进度条先被垫到 50%,之后真正下载的文件再怎么涨也推不动指针。修复:已完成文件从分子分母一起剔除,只统计"本轮还有活要干"的文件(全部完成时显示 100%)。同时表头"当前文件"改为优先取尚未完成且 size 最大者,不再被已完成的文件长期霸占。配套:app.py新增_tracked_size()——.part不存在时回落到同名最终文件,区分"尚未开始"(0)与"已完成"(满值);.part与最终名并存时以.part为准。修复后同一场景进度平滑推进 0% → 25% → 50% → 76% → 100%。 -
1.3.1 — 修掉三个"看起来像镜像站有问题、其实是客户端 bug"的问题。① 两个高可信源都报"校验和验证失败"(P0,真因):日志里清华源、科大源接连失败,看起来离谱——因为问题跟镜像站无关。下载期间文件叫
xxx.iso.part,而sha256sums.txt里登记的是xxx.iso;老代码在行内做子串包含判断(if filename in line),查询串比行内容更长("...iso.part"不是"...iso"的子串)→ 永远匹配不上 → 返回None→ 报"所有校验和验证都失败" → 半成品被当成损坏丢弃。修复:查表前先剥离.part后缀,并把匹配从"子串包含"改为按空白切分后精确比对文件名字段(顺带消除了archlinux-x86_64.iso误命中archlinux-2026.09.01-x86_64.iso的前缀隐患),同时把"清单里没这个文件"与"内容确实不符"分开报错(后者带上实际 SHA256,便于诊断)。② 正在下载的文件显示「下载停止」:列表状态此前完全由磁盘推断,而"正在下载"和"被中断"在磁盘上都表现为一个.part,无法区分。修复:新增downloading状态——由运行中任务的targets/downloads生成活跃.part路径快照(锁外获取,只读内存,遵守 D2 教训),命中则判「下载中」,前端配品牌色呼吸动画徽标;中断残留的.part仍归「下载停止」。③ 文件级"下载优先级"下拉默认选中主源而非「自动」:srcSelHtml用u===e.download_url?' selected'把配置主源标成选中,导致「自动」选项虽渲染却从未被选中;更糟的是提交时会把它当作用户显式选择写成pin,在「策略B 实测选最快源」下会跳过测速、锁死该源。修复:默认选「自动」,仅entry.pin被显式设置时才预选,并把主源改为在下拉里标注 "(主源)",靠说明而非默认选中来传达"自动会优先用它"。另修复 v1.2.9 遗留的_head_content_length(url)变量名笔误(应为target_dist["download_url"])。 -
1.2.9 — 修复"ISO 还没下完就开始校验":现象是日志里下载 19:45 开始、19:47 就报
✗ 校验和验证失败, 丢弃半成品重试——一个 1523 MiB 的文件只下了 56 MiB 就去算校验和,必然失败。根因不是校验时机不对(校验代码确实在下载循环之后),而是完整性判据被短路:老代码写的是if total and part.stat().st_size != total,当服务器没有返回Content-Length(或其非法/为 0)时total == 0,整个大小核对被if total and ...静默跳过,不完整的文件就直接进入 SHA256 比对 → 必然不符 → 日志看起来像"没下完就校验"。三处修复:① 长度三级兜底(新增_resolve_total()):Content-Range全长 >Content-Length + 已下量> 调用方 HEAD 预取大小,三者皆无才返回 0,且 0 的语义明确为"确实无从判断"而非"放行";#TARGET上报大小归零时会自动扫描后续候选源补测,避免单源故障导致进度条失去百分比基准。② 区分"传输截断"与"内容损坏"(新增TruncatedTransfer/CorruptPayload两个异常):截断 = 有预期长度但写入不足 → 保留.part供续传(此前一律删除,一次网络抖动就把几百 MB 进度全丢);长度达标却校验不过 → 内容确实损坏 → 丢弃重下;长度完全未知且校验不过 → 无法区分 → 保守保留(宁可多占磁盘也不误删用户进度)。③ 订阅同步路径(download_linux.py)同步补上 HEAD 长度兜底与"无法核对完整性"显式告警。修复后同场景行为:截断保留半成品并提示"缺 N B, 可续传",下次运行带Range从断点接着下。 -
1.2.8 — 「刷新列表」不再"点了没反应":该按钮绑定
loadDistros(),作用是重新读取当前状态(拉/api/subscriptions+/api/distros并重绘列表),不会联网抓新元数据(那是「⤓ 抓取最新版本元数据」)。问题是它成功时一声不吭——同一排其它按钮(抓取元数据 / 删除所选 / 清理过期)都有 toast,只有它没有。于是数据没变化时,重绘结果与当前画面完全一致,连四个统计数字都纹丝不动,用户无从判断点击是否被受理。修复:① 成功时提示「列表已刷新 · MM-DD HH:MM」;② 点击瞬间按钮进入旋转 loading 态,立刻有回应;③ 失败时明确报错(刷新失败 (HTTP xxx)/ 401 提示「请先登录」)而不是静默渲染空列表;④ 顺带修掉jget()的静默失败隐患——它的 401 分支过去无条件return {}(因askToken()现恒返回 false,实际已变成"无条件吞掉 401"),调用方拿到的空对象与"接口真的返回空"无法区分。现通过模块级LAST_GET_FAILED暴露失败信号,同时保持返回{}的结构不变,以免影响另外 20+ 个依赖"字段缺失即失败"的既有调用点。各写操作完成后的"顺带刷新"传{silent:true},避免与主提示叠加。 -
1.2.7 — 修复「下载停止」的半成品无法删除:1.2.5 改用
.part原子落盘后,「下载停止」的文件在磁盘上叫xxx.iso.part,而前端传给删除接口的是目标名xxx.iso。后端api_delete_files只检查target/xxx.iso是否存在,于是判为「文件不存在」直接跳过——用户明明在列表里看得到这个占着磁盘的停止文件,点删除却只得到「已删除 0 个文件 · 1 跳过」。三处一起修:① 后端改为同时解析最终名与.part两条路径(都做目录越界校验),删除实际存在的那些,并在删掉半成品后清理该文件的下载失败记录(否则文件删了 UI 仍显示「下载停止」);② **前端「删除所选」**的可删判定从「只认local_size>0」放宽为「完整文件 / 半成品 / 已下载 / 下载停止 / 下载失败 任一」,不再把半成品过滤掉;③ 行内删除按钮的显示条件从「仅已下载」扩展到「已下载 / 下载停止 / 下载失败」,停止和失败的行也能直接点 🗑 删除。另补上此前缺失的delNothingRemoved提示文案(缺 key 时一个都没删掉会直接显示原始 key)。 -
1.2.6 — 修复 .part 协议的三个连锁问题:1.2.5 引入
.part原子落盘后,暴露了三处未同步适配的地方。① 进度条卡 0%:/api/download给downloads.path填的是最终名(xxx.iso),而下载器打印的#TARGET哨兵用的是.part名,两者对不上——running_task()对最终名stat()得 0,且targets的 key 取不到 → 进度恒为 0%。现统一为.part路径。②.part被误报为"过期文件":disk_inventory()第一趟循环无差别收录目录所有文件(含.part本身),第二趟又把同一文件归到目标名下,于是.part的原始名进了stray_files,UI 显示「不在最新清单元数据中(通常已被更新淘汰的旧版 ISO)」并给出「清理过期」红按钮——而它其实是可续传的半成品。现第一趟跳过半成品后缀,并在build_distros里对 partial 再做一道兜底过滤。③ 重下后状态不变 + 没有真正续传:注释一直声称"可续传",但_download_file_with_failover()始终以"wb"打开.part(截断重写),从未实现 HTTP Range 续传。现已实现真正的断点续传:带Range: bytes=N-请求,服务器返回206时用"ab"追加写入;不支持 Range(返回200)则明确告警并从头下载,避免拼接出错;校验失败时丢弃已损坏的半成品。另:任务运行期间每 5 秒刷新一次镜像列表,下载中途也能看到状态变化(此前只在任务结束那一刻刷新,用户中途看列表会以为没生效)。 -
1.2.5 — 修复"中断的下载被显示为已下载":1.2.3 引入的五态状态判定基于"半成品带
.part后缀"这一假设,但下载器实际是直接写最终文件名(xxx.iso),半成品根本没有.part后缀;而「停止任务」是向进程组发信号杀进程,main()里下载循环之后的_record_failure()永远执行不到,失败记录也不会写。两者叠加的结果是:一个下载到一半的残缺 ISO 在磁盘上看起来和完整文件一模一样,于是被判为「已下载」。修复:下载器改为下载期间一律写<最终名>.part,只有大小校验 + 校验和全部通过后才os.replace原子改名为最终文件名。这样无论进程被 kill、断网还是磁盘写满,磁盘上留下的都是.part,后端disk_inventory()能稳定识别为半成品 → 显示「下载停止(可续传)」,残缺文件绝不可能再以正式文件名出现在磁盘上。同步修正:①#TARGET进度哨兵改为上报.part路径(否则前端进度条永远读到 0);② 状态判定改为取「完整文件与.part中较新的那个」作为代表,避免"旧残留文件 + 正在写的.part"被误判为已下载;③ 目录清理与订阅同步的快照逻辑一并适配.part。 -
1.2.4 — 修复登录错误提示被吞:1.2.3 已让后端返回精确的
code(no_user/bad_pass),但前端jpost()对所有 401 响应做了统一兜底——在把响应交给调用方之前就把 body 替换成"请先登录后再操作"。而登录失败恰好也是 401,于是后端区分好的错误被这一层无条件覆盖,用户仍然只看到笼统提示。修复:新增AUTH_ENDPOINTS白名单,登录接口自身的 401 属于"凭据错误"业务响应,必须原样透传;其余接口的 401 兜底行为不变。 -
1.2.3 — 锁定优先级 + 登录提示 + 下载状态细化:① 锁定优先级高于手动删除:被锁定(🔒)的文件无法通过任何方式手动删除(后端已彻底移除
force绕过参数),点击删除会提示"该文件已锁定,请先点击解锁按钮"。锁定仍然同时保护「删旧版/订阅同步」策略。② 登录错误精确化:以前用户名错和密码错都笼统提示"请先登录后再操作",现在分别提示「用户名不存在」和「密码错误」(接口返回code: no_user / bad_pass)。③ 下载状态细化:文件状态从「已下载/未下载」两态扩展为五态——已下载 / 下载停止(可续传)/ 下载失败(需重下)/ 未下载。识别依据:磁盘上残留的.part半成品 → 下载停止;失败记录中标记为需重下 → 下载失败。筛选器新增「下载失败/停止」选项,状态徽章带 tooltip 说明含义。 -
1.2.2 — 镜像删除 + 筛选隔离修复:① 新增勾选删除镜像:镜像列表每行加了复选框,操作栏「🗑 删除所选」批量删除,每行也提供单文件删除按钮;后端新增
POST /api/delete-files,带四重安全约束——路径穿越校验、只允许删当前清单内的文件(保护用户自建 ISO,清单外文件请用「清理过期」)、受保护文件需二次确认force才删、任务运行中返回 409 拒绝。② 修复筛选污染其他面板的 Bug:镜像筛选(全部/已下载/未下载/已收藏)此前用全文档.card扫描,而自定义源、订阅同步、设置、BT 面板复用了同一套.card/.g-name/.chip结构,导致选中非「全部」后这些面板被误判为发行版分组而整体隐藏,出现"只剩一两个功能"甚至"整页空白"。现改为只在#groups容器内生效,其他面板完全不受影响;筛选无命中时显示友好提示而非空白页。 -
1.2.1 — 启动收敛修复:修掉 sidecar 状态与设置开关不一致的启动时序竞态。
docker compose up -d并发创建容器时,主容器启动检查禁用 sidecar 的那一刻它们往往尚未创建(返回 404),一次性快照判断被跳过;随后 compose 才把容器拉起,于是长期停留在"设置里未勾选启用、容器却一直在运行"的不一致状态。改为后台线程反复收敛:每轮检查所有禁用 sidecar,发现运行就停止,直到全部一致或尝试次数耗尽;停止请求失败会自动重试,同一容器不重复 stop。收敛在守护线程中执行,不阻塞 web 服务启动。 -
beta 2.4 — 容器查询失败日志降噪 + 外部 qBittorrent 短路:① 面板日志里反复刷「查询容器 … FileNotFoundError」的噪声压掉了真正信息——现在同一容器 + 同一异常 5 分钟内只打首条,并在首条附「挂 docker.sock 或设 DOCKER_HOST」的提示;② 使用外部 qBittorrent 时,不再去查 iso-hub 配套的 sidecar 容器(外部实例根本不需要 socket-proxy),避免每次查询都失败刷日志。返回值语义完全不变(仍
unknown/not_deployed/running/stopped四态)。 -
beta 2.4 之后 · settings.json 静态加密(可选) — 设置
ISO_HUB_SECRET_KEY(或ISO_HUB_SECRET_KEY_FILE)后,settings.json整文件以 AES-GCM 加密落盘(ISOHUB-SETTINGS-ENC-v1魔数),密钥经 SHA-256 派生 32 字节 AES-256 密钥;写后chmod 600。不设密钥则保持明文(向后兼容)。密钥与登录密码完全解耦(登录认证仍是 PBKDF2 哈希),用户侧零感知;密钥错/文件被篡改抛SettingsDecryptError且绝不静默重置(防数据丢失)。相关测试test_settings_encryption.py7 例。示例 compose /.env.example/ wiki 已同步列出两个密钥变量(留空=明文、填入=加密)。 -
beta 2.3 — 共享凭据脱敏(密码不回传前端):SMB / WebDAV / qBittorrent 的密码保存后不再回传前端——
GET /api/shares、GET/POST /api/qb/settings响应里的password字段被移除,改为布尔password_set(标记「已设置密码」);前端密码框留空 = 沿用已保存的那份(避免"只想改用户名却把密码清了")。与邮件通知的脱敏是同一套约定。 - beta 2.2 — 下载完成邮件通知:下载/订阅同步任务完成后可发 SMTP 邮件通知(465/SSL 加密),配置在「设置 → 邮件通知」,密码同样脱敏不回传。支持测试邮件(不落盘)。默认关闭。
-
beta 2.1 — ISO 存放模式开关:
classified(按类型/发行版/文件分文件夹,默认)与flat(所有 ISO 平铺到单一iso/目录)两种存放模式,可在「设置 → 存放模式」随时切换(仅影响之后下载,已下载文件不搬迁)。底层以settings.json的storage_mode键 + 环境变量ISO_HUB_STORAGE_MODE表达。
若你看到的徽章数字小于最新 release,说明浏览器缓存了旧前端,请强制刷新(Ctrl+F5)。