From 09b35b050b495645e3343afc90ef4690e394be83 Mon Sep 17 00:00:00 2001 From: Super <20788648+supertj@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:06:14 +0800 Subject: [PATCH 1/3] fix: a random password for each drill sandbox, kept off the process list (2.10.1) The sandbox holds restored production data. Its port is bound to 127.0.0.1, which every account on the machine can reach, so the fixed password 'drill' protected nothing on a shared machine. Each sandbox now gets 16 random bytes; docker reads the value from its own environment, so it never appears in argv. The skill's by-hand restore test does the same: a password from openssl rand, passed to each command through PGPASSWORD rather than in the URL. --- README.md | 2 +- package.json | 2 +- plugin/.tessl-plugin/plugin.json | 2 +- .../references/postgres-backup-basics.md | 20 ++++---- src/drill.ts | 46 +++++++++++-------- test/drill.test.js | 11 +++++ 6 files changed, 53 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3587b9b..21ca8d5 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ docker run -d --name backupdrill --restart unless-stopped \ - Arguments go straight to the CLI: `docker run --rm -e … ghcr.io/backupdrill/backupdrill estimate` or `… --help`. To start a backup now in a running scheduled container: `docker exec backupdrill backupdrill backup`. - `BACKUPDRILL_CRON` takes a standard 5-field expression or a shortcut such as `@daily`. Anything else stops the container at start (exit code 64) instead of leaving it idle. - `drill` needs Docker to start its throwaway Postgres, and the image doesn't get access to your Docker daemon, so run drills where Docker is available (your machine, or the GitHub Action with `drill: "true"`). -- Tags: `latest`, the CLI version (e.g. `2.10.0`), and `sha-`. Images are built for amd64 and arm64. +- Tags: `latest`, the CLI version (e.g. `2.10.1`), and `sha-`. Images are built for amd64 and arm64. ## Alerts with Healthchecks.io diff --git a/package.json b/package.json index 2e9ecbc..0a2b23a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "backupdrill", - "version": "2.10.0", + "version": "2.10.1", "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.", "type": "module", "bin": { diff --git a/plugin/.tessl-plugin/plugin.json b/plugin/.tessl-plugin/plugin.json index 87855a5..6f60699 100644 --- a/plugin/.tessl-plugin/plugin.json +++ b/plugin/.tessl-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "backupdrill/supabase-backup", - "version": "1.0.0", + "version": "1.0.1", "description": "Back up a Supabase project to your own S3, R2 or B2 bucket with the open-source backupdrill CLI, run a restore drill to prove the snapshot restores, and restore into a new project. Includes plain pg_dump and pg_restore notes for Supabase and what the Free plan does and doesn't back up.", "private": false, "skills": "./skills/" diff --git a/plugin/skills/supabase-backup/references/postgres-backup-basics.md b/plugin/skills/supabase-backup/references/postgres-backup-basics.md index 08310dc..43a3d3c 100644 --- a/plugin/skills/supabase-backup/references/postgres-backup-basics.md +++ b/plugin/skills/supabase-backup/references/postgres-backup-basics.md @@ -46,11 +46,13 @@ export BACKUP_FILE="backup-$(date +%Y%m%dT%H%M%S).dump" ## Test the restore by hand -A dump that has never been restored is a guess. Restore it into a throwaway Postgres and look at the data. Start the container and wait until it accepts connections: +A dump that has never been restored is a guess. Restore it into a throwaway Postgres and look at the data. The container will hold a copy of production data, and every account on the machine can reach a port on `127.0.0.1`, so give it a new random password. Start the container and wait until it accepts connections: ```sh -# docker run can print an ID and still fail (port taken), so only a zero exit counts -cid="$(docker run -d --rm -e POSTGRES_PASSWORD=drill -p 127.0.0.1:55432:5432 postgres:17-alpine)" || cid="" +TEST_PW="$(openssl rand -hex 16)" +# docker run can print an ID and still fail (port taken), so only a zero exit counts. +# -e with no value makes docker read the password from its own environment, off the process list. +cid="$(POSTGRES_PASSWORD="$TEST_PW" docker run -d --rm -e POSTGRES_PASSWORD -p 127.0.0.1:55432:5432 postgres:17-alpine)" || cid="" [ -n "$cid" ] \ && for i in $(seq 60); do docker exec "$cid" pg_isready -q -h 127.0.0.1 -U postgres && break; sleep 1; done \ && docker exec "$cid" pg_isready -h 127.0.0.1 -U postgres @@ -60,12 +62,14 @@ echo "container: ${cid:-not started}" If it prints `container: not started` (port 55432 already taken, for example) or the last `pg_isready` says `no response`, stop and fix that first. Then restore and check: ```sh -export TEST_DB="postgresql://postgres:drill@127.0.0.1:55432/postgres" +export TEST_DB="postgresql://postgres@127.0.0.1:55432/postgres" +# PGPASSWORD goes on each command, not exported: exported, it would also replace +# ~/.pgpass for the production connection in this shell. # The dump creates schema public itself, so drop the empty one first. Then add stand-ins # for what a Supabase project provides, so policies that mention them restore. # --no-privileges skips GRANT and REVOKE: this test checks the data, not who can read it. -psql "$TEST_DB" --set ON_ERROR_STOP=1 -c " +PGPASSWORD="$TEST_PW" psql "$TEST_DB" --set ON_ERROR_STOP=1 -c " drop schema public; create role anon; create role authenticated; create role service_role; create schema auth; @@ -73,10 +77,10 @@ psql "$TEST_DB" --set ON_ERROR_STOP=1 -c " create function auth.role() returns text language sql stable as 'select null::text'; create function auth.email() returns text language sql stable as 'select null::text'; create function auth.jwt() returns jsonb language sql stable as 'select null::jsonb';" \ -&& pg_restore --no-owner --no-privileges --dbname "$TEST_DB" "$BACKUP_FILE" +&& PGPASSWORD="$TEST_PW" pg_restore --no-owner --no-privileges --dbname "$TEST_DB" "$BACKUP_FILE" -psql "$TEST_DB" -c "select count(*) from pg_tables where schemaname = 'public';" -psql "$TEST_DB" -c "select count(*) from public.;" +PGPASSWORD="$TEST_PW" psql "$TEST_DB" -c "select count(*) from pg_tables where schemaname = 'public';" +PGPASSWORD="$TEST_PW" psql "$TEST_DB" -c "select count(*) from public.;" ``` When you're done, remove only the container you started: `docker rm -f "$cid"`. diff --git a/src/drill.ts b/src/drill.ts index 2a90ed8..12b74d0 100644 --- a/src/drill.ts +++ b/src/drill.ts @@ -252,25 +252,33 @@ async function startEphemeralPostgres(image: string, major: number, resourceName await ensureImage(image); // 名字先定好:Ctrl+C 可能落在 docker run 还没返回 ID 的时候,按名字才删得掉 const name = resourceName ?? `backupdrill-drill-${randomBytes(6).toString("hex")}`; + // 沙箱里是恢复出来的生产数据。端口只绑 127.0.0.1,但同机的其他系统账号照样连得上, + // 固定密码等于没设:每个沙箱一个随机密码。只写 "-e POSTGRES_PASSWORD" 不带值, + // docker 从它自己的环境变量取值,密码就不进 argv(ps 看得到 argv) + const password = randomBytes(16).toString("hex"); throwIfInterrupted(); - const started = execFileAsync("docker", [ - "run", - "-d", - "--rm", - "--name", - name, - ...(resourceName ? ["--mount", `type=volume,source=${resourceName},target=${dataVolumeTarget}`] : []), - "-e", - "POSTGRES_PASSWORD=drill", - "-e", - "POSTGRES_DB=postgres", - "-p", - "127.0.0.1:0:5432", // 随机主机端口,避免撞端口 - `--shm-size=${tuning.shmSize}`, - image, - "postgres", - ...tuning.settings.flatMap((kv) => ["-c", kv]), - ]); + const started = execFileAsync( + "docker", + [ + "run", + "-d", + "--rm", + "--name", + name, + ...(resourceName ? ["--mount", `type=volume,source=${resourceName},target=${dataVolumeTarget}`] : []), + "-e", + "POSTGRES_PASSWORD", + "-e", + "POSTGRES_DB=postgres", + "-p", + "127.0.0.1:0:5432", // 随机主机端口,避免撞端口 + `--shm-size=${tuning.shmSize}`, + image, + "postgres", + ...tuning.settings.flatMap((kv) => ["-c", kv]), + ], + { env: { ...process.env, POSTGRES_PASSWORD: password } } + ); // 删容器只有一份:正常收尾(drillDump 的 finally)和中断收尾(interrupt.ts)等的是同一个 promise。 // 两边各发一次 docker rm 会撞上 "removal already in progress",后到的那个报删除失败, // 进程又在真正删完之前就退出了(信号测试抓到的竞争)。 @@ -343,7 +351,7 @@ async function startEphemeralPostgres(image: string, major: number, resourceName ]); // 形如 "127.0.0.1:54321" const port = portOut.stdout.trim().split(":").pop()!.trim(); - const connString = `postgresql://postgres:drill@127.0.0.1:${port}/postgres`; + const connString = `postgresql://postgres:${password}@127.0.0.1:${port}/postgres`; // 等就绪(容器内 pg_isready),最多 ~30s。 // 必须 -h 127.0.0.1 强制走 TCP:官方镜像 initdb 阶段先起一个只听 unix socket 的临时 diff --git a/test/drill.test.js b/test/drill.test.js index db0552d..393e9ea 100644 --- a/test/drill.test.js +++ b/test/drill.test.js @@ -6,6 +6,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import pg from "pg"; import { drillDump, classifyPostDataErrors, @@ -237,6 +238,16 @@ test( "inspect", "-f", "{{.State.Running}}", kept.keptSandbox.containerId, ]); assert.equal(running.trim(), "true", "kept sandbox container must still be running"); + // 沙箱密码每次随机(同机其他账号能连 127.0.0.1),连接串里的密码要真能登录,旧的固定密码不行 + const sandboxUrl = new URL(kept.keptSandbox.connString); + assert.match(sandboxUrl.password, /^[0-9a-f]{32}$/, "sandbox password must be random"); + const client = new pg.Client({ connectionString: kept.keptSandbox.connString }); + await client.connect(); + await client.end(); + sandboxUrl.password = "drill"; + const stale = new pg.Client({ connectionString: sandboxUrl.toString() }); + await assert.rejects(stale.connect(), /password authentication failed/); + await stale.end().catch(() => {}); } finally { // 断言失败也不能把容器留在机器上(xreview:测试自身不许泄漏沙箱) await x("docker", ["rm", "-f", "-v", kept.keptSandbox.containerId]).catch(() => {}); From 04604bfccc97c6f07664427aed042e6671965d7d Mon Sep 17 00:00:00 2001 From: Super <20788648+supertj@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:09:34 +0800 Subject: [PATCH 2/3] docs: PITR is available on every compute size; drop the Small compute requirement --- .../skills/supabase-backup/references/postgres-backup-basics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/skills/supabase-backup/references/postgres-backup-basics.md b/plugin/skills/supabase-backup/references/postgres-backup-basics.md index 43a3d3c..0cdb37c 100644 --- a/plugin/skills/supabase-backup/references/postgres-backup-basics.md +++ b/plugin/skills/supabase-backup/references/postgres-backup-basics.md @@ -5,7 +5,7 @@ Read this when the user asks what Supabase already backs up for them, wants a pl ## What Supabase keeps for you - **Free plan:** no access to automatic backups. Supabase's docs tell Free projects to export their data themselves (`supabase db dump`) and keep off-site copies. Free projects are paused after a week of inactivity; the pause snapshot lets the user restore the paused project from the dashboard for up to a year. -- **Pro and Team:** daily backups, kept 7 days on Pro and 14 on Team, restorable from the dashboard. On the physical backup process that current projects use they can't be downloaded. Point-in-time recovery is a paid add-on: about $100/month per project for 7 days of history, and it needs a Small compute add-on or larger. +- **Pro and Team:** daily backups, kept 7 days on Pro and 14 on Team, restorable from the dashboard. On the physical backup process that current projects use they can't be downloaded. Point-in-time recovery is a paid add-on: about $100/month per project for 7 days of history. - **Storage files are not in any database backup.** A database restore brings back the `storage.objects` rows, which only point at files. The files themselves need a separate copy (the CLI copies them once the Storage S3 keys are set). Every backup taken from outside the project counts as egress on the user's Supabase bill. Free includes 5 GB a month, shared with whatever the app already serves. Run `backupdrill estimate --plan free` for an upper-bound estimate of the project's backup egress. As a rough guide, if each run transfers 500 MB (the Free database limit), weekly backups use about 2 GB a month and daily ones about 15 GB, three times the allowance. From 0974728cf6355f959eee1ac7a8240c9b3cae41c9 Mon Sep 17 00:00:00 2001 From: Super <20788648+supertj@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:20:57 +0800 Subject: [PATCH 3/3] fix: --with-login checks for pg_restore 17.6+ and a psql no older, before any container starts The CLI's Auth restore refuses a pg_restore without \restrict and an older psql, so a 17.0-17.5 client used to fail only at the restore step, after the backup and drill had run. The skill doc's comment no longer says the password is off the process list, only off docker's arguments. --- examples/reproducible-drill/README.md | 5 +++-- examples/reproducible-drill/run.sh | 13 +++++++++++++ .../references/postgres-backup-basics.md | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/examples/reproducible-drill/README.md b/examples/reproducible-drill/README.md index 149a43f..251d33a 100644 --- a/examples/reproducible-drill/README.md +++ b/examples/reproducible-drill/README.md @@ -11,8 +11,9 @@ pnpm install --frozen-lockfile && pnpm build # run.sh calls ../../dist, which ``` Requires Docker, Node ≥ 20, and `pg_dump`/`pg_restore` ≥ 17 on your `PATH` (or -point `PG_BIN` at them); `--with-login` also needs `psql` from the same client -package. It starts two fixture containers — Postgres and an S3-compatible store — +point `PG_BIN` at them); `--with-login` also needs `pg_restore` 17.6 or newer +and `psql` from the same client package, because the CLI's Auth restore refuses +older ones. It starts two fixture containers — Postgres and an S3-compatible store — and the drill starts a third for its restore sandbox. `--with-login` then starts two more on a Docker network of their own, once the sandbox is gone: Supabase's Postgres image (`supabase/postgres`, about 1.7 GB to pull) and GoTrue, the diff --git a/examples/reproducible-drill/run.sh b/examples/reproducible-drill/run.sh index efa7edf..ed5b613 100755 --- a/examples/reproducible-drill/run.sh +++ b/examples/reproducible-drill/run.sh @@ -59,6 +59,19 @@ if [ "$WITH_LOGIN" = 1 ]; then *) PSQL=psql ;; esac command -v "$PSQL" >/dev/null 2>&1 || { echo "--with-login needs psql ($PSQL), which the CLI uses for the restore" >&2; exit 1; } + # CLI 的 Auth 恢复(auth-restore.ts 的 psqlProblem)拒绝不带 \restrict 的 pg_restore(17 要 17.6 起) + # 和比 pg_restore 旧的 psql。在这里先拦,不然要等备份和演练都跑完才在恢复那一步失败 + version_of() { "$1" --version | sed -E 's/.* ([0-9]+)\.([0-9]+).*/\1 \2/'; } + read -r restore_major restore_minor <<< "$(version_of "$PG_RESTORE")" + read -r psql_major psql_minor <<< "$(version_of "$PSQL")" + if [ "$restore_major" -eq 17 ] && [ "$restore_minor" -lt 6 ]; then + echo "--with-login needs pg_restore 17.6 or newer (found $restore_major.$restore_minor); the CLI's Auth restore refuses older ones" >&2 + exit 1 + fi + if [ "$psql_major" -lt "$restore_major" ] || { [ "$psql_major" -eq "$restore_major" ] && [ "$psql_minor" -lt "$restore_minor" ]; }; then + echo "psql $psql_major.$psql_minor is older than pg_restore $restore_major.$restore_minor; --with-login needs psql from the same client package" >&2 + exit 1 + fi fi RUN_ID="$$" diff --git a/plugin/skills/supabase-backup/references/postgres-backup-basics.md b/plugin/skills/supabase-backup/references/postgres-backup-basics.md index 0cdb37c..f4ac161 100644 --- a/plugin/skills/supabase-backup/references/postgres-backup-basics.md +++ b/plugin/skills/supabase-backup/references/postgres-backup-basics.md @@ -51,7 +51,7 @@ A dump that has never been restored is a guess. Restore it into a throwaway Post ```sh TEST_PW="$(openssl rand -hex 16)" # docker run can print an ID and still fail (port taken), so only a zero exit counts. -# -e with no value makes docker read the password from its own environment, off the process list. +# -e with no value makes docker read the password from its own environment, not from its arguments. cid="$(POSTGRES_PASSWORD="$TEST_PW" docker run -d --rm -e POSTGRES_PASSWORD -p 127.0.0.1:55432:5432 postgres:17-alpine)" || cid="" [ -n "$cid" ] \ && for i in $(seq 60); do docker exec "$cid" pg_isready -q -h 127.0.0.1 -U postgres && break; sleep 1; done \