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 +17 -0
- package/dist/backup.js +9 -2
- package/dist/drill.js +2 -1
- package/dist/estimate.js +2 -1
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +3 -0
- package/dist/manifest.d.ts +16 -0
- package/dist/manifest.js +30 -1
- package/dist/restore.js +2 -1
- package/dist/supabase-ca.d.ts +32 -0
- package/dist/supabase-ca.js +240 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
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";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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