backupdrill 0.2.1 → 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.
package/README.md CHANGED
@@ -198,6 +198,23 @@ Before switching to a **daily** schedule, read the egress-cost section above —
198
198
 
199
199
  For scheduling, automated restore drills, and alerting, use the hosted service at [backupdrill.com](https://backupdrill.com).
200
200
 
201
+ ## Versioning & stability
202
+
203
+ `backupdrill` follows [semantic versioning](https://semver.org/). From **1.0.0** onwards the
204
+ following is the stable public surface — breaking it requires a major version bump:
205
+
206
+ - the commands (`backup`, `drill`, `restore`, `estimate`) and their flags
207
+ - the `BACKUPDRILL_*` environment variables and the config-file keys
208
+ - the layout written to your bucket, and the shape of `manifest.json`
209
+
210
+ `manifest.json` carries a `schemaVersion` so the format can evolve safely. A snapshot written by
211
+ a newer release is **refused with a clear message** rather than parsed on a best-effort basis —
212
+ for a backup tool, silently misreading an archive is the worst possible failure. Snapshots taken
213
+ before 1.0 have no `schemaVersion` and are treated as version 1, so older backups stay readable.
214
+
215
+ The package's **JavaScript exports are internal** to the hosted BackupDrill product and are not
216
+ covered by this promise; they may change in any release. Use the CLI if you need a stable contract.
217
+
201
218
  ## License
202
219
 
203
220
  MIT
package/dist/backup.js CHANGED
@@ -5,9 +5,11 @@ import { promisify } from "node:util";
5
5
  import { Client } from "pg";
6
6
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
7
7
  import { Upload } from "@aws-sdk/lib-storage";
8
+ import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
8
9
  import { syncStorage } from "./storage.js";
9
10
  import { log } from "./log.js";
10
11
  import { TOOL_VERSION } from "./version.js";
12
+ import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
11
13
  const execFileAsync = promisify(execFile);
12
14
  function majorOf(serverVersionNum) {
13
15
  // server_version_num: 150004 → 15, 170002 → 17
@@ -45,7 +47,9 @@ async function pgDumpVersion(bin) {
45
47
  }
46
48
  }
47
49
  async function inspectDatabase(databaseUrl, schemas) {
48
- const client = new Client({ connectionString: databaseUrl });
50
+ // Supabase 主机 → 用打包的根 CA 做 verify-full(pooler 证书自签、不在系统信任库);
51
+ // 非 Supabase(本 CLI 不限制目标)→ 保持默认,别把开源用户挡在门外
52
+ const client = new Client(pgConnectOptions(databaseUrl));
49
53
  await client.connect();
50
54
  try {
51
55
  const version = await client.query("show server_version");
@@ -97,13 +101,15 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
97
101
  },
98
102
  });
99
103
  const schemaArgs = config.schemas.map((s) => `--schema=${s}`);
104
+ // pg_dump(libpq):Supabase 主机才改写成 sslmode=verify-full + sslrootcert=<打包CA临时文件>
105
+ const dumpDbUrl = dumpUrlFor(config.databaseUrl);
100
106
  const dump = spawn(pgDumpBin, [
101
107
  "--format=custom",
102
108
  "--no-owner",
103
109
  "--no-privileges",
104
110
  ...schemaArgs,
105
111
  "--dbname",
106
- config.databaseUrl,
112
+ dumpDbUrl,
107
113
  ], { stdio: ["ignore", "pipe", "pipe"] });
108
114
  let stderr = "";
109
115
  dump.stderr.on("data", (d) => {
@@ -189,6 +195,7 @@ export async function runBackup(config) {
189
195
  `${(synced.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
190
196
  }
191
197
  const manifest = {
198
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
192
199
  tool: "backupdrill-cli",
193
200
  toolVersion: TOOL_VERSION,
194
201
  createdAt: new Date().toISOString(),
package/dist/drill.js CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { Client } from "pg";
7
+ import { parseManifest } from "./manifest.js";
7
8
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
8
9
  import { resolvePgRestoreBin } from "./pgbin.js";
9
10
  import { log } from "./log.js";
@@ -488,7 +489,7 @@ export async function runDrill(config, opts = {}) {
488
489
  const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
489
490
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
490
491
  log.step(`Drilling snapshot ${snapshot}`);
491
- const manifest = JSON.parse(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
492
+ const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
492
493
  const workdir = await mkdtemp(join(tmpdir(), "backupdrill-"));
493
494
  const dumpPath = join(workdir, "dump.pgcustom");
494
495
  try {
package/dist/estimate.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Client } from "pg";
2
2
  import { measureStorage } from "./storage.js";
3
3
  import { log } from "./log.js";
4
+ import { pgConnectOptions } from "./supabase-ca.js";
4
5
  // 超出这个月度成本就该提醒用户"备份的 egress 账单快赶上订阅费了"
5
6
  const SUBSCRIPTION_FLOOR_USD = 19;
6
7
  const GB = 1024 * 1024 * 1024;
7
8
  async function measureDbBytes(databaseUrl, schemas) {
8
- const client = new Client({ connectionString: databaseUrl });
9
+ const client = new Client(pgConnectOptions(databaseUrl));
9
10
  await client.connect();
10
11
  try {
11
12
  // pg_total_relation_size 含索引+TOAST,是 egress 上界(真实转储经压缩、不含索引,更小)
package/dist/lib.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { runBackup } from "./backup.js";
2
+ export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
2
3
  export { runDrill, drillDump } from "./drill.js";
3
4
  export { runRestore } from "./restore.js";
4
5
  export { runEstimate, project } from "./estimate.js";
package/dist/lib.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // 库入口:把引擎函数暴露给下游复用(产品 worker 直接 import 这些,而不是重写)。
2
2
  // CLI(bin)是这套引擎的薄封装;worker 在其外加调度、凭据托管、告警、报告。
3
3
  export { runBackup } from "./backup.js";
4
+ // verify-full:只导出**原子** API。刻意不导出裸 SUPABASE_SSL —— 它一旦和 connectionString
5
+ // 搭配使用,URL 里的 sslmode=disable 又会把它覆盖掉,等于把这次修掉的绕过再放回去。
6
+ export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
4
7
  export { runDrill, drillDump } from "./drill.js";
5
8
  export { runRestore } from "./restore.js";
6
9
  export { runEstimate, project } from "./estimate.js";
@@ -19,7 +19,18 @@ export interface ExtensionInfo {
19
19
  version: string;
20
20
  schema: string;
21
21
  }
22
+ /**
23
+ * manifest.json 的格式版本。**这是留在客户桶里、可能躺很多年的持久产物**——将来改结构时,
24
+ * 读取端要靠它分支判断,而不是去猜 toolVersion。
25
+ *
26
+ * 约定:
27
+ * - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
28
+ * - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
29
+ * ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
30
+ */
31
+ export declare const MANIFEST_SCHEMA_VERSION = 1;
22
32
  export interface Manifest {
33
+ schemaVersion?: number;
23
34
  tool: "backupdrill-cli";
24
35
  toolVersion: string;
25
36
  createdAt: string;
@@ -51,3 +62,8 @@ export interface StorageFile {
51
62
  bytes: number;
52
63
  sha256: string;
53
64
  }
65
+ /**
66
+ * 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
67
+ * 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
68
+ */
69
+ export declare function parseManifest(json: string): Manifest;
package/dist/manifest.js CHANGED
@@ -1 +1,30 @@
1
- export {};
1
+ /**
2
+ * manifest.json 的格式版本。**这是留在客户桶里、可能躺很多年的持久产物**——将来改结构时,
3
+ * 读取端要靠它分支判断,而不是去猜 toolVersion。
4
+ *
5
+ * 约定:
6
+ * - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
7
+ * - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
8
+ * ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
9
+ */
10
+ export const MANIFEST_SCHEMA_VERSION = 1;
11
+ /**
12
+ * 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
13
+ * 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
14
+ */
15
+ export function parseManifest(json) {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(json);
19
+ }
20
+ catch (error) {
21
+ throw new Error(`manifest.json is not valid JSON: ${error.message}`);
22
+ }
23
+ const version = parsed.schemaVersion ?? 1; // 缺失 = 1.0 之前的旧快照
24
+ if (!Number.isFinite(version) || version > MANIFEST_SCHEMA_VERSION) {
25
+ throw new Error(`This snapshot's manifest uses schema version ${String(parsed.schemaVersion)}, but this ` +
26
+ `BackupDrill understands up to ${MANIFEST_SCHEMA_VERSION}. Upgrade the CLI ` +
27
+ `(npm i -g backupdrill@latest) to read it.`);
28
+ }
29
+ return parsed;
30
+ }
package/dist/restore.js CHANGED
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join, dirname } from "node:path";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { GetObjectCommand } from "@aws-sdk/client-s3";
8
+ import { parseManifest } from "./manifest.js";
8
9
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
9
10
  import { resolvePgRestoreBin } from "./pgbin.js";
