fix(backups): защитить подключение S3 и согласовать операции каталога

This commit is contained in:
Denozordec committed 2026-10-14 11:05:38 +07:00
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
+7 -1
View File
@@ -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) => {
+34 -2
View File
@@ -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 = {