feat(db): онлайн-бэкап SQLite, CLI backup, ротация и восстановление

This commit is contained in:
Denozordec committed 2026-10-23 17:02:50 +07:00
1 parent 8298454c63
commit 8cba7a5c41
9 files changed
+345 -2

No files matched your search

+9
View File
@@ -35,6 +35,7 @@ import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { startHousekeepingJob } from './services/housekeeping.js'
import { startBackupJob } from './services/backup.js'
import {
ensureOidcSigningKey,
isOidcKeyReady,
@@ -196,10 +197,18 @@ export async function buildApp(opts: {
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
const stopHousekeeping = startHousekeepingJob(app)
const stopBackup = startBackupJob(app)
app.addHook('onClose', async () => {
stopRetention()
stopHousekeeping()
stopBackup()
})
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
app.log.warn(
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
)
}
}
if (config.staticDir && existsSync(config.staticDir)) {
+9
View File
@@ -33,6 +33,12 @@ export const configSchema = z.object({
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
backupDir: z.string().optional(),
/** Сколько последних копий хранить. */
backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
@@ -79,6 +85,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
+18
View File
@@ -30,6 +30,24 @@ for (const path of [
}
const config = loadConfig()
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
// (для cron / `docker exec auth-portal node dist/server.js backup`).
if (process.argv[2] === 'backup') {
const { createDb, closeDb } = await import('@authportal/db')
const { runBackup } = await import('./services/backup.js')
const { sqlite } = createDb(config.databaseUrl)
try {
const res = await runBackup(sqlite, config)
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
closeDb(sqlite)
process.exit(0)
} catch (err) {
console.error('backup failed:', err)
process.exit(1)
}
}
const app = await buildApp({ config })
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
+43
View File
@@ -0,0 +1,43 @@
import { dirname, join, resolve } from 'node:path'
import type { FastifyInstance } from 'fastify'
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
import type { AppConfig } from '../config.js'
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
if (config.backupDir) return resolve(config.backupDir)
const path = resolveDatabasePath(databaseUrl)
return join(dirname(resolve(path)), 'backups')
}
export async function runBackup(
sqlite: Sqlite,
config: AppConfig,
databaseUrl = config.databaseUrl,
) {
return backupDatabase(sqlite, {
dir: backupDirFor(config, databaseUrl),
keep: config.backupKeep,
})
}
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
export function startBackupJob(app: FastifyInstance): () => void {
const hours = app.config.backupIntervalHours
if (hours <= 0 || app.sqlite.memory) return () => {}
let running = false
const run = async () => {
if (running) return
running = true
try {
const res = await runBackup(app.sqlite, app.config)
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
} catch (err) {
app.log.error({ err }, 'db backup failed')
} finally {
running = false
}
}
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
interval.unref()
return () => clearInterval(interval)
}
+82
View File
@@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listBackups } from '@authportal/db'
import { loadConfig } from '../src/config.js'
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
import { buildApp } from '../src/app.js'
import { adminToken } from './helpers.js'
const dirs: string[] = []
afterEach(() => {
vi.useRealTimers()
for (const d of dirs.splice(0)) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* Windows может держать файл БД до GC — временный каталог не критичен */
}
}
})
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
return loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: 'admin@test.local',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
NODE_ENV: 'test',
...extra,
})
}
describe('backup', () => {
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
const cfg = env('/data/x')
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
})
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_KEEP: '2' })
const app = await buildApp({ config })
const res = await runBackup(app.sqlite, config)
expect(listBackups(backupDirFor(config))).toHaveLength(1)
await app.close()
const restored = await buildApp({
config: env(dir),
databaseUrl: `sqlite:${res.file}`,
})
const token = await adminToken(restored as never)
expect(token).toBeTruthy()
await restored.close()
})
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
const app = await buildApp({ config })
const base = vi.getTimerCount()
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
expect(vi.getTimerCount()).toBe(base)
off()
const stop = startBackupJob(app)
expect(vi.getTimerCount()).toBe(base + 1)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
vi.useRealTimers()
await vi.waitFor(() =>
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
)
stop()
await app.close()
})
})
+12 -2
View File
@@ -211,14 +211,24 @@ Portal — IdP; Technitium — RP. После `docker compose up`:
## Бэкап
```bash
# SQLite
cp /opt/auth-portal/data/app.db /opt/auth-portal/data/app.db.bak-$(date +%F)
# SQLite: согласованная онлайн-копия (WAL учитывается, сервис не останавливается),
# проверка integrity_check и ротация (BACKUP_KEEP, по умолчанию 7) → /data/backups/
docker exec auth-portal node dist/server.js backup
# Либо встроенный планировщик: BACKUP_INTERVAL_HOURS=24 в .env (0 = выключен).
# Каталог: BACKUP_DIR (по умолчанию <каталог БД>/backups). Не копируйте app.db
# через `cp` на живой БД — без -wal/-shm копия может быть неполной.
# ACME
docker run --rm -v auth_portal_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \
tar czf /backup/traefik-acme-$(date +%F).tgz -C /data .
```
**Восстановление:** `docker compose stop app`, затем в `./data` удалить
`app.db-wal`/`app.db-shm`, скопировать нужный `backups/app-*.db` как `app.db`,
`chown 1000:1000`, `docker compose up -d`. OIDC-ключ подписи хранится в этой же
БД, если не задан `OIDC_RSA_PRIVATE_KEY` — храните ключ отдельным секретом.
Откат образа: в `.env` `AUTH_IMAGE_TAG=vX.Y.Z` → `docker compose pull && docker compose up -d`.
---
+66
View File
@@ -0,0 +1,66 @@
import { mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import type { Sqlite } from './index.js'
const BACKUP_RE = /^app-\d{8}-\d{6}-\d{3}\.db$/
function stamp(d: Date): string {
const p = (n: number, w = 2) => String(n).padStart(w, '0')
return (
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
`-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}` +
`-${p(d.getUTCMilliseconds(), 3)}`
)
}
/** PRAGMA integrity_check для файла БД; true, если результат 'ok'. */
export function checkIntegrity(path: string): boolean {
const db = new Database(path, { readonly: true, fileMustExist: true })
try {
return db.pragma('integrity_check', { simple: true }) === 'ok'
} finally {
db.close()
}
}
export function listBackups(dir: string): string[] {
try {
return readdirSync(dir)
.filter((f) => BACKUP_RE.test(f))
.sort()
.reverse()
} catch {
return []
}
}
/**
* Согласованный онлайн-бэкап (better-sqlite3 backup API работает с WAL без
* остановки сервиса), проверка integrity_check, ротация по числу копий.
*/
export async function backupDatabase(
sqlite: Sqlite,
opts: { dir: string; keep: number; now?: Date },
): Promise<{ file: string; removed: string[] }> {
mkdirSync(opts.dir, { recursive: true })
const name = `app-${stamp(opts.now ?? new Date())}.db`
const finalPath = join(opts.dir, name)
const partial = `${finalPath}.partial`
try {
await sqlite.backup(partial)
if (!checkIntegrity(partial)) {
throw new Error('backup integrity_check failed')
}
renameSync(partial, finalPath)
} catch (err) {
rmSync(partial, { force: true })
throw err
}
const removed: string[] = []
for (const old of listBackups(opts.dir).slice(Math.max(1, opts.keep))) {
rmSync(join(opts.dir, old), { force: true })
removed.push(old)
}
return { file: finalPath, removed }
}
+1
View File
@@ -270,6 +270,7 @@ export function healthCheck(sqlite: Sqlite): void {
export * from './schema/index.js'
export * from './tx.js'
export * from './backup.js'
export * from './generation.js'
export * from './housekeeping.js'
export * from './users.js'
+105
View File
@@ -0,0 +1,105 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, describe, expect, it } from 'vitest'
import {
backupDatabase,
checkIntegrity,
closeDb,
createDb,
createUser,
getUserByEmail,
listBackups,
migrateSchema,
} from '../src/index.js'
const dirs: string[] = []
const dbs: Array<ReturnType<typeof createDb>['sqlite']> = []
afterEach(() => {
for (const s of dbs.splice(0)) closeDb(s)
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
function fileDb() {
const dir = mkdtempSync(join(tmpdir(), 'ap-bk-'))
dirs.push(dir)
const r = createDb(join(dir, 'app.db'))
migrateSchema(r.sqlite)
dbs.push(r.sqlite)
return { dir, ...r }
}
describe('backupDatabase', () => {
it('создаёт консистентную копию с данными из WAL', async () => {
const { dir, db, sqlite } = fileDb()
createUser(db, { email: 'a@x.io', name: 'A', passwordHash: 'h', isAdmin: false })
const res = await backupDatabase(sqlite, { dir: join(dir, 'backups'), keep: 3 })
expect(existsSync(res.file)).toBe(true)
expect(checkIntegrity(res.file)).toBe(true)
const copy = new Database(res.file, { readonly: true })
const row = copy.prepare('SELECT email FROM users').get()
copy.close()
expect(row).toEqual({ email: 'a@x.io' })
})
it('бэкап во время записи остаётся валидным', async () => {
const { dir, db, sqlite } = fileDb()
const writer = setInterval(() => {
try {
createUser(db, {
email: `u${Math.random()}@x.io`,
name: 'U',
passwordHash: 'h',
isAdmin: false,
})
} catch {
/* БД могла закрыться */
}
}, 1)
const res = await backupDatabase(sqlite, { dir, keep: 3 })
clearInterval(writer)
expect(checkIntegrity(res.file)).toBe(true)
})
it('ротация оставляет keep последних', async () => {
const { dir, sqlite } = fileDb()
const bdir = join(dir, 'b')
for (let i = 0; i < 5; i++) {
await backupDatabase(sqlite, {
dir: bdir,
keep: 2,
now: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
})
}
const left = listBackups(bdir)
expect(left).toHaveLength(2)
expect(left[0]).toContain('000004')
})
it('восстановленная копия открывается и содержит данные', async () => {
const { dir, db, sqlite } = fileDb()
createUser(db, { email: 'r@x.io', name: 'R', passwordHash: 'h', isAdmin: false })
const res = await backupDatabase(sqlite, { dir, keep: 1 })
const restored = createDb(res.file)
dbs.push(restored.sqlite)
migrateSchema(restored.sqlite)
expect(getUserByEmail(restored.db, 'r@x.io')?.name).toBe('R')
})
it('битый файл не проходит integrity_check', () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-bad-'))
dirs.push(dir)
const p = join(dir, 'bad.db')
writeFileSync(p, 'not a sqlite file'.repeat(100))
expect(() => checkIntegrity(p)).toThrow()
})
it('ошибка бэкапа не оставляет .partial', async () => {
const { dir, sqlite } = fileDb()
closeDb(sqlite)
await expect(backupDatabase(sqlite, { dir: join(dir, 'x'), keep: 1 })).rejects.toThrow()
expect(listBackups(join(dir, 'x'))).toHaveLength(0)
})
})