10
11
  import { log } from "./log.js";
@@ -28,7 +29,7 @@ export async function runRestore(config, opts) {
28
29
  const s3 = targetClient(config);
29
30
  const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
30
31
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
31
- const manifest = JSON.parse(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
32
+ const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
32
33
  const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
33
34
  const result = {
34
35
  snapshot,
@@ -0,0 +1,32 @@
1
+ export declare const SUPABASE_ROOT_CA = "-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n";
2
+ /** node-pg 的 ssl 配置:验签(用打包的 Supabase 根 CA)+ 验主机名(rejectUnauthorized)。 */
3
+ export declare const SUPABASE_SSL: {
4
+ readonly ca: "-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n";
5
+ readonly rejectUnauthorized: true;
6
+ };
7
+ /**
8
+ * 把打包的 CA 写到临时文件并返回路径:pg_dump(libpq)的 sslrootcert 只认文件路径,不认字符串。
9
+ * 缓存,进程内只写一次;内容是公开 CA(非密),mode 0600 足矣。
10
+ */
11
+ export declare function supabaseCaFile(): string;
12
+ /**
13
+ * 把连接串强制改写成 verify-full + 我们的 CA(给 pg_dump 用)。剥掉用户自带的所有 ssl* 参数
14
+ * ——libpq 里 URL 参数优先级高于环境变量,残留的 sslmode=require 会盖掉我们的 verify-full。
15
+ */
16
+ export declare function forceVerifyFull(databaseUrl: string, caPath: string): string;
17
+ /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
18
+ export declare function dumpUrlFor(databaseUrl: string): string;
19
+ /**
20
+ * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
21
+ *
22
+ * 为什么必须一起做(2026-07-22 实测,pg 8.22):连接串里的 SSL 参数**会覆盖**显式传的
23
+ * `ssl` 选项——`?sslmode=disable` / `?ssl=0` 会让我们的 CA 被整个丢弃、退化成不加密,
24
+ * `?ssl=true` 则加密但不带我们的 CA。而 sslmode 在产品的参数黑名单里是**放行**的(为兼容
25
+ * 存量连接串),所以不剥就等于强制 verify-full 可被用户参数静默绕过。
26
+ *
27
+ * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
28
+ */
29
+ export declare function pgConnectOptions(databaseUrl: string): {
30
+ connectionString: string;
31
+ ssl?: typeof SUPABASE_SSL;
32
+ };
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Supabase Root 2021 CA —— 打包进引擎,用于对用户 Supabase 源库连接做 verify-full。
3
+ *
4
+ * 为什么必须自带:Supabase pooler(Supavisor)的证书由 Supabase 自签 CA 签发
5
+ * (链:*.pooler.supabase.com ← Supabase Intermediate 2021 CA ← Supabase Root 2021 CA),
6
+ * **不在系统 CA 信任库里**——直接 rejectUnauthorized:true 会报 SELF_SIGNED_CERT_IN_CHAIN
7
+ * (2026-07-20 真机实测)。把这个自签根作为 ca 传入,node-pg / libpq(pg_dump)即可完成
8
+ * verify-full(验签 + 验通配主机名 *.pooler.supabase.com)。所有 Supabase 项目共用这一张根,
9
+ * 有效期至 2031-04(过期前需更新)。
10
+ *
11
+ * 只对**用户 Supabase 源库**连接使用(backup 探查 / pg_dump / estimate / 预检);演练用的
12
+ * 本地 Docker 沙箱、restore 的任意目标库不适用,勿套用。
13
+ */
14
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ export const SUPABASE_ROOT_CA = `-----BEGIN CERTIFICATE-----
18
+ MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
19
+ BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
20
+ dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
21
+ c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
22
+ azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
23
+ YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
24
+ Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
25
+ QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
26
+ DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
27
+ GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
28
+ cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
29
+ O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
30
+ NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
31
+ uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
32
+ aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
33
+ tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
34
+ VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
35
+ jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
36
+ Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
37
+ CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
38
+ o/bKiIz+Fq8=
39
+ -----END CERTIFICATE-----
40
+ `;
41
+ /** node-pg 的 ssl 配置:验签(用打包的 Supabase 根 CA)+ 验主机名(rejectUnauthorized)。 */
42
+ export const SUPABASE_SSL = { ca: SUPABASE_ROOT_CA, rejectUnauthorized: true };
43
+ let caFilePath = null;
44
+ /**
45
+ * 把打包的 CA 写到临时文件并返回路径:pg_dump(libpq)的 sslrootcert 只认文件路径,不认字符串。
46
+ * 缓存,进程内只写一次;内容是公开 CA(非密),mode 0600 足矣。
47
+ */
48
+ export function supabaseCaFile() {
49
+ // 缓存的文件可能被系统 tmp 清理删掉;长驻 worker 若一直返回失效路径,之后**每次**
50
+ // pg_dump 都会失败直到重启。发现不存在就重建。
51
+ if (caFilePath && !existsSync(caFilePath))
52
+ caFilePath = null;
53
+ if (!caFilePath) {
54
+ // 私有随机目录(mkdtemp 建的是 0700、属主为本进程):固定的 /tmp 路径在多用户机器上
55
+ // 可被他人预置符号链接,或**替换成伪造 CA** —— 那我们的 pg_dump 就会去信任攻击者的
56
+ // 证书,等于自己开了 MITM 的门。
57
+ const dir = mkdtempSync(join(tmpdir(), "backupdrill-ca-"));
58
+ const p = join(dir, "supabase-root-ca.pem");
59
+ writeFileSync(p, SUPABASE_ROOT_CA, { mode: 0o600 });
60
+ caFilePath = p;
61
+ // 长驻进程(worker)整个生命周期都要用它,退出时再尽力清掉,避免反复起停堆积
62
+ process.once("exit", () => {
63
+ try {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ catch {
67
+ /* 清理失败无所谓,内容是公开 CA */
68
+ }
69
+ });
70
+ }
71
+ return caFilePath;
72
+ }
73
+ /** 所有会影响 TLS 行为的连接串参数——必须全部剥掉,否则会盖过我们显式传的 ssl 配置。 */
74
+ const SSL_QUERY_PARAMS = [
75
+ "sslmode",
76
+ "ssl",
77
+ "requiressl", // 老式开关
78
+ "sslrootcert",
79
+ "sslcert",
80
+ "sslkey",
81
+ "sslcrl",
82
+ "sslcrldir",
83
+ "sslpassword",
84
+ "sslcertmode",
85
+ "sslcompression",
86
+ "sslsni",
87
+ "sslnegotiation",
88
+ "ssl_min_protocol_version",
89
+ "ssl_max_protocol_version",
90
+ // PG18:把 TLS 会话密钥写到文件 —— 留着它,"强制加密"就是句空话(流量可被解密)
91
+ "sslkeylogfile",
92
+ "uselibpqcompat",
93
+ // GSS 会在 TLS 之前协商,带 GSS 的 libpq 构建可能先走 GSS 再谈 TLS;下面统一置 disable
94
+ "gssencmode",
95
+ ];
96
+ /**
97
+ * 手工摘除查询参数——**不能**用 URLSearchParams:一旦碰它,URL 会按 form-urlencoded
98
+ * 重新序列化整个查询串,把 `%20` 写成 `+`(2026-07-22 实测)。而 libpq 把 `+` 当字面量,
99
+ * 于是合法的 `?options=-c%20statement_timeout%3D0` 会被改成 `-c+statement_timeout=0`
100
+ * 让 pg_dump 直接失败。这里逐段过滤、原样保留其余片段的编码。
101
+ */
102
+ function stripSslParams(databaseUrl) {
103
+ const q = databaseUrl.indexOf("?");
104
+ if (q === -1)
105
+ return databaseUrl;
106
+ const base = databaseUrl.slice(0, q);
107
+ const pairs = databaseUrl
108
+ .slice(q + 1)
109
+ .split("&")
110
+ .filter((pair) => pair !== "");
111
+ const keyOf = (pair) => {
112
+ const raw = pair.split("=")[0] ?? "";
113
+ try {
114
+ return decodeURIComponent(raw).toLowerCase();
115
+ }
116
+ catch {
117
+ return raw.toLowerCase(); // 编码坏了就按原文比对
118
+ }
119
+ };
120
+ const valueOf = (pair) => pair.slice((pair.split("=")[0] ?? "").length + 1);
121
+ // 两端都是"最后一个 host 生效"。若最后一个是**空值**,实际目标回落到 authority ——
122
+ // 这时必须删掉**所有** host 条目:只删空的那条,会把前面的 host=other 留给 libpq,
123
+ // 造成探查连 authority、pg_dump 连 other 的分裂。
124
+ const hostValues = pairs.filter((p) => keyOf(p) === "host").map(valueOf);
125
+ const dropAllHosts = hostValues.length > 0 && hostValues[hostValues.length - 1] === "";
126
+ const kept = pairs.filter((pair) => {
127
+ const key = keyOf(pair);
128
+ if (SSL_QUERY_PARAMS.includes(key))
129
+ return false;
130
+ if (key === "host" && dropAllHosts)
131
+ return false;
132
+ return true;
133
+ });
134
+ return kept.length ? `${base}?${kept.join("&")}` : base;
135
+ }
136
+ /**
137
+ * 把连接串强制改写成 verify-full + 我们的 CA(给 pg_dump 用)。剥掉用户自带的所有 ssl* 参数
138
+ * ——libpq 里 URL 参数优先级高于环境变量,残留的 sslmode=require 会盖掉我们的 verify-full。
139
+ */
140
+ export function forceVerifyFull(databaseUrl, caPath) {
141
+ const stripped = stripSslParams(databaseUrl);
142
+ const sep = stripped.includes("?") ? "&" : "?";
143
+ // gssencmode=disable:带 GSS 的 libpq 构建会在 TLS 之前尝试 GSS 协商,可能在用上我们的
144
+ // CA 之前就失败;我们从不用 Kerberos,显式关掉让行为跨构建确定。
145
+ return `${stripped}${sep}sslmode=verify-full&sslrootcert=${encodeURIComponent(caPath)}&gssencmode=disable`;
146
+ }
147
+ /**
148
+ * 按主机名决定是否启用 Supabase verify-full。
149
+ *
150
+ * 关键:本 CLI 是公开工具,**不限制**连接串必须是 Supabase(那道校验只在产品侧的
151
+ * shared/endpoints.ts)。若无条件套 Supabase CA,指向自建 Supabase / 其它 Postgres 的
152
+ * 用户会直接 TLS 失败——对开源工具是回归。因此只在目标确实是 Supabase 主机时启用;
153
+ * 其余情形返回 undefined,保持 pg 的默认行为(由连接串自身的 sslmode 决定)。
154
+ */
155
+ const SUPABASE_HOST = /\.supabase\.(co|com|net)$/;
156
+ /**
157
+ * 真正会被连接的主机。两个坑:
158
+ * - libpq/pg 允许 `?host=` 覆盖 authority 里的主机 → 必须按实际目标判定,否则会出现
159
+ * "给 A 套了 B 的 CA"(证书必然不匹配)或"真连 Supabase 却没套 CA"。
160
+ * - DNS 末尾根点(`...supabase.com.`)是合法写法,不归一化会把合法 Supabase 主机漏判成外部库。
161
+ */
162
+ const EMPTY_AUTHORITY_HOST = "backupdrill-empty-authority.invalid";
163
+ /**
164
+ * 按 node-pg 的宽容度解析连接串。WHATWG URL **拒绝空 authority**
165
+ * (`postgresql://u:p@/db?host=…`),但 node-pg 接受并连到 host= 指定的主机
166
+ * ——直接 try/catch 判 null 会把这种串当成外部库放行,于是 `sslmode=disable`
167
+ * 原样保留、真连 Supabase 却走明文(2026-07-22 审查抓到)。
168
+ * 这里给空 authority 塞个占位主机让它可解析,并记住 authority 实为空。
169
+ */
170
+ function parseLikePg(databaseUrl) {
171
+ try {
172
+ const url = new URL(databaseUrl);
173
+ return { url, authorityHost: url.hostname };
174
+ }
175
+ catch {
176
+ /* 可能正是 node-pg 允许、WHATWG 拒绝的空 authority */
177
+ }
178
+ const m = databaseUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#]*)([\s\S]*)$/);
179
+ if (!m)
180
+ return null;
181
+ const [, scheme, authority, rest] = m;
182
+ // 只处理"空 authority"或"有用户名但无主机(以 @ 结尾)"这两种
183
+ if (authority !== "" && !authority.endsWith("@"))
184
+ return null;
185
+ try {
186
+ return { url: new URL(`${scheme}${authority}${EMPTY_AUTHORITY_HOST}${rest}`), authorityHost: "" };
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ function effectiveHost(databaseUrl) {
193
+ const parsed = parseLikePg(databaseUrl);
194
+ if (!parsed)
195
+ return null; // 实在解析不了就别自作主张套 CA
196
+ const { url, authorityHost } = parsed;
197
+ // 必须**与 node-pg 的解析语义逐字对齐**(2026-07-22 实测):
198
+ // - 重复的 host= 取**最后一个**(URLSearchParams.get 取第一个,会判错)
199
+ // - host= 为**空值**时回落到 authority 主机(当成"没写")
200
+ // 判错的后果不是小事:真连 Supabase 却被当成外部库 → 不剥 sslmode=disable → 明文。
201
+ // query 里的 host= 已被 URLSearchParams 解码;authority 主机名 WHATWG URL **不解码**,
202
+ // 而 node-pg 会解码(实测 `supabase%2Ecom` → `supabase.com` 并真的连上去)。不解码就会
203
+ // 把合法 Supabase 主机判成外部库 → 保留 sslmode=disable → 明文。坏转义按原文处理。
204
+ let decodedAuthority = authorityHost;
205
+ try {
206
+ decodedAuthority = decodeURIComponent(authorityHost);
207
+ }
208
+ catch {
209
+ /* 转义损坏:按原文比对,宁可判成外部库也不误套 CA */
210
+ }
211
+ const all = url.searchParams.getAll("host");
212
+ const last = all.length ? all[all.length - 1] : "";
213
+ const host = (last !== "" ? last : decodedAuthority).toLowerCase().replace(/\.$/, "");
214
+ return host || null;
215
+ }
216
+ function isSupabaseHost(databaseUrl) {
217
+ const host = effectiveHost(databaseUrl);
218
+ return host !== null && SUPABASE_HOST.test(host);
219
+ }
220
+ /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
221
+ export function dumpUrlFor(databaseUrl) {
222
+ return isSupabaseHost(databaseUrl)
223
+ ? forceVerifyFull(databaseUrl, supabaseCaFile())
224
+ : databaseUrl;
225
+ }
226
+ /**
227
+ * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
228
+ *
229
+ * 为什么必须一起做(2026-07-22 实测,pg 8.22):连接串里的 SSL 参数**会覆盖**显式传的
230
+ * `ssl` 选项——`?sslmode=disable` / `?ssl=0` 会让我们的 CA 被整个丢弃、退化成不加密,
231
+ * `?ssl=true` 则加密但不带我们的 CA。而 sslmode 在产品的参数黑名单里是**放行**的(为兼容
232
+ * 存量连接串),所以不剥就等于强制 verify-full 可被用户参数静默绕过。
233
+ *
234
+ * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
235
+ */
236
+ export function pgConnectOptions(databaseUrl) {
237
+ if (!isSupabaseHost(databaseUrl))
238
+ return { connectionString: databaseUrl };
239
+ return { connectionString: stripSslParams(databaseUrl), ssl: SUPABASE_SSL };
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "0.2.1",
3
+ "version": "1.0.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {