Files
blog/scripts/backup-bundle.mjs
T
zqlit 5f27ad321d chore(备份): 异地备份定案为 OpenList 离线 bundle,撤销全部 git 辅仓
用户定案:「辅助仓就用 openlist,其他不再考虑」。
据此把前一轮为 Gitee 铺的路全部收回,git 远端只剩 CNB 一个。

一、git 配置收口
- `pushall` 别名 `origin + gitee` → `!git push origin main`(只推唯一远端)
- 移除 `gitea` remote(自建 23.254.236.47:3001)—— 远端仓库本身没删,
  需要时可 `git remote add` 恢复;改动前配置存
  `.workbuddy-backup/git-remotes.20261006-211516.txt`
- 确认无 gitee 相关 credential 残留

二、发布链路去 Gitee 化(6 处)
- `deploy/editor-api/bootstrap.sh`
    · 删掉 GITEE_URL / GITEE_SSH_KEY 两个变量与「没给私钥就降级」的分支
    · PUSH_REMOTES 默认 → origin
    · 原「配 gitee 辅仓远端」一节改为「清理退役远端」循环(gh/gitee/gitea),
      让从旧部署续用的工作区自动恢复干净
- `docker-compose.editor.yml`、`editor-api/server.mjs` → 默认值 origin
- `editor-api/README.md` → 变量表同步
- `editor-api/Dockerfile` → 注释里的「CNB / GitHub」改「CNB」
- `blog-admin/src/routes/rss/tools.ts` → deploy-notify 的注释里
  「与 Gitea Actions 的构建通知配套」改为中性描述
  (该轮询链路 2026-10-04 起已被 CNB 国内节点直传取代)

三、`scripts/setup-cnb-remotes.sh` 重写为单远端模式
- 去掉 GITEE_URL / GITEE_TOKEN 参数、校验、凭据写入与自检提示
- 新增「清理退役远端」步骤
- 凭据处理改为「已存在空的 credential.helper 就不再添加」,
  不再用 --replace-all —— 本仓另有一个从 `$HOME/.workbuddy/secrets/cnb-token`
  读令牌的自定义 helper,那是有效的,不能被脚本抹掉

四、备份升级为「唯一辅仓」的配置
- 保留份数 3 → 7(一周窗口;每份 605.5 MB ≈ 4.2 GB,F50 有 256 GB)
    · `scripts/backup-task.cmd` 默认参数 --keep 7
    · `scripts/backup-bundle.mjs` 的 KEEP 默认值同步为 7
      (原先写的是 2,一直被命令行参数掩盖着)
- 远端目录 `/本地/备份` → `/本地/备份/blog-bundle`:
  根目录是用户自己在用的(放着 github-zqlit-*、local-repos-* 等手工备份),
  实测发现直接放根下的 bundle 已被清掉 —— 改子目录隔离,避免混放与误删

五、新增 `scripts/backup-run.mjs`:备份的推荐入口 + 失败告警
- 读 `.workbuddy-backup/openlist-backup.env`(只补空缺,环境变量优先)
- 跑 backup-bundle.mjs 并实时透传输出,同时留一份日志尾部
- 退出码非 0 → 经 `scripts/send_mail.js` 发告警邮件(附日志尾部与常见原因);
  成功默认不发,`--notify-success` 才发
- 退出用 `process.exitCode` 而非 `process.exit()`,避免截断未排干的 stdout
- 发信失败不改判备份退出码 —— 通知不该掩盖真正的故障
- 理由:这是当前**唯一**的异地备份,而「每天自动跑」的任务最典型的失败模式
  恰恰是静默的(F50 被带出门、换了网段、OpenList 没起来、口令改过……),
  没有告警就要等到真要用备份那天才发现
- `scripts/backup-task.cmd` 改调它

