From 8abe93d17a0ed336937e17282867ae6258f870b8 Mon Sep 17 00:00:00 2001 From: zqlit Date: Tue, 6 Oct 2026 21:03:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(backup):=20=E6=95=B4=E4=BB=93=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=E5=A4=87=E4=BB=BD=E4=B8=8A=E7=BA=BF=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20bundle=20=E5=8A=A0=E5=AF=86=E5=90=8E=E4=BC=A0?= =?UTF-8?q?=E4=B8=AD=E5=85=B4=20F50=20=E4=B8=8A=E7=9A=84=20OpenList?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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 备份已上线 + 三项安全待办 --- scripts/backup-bundle.mjs | 331 ++++++++++++++++++++++++++++++++++++++ scripts/backup-task.cmd | 61 +++++++ 架构总览.md | 125 ++++++++++++-- 3 files changed, 501 insertions(+), 16 deletions(-) create mode 100644 scripts/backup-bundle.mjs create mode 100644 scripts/backup-task.cmd diff --git a/scripts/backup-bundle.mjs b/scripts/backup-bundle.mjs new file mode 100644 index 00000000..ff87e160 --- /dev/null +++ b/scripts/backup-bundle.mjs @@ -0,0 +1,331 @@ +#!/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); +} diff --git a/scripts/backup-task.cmd b/scripts/backup-task.cmd new file mode 100644 index 00000000..a87a5724 --- /dev/null +++ b/scripts/backup-task.cmd @@ -0,0 +1,61 @@ +@echo off +rem ============================================================================ +rem blog full-repo backup - entry point for Windows Task Scheduler +rem +rem scripts/backup-bundle.mjs does the real work: +rem git bundle --all -> AES-256-GCM encrypt -> WebDAV upload to OpenList +rem on the ZTE F50, then downloads it back and compares sha256. +rem +rem Notes: +rem * This file is intentionally pure ASCII. cmd.exe parses a .bat/.cmd using +rem the *current* code page (GBK on zh-CN) while node emits UTF-8. A UTF-8 +rem Chinese comment can swallow the CR/LF and break the following line +rem (seen in practice: a rem line got executed as a command). ASCII is a +rem subset of UTF-8, so plain-English text mixes safely with node output. +rem * node resolution order: PATH -> WorkBuddy managed -> D:\nodejs +rem * Log appends to .workbuddy-backup\logs\backup.log, rotated at 5 MB +rem * .workbuddy-backup is git-ignored; logs and passphrase never enter git +rem * Exit code is passed through (0 = success), visible in Task Scheduler +rem ============================================================================ +setlocal + +set "REPO=%~dp0.." +if "%REPO:~-1%"=="\" set "REPO=%REPO:~0,-1%" +set "LOG=%REPO%\.workbuddy-backup\logs" +set "SCRIPT=%REPO%\scripts\backup-bundle.mjs" + +if not exist "%LOG%" mkdir "%LOG%" + +rem --- rotate log at 5 MB. Guarded by if exist: on the very first run the file +rem does not exist yet and for/f would fail with a "path not found" error --- +if exist "%LOG%\backup.log" for %%F in ("%LOG%\backup.log") do if %%~zF GTR 5242880 move /y "%LOG%\backup.log" "%LOG%\backup.prev.log" >nul 2>&1 + +rem --- locate node --- +set "NODE=" +for %%P in (node.exe) do if not defined NODE set "NODE=%%~$PATH:P" +if not defined NODE if exist "%USERPROFILE%\.workbuddy\binaries\node\versions\22.22.2-6\node.exe" set "NODE=%USERPROFILE%\.workbuddy\binaries\node\versions\22.22.2-6\node.exe" +if not defined NODE if exist "D:\nodejs\node.exe" set "NODE=D:\nodejs\node.exe" + +if not defined NODE call :fail "node not found in PATH, WorkBuddy managed dir, or D:\nodejs" +if not exist "%SCRIPT%" call :fail "script not found: %SCRIPT%" + +rem --- ASCII timestamp. Do NOT use %date%: on zh-CN it carries localized +rem weekday text (e.g. Chinese "Tuesday") which would mix encodings --- +set "STAMP=%TIME%" +for /f "delims=" %%I in ('powershell -NoProfile -Command "[DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss')" 2^>nul') do set "STAMP=%%I" + +rem --- arguments: use %* when given (handy for manual runs, e.g. --dry / --list) --- +set "ARGS=%*" +if "%ARGS%"=="" set "ARGS=--verify --keep 3" + +echo. >> "%LOG%\backup.log" +echo [%STAMP%] ===== backup start ===== >> "%LOG%\backup.log" +"%NODE%" "%SCRIPT%" %ARGS% >> "%LOG%\backup.log" 2>&1 +set "RC=%ERRORLEVEL%" +echo [%STAMP%] ===== backup end, exit=%RC% ===== >> "%LOG%\backup.log" + +endlocal & exit /b %RC% + +:fail +echo [%TIME%] ERROR: %~1 >> "%LOG%\backup.log" +endlocal & exit /b 1 diff --git a/架构总览.md b/架构总览.md index 256d0d46..06c934c8 100644 --- a/架构总览.md +++ b/架构总览.md @@ -138,17 +138,7 @@ git pushall # 别名 = git push origin main; git push gitee main > 且一旦某次历史被重写,SHA 从此再也对不上。 > 远端配置的改动前快照留在 `.workbuddy-backup/git-remotes.20261006-201354.txt`。 -### 5.3 Gitee 辅仓的两条硬约束(2026-10-06 实测核算) - -| 约束 | Gitee 免费版 | 本仓库当前 | 判定 | -|---|---|---|---| -| 单仓库容量 | **≤ 500 MB** | `.git` **620 MB** | ❌ 超 24% | -| 单文件大小 | **≤ 50 MB** | `bin/linux/hugo` **83.1 MB** | ❌ **必删** | -| 用户总仓库容量 | 5 GB | 620 MB | ✅ | -| 私有仓协作人数 | 5 人 | 个人 | ✅ 无关 | - -`bin/linux/hugo` 这一条是**硬门槛**:它 83.1MB,超过单文件上限, -**不管怎么瘦身历史,只要它还跟踪在 HEAD 里,Gitee 一律拒收**。 +### 5.3 辅仓选型(2026-10-06 实测核算) 体积分布(HEAD,实测 457.4 MB / 2509 文件): @@ -160,7 +150,39 @@ git pushall # 别名 = git push origin main; git push gitee main | `themes/` | 20.3 MB | Ying 主题 | | 其余全部 | ≈ 8 MB | 代码、文档、脚本 | -→ 移出 `bin/linux/hugo` 后 HEAD ≈ **374 MB**,Gitee 有一份余量。 +**Gitee 的两条硬约束 —— 都不满足:** + +| 约束 | Gitee 免费版 | 本仓库当前 | 判定 | +|---|---|---|---| +| 单仓库容量 | **≤ 500 MB** | `.git` **620 MB** | ❌ 超 24% | +| 单文件大小 | **≤ 50 MB** | `bin/linux/hugo` **83.1 MB** | ❌ **必删** | +| 用户总仓库容量 | 5 GB | 620 MB | ✅ | +| 私有仓协作人数 | 5 人 | 个人 | ✅ 无关 | + +`bin/linux/hugo` 是**硬门槛**:**不管怎么瘦身历史,只要它还跟踪在 HEAD 里,Gitee 一律拒收**。 +→ 移出后 HEAD ≈ **374 MB**,才有一份余量。 + +**对照:阿里云效 Codeup 基础版 —— 两条硬约束一条都不存在:** + +| 项 | Gitee 免费版 | 云效 Codeup 基础版 | +|---|---|---| +| 单库容量 | ≤ 500 MB | **Git 5 GiB + LFS 5 GiB** | +| 单文件 | ≤ 50 MB | **Web 50 MB / 命令行 200 MB** | +| 620 MB 的 `.git` | 超 24%,推不上 | 占 **12.1%** ✅ | +| 83.1 MB 的 hugo | 超 66%,一律拒收 | 占 **41.6%** ✅ | +| 价格 | 免费 | 0 元/人/年,不限人数、不限仓库数 | + +→ 若选 Codeup,`bin/linux/hugo` **不必出库**,`deploy/Dockerfile` 与 `.cnb.yml` 的 `by:` +字段**一行都不用改**(首推 620 MB 也在其 60 分钟推送超时内)。 +注意两点:① 默认「中心组织」**只支持阿里云 RAM 账号登录、且必须绑钉钉**, +不接受的可选 2025-12 上线的「地域组织」(华东2上海,支持自建账号密码); +② 基础版无「删库保护」,容量到 90% 提醒、**超限直接禁止写操作**(连删文件都做不了)。 +已有的阿里云 OSS AccessKey **不能**用来建 Codeup 仓库 —— git 推送走独立的克隆账号密码或 SSH Key。 + +> ★ **但这些都只是「换一个篮子」,不是容灾。** +> CNB 与 Gitee/Codeup 同属国内大区,GitHub/GitLab 又执行同一套合规逻辑 +> (本轮 GitHub 被按 AUP 清空即为例证)。 +> 真正的第三层是**不依赖任何平台账号**的离线备份 —— 见 §5.6。 ### 5.4 凭据 @@ -175,6 +197,75 @@ git pushall # 别名 = git push origin main; git push gitee main - ⚠️ CNB 的 `imports` 是**一层映射**(key 就是变量名本身);又拍云服务名在 CNB 里必须叫 **`UPYUN_SERVICE`**(旧 Gitea 里叫 `UPYUN_BUCKET`,照抄会「变量未定义」) +### 5.6 整仓离线备份(中兴 F50 / OpenList,2026-10-06 上线) + +**目标介质**:中兴 F50 5G CPE 内置 256 GB 存储,上面跑 OpenList +(`http://192.168.0.1:5244`,WebDAV 端点带 `/dav` 前缀)。7×24 常开、走局域网、 +**不依赖任何 git 平台账号** —— 这是它比「再选一个代码托管平台」更值钱的地方。 + +**为什么是 bundle 而不是直接推 git**:WebDAV 不支持原子的 rename/lock, +把 bare repo 挂上去直接 `git push` 会让对象写坏 —— 表面成功、实际随机损坏, +可能几个月后才发现。bundle 是**单文件顺序写**,没有这个问题。 + +**为什么加密**:本仓库历史里含 `.env`、TLS 私钥、`GITEA_SECRETS.md`。 +介质是一台随身设备的内部存储,明文等于把密钥放在可能丢失 / 刷机 / 送修的设备上。 +OpenList 的登录只保护**访问通道**,不保护**存储介质** —— F50 丢了拆开就能读。 + +**为什么不用 gpg**:本机 gpg 2.4.9 在 Windows 下已损坏(反复 `removing stale lockfile`, +node spawn 直接 `EBUSY`)。改用 Node 内置 `crypto` 的 **AES-256-GCM**: +零外部依赖、带认证标签(能检测篡改/截断)、可流式处理 600 MB 不爆内存。 + +**加密文件布局**:`magic(8) | salt(16) | iv(12) | 密文(...) | GCM tag(16)`, +密钥由 `scrypt(N=32768, r=8, p=1)` 从口令派生。 + +**用法**(`scripts/backup-bundle.mjs`,零 npm 依赖): + +```bash +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 --list # 列出远端现有备份 +node scripts/backup-bundle.mjs --decrypt <文件> [--out x.bundle] # 恢复用 +``` + +**配置**(按序查找,先找到生效):环境变量 → `~/.openlist-backup.env` +→ `.workbuddy-backup/openlist-backup.env`(**已在 .gitignore 内**)。 +键为 `OPENLIST_URL` / `OPENLIST_USER` / `OPENLIST_PASS` / `OPENLIST_DIR` / `BACKUP_PASSPHRASE`。 + +> ⚠️ `BACKUP_PASSPHRASE` 是恢复 bundle 的唯一钥匙,**必须另存进密码管理器**。 +> 但它不构成单点:CNB 主仓与自建 Gitea 仍是明文,口令丢了只是少一份备份。 + +**定时任务**:Windows 计划任务 `Blog-BundleBackup`,每天 **03:30(本地)** +执行 `scripts/backup-task.cmd`(默认 `--verify --keep 3`)。 +设置:`InteractiveToken` + `LeastPrivilege`、`StartWhenAvailable`(错过就补跑)、 +`ExecutionTimeLimit=PT1H`、`MultipleInstances=IgnoreNew`(防重入)。 +日志追加到 `.workbuddy-backup/logs/backup.log`,超 5 MB 自动轮转。 + +★ `backup-task.cmd` **内容必须全 ASCII**:cmd.exe 按**当前代码页**(zh-CN 是 GBK) +解析批处理文件,而 node 输出 UTF-8;UTF-8 中文注释会吞掉 CR/LF、 +把下一行当命令执行(实测踩到:一条 `rem` 被当命令跑了)。 +ASCII 是 UTF-8 子集,纯英文注释与 node 的中文输出混写不会乱。 + +**恢复流程**: + +```bash +node scripts/backup-bundle.mjs --decrypt blog-<时间戳>.bundle.enc --out blog.bundle +git bundle verify blog.bundle # 应报 "records a complete history" +git clone blog.bundle blog # 或 git fetch blog.bundle 'refs/*:refs/*' +``` + +**实测数据**(2026-10-06,由计划任务实际拉起跑通): + +| 阶段 | 耗时 | +|---|---| +| `git bundle create --all` | 6.8 ~ 9.9 s(605.5 MB) | +| AES-256-GCM 加密 | 1.9 ~ 2.9 s(体积不变) | +| WebDAV 上传 | 19.4 ~ 20.6 s → **29.4 ~ 31.3 MB/s** | +| 下载回读 + sha256 比对 | ≈ 19 s,**一致** | + +端到端退出码 0;恢复链路已演练(解密 → `git bundle verify` → 1008 提交完整一致)。 + --- ## 6. 遗留待办 @@ -182,14 +273,15 @@ git pushall # 别名 = git push origin main; git push gitee main | # | 项 | 说明 | |---|---|---| | 1 | `gh` remote(GitHub) | ✅ **已移除**(2026-10-06)。GitHub 账号被标记、仓库被 AUP 清空,改以 Gitee 为辅仓 | -| 2 | **Gitee 辅仓** | 待办:①「个人设置 → 访问令牌」建令牌(勾 `projects` 读写);②建**私有**仓库;③`git remote add gitee …`;④解决 §5.3 的两条硬约束 | -| 3 | `bin/linux/hugo` 出库 | 83.1MB,超 Gitee 单文件上限。需改为构建时获取(国内镜像 / CNB 对象存储),同时看 CNB 的 `docker build by:` 字段要跟着改 | +| 2 | **辅仓选型(未定)** | 当前 `pushall` 已写成 `origin + gitee`,但 `gitee` remote **尚不存在** → 每次 `git pushall` 的第二段会报错(**不影响 CNB 那段**)。两条路:**Gitee**(需先解 §5.3 两条硬约束)或**云效 Codeup**(见 §5.3 对照,零改造,但需绑钉钉或选「地域组织」)。定下后再 `git remote add` | +| 3 | `bin/linux/hugo` 出库 | 83.1MB,超 Gitee 单文件上限。**只有选 Gitee 才必须做**;选 Codeup 则不必(占其命令行 200MB 上限的 41.6%)。要做的话需改为构建时获取(国内镜像 / CNB 对象存储),并同步改 `.cnb.yml` 的 `by:` 字段 | | 4 | `gitea` remote | 只读保留,稳定几天后 `git remote remove gitea` | | 5 | GitHub 侧旧 workflow | `.github/workflows/*`(`deploy.yml` 1169 行 + 两个辅助)已随 GitHub 一并退役,可删 | | 6 | 令牌 scope | 缺 `repo-cnb-history:r`(读构建日志);补上后 agent 可自行排错 | | 7 | Gitea 主机 / 广州中转机 | 发布链路已不依赖;是否退役取决于其它用途 | | 8 | `README.md` | 仍写着旧 GitHub Actions 流程,待更新 | -| 9 | **敏感文件仍在跟踪中** | `.env`、`GITEA_SECRETS.md`、`write-server/nginx/ssl/privkey.pem`、`content/posts/2024/.../setup-secrets.png` —— 推任何一个新平台前都应先 `git rm --cached` 并轮换凭据 | +| 9 | **敏感文件仍在跟踪中** | `.env`、`GITEA_SECRETS.md`、`write-server/nginx/ssl/privkey.pem`、`content/posts/2024/.../setup-secrets.png` —— 推任何一个新平台前都应先 `git rm --cached` 并轮换凭据。⚠️ 当初「不改写历史」的唯一顾虑是备份会分叉;**GitHub 那份已消失,这个顾虑没有了** → 现在是 `git filter-repo` 彻底抹掉它们的最佳窗口(代价:全部提交 SHA 改变、自建 Gitea 需重推)。离线 bundle 已加密(§5.6),不受此影响 | +| 10 | **整仓离线备份** | ✅ **已上线**(2026-10-06),见 §5.6。计划任务 `Blog-BundleBackup` 每天 03:30 跑,端到端已验证。待补三件安全项:把 `BACKUP_PASSPHRASE` 抄进密码管理器;给 OpenList **改掉 admin 密码 + 开两步验证**(现 `otp: false`,且旧密码已出现在对话里);确认 5244 端口**未暴露到公网** | --- @@ -201,5 +293,6 @@ git pushall # 别名 = git push origin main; git push gitee main - `EdgeOne双区域方案评估.md`、`Gitee方案评估.md`、`阿里云ESA评估.md`、`精简方案-只留CF和Hugo.md` — 决策期评估 **证据出处(现行)**:`.cnb.yml`(284 行)、`deploy/Dockerfile`、`bin/linux/hugo`、 -`scripts/send_mail.js`、`scripts/refresh_cdn.js`、`scripts/setup-cnb-remotes.sh`。 +`scripts/send_mail.js`、`scripts/refresh_cdn.js`、`scripts/setup-cnb-remotes.sh`、 +`scripts/backup-bundle.mjs`、`scripts/backup-task.cmd`。 已退役参考:`.github/workflows/deploy.yml`(1169 行)。