pi-webdav-backup 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * migrate.mjs — 把配置里的明文密钥迁移为安全引用
3
+ *
4
+ * 迁移策略:
5
+ * plain → dpapi:<名称> (Windows,推荐:本机本用户可解,无主密码)
6
+ * plain → $ENV_VAR (跨平台,写入用户级环境变量)
7
+ * plain → file:<路径> (写入 0600 权限的独立文件)
8
+ *
9
+ * 迁移后原配置文件只保留引用字符串,明文不再落盘。
10
+ */
11
+
12
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+
15
+ import { configPath, getAgentDir } from "./config-paths.mjs";
16
+ import {
17
+ setDpapiSecret,
18
+ dpapiAvailable,
19
+ setUserEnvVar,
20
+ DPAPI_SCHEME,
21
+ FILE_SCHEME,
22
+ } from "./secrets.mjs";
23
+
24
+ const ENV_PASSWORD = "PI_WEBDAV_BACKUP_PASSWORD";
25
+ const ENV_KEY = "PI_WEBDAV_BACKUP_KEY";
26
+ const DPAPI_PASSWORD_NAME = "webdav-password";
27
+ const DPAPI_KEY_NAME = "backup-key";
28
+
29
+ function stripBom(t) {
30
+ return t.replace(/^\uFEFF/, "");
31
+ }
32
+
33
+ /** 判断配置里的值是否为明文(需要迁移) */
34
+ function needsMigration(v) {
35
+ if (typeof v !== "string") return false;
36
+ const t = v.trim();
37
+ if (!t) return false;
38
+ if (
39
+ t.startsWith(DPAPI_SCHEME) ||
40
+ t.startsWith(FILE_SCHEME) ||
41
+ t.startsWith("plain:") ||
42
+ t.startsWith("!")
43
+ ) {
44
+ return false;
45
+ }
46
+ if (t.includes("$")) return false;
47
+ return true;
48
+ }
49
+
50
+ function backupFile(p) {
51
+ if (!existsSync(p)) return null;
52
+ const dest = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
53
+ copyFileSync(p, dest);
54
+ return dest;
55
+ }
56
+
57
+ /**
58
+ * 执行迁移。
59
+ * @param {object} opts
60
+ * @param {"dpapi"|"env"|"file"} opts.method
61
+ * @param {(s: string) => void} opts.log
62
+ * @param {boolean} [opts.dryRun]
63
+ */
64
+ export async function migrateSecrets({ method = "dpapi", log = () => {}, dryRun = false } = {}) {
65
+ const p = configPath();
66
+ if (!existsSync(p)) {
67
+ return { ok: false, error: `配置文件不存在:${p}` };
68
+ }
69
+
70
+ let raw;
71
+ try {
72
+ raw = JSON.parse(stripBom(readFileSync(p, "utf8")));
73
+ } catch (e) {
74
+ return { ok: false, error: `配置文件不是合法 JSON:${e.message}` };
75
+ }
76
+
77
+ const remote = raw.remote ?? {};
78
+ const changes = [];
79
+
80
+ const plan = [];
81
+ if (needsMigration(remote.password)) {
82
+ plan.push({ field: "remote.password", value: remote.password.trim(), kind: "password" });
83
+ }
84
+ if (needsMigration(raw.encryptKey)) {
85
+ plan.push({ field: "encryptKey", value: raw.encryptKey.trim(), kind: "key" });
86
+ }
87
+
88
+ if (plan.length === 0) {
89
+ return { ok: true, migrated: 0, message: "配置中没有明文密钥,无需迁移" };
90
+ }
91
+
92
+ if (method === "dpapi" && !dpapiAvailable()) {
93
+ return { ok: false, error: "DPAPI 仅支持 Windows;请改用 --method env 或 --method file" };
94
+ }
95
+
96
+ if (dryRun) {
97
+ return {
98
+ ok: true,
99
+ dryRun: true,
100
+ wouldMigrate: plan.map((x) => ({ field: x.field, method, target: method === "env" ? (x.kind === "password" ? ENV_PASSWORD : ENV_KEY) : method === "dpapi" ? `${DPAPI_SCHEME}${x.kind === "password" ? DPAPI_PASSWORD_NAME : DPAPI_KEY_NAME}` : `${FILE_SCHEME}secrets/${x.kind}.txt` })),
101
+ };
102
+ }
103
+
104
+ // 先备份原文件,再逐项迁移
105
+ const backup = backupFile(p);
106
+ if (backup) log(`已备份原配置:${backup}`);
107
+
108
+ const secretsDir = join(getAgentDir(), "secrets");
109
+
110
+ for (const item of plan) {
111
+ if (method === "dpapi") {
112
+ const name = item.kind === "password" ? DPAPI_PASSWORD_NAME : DPAPI_KEY_NAME;
113
+ setDpapiSecret(name, item.value);
114
+ const ref = `${DPAPI_SCHEME}${name}`;
115
+ if (item.kind === "password") remote.password = ref;
116
+ else raw.encryptKey = ref;
117
+ changes.push({ field: item.field, to: ref });
118
+ log(`已用 DPAPI 加密保存并替换 ${item.field} → ${ref}`);
119
+ continue;
120
+ }
121
+
122
+ if (method === "env") {
123
+ const envName = item.kind === "password" ? ENV_PASSWORD : ENV_KEY;
124
+ const r = setUserEnvVar(envName, item.value);
125
+ const ref = `$${envName}`;
126
+ if (item.kind === "password") remote.password = ref;
127
+ else raw.encryptKey = ref;
128
+ changes.push({ field: item.field, to: ref, note: r.note });
129
+ log(r.ok ? `已写入用户环境变量 ${envName},并替换 ${item.field} → ${ref}` : `请手动设置 ${envName};配置已改为 ${ref}`);
130
+ continue;
131
+ }
132
+
133
+ if (method === "file") {
134
+ mkdirSync(secretsDir, { recursive: true });
135
+ const file = join(secretsDir, `${item.kind}.txt`);
136
+ writeFileSync(file, item.value, { encoding: "utf8", mode: 0o600 });
137
+ try {
138
+ chmodSync(file, 0o600);
139
+ } catch {
140
+ /* ignore */
141
+ }
142
+ const ref = `${FILE_SCHEME}${file}`;
143
+ if (item.kind === "password") remote.password = ref;
144
+ else raw.encryptKey = ref;
145
+ changes.push({ field: item.field, to: ref });
146
+ log(`已写入 ${file}(0600)并替换 ${item.field} → ${ref}`);
147
+ continue;
148
+ }
149
+
150
+ return { ok: false, error: `未知迁移方式:${method}` };
151
+ }
152
+
153
+ raw.remote = remote;
154
+ writeFileSync(p, JSON.stringify(raw, null, 2), { encoding: "utf8", mode: 0o600 });
155
+ try {
156
+ chmodSync(p, 0o600);
157
+ } catch {
158
+ /* ignore */
159
+ }
160
+
161
+ // 迁移到 file 方式时提醒保护目录
162
+ if (method === "file") {
163
+ log(`注意:${secretsDir} 内为明文密钥文件,请确保该目录不被同步/备份到不可信位置。`);
164
+ }
165
+
166
+ return { ok: true, migrated: changes.length, method, changes, backup };
167
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * restore.mjs — 从 WebDAV 恢复备份
3
+ *
4
+ * 支持两种远端布局:
5
+ * - 归档:<remoteDir>/<remoteName>-<时间戳>.tar.gz[.pibak]
6
+ * → 下载、解压(必要时先解密)、解包 tar 到目标目录
7
+ * - 快照:<remoteDir>/<remoteName>/ 逐文件下载
8
+ *
9
+ * 安全:解包时拒绝绝对路径与 .. 穿越;默认恢复到临时目录而非直接覆盖 ~/.pi/agent。
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ createReadStream,
20
+ createWriteStream as createWriteStreamImpl,
21
+ } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join, dirname } from "node:path";
24
+ import { createGunzip } from "node:zlib";
25
+ import { pipeline } from "node:stream/promises";
26
+
27
+ import { WebDAVClient } from "./webdav.mjs";
28
+ import { archiveNameRe } from "./backup.mjs";
29
+ import { decryptFile } from "./crypto.mjs";
30
+ import { extractTar } from "./tar.mjs";
31
+
32
+ /** 列出远端可恢复的备份(归档 + 快照目录) */
33
+ export async function listBackups(config) {
34
+ const client = new WebDAVClient({
35
+ baseUrl: config.url,
36
+ username: config.username,
37
+ password: config.password,
38
+ timeoutMs: config.timeoutMs,
39
+ insecureTls: config.insecureTls,
40
+ });
41
+ const entries = await client.list(config.remoteDir);
42
+ const isArchive = archiveNameRe(config.remoteName);
43
+ const archives = entries
44
+ .filter((e) => !e.isCollection && isArchive.test(e.name))
45
+ .sort((a, b) => (a.name < b.name ? 1 : -1))
46
+ .map((e) => ({ type: "archive", name: e.name, size: e.size, lastModified: e.lastModified }));
47
+
48
+ const snapDir = `${config.remoteDir}/${config.remoteName}`;
49
+ let snapshot = null;
50
+ try {
51
+ const m = await client.get(`${snapDir}/manifest.json`);
52
+ if (m) {
53
+ const manifest = JSON.parse(m.toString("utf8"));
54
+ snapshot = {
55
+ type: "snapshot",
56
+ name: `${config.remoteName}/`,
57
+ remoteDir: snapDir,
58
+ fileCount: manifest.fileCount ?? manifest.files?.length ?? 0,
59
+ createdAt: manifest.createdAt ?? manifest.updatedAt,
60
+ };
61
+ }
62
+ } catch {
63
+ /* 无快照 */
64
+ }
65
+
66
+ return { archives, snapshot, all: snapshot ? [...archives, snapshot] : archives };
67
+ }
68
+
69
+ /**
70
+ * 恢复。
71
+ * @param {object} config
72
+ * @param {object} opts
73
+ * @param {string} opts.agentDir
74
+ * @param {string|null} opts.file 指定归档名;null 则恢复最新
75
+ * @param {string|null} opts.to 目标目录;null 则解到临时目录
76
+ * @param {boolean} opts.listOnly 只列出不下载
77
+ * @param {(s: string) => void} opts.log
78
+ */
79
+ export async function restoreBackup(config, opts) {
80
+ const log = opts.log ?? (() => {});
81
+ const client = new WebDAVClient({
82
+ baseUrl: config.url,
83
+ username: config.username,
84
+ password: config.password,
85
+ timeoutMs: config.timeoutMs,
86
+ insecureTls: config.insecureTls,
87
+ });
88
+
89
+ const listing = await listBackups(config);
90
+ if (opts.listOnly) {
91
+ return { ok: true, listOnly: true, ...listing };
92
+ }
93
+
94
+ if (listing.all.length === 0) {
95
+ return { ok: false, error: `远端 ${config.remoteDir}/ 下找不到可恢复的备份` };
96
+ }
97
+
98
+ // 选择目标
99
+ let target = null;
100
+ if (opts.file) {
101
+ target = listing.archives.find((a) => a.name === opts.file) ?? null;
102
+ if (!target && opts.file.replace(/\/$/, "") === config.remoteName) target = listing.snapshot;
103
+ if (!target) {
104
+ return {
105
+ ok: false,
106
+ error: `找不到指定的备份:${opts.file}`,
107
+ available: listing.all.map((x) => x.name),
108
+ };
109
+ }
110
+ } else {
111
+ target = listing.archives[0] ?? listing.snapshot;
112
+ }
113
+
114
+ const workDir = mkdtempSync(join(tmpdir(), "pi-restore-"));
115
+ const destDir = opts.to ?? join(workDir, "restored");
116
+ mkdirSync(destDir, { recursive: true });
117
+
118
+ try {
119
+ if (target.type === "snapshot") {
120
+ return await restoreSnapshot({ client, config, target, destDir, log, workDir });
121
+ }
122
+ return await restoreArchive({ client, config, target, destDir, log, workDir });
123
+ } finally {
124
+ // 只在未指定目标目录时保留临时目录(用户需要去那里取文件)
125
+ if (!opts.to) {
126
+ log(`(临时文件保留在 ${workDir},确认后可自行删除)`);
127
+ } else {
128
+ try {
129
+ rmSync(workDir, { recursive: true, force: true });
130
+ } catch {
131
+ /* ignore */
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ async function restoreArchive({ client, config, target, destDir, log, workDir }) {
138
+ const remotePath = `${config.remoteDir}/${target.name}`;
139
+ log(`下载 ${target.name} …`);
140
+ const blob = await client.get(remotePath);
141
+ if (!blob) return { ok: false, error: `下载失败:${remotePath} 不存在` };
142
+
143
+ const downloaded = join(workDir, target.name);
144
+ writeFileSync(downloaded, blob);
145
+ log(`已下载 ${(blob.length / 1024 / 1024).toFixed(2)} MB`);
146
+
147
+ let tarball = downloaded;
148
+ let header = null;
149
+ if (target.name.endsWith(".pibak")) {
150
+ if (!config.encryptKey) {
151
+ return {
152
+ ok: false,
153
+ error: "该备份是加密的,但配置里没有 encryptKey(或环境变量 PI_WEBDAV_BACKUP_KEY)",
154
+ };
155
+ }
156
+ log("解密中…");
157
+ const decFile = join(workDir, "payload.tar.gz");
158
+ const res = await decryptFile(downloaded, decFile, String(config.encryptKey));
159
+ header = res.header;
160
+ tarball = decFile;
161
+ if (header?.name && header.name !== target.name) {
162
+ log(`注意:归档头部记录的文件名是 ${header.name}`);
163
+ }
164
+ }
165
+
166
+ log("解压中…");
167
+ const rawTar = join(workDir, "payload.tar");
168
+ await pipeline(createReadStream(tarball), createGunzip({ chunkSize: 1024 * 1024 }), createWriteStreamImpl(rawTar));
169
+
170
+ log("解包中…");
171
+ const entries = await extractTar(rawTar, destDir);
172
+ const fileCount = entries.filter((e) => !e.dir).length;
173
+ log(`恢复完成:${fileCount} 个文件 → ${destDir}`);
174
+
175
+ return {
176
+ ok: true,
177
+ mode: "archive",
178
+ source: target.name,
179
+ dest: destDir,
180
+ files: fileCount,
181
+ encrypted: target.name.endsWith(".pibak"),
182
+ header,
183
+ };
184
+ }
185
+
186
+ async function restoreSnapshot({ client, config, target, destDir, log, workDir }) {
187
+ const manifestRaw = await client.get(`${target.remoteDir}/manifest.json`);
188
+ if (!manifestRaw) return { ok: false, error: "快照缺少 manifest.json" };
189
+ const manifest = JSON.parse(manifestRaw.toString("utf8"));
190
+ const files = manifest.files ?? [];
191
+
192
+ log(`快照包含 ${files.length} 个文件,开始下载…`);
193
+ let done = 0;
194
+ const failed = [];
195
+
196
+ for (const f of files) {
197
+ const buf = await client.get(`${target.remoteDir}/${f.rel}`);
198
+ if (!buf) {
199
+ failed.push(f.rel);
200
+ continue;
201
+ }
202
+ // 防穿越
203
+ const parts = String(f.rel).split("/").filter((s) => s && s !== "." && s !== "..");
204
+ const outPath = join(destDir, ...parts);
205
+ mkdirSync(dirname(outPath), { recursive: true });
206
+ writeFileSync(outPath, buf);
207
+ done++;
208
+ if (done % 20 === 0) log(` 已恢复 ${done}/${files.length}`);
209
+ }
210
+
211
+ log(`恢复完成:${done} 个文件 → ${destDir}${failed.length ? `(${failed.length} 个失败)` : ""}`);
212
+ return {
213
+ ok: failed.length === 0,
214
+ mode: "snapshot",
215
+ source: target.name,
216
+ dest: destDir,
217
+ files: done,
218
+ failed,
219
+ };
220
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * run-tests.mjs — 依次运行所有测试文件并汇总
3
+ */
4
+ import { spawn } from "node:child_process";
5
+ import { readdirSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const files = readdirSync(here)
11
+ .filter((f) => /^test.*\.mjs$/.test(f) && f !== "run-tests.mjs")
12
+ .sort();
13
+
14
+ let failed = 0;
15
+ const summary = [];
16
+
17
+ for (const f of files) {
18
+ const started = Date.now();
19
+ const res = await new Promise((resolve) => {
20
+ const child = spawn(process.execPath, [join(here, f)], { cwd: here, windowsHide: true });
21
+ let out = "";
22
+ child.stdout.on("data", (d) => (out += d.toString("utf8")));
23
+ child.stderr.on("data", (d) => (out += d.toString("utf8")));
24
+ child.on("close", (code) => resolve({ code, out }));
25
+ });
26
+ const ms = Date.now() - started;
27
+ const failLines = res.out.split("\n").filter((l) => l.includes("❌"));
28
+ const resultLine = res.out.split("\n").filter((l) => /^结果:/.test(l)).pop() ?? "";
29
+ if (res.code !== 0) failed++;
30
+ summary.push(
31
+ `${res.code === 0 ? "✅" : "❌"} ${f.padEnd(20)} ${resultLine || "(no summary)"} ${(ms / 1000).toFixed(1)}s`,
32
+ );
33
+ if (failLines.length) summary.push(...failLines.map((l) => ` ${l.trim()}`));
34
+ if (res.code !== 0 && !resultLine) {
35
+ summary.push(" " + res.out.split("\n").slice(0, 12).join("\n "));
36
+ }
37
+ }
38
+
39
+ console.log(summary.join("\n"));
40
+ console.log(`\n${files.length} 个测试文件,${failed} 个失败`);
41
+ process.exit(failed ? 1 : 0);