Files
blog/scripts/backup-bundle.mjs
T
zqlit 8abe93d17a feat(backup): 整仓离线备份上线 —— bundle 加密后传中兴 F50 上的 OpenList
背景:GitHub 被按 AUP 清空后,用户提出自己的中兴 F50(5G CPE + 内置 256GB)
上跑着 OpenList,想用它当第三层备份。实测可行,已落地并跑通。

为什么是 bundle 而不是直接推 git:
  WebDAV 不支持原子的 rename/lock,bare repo 挂上去 push 会让对象写坏 ——
  表面成功、实际随机损坏,可能几个月后才发现。bundle 是单文件顺序写,安全。

为什么加密(用户一度想省掉):
  历史里含 .env、TLS 私钥、GITEA_SECRETS.md。介质是随身设备的内部存储,
  明文 = 把密钥放在一台可能丢失/刷机/送修的机器上。
  OpenList 的登录只保护「访问通道」,不保护「存储介质」——拆机就能读。
  加密成本实测仅 1.9~2.9 秒、体积不变;且 CNB/Gitea 仍是明文副本,
  口令丢失只是少一份备份,不构成单点。

实现(scripts/backup-bundle.mjs,零 npm 依赖):
  - git bundle create --all → AES-256-GCM(Node 内置 crypto)
  - 布局 magic(8)|salt(16)|iv(12)|密文|tag(16),scrypt(N=32768,r=8,p=1) 派生密钥
  - 为什么不用 gpg:本机 gpg 2.4.9 在 Windows 下已损坏(反复 stale lockfile,
    node spawn 直接 EBUSY);换内置 crypto 后零外部依赖且带认证标签
  - 上传后可选 --verify:下载回来比对 sha256,端到端闭环
  - --keep 控制远端保留份数;--decrypt 恢复;--list 盘点;--dry 不上传

定时任务(scripts/backup-task.cmd + Windows 计划任务 Blog-BundleBackup):
  - 每天 03:30 本地时间,默认 --verify --keep 3
  - InteractiveToken + LeastPrivilege、StartWhenAvailable、1h 超时、IgnoreNew 防重入
  - 日志追加到 .workbuddy-backup/logs/backup.log,超 5MB 轮转

★ backup-task.cmd 内容必须全 ASCII:
  cmd.exe 按当前代码页(zh-CN 是 GBK)解析批处理文件,而 node 输出 UTF-8。
  UTF-8 中文注释会吞掉 CR/LF 并把下一行当命令执行 —— 实测踩到(一条 rem 被当命令跑)。
  ASCII 是 UTF-8 子集,纯英文注释与 node 的中文输出混写不会乱。
  同理不能用 %date%(含本地化星期),改用系统时间 API 取 ISO 格式时间。
  日志轮转的 for 语句必须加 if exist 守卫,否则首次运行报「系统找不到指定的路径」。

实测(由计划任务实际拉起,非手工执行):
  bundle 6.8~9.9s(605.5MB)/ 加密 1.9~2.9s / 上传 19.4~20.6s(29.4~31.3 MB/s)
  / 下载回读 sha256 一致,端到端退出码 0
  恢复链路已演练:--decrypt → git bundle verify 报 "records a complete history"
  → 1008 提交完整一致

文档(架构总览.md):
  - §5.3 从「Gitee 两条硬约束」扩写为「辅仓选型」,补入云效 Codeup 基础版对照
    (Git 5GiB + 单文件命令行 200MB)—— 选它则 bin/linux/hugo 不必出库、
    构建链路一行不用改
  - 新增 §5.6 整仓离线备份(介质 / 为什么 bundle / 为什么加密 / 加密格式 / 用法 /
    配置 / 定时任务 / 恢复流程 / 实测数据)
  - §6 待办:#2 改为「辅仓选型未定」并说明 pushall 现状;#3 标注只有选 Gitee 才必须做;
    #9 补记「不改写历史」的唯一障碍已随 GitHub 消失;新增 #10 备份已上线 + 三项安全待办
2026-10-06 21:03:29 +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 3 # 远端保留最近 3 份
* 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', 2)) || 2;
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);
}