diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3f35dc0..5021bda 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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)) { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index c33bf50..8c1e879 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 7941796..ea7604c 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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 }) diff --git a/apps/api/src/services/backup.ts b/apps/api/src/services/backup.ts new file mode 100644 index 0000000..a79ae9b --- /dev/null +++ b/apps/api/src/services/backup.ts @@ -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) +} diff --git a/apps/api/test/backup.test.ts b/apps/api/test/backup.test.ts new file mode 100644 index 0000000..7f6207c --- /dev/null +++ b/apps/api/test/backup.test.ts @@ -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() + }) +}) diff --git a/docs/deploy-traefik.md b/docs/deploy-traefik.md index 8e37909..26c0bd8 100644 --- a/docs/deploy-traefik.md +++ b/docs/deploy-traefik.md @@ -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`. --- diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts new file mode 100644 index 0000000..97c3e68 --- /dev/null +++ b/packages/db/src/backup.ts @@ -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 } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 2e57ebe..9472029 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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' diff --git a/packages/db/test/backup.test.ts b/packages/db/test/backup.test.ts new file mode 100644 index 0000000..e6ab801 --- /dev/null +++ b/packages/db/test/backup.test.ts @@ -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['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) + }) +})