fix(backups): защитить подключение S3 и согласовать операции каталога
This commit is contained in:
1 parent
7cef0cc133
commit
faf452d149
18 files changed
+1385
-14
No files matched your search
@@ -0,0 +1,6 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const baseline=process.env.MMAPP_ORIGIN_BASELINE==='true',report={date:'2026-10-14',block:'12.5.233',baseline,checks:[],externalS3Contacts:0,chrContacts:0,passed:false};const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const config={provider:'s3',s3Endpoint:'https://s3.invalid',s3Region:'test-region',s3Bucket:'origin-bucket',s3Prefix:'owned-test',s3AccessKeyId:'fixture',s3SecretAccessKey:'fixture',s3ForcePathStyle:false,keepLocalCopy:true};async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path);return r}
|
||||
async function insert(){await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES('owned-origin',NULL,'fixture','fixture.rsc',1,'manual','s3','owned-test/fixture.rsc',now())")}
|
||||
try{await api('PUT','/backups/storage',config);await insert();for(const patch of[{s3Bucket:'other-bucket'},{s3Endpoint:'https://other.invalid'},{s3Region:'other'},{s3ForcePathStyle:true},{provider:'local'}]){const before=(await api('GET','/backups/storage')).json();await api('PUT','/backups/storage',patch,baseline?200:409);const after=(await api('GET','/backups/storage')).json();if(!baseline)assert.deepEqual(after,before);else await api('PUT','/backups/storage',config);report.checks.push({change:Object.keys(patch)[0],status:baseline?200:409,settingsUnchanged:!baseline})}for(const patch of[{s3Prefix:'new-prefix'},{s3SecretAccessKey:'rotated-fixture'},{keepLocalCopy:false}]){await api('PUT','/backups/storage',patch);report.checks.push({allowed:Object.keys(patch)[0],status:200})}await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await api('PUT','/backups/storage',{s3Bucket:'empty-catalog-bucket'});report.checks.push({emptyCatalogChangeAllowed:true});if(!baseline){const{withBackupStorageOperation}=await import('/app/backend/dist/services/backup-storage-operation.js');let release,started;const active=new Promise(r=>started=r),wait=new Promise(r=>release=r);const creator=withBackupStorageOperation(async()=>{started();await wait;await insert()});await active;let settled=false;const update=api('PUT','/backups/storage',{s3Bucket:'racing-bucket'},409).then(()=>settled=true);await new Promise(r=>setTimeout(r,20));await update;assert.equal(settled,true);release();await creator;assert.equal((await api('GET','/backups/storage')).json().s3Bucket,'empty-catalog-bucket');report.checks.push({inFlightUpdateRejectedBeforeCatalogPublication:true});const{readBackupContent,getBackupById}=await import('/app/backend/dist/services/backup-service.js');const stale=await getBackupById('owned-origin');let releaseDelete;const deletion=withBackupStorageOperation(async()=>{await new Promise(r=>releaseDelete=r);await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'")});const download=readBackupContent(stale);const rejected=assert.rejects(download,/Бэкап не найден/);await Promise.resolve();releaseDelete();await deletion;await rejected;report.checks.push({staleDownloadRejectsBeforeS3AfterDeletion:true})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await databaseBackgroundWork.drain();await app.close();await closePool();fs.writeFileSync('/checks/storage-origin233.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm233_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm233_pass="$(openssl rand -hex 24)"
|
||||
mm233_role_created=false
|
||||
mm233_db_created=false
|
||||
cleanup233() {
|
||||
docker rm mmapp-s3app233-owned >/dev/null 2>&1 || true
|
||||
if $mm233_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm233_db\" WITH (FORCE)"; fi
|
||||
if $mm233_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm233_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/backup233-checks/storage-origin233.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm233_pass
|
||||
}
|
||||
trap cleanup233 EXIT
|
||||
[[ "$mm233_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm233_db" "$mm233_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm233_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm233_db\" OWNER \"$mm233_db\""
|
||||
mm233_db_created=true
|
||||
docker run --name mmapp-s3app233-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm233_db:$mm233_pass@postgres:5432/$mm233_db" -e "MMAPP_OWN_SQL_DB=$mm233_db" -e "DATABASE_PATH=/tmp/$mm233_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm233_db/geoip" -v /opt/mmapp-test/artifacts/backup233-checks:/checks --entrypoint node "${1:-mmapp-test/backend:ux-backup233}" /checks/backup-storage-origin.mjs
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../services/backup-service.js"
|
||||
|
||||
import { BackupRestoreUnknownError } from "../services/backup-restore.js"
|
||||
import { BackupStorageConflictError } from "../services/backup-storage-operation.js"
|
||||
import { processBackupJob, recordBackupEvent } from "../services/backup-job-execution.js"
|
||||
|
||||
type BackupJob = BackupJobStatusResponse
|
||||
@@ -69,7 +70,12 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
return reply.send(await updateBackupStorageSettings(parsed.data))
|
||||
try {
|
||||
return reply.send(await updateBackupStorageSettings(parsed.data))
|
||||
} catch (error) {
|
||||
if (error instanceof BackupStorageConflictError) return reply.status(409).send({ error: error.message })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/backups/storage/test", async (_req, reply) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { and, desc, eq, isNotNull } from "drizzle-orm"
|
||||
import type {
|
||||
BackupScheduleSettingsDto,
|
||||
BackupStorageSettingsDto,
|
||||
@@ -11,7 +11,8 @@ import { mapBackupEntry } from "./backup-dto.js"
|
||||
import { pruneBackupRecords } from "./backup-prune.js"
|
||||
import { importBackupOnce } from "./backup-restore.js"
|
||||
import { deleteStoredBackup } from "./backup-deletion.js"
|
||||
import { planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { assertBackupStorageConnectionChange, planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js"
|
||||
import { db } from "../db/index.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
|
||||
@@ -67,6 +68,10 @@ export async function getBackupById(id: string): Promise<BackupMeta | null> {
|
||||
}
|
||||
|
||||
export async function insertBackup(meta: BackupMeta): Promise<void> {
|
||||
return withBackupStorageOperation(() => insertBackupRecord(meta))
|
||||
}
|
||||
|
||||
async function insertBackupRecord(meta: BackupMeta): Promise<void> {
|
||||
await db.insert(backupEntries).values({
|
||||
id: meta.id,
|
||||
serverId: meta.serverId,
|
||||
@@ -88,6 +93,10 @@ async function getBackupRow(id: string): Promise<BackupRow | undefined> {
|
||||
}
|
||||
|
||||
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
return withBackupStorageOperation(() => deleteStoredBackupRecord(id))
|
||||
}
|
||||
|
||||
async function deleteStoredBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
const row = await getBackupRow(id)
|
||||
if (!row) return null
|
||||
const meta = rowToMeta(row)
|
||||
@@ -245,9 +254,15 @@ export async function getBackupStorageSettings(): Promise<BackupStorageSettingsD
|
||||
export async function updateBackupStorageSettings(
|
||||
patch: PutBackupStorageSettings,
|
||||
): Promise<BackupStorageSettingsDto> {
|
||||
return withBackupStorageSettingsUpdate(() => updateStorageSettings(patch))
|
||||
}
|
||||
|
||||
async function updateStorageSettings(patch: PutBackupStorageSettings): Promise<BackupStorageSettingsDto> {
|
||||
const prev = await getBackupStorageSettingsRow()
|
||||
const now = new Date().toISOString()
|
||||
const next = planBackupStorageUpdate(prev, patch, now)
|
||||
const remote = await db.select({ id: backupEntries.id }).from(backupEntries).where(isNotNull(backupEntries.s3Key)).limit(1)
|
||||
assertBackupStorageConnectionChange(prev, next, remote.length > 0)
|
||||
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
|
||||
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
|
||||
} else {
|
||||
@@ -375,6 +390,10 @@ export async function runBackupForServer(
|
||||
kind: BackupMeta["kind"],
|
||||
notes?: string,
|
||||
): Promise<BackupMeta> {
|
||||
return withBackupStorageOperation(() => createBackupForServer(id, kind, notes))
|
||||
}
|
||||
|
||||
async function createBackupForServer(id: string, kind: BackupMeta["kind"], notes?: string): Promise<BackupMeta> {
|
||||
const serverIdNum = Number.parseInt(id, 10)
|
||||
if (!Number.isFinite(serverIdNum)) {
|
||||
throw new Error("Невалидный id сервера")
|
||||
@@ -453,6 +472,15 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number)
|
||||
}
|
||||
|
||||
export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
|
||||
return withBackupStorageOperation(async () => {
|
||||
// The route may have read metadata before a concurrent deletion changed the connection.
|
||||
const current = await getBackupById(meta.id)
|
||||
if (!current) throw new Error("Бэкап не найден")
|
||||
return readStoredBackupContent(current)
|
||||
})
|
||||
}
|
||||
|
||||
async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
|
||||
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
|
||||
try {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
@@ -481,6 +509,10 @@ export async function restoreBackupToDevice(id: string): Promise<{ backupId: str
|
||||
}
|
||||
|
||||
export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
||||
return withBackupStorageOperation(syncStoredBackupsFromS3)
|
||||
}
|
||||
|
||||
async function syncStoredBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
if (!cfg) throw new Error("S3 не настроен")
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Serializes storage changes with catalog-producing operations in one backend process. */
|
||||
let tail: Promise<unknown> = Promise.resolve()
|
||||
let pending = 0
|
||||
export function withBackupStorageOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
pending += 1
|
||||
const result = tail.then(operation).finally(() => { pending -= 1 })
|
||||
tail = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
export async function withBackupStorageSettingsUpdate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (pending > 0) throw new BackupStorageConflictError("Хранилище занято созданием или синхронизацией резервных копий. Дождитесь завершения операции и повторите сохранение.")
|
||||
return withBackupStorageOperation(operation)
|
||||
}
|
||||
|
||||
export class BackupStorageConflictError extends Error {
|
||||
constructor(message = "Смена подключения S3 недоступна: в каталоге есть резервные копии из текущего хранилища. Сохраните исходное подключение для скачивания и удаления этих файлов.") {
|
||||
super(message)
|
||||
this.name = "BackupStorageConflictError"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { assertBackupStorageConnectionChange, planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { BackupStorageConflictError, withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js"
|
||||
const row = { provider: "s3" as const, s3Endpoint: "https://s3.example.test", s3Region: "us-east-1", s3Bucket: "bucket", s3Prefix: "prefix", s3AccessKeyId: "test-key", s3SecretAccessKey: "test-secret", s3ForcePathStyle: false, keepLocalCopy: true, lastTestAt: "2026-10-01T00:00:00Z", lastTestError: null, updatedAt: "2026-10-01T00:00:00Z" }
|
||||
const now = "2026-10-01T00:01:00Z"
|
||||
test("unchanged credentials and blank key preserve secret and last receipt", () => {
|
||||
@@ -18,3 +19,35 @@ test("local retention does not invent a connection change or discard a failed te
|
||||
const result = planBackupStorageUpdate({ ...row, lastTestError: "failed" }, { keepLocalCopy: false }, now)
|
||||
assert.equal(result.keepLocalCopy, false); assert.equal(result.lastTestAt, row.lastTestAt); assert.equal(result.lastTestError, "failed")
|
||||
})
|
||||
|
||||
test("remote catalog prevents addressing existing keys through another S3 connection", () => {
|
||||
for (const patch of [{ provider: "local" as const }, { s3Endpoint: "https://other.example.test" }, { s3Region: "new" }, { s3Bucket: "other" }, { s3ForcePathStyle: true }]) {
|
||||
const next = planBackupStorageUpdate(row, patch, now)
|
||||
assert.throws(() => assertBackupStorageConnectionChange(row, next, true), BackupStorageConflictError)
|
||||
assert.doesNotThrow(() => assertBackupStorageConnectionChange(row, next, false))
|
||||
}
|
||||
for (const patch of [{ s3Prefix: "new" }, { s3AccessKeyId: "rotated" }, { s3SecretAccessKey: "rotated" }, { keepLocalCopy: false }]) {
|
||||
assert.doesNotThrow(() => assertBackupStorageConnectionChange(row, planBackupStorageUpdate(row, patch, now), true))
|
||||
}
|
||||
assert.doesNotThrow(() => assertBackupStorageConnectionChange({ ...row, provider: "local" }, row, true))
|
||||
assert.throws(() => assertBackupStorageConnectionChange({ ...row, provider: "local" }, { ...row, s3Bucket: "other" }, true), BackupStorageConflictError)
|
||||
})
|
||||
|
||||
test("storage update rejects before mutation during upload, then sees the published catalog", async () => {
|
||||
let release!: () => void
|
||||
const uploaded = new Promise<void>(resolve => { release = resolve })
|
||||
let hasRemoteEntries = false, updateStarted = false
|
||||
const create = withBackupStorageOperation(async () => { await uploaded; hasRemoteEntries = true })
|
||||
const update = withBackupStorageSettingsUpdate(async () => {
|
||||
updateStarted = true
|
||||
assertBackupStorageConnectionChange(row, planBackupStorageUpdate(row, { s3Bucket: "other" }, now), hasRemoteEntries)
|
||||
})
|
||||
const rejected = assert.rejects(update, BackupStorageConflictError)
|
||||
await Promise.resolve(); assert.equal(updateStarted, false)
|
||||
release(); await create; await rejected
|
||||
assert.equal(updateStarted, false)
|
||||
await assert.rejects(withBackupStorageSettingsUpdate(async () => {
|
||||
assertBackupStorageConnectionChange(row, planBackupStorageUpdate(row, { s3Bucket: "other" }, now), hasRemoteEntries)
|
||||
}), BackupStorageConflictError)
|
||||
assert.equal(await withBackupStorageOperation(async () => "next operation"), "next operation")
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { BackupStorageSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups"
|
||||
import { BackupStorageConflictError } from "./backup-storage-operation.js"
|
||||
type StorageRecord = Omit<BackupStorageSettingsDto, "secretConfigured"> & { s3SecretAccessKey: string }
|
||||
|
||||
export function assertBackupStorageConnectionChange(prev: StorageRecord, next: StorageRecord, hasRemoteEntries: boolean): void {
|
||||
const addressFields = ["s3Endpoint", "s3Region", "s3Bucket", "s3ForcePathStyle"] as const
|
||||
// Older installations may have disabled S3 while retaining its connection and catalog.
|
||||
if (hasRemoteEntries && (next.provider !== "s3" || addressFields.some(key => next[key] !== prev[key]))) throw new BackupStorageConflictError()
|
||||
}
|
||||
/** A test of previous credentials cannot confirm the new configuration. */
|
||||
export function planBackupStorageUpdate(prev: StorageRecord, patch: PutBackupStorageSettings, updatedAt: string) {
|
||||
const next = {
|
||||
|
||||
Reference in new issue
Block a user