六、文档
- `架构总览.md`
    · §1.2 地址地图:备份行改指 F50/OpenList;通知行补「兼做备份失败告警」
    · §2 旅程图:双推改单推,并说明备份换了介质
    · §5.1 / §5.2 推送与远端:只剩 origin;补「已移除远端」表与恢复命令;
      GitHub 退役记录保留并补上「CI 定义也已删除」
    · §5.3 由「辅仓选型」改为「异地备份的定案」—— 明确不走 git 远端;
      平台对比数据保留备查,并注明 `bin/linux/hugo` 出库不必再做了
    · §5.6 补「唯一备份」定位、专属子目录、失败告警、keep 7、SMTP 配置键,
      实测数据更新为本次复测值
    · §6 待办:#2 定案、#3 不必做、#4 已移除、#8 已更新、#10 定位升级,
      新增 #11(F50 目录使用约定)
- `CNB构建落地方案.md` §4.0:双远端改单远端,脚本示例去掉 Gitee 参数
- `README.md`:推送说明改单推;脚本表补 backup-run.mjs

实测(2026-10-06,本轮复测):
  bundle 7.4s / AES-256-GCM 加密 1.3s / 上传 19.2s(31.6 MB/s)
  / 读回 sha256 一致 → 端到端 60.6s,退出码 0
  告警邮件链路已实测(发出一封「备份成功」验证信)

★ 一处过程记录,供以后避免重复踩坑:
  中途我把「本机沙箱里 `env -u ... cmd > file` 会让输出整个消失」
  误判成 process.exit 截断 stdout,并据此改了日志实现;
  随后用 `env -u FOO echo hi > file`(同样零输出)证伪 ——
  那是沙箱文件重定向的伪影,与脚本无关。相关改动已回滚,
  只留下本身无害的 process.exitCode 写法。
2026-10-06 21:27:28 +08:00

