dorfteich/apps/api/src/admin/backup-admin.service.ts
Claude Fable 5 394d1c811d
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s
#192: deploy-level backup target allowlist
BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains
where backups may go, enforced twice: the api rejects settings writes
and connection tests towards non-allowlisted hosts with admin-visible
error codes and resolves a non-allowlisted configured target to null,
and the sidecar enforces the same policy at the point of egress for the
WebDAV upload and the rsync mirror alike (shared policy helpers in
packages/shared/src/backup-target-policy.ts).

BREAKING: the empty default disables every remote target - backups stay
local only, the VS-NfD reference configuration (ADR 0026). Existing
deployments with a remote target must list its host or uploads and
mirror stop. The admin UI distinguishes unavailable-by-policy from
unconfigured (i18n de+en) and shows the permitted hosts.

Refs #192 (ADR 0026)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:17:14 +02:00

227 lines
8.5 KiB
TypeScript

import { readdirSync } from 'node:fs';
import { statSync } from 'node:fs';
import { join } from 'node:path';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
BACKUP_COMMAND_CHANNEL,
archiveFileName,
backupIdTime,
dumpFileName,
listSets,
remoteBundleId,
type BackupCommand,
type BackupConnectionTestInput,
type BackupConnectionTestResult,
type BackupRestoreInput,
type BackupSetView,
type BackupSetsView,
type BackupSettingsInput,
type BackupSettingsView,
} from '@dorfteich/shared';
import { webdavList } from '@dorfteich/shared/webdav';
import { User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { BackupTargetService } from '../backup/backup-target.service';
import { MaintenanceStateService } from '../backup/maintenance-state.service';
import { AppConfig } from '../config/app-config.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
/**
* Site-Admin backup management (issue #103): the Nextcloud target
* configuration, manual "back up now", the restore picker (local sets from
* the read-only backups mount, remote sets via WebDAV), and the restore
* trigger. The sidecar does the actual work — commands travel over the
* {@link BACKUP_COMMAND_CHANNEL} NOTIFY bus, progress comes back through
* `status.json`/`restore-status.json`.
*/
@Injectable()
export class BackupAdminService {
constructor(
private readonly target: BackupTargetService,
private readonly maintenance: MaintenanceStateService,
private readonly settings: InstanceSettingsService,
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly config: AppConfig,
) {}
settingsView(): Promise<BackupSettingsView> {
return this.target.settingsView();
}
/**
* Persists the backup settings. When the Nextcloud side is enabled, the
* connection is live-tested first (with the new password if provided,
* else the stored one) — like the setup wizard's SMTP step, nothing is
* saved on failure.
*/
async saveSettings(input: BackupSettingsInput, actor: User): Promise<BackupSettingsView> {
if (input.nextcloud.enabled) {
this.assertTargetAllowed(input.nextcloud.baseUrl);
const test = await this.target.testConnection({
baseUrl: input.nextcloud.baseUrl,
username: input.nextcloud.username,
folder: input.nextcloud.folder,
password: input.nextcloud.password,
});
if (!test.ok) {
throw new BadRequestException({
code: 'backup_connection_failed',
details: { nextcloud: [test.error ?? 'connection failed'] },
});
}
}
await this.target.storePassword(input.nextcloud.password ?? '');
await this.settings.set('backup.localRetentionDays', input.localRetentionDays, actor.id);
await this.settings.set('backup.remoteRetentionDays', input.remoteRetentionDays, actor.id);
await this.settings.set('backup.nextcloud.enabled', input.nextcloud.enabled, actor.id);
await this.settings.set('backup.nextcloud.baseUrl', input.nextcloud.baseUrl, actor.id);
await this.settings.set('backup.nextcloud.username', input.nextcloud.username, actor.id);
await this.settings.set('backup.nextcloud.folder', input.nextcloud.folder, actor.id);
await this.settings.set(
'backup.nextcloud.uploadSchedule',
input.nextcloud.uploadSchedule,
actor.id,
);
// settings.set audits each key; one summary entry names the intent.
await this.audit.record({
action: 'backup.settings_changed',
actorId: actor.id,
details: { nextcloudEnabled: input.nextcloud.enabled },
});
return this.settingsView();
}
testConnection(input: BackupConnectionTestInput): Promise<BackupConnectionTestResult> {
// Policy first (issue #192): the "test connection" button must not be
// usable as an egress probe towards non-allowlisted hosts.
this.assertTargetAllowed(input.baseUrl);
return this.target.testConnection(input);
}
/**
* Deploy-level target policy (issue #192, ADR 0026): an empty
* `BACKUP_ALLOWED_TARGETS` disables remote targets outright; a host
* outside the list is rejected with an admin-visible error.
*/
private assertTargetAllowed(baseUrl: string): void {
if (!this.target.remoteAllowed()) {
throw new BadRequestException({ code: 'backup_remote_disabled_by_policy' });
}
if (!this.target.targetAllowed(baseUrl)) {
throw new BadRequestException({
code: 'backup_target_not_allowed',
details: { nextcloud: [`host is not in BACKUP_ALLOWED_TARGETS`] },
});
}
}
/** Both restore sources for the picker: newest first. */
async sets(): Promise<BackupSetsView> {
const local = this.localSets();
const target = await this.target.resolveTarget();
if (!target) return { local, remoteConfigured: false, remote: [] };
const listed = await webdavList(target);
if (!listed.ok) {
return { local, remoteConfigured: true, remote: [], remoteError: listed.error };
}
const remote = listed.value
.filter((entry) => !entry.isCollection)
.map((entry) => ({ id: remoteBundleId(entry.name), sizeBytes: entry.sizeBytes }))
.filter((entry): entry is { id: string; sizeBytes: number | null } => entry.id !== null)
.map((entry) => this.setView(entry.id, entry.sizeBytes))
.sort((a, b) => b.backupId.localeCompare(a.backupId));
return { local, remoteConfigured: true, remote };
}
/** "Back up now": dump + upload, executed by the sidecar (202-style). */
async requestRun(actor: User): Promise<void> {
await this.notify({ kind: 'run', requestedBy: actor.username });
await this.audit.record({ action: 'backup.run_triggered', actorId: actor.id });
}
/**
* Requests an in-app restore. The type-to-confirm value must repeat the
* backup id — the UI enforces it too, this is the server-side backstop
* for the most destructive action the instance has.
*/
async requestRestore(input: BackupRestoreInput, actor: User): Promise<void> {
if (input.confirm !== input.backupId) {
throw new BadRequestException({ code: 'backup_restore_confirm_mismatch' });
}
if (this.maintenance.current()?.state === 'running' && this.maintenance.isActive()) {
throw new ConflictException({ code: 'backup_restore_running' });
}
if (input.source === 'remote') {
const target = await this.target.resolveTarget();
if (!target) throw new BadRequestException({ code: 'backup_remote_not_configured' });
const listed = await webdavList(target);
const exists =
listed.ok && listed.value.some((entry) => remoteBundleId(entry.name) === input.backupId);
if (!exists) throw new NotFoundException({ code: 'backup_set_not_found' });
} else {
const complete = this.localSets().some((set) => set.backupId === input.backupId);
if (!complete) throw new NotFoundException({ code: 'backup_set_not_found' });
}
await this.notify({
kind: 'restore',
source: input.source,
backupId: input.backupId,
requestedBy: actor.username,
});
await this.audit.record({
action: 'backup.restore_requested',
actorId: actor.id,
targetType: 'backup',
targetId: input.backupId,
details: { source: input.source },
});
}
private async notify(command: BackupCommand): Promise<void> {
await this.prisma
.$executeRaw`SELECT pg_notify(${BACKUP_COMMAND_CHANNEL}, ${JSON.stringify(command)})`;
}
/** Complete sets on the read-only backups mount, newest first. */
private localSets(): BackupSetView[] {
let names: string[];
try {
names = readdirSync(this.config.env.BACKUPS_DIR);
} catch {
return [];
}
return listSets(names)
.filter((set) => set.complete)
.map((set) => {
let sizeBytes: number | null = 0;
for (const file of [dumpFileName(set.id), archiveFileName(set.id)]) {
try {
sizeBytes = (sizeBytes ?? 0) + statSync(join(this.config.env.BACKUPS_DIR, file)).size;
} catch {
sizeBytes = null;
}
}
return this.setView(set.id, sizeBytes);
})
.sort((a, b) => b.backupId.localeCompare(a.backupId));
}
private setView(backupId: string, sizeBytes: number | null): BackupSetView {
return {
backupId,
startedAt: backupIdTime(backupId)?.toISOString() ?? new Date(0).toISOString(),
sizeBytes,
};
}
}