332 lines
14 KiB
JavaScript
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 node
/**
* 整仓备份 → 单个 bundle 文件 → AES-256-GCM 加密 → WebDAV 上传到 OpenList。
*
* 为什么是 bundle 而不是直接推 git:
* WebDAV 不支持原子的 rename/lock,把 bare repo 挂上去直接 `git push` 会让对象写坏
* (表面成功、实际随机损坏,可能几个月后才发现)。bundle 是单文件顺序写,安全。
*
* 为什么默认加密:
* 本仓库历史里含 .env、TLS 私钥、GITEA_SECRETS.md。目标介质是手机内部存储,
* 未加密等于把密钥明文放在一台可能被刷机/丢失/他人访问的设备上。
*
* 为什么不用 gpg:
* 本机 gpg 2.4.9 在 Windows 下已损坏(反复 `removing stale lockfile`,node spawn 直接 EBUSY)。
* 改用 Node 内置 crypto 的 AES-256-GCM:零外部依赖、带认证标签(能检测篡改/截断)、
* 可流式处理 600 MB 不爆内存。加密格式见下方注释,解密由本脚本 `--decrypt` 完成。
*
* 用法:
* node scripts/backup-bundle.mjs # 加密 + 上传 + 比对字节数
* node scripts/backup-bundle.mjs --verify # 额外下载回来比对 sha256(慢,但端到端最可靠)
* node scripts/backup-bundle.mjs --dry # 只打包加密,不上传
* node scripts/backup-bundle.mjs --keep 7 # 远端保留最近 7 份(默认)
* node scripts/backup-bundle.mjs --no-encrypt # 不加密(仅当历史里的敏感文件已洗净)
* node scripts/backup-bundle.mjs --decrypt <文件> [--out x.bundle] # 解密(恢复用)
* node scripts/backup-bundle.mjs --list # 列出远端现有备份
*
* 配置(按此顺序查找,先找到的生效):
* 1. 环境变量 OPENLIST_URL / OPENLIST_USER / OPENLIST_PASS / BACKUP_PASSPHRASE / OPENLIST_DIR
* 2. ~/.openlist-backup.env
* 3. .workbuddy-backup/openlist-backup.env (已在 .gitignore 内)
*
* 加密文件布局: magic(8) | salt(16) | iv(12) | 密文(...) | GCM tag(16)
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import http from 'node:http';
import https from 'node:https';
import { execFileSync } from 'node:child_process';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const MAGIC = Buffer.from('BLOGBKP1', 'ascii');
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 128 * 1024 * 1024 };
const HEAD = MAGIC.length + 16 + 12;
function arg(name, def) {
const i = process.argv.indexOf('--' + name);
if (i < 0) return def;
const next = process.argv[i + 1];
return next && !next.startsWith('--') ? next : true;
}
const has = (n) => process.argv.includes('--' + n);
function log(...a) {
console.log('[' + new Date().toTimeString().slice(0, 8) + ']', ...a);
}
const mb = (n) => (n / 1048576).toFixed(1) + ' MB';
function loadEnvFile(p) {
if (!fs.existsSync(p)) return {};
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
if (line.trim().startsWith('#')) continue;
const m = /^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
const cfg = {
...loadEnvFile(path.join(REPO, '.workbuddy-backup', 'openlist-backup.env')),
...loadEnvFile(path.join(os.homedir(), '.openlist-backup.env')),
...Object.fromEntries(
['OPENLIST_URL', 'OPENLIST_USER', 'OPENLIST_PASS', 'BACKUP_PASSPHRASE', 'OPENLIST_DIR']
.filter((k) => process.env[k])
.map((k) => [k, process.env[k]])
),
};
/* ---------- 加密 ---------- */
async function encryptFile(src, dst, passphrase) {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
fs.writeFileSync(dst, Buffer.concat([MAGIC, salt, iv]));
await pipeline(fs.createReadStream(src), cipher, fs.createWriteStream(dst, { flags: 'a' }));
fs.appendFileSync(dst, cipher.getAuthTag());
}
async function decryptFile(src, passphrase, out) {
const size = fs.statSync(src).size;
if (size < HEAD + 16) throw new Error('文件太小,不是有效备份');
const fd = fs.openSync(src, 'r');
const head = Buffer.alloc(HEAD);
fs.readSync(fd, head, 0, HEAD, 0);
const tag = Buffer.alloc(16);
fs.readSync(fd, tag, 0, 16, size - 16);
fs.closeSync(fd);
if (!head.subarray(0, 8).equals(MAGIC)) throw new Error('magic 不匹配,不是本脚本产生的备份');
const salt = head.subarray(8, 24);
const iv = head.subarray(24, 36);
const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
await pipeline(
fs.createReadStream(src, { start: HEAD, end: size - 17 }),
decipher,
fs.createWriteStream(out)
);
}
/* ---------- WebDAV ---------- */
const BASE = new URL(String(cfg.OPENLIST_URL || 'http://127.0.0.1').replace(/\/+$/, ''));
const AUTH = 'Basic ' + Buffer.from((cfg.OPENLIST_USER || '') + ':' + (cfg.OPENLIST_PASS || '')).toString('base64');
const isHttps = BASE.protocol === 'https:';
const transport = isHttps ? https : http;
const DAVDIR = cfg.OPENLIST_DIR || '/本地/git-backup';
// ★ OpenList 的 WebDAV 端点挂在 /dav 下。少了这个前缀会打到普通 HTTP 路由上,
// 表现是 MKCOL/PUT 全部返回 405 Method Not Allowed(很容易误以为是权限问题)。
const DAV_PREFIX = cfg.OPENLIST_DAV_PREFIX || '/dav';
function davReq(method, urlPath, { file, extraHeaders = {} } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(BASE.href.replace(/\/+$/, '') + urlPath.replace(/ /g, '%20'));
const headers = { Authorization: AUTH, ...extraHeaders };
if (file) headers['Content-Length'] = fs.statSync(file).size;
const req = transport.request(
{ hostname: u.hostname, port: u.port || (isHttps ? 443 : 80), path: u.pathname + u.search, method, headers },
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
}
);
req.on('error', reject);
if (file) {
const rs = fs.createReadStream(file);
rs.on('error', reject);
rs.pipe(req);
} else req.end();
});
}
// prefixEncoded: 已带好并已存在的端点前缀(如 /dav),不参与创建
// relEncoded: 前缀之下、各段已编码的相对目录
async function ensureDir(prefixEncoded, relEncoded) {
let cur = prefixEncoded;
for (const seg of relEncoded.split('/').filter(Boolean)) {
cur += '/' + seg;
const r = await davReq('MKCOL', cur);
if (r.status === 201) { log(' 建目录', decodeURIComponent(cur)); continue; }
// 405 在 WebDAV 里表示「已存在」,但不能无脑相信 —— 真回到普通 HTTP 路由也会给 405。
// 所以补一次 PROPFIND 确认,避免把路径写错(例如漏了 /dav 前缀)当成「目录已存在」而静默放过。
if ([405, 301, 200].includes(r.status)) {
const chk = await davReq('PROPFIND', cur, { extraHeaders: { Depth: '0' } });
if (chk.status >= 400) {
throw new Error(`目录 ${decodeURIComponent(cur)} 既没建成也不存在(MKCOL ${r.status} / PROPFIND ${chk.status})—— 检查 OPENLIST_DIR 与 /dav 前缀`);
}
continue;
}
throw new Error(`建目录失败 ${decodeURIComponent(cur)} → HTTP ${r.status}`);
}
}
function linkNames(xml) {
const out = [];
const re = /<(?:D:|d:)?href>([^<]+)<\/(?:D:|d:)?href>/g;
let m;
while ((m = re.exec(xml))) {
const name = decodeURIComponent(m[1]).replace(/\/+$/, '').split('/').pop();
if (name) out.push(name);
}
return out;
}
function sha256File(p) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
const rs = fs.createReadStream(p);
rs.on('data', (c) => h.update(c));
rs.on('end', () => resolve(h.digest('hex')));
rs.on('error', reject);
});
}
const relDirEncoded = DAVDIR.split('/').filter(Boolean).map(encodeURIComponent).join('/');
const davDirPath = DAV_PREFIX + '/' + relDirEncoded;
const RE_BACKUP = /^blog-\d{8}-\d{6}\.bundle(\.enc)?$/;
/* ---------- 子命令:解密 / 列出 ---------- */
const decArg = arg('decrypt', null);
if (decArg) {
const src = path.resolve(String(decArg));
const out = String(arg('out', src.replace(/\.enc$/, '')));
if (!cfg.BACKUP_PASSPHRASE) { console.error('缺少 BACKUP_PASSPHRASE'); process.exit(2); }
await decryptFile(src, cfg.BACKUP_PASSPHRASE, out);
log('解密完成 →', out, mb(fs.statSync(out).size));
log('恢复仓库: git clone "' + out + '" blog-restored');
process.exit(0);
}
if (has('list')) {
await ensureDir(DAV_PREFIX, relDirEncoded);
const ls = await davReq('PROPFIND', davDirPath, { extraHeaders: { Depth: '1' } });
const files = linkNames(ls.body).filter((n) => RE_BACKUP.test(n)).sort().reverse();
log('远端目录', DAVDIR);
for (const f of files) {
const st = await davReq('HEAD', davDirPath + '/' + encodeURIComponent(f));
const when = new Date(st.headers['last-modified'] || Date.now()).toISOString().replace('T', ' ').slice(0, 19);
log(' ' + f + ' ' + mb(Number(st.headers['content-length'] || 0)) + ' ' + when);
}
if (!files.length) log(' (无备份)');
process.exit(0);
}
/* ---------- 主流程 ---------- */
if (!cfg.OPENLIST_URL || !cfg.OPENLIST_USER || !cfg.OPENLIST_PASS) {
console.error('缺少 OpenList 配置(OPENLIST_URL / OPENLIST_USER / OPENLIST_PASS)');
process.exit(2);
}
const ENCRYPT = !has('no-encrypt');
if (ENCRYPT && !cfg.BACKUP_PASSPHRASE) {
console.error('缺少 BACKUP_PASSPHRASE —— 加密备份必须有口令(确实要明文存放请显式加 --no-encrypt)');
process.exit(2);
}
const KEEP = Number(arg('keep', 7)) || 7;
const VERIFY = has('verify');
const DRY = has('dry');
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
const tmpRoot = path.join(REPO, '.workbuddy-backup', 'tmp');
fs.mkdirSync(tmpRoot, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(tmpRoot, 'run-'));
const rawBundle = path.join(tmpDir, `blog-${stamp}.bundle`);
try {
log('仓库:', REPO);
log('1/6 打包 bundle(git bundle create --all)…');
let t = Date.now();
execFileSync('git', ['-C', REPO, 'bundle', 'create', rawBundle, '--all'], { stdio: ['ignore', 'inherit', 'inherit'] });
log(` 完成 ${mb(fs.statSync(rawBundle).size)} 用时 ${((Date.now() - t) / 1000).toFixed(1)}s`);
let upload = rawBundle;
let finalName = path.basename(rawBundle);
if (ENCRYPT) {
log('2/6 AES-256-GCM 加密(scrypt 派生密钥)…');
const enc = rawBundle + '.enc';
t = Date.now();
await encryptFile(rawBundle, enc, cfg.BACKUP_PASSPHRASE);
fs.unlinkSync(rawBundle);
upload = enc;
finalName = path.basename(enc);
log(` 完成 ${mb(fs.statSync(upload).size)} 用时 ${((Date.now() - t) / 1000).toFixed(1)}s`);
} else {
log('2/6 跳过加密(--no-encrypt)—— 请确认历史里已无敏感文件');
}
const size = fs.statSync(upload).size;
const sha = await sha256File(upload);
log(` 本地 sha256 = ${sha}`);
if (DRY) {
log('--dry:不上传。文件留在', upload);
process.exit(0);
}
log('3/6 准备远端目录', DAVDIR);
await ensureDir(DAV_PREFIX, relDirEncoded);
log('4/6 上传中…');
t = Date.now();
const put = await davReq('PUT', davDirPath + '/' + encodeURIComponent(finalName), {
file: upload, extraHeaders: { 'Content-Type': 'application/octet-stream' },
});
const secs = (Date.now() - t) / 1000;
if (![200, 201, 204].includes(put.status)) throw new Error('上传失败 HTTP ' + put.status);
log(` 完成 ${mb(size)} / ${secs.toFixed(1)}s → ${(size / 1048576 / secs).toFixed(1)} MB/s`);
log('5/6 校验远端');
const stat = await davReq('HEAD', davDirPath + '/' + encodeURIComponent(finalName));
const remoteLen = Number(stat.headers['content-length'] || 0);
if (remoteLen !== size) log(` ★ 远端字节数不一致(本地 ${size} / 远端 ${remoteLen})`);
else log(` 远端字节数一致 (${mb(remoteLen)})`);
if (VERIFY) {
log(' 下载回来比对 sha256…');
const back = path.join(tmpDir, 'readback.bin');
await new Promise((res, rej) => {
const req = transport.request(
{ hostname: BASE.hostname, port: BASE.port || (isHttps ? 443 : 80),
path: davDirPath + '/' + encodeURIComponent(finalName), method: 'GET', headers: { Authorization: AUTH } },
(r) => {
if (r.statusCode !== 200) return rej(new Error('GET ' + r.statusCode));
const ws = fs.createWriteStream(back);
r.pipe(ws); ws.on('finish', res); ws.on('error', rej);
}
);
req.on('error', rej); req.end();
});
const sha2 = await sha256File(back);
log(' ' + (sha2 === sha ? '✓ 读回 sha256 一致 —— 数据完整' : '★ 读回 sha256 不一致 —— 备份已损坏'));
fs.unlinkSync(back);
}
log(`6/6 清理旧份(保留最近 ${KEEP} 份)`);
const ls = await davReq('PROPFIND', davDirPath, { extraHeaders: { Depth: '1' } });
const files = linkNames(ls.body).filter((n) => RE_BACKUP.test(n)).sort().reverse();
log(' 远端现有:', files.join(', ') || '(无)');
for (const old of files.slice(KEEP)) {
const r = await davReq('DELETE', davDirPath + '/' + encodeURIComponent(old));
log(` 删除 ${old} → HTTP ${r.status}`);
}
log('完成。备份文件:', DAVDIR + '/' + finalName);
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (e) {
console.error('\n失败:', e.message);
console.error('(临时文件保留在 ' + tmpDir + ' 便于排查)');
process.exit(1);
}