feat(s3-storage): add backup metadata, upload verification, restore, and retention

This commit is contained in:
KodekaTeam committed 2026-07-15 12:07:55 +07:00
1 parent dbefb42725
commit 8470a68e78
12 files changed
+1278 -558

No files matched your search

@@ -0,0 +1,6 @@
ALTER TABLE "backups"
ADD COLUMN "storageBucket" TEXT,
ADD COLUMN "storageKey" TEXT,
ADD COLUMN "storageEtag" TEXT,
ADD COLUMN "storageDestinationId" TEXT;
@@ -0,0 +1,5 @@
ALTER TABLE "user_storage_destinations"
ADD COLUMN "retentionDays" INTEGER,
ADD COLUMN "maxBackupCount" INTEGER,
ADD COLUMN "objectPrefix" TEXT;
+8
View File
@@ -219,6 +219,9 @@ model UserStorageDestination {
bucket String bucket String
endpoint String? endpoint String?
additionalFlags String[] @default([]) additionalFlags String[] @default([])
retentionDays Int?
maxBackupCount Int?
objectPrefix String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -746,6 +749,10 @@ model Backup {
target String @default("Local") target String @default("Local")
sizeMb Float? sizeMb Float?
filePath String? filePath String?
storageBucket String?
storageKey String?
storageEtag String?
storageDestinationId String?
status BackupStatus @default(PENDING) status BackupStatus @default(PENDING)
error String? error String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -774,3 +781,4 @@ enum BackupStatus {
COMPLETED COMPLETED
FAILED FAILED
} }
File diff suppressed because it is too large. Load diff
+13
View File
@@ -48,6 +48,10 @@ function toDestinationInput(
additionalFlags: overrides?.additionalFlags ?? [ additionalFlags: overrides?.additionalFlags ?? [
...destination.additionalFlags, ...destination.additionalFlags,
], ],
retentionDays: overrides?.retentionDays ?? destination.retentionDays,
maxBackupCount:
overrides?.maxBackupCount ?? destination.maxBackupCount,
objectPrefix: overrides?.objectPrefix ?? destination.objectPrefix,
serverId: overrides?.serverId ?? destination.serverId, serverId: overrides?.serverId ?? destination.serverId,
}; };
} }
@@ -123,6 +127,10 @@ export default function S3StoragePage() {
!draft.region && "Region", !draft.region && "Region",
!draft.bucket && "Bucket", !draft.bucket && "Bucket",
!draft.name && "Destination Name", !draft.name && "Destination Name",
draft.provider !== "awsS3" && !draft.endpoint && "Endpoint",
(draft.retentionDays || draft.maxBackupCount) &&
!draft.objectPrefix &&
"Object Prefix",
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
if (missing.length > 0) { if (missing.length > 0) {
@@ -175,6 +183,10 @@ export default function S3StoragePage() {
!draft.region && "Region", !draft.region && "Region",
!draft.bucket && "Bucket", !draft.bucket && "Bucket",
!draft.name && "Destination Name", !draft.name && "Destination Name",
draft.provider !== "awsS3" && !draft.endpoint && "Endpoint",
(draft.retentionDays || draft.maxBackupCount) &&
!draft.objectPrefix &&
"Object Prefix",
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
if (missing.length > 0) { if (missing.length > 0) {
@@ -333,3 +345,4 @@ export default function S3StoragePage() {
</GuardedPage> </GuardedPage>
); );
} }
+22 -15
View File
@@ -1,5 +1,5 @@
/** /**
* API Client — DOKTAINER * API Client — DOKTAINER
* Centralized HTTP client with auth token injection * Centralized HTTP client with auth token injection
*/ */
@@ -130,7 +130,7 @@ function handleUnauthorizedSession(
throw new Error("Unauthorized - session expired"); throw new Error("Unauthorized - session expired");
} }
// ─── Token helpers ──────────────────────────────────────────────────────────── // ─── Token helpers ────────────────────────────────────────────────────────────
export function getToken(): string | null { export function getToken(): string | null {
return getSensitiveStorageItem(sensitiveStorageKeys.token); return getSensitiveStorageItem(sensitiveStorageKeys.token);
@@ -165,7 +165,7 @@ export function setUser(user: UserInfo): void {
emitAuthStateChanged(); emitAuthStateChanged();
} }
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export interface UserInfo { export interface UserInfo {
id: string; id: string;
@@ -457,6 +457,9 @@ export interface S3StorageDestinationRecord {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
additionalFlags: string[]; additionalFlags: string[];
retentionDays: number | null;
maxBackupCount: number | null;
objectPrefix: string;
serverId: string | null; serverId: string | null;
targetServer: S3StorageTargetServer | null; targetServer: S3StorageTargetServer | null;
createdAt: string; createdAt: string;
@@ -473,6 +476,9 @@ export interface S3StorageDestinationInput {
bucket: string; bucket: string;
endpoint?: string; endpoint?: string;
additionalFlags: string[]; additionalFlags: string[];
retentionDays?: number | null;
maxBackupCount?: number | null;
objectPrefix?: string;
serverId?: string | null; serverId?: string | null;
} }
@@ -622,7 +628,7 @@ async function readApiErrorMessage(
} }
} }
// ─── Core fetch wrapper ─────────────────────────────────────────────────────── // ─── Core fetch wrapper ───────────────────────────────────────────────────────
async function request<T>( async function request<T>(
path: string, path: string,
@@ -748,7 +754,7 @@ function del<T>(path: string, body?: unknown, config?: { timeoutMs?: number }) {
); );
} }
// ─── WebSocket helper ───────────────────────────────────────────────────────── // ─── WebSocket helper ─────────────────────────────────────────────────────────
export function createWs(path: string): WebSocket { export function createWs(path: string): WebSocket {
const token = getToken(); const token = getToken();
@@ -758,7 +764,7 @@ export function createWs(path: string): WebSocket {
); );
} }
// ─── Auth ───────────────────────────────────────────────────────────────────── // ─── Auth ─────────────────────────────────────────────────────────────────────
export const auth = { export const auth = {
registrationStatus: () => registrationStatus: () =>
@@ -852,7 +858,7 @@ export const auth = {
), ),
}; };
// ─── Users ─────────────────────────────────────────────────────────────────── // ─── Users ───────────────────────────────────────────────────────────────────
export type UserRole = "SUPER_ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER"; export type UserRole = "SUPER_ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER";
@@ -1238,7 +1244,7 @@ export const gitProvidersApi = {
), ),
}; };
// ─── Servers ────────────────────────────────────────────────────────────────── // ─── Servers ──────────────────────────────────────────────────────────────────
export interface Server { export interface Server {
id: string; id: string;
@@ -1530,7 +1536,7 @@ export const servers = {
), ),
}; };
// ─── Containers ─────────────────────────────────────────────────────────────── // ─── Containers ───────────────────────────────────────────────────────────────
export interface Container { export interface Container {
id: string; id: string;
@@ -1997,7 +2003,7 @@ export const containers = {
), ),
}; };
// ─── Metrics ────────────────────────────────────────────────────────────────── // ─── Metrics ──────────────────────────────────────────────────────────────────
export interface DashboardOverview { export interface DashboardOverview {
servers: { total: number; online: number }; servers: { total: number; online: number };
@@ -2053,7 +2059,7 @@ export const metrics = {
), ),
}; };
// ─── Domains ────────────────────────────────────────────────────────────────── // ─── Domains ──────────────────────────────────────────────────────────────────
export interface Domain { export interface Domain {
id: string; id: string;
@@ -2444,7 +2450,7 @@ export const security = {
), ),
}; };
// ─── Apps ──────────────────────────────────────────────────────────────────── // ─── Apps ────────────────────────────────────────────────────────────────────
export interface AppTemplate { export interface AppTemplate {
id: string; id: string;
@@ -2643,7 +2649,7 @@ export const apps = {
}), }),
}; };
// ─── Backups ────────────────────────────────────────────────────────────────── // ─── Backups ──────────────────────────────────────────────────────────────────
export interface Backup { export interface Backup {
id: string; id: string;
@@ -2744,7 +2750,7 @@ export const backups = {
delete: (id: string) => del<{ success: boolean }>(`/backups/${id}`), delete: (id: string) => del<{ success: boolean }>(`/backups/${id}`),
}; };
// ─── Logs ───────────────────────────────────────────────────────────────────── // ─── Logs ─────────────────────────────────────────────────────────────────────
export const logs = { export const logs = {
list: (params?: { list: (params?: {
@@ -2769,7 +2775,7 @@ export const logs = {
}, },
}; };
// ─── Terminal ───────────────────────────────────────────────────────────────── // ─── Terminal ─────────────────────────────────────────────────────────────────
export const terminal = { export const terminal = {
sessions: () => sessions: () =>
@@ -2841,3 +2847,4 @@ export const apiClient = {
terminal, terminal,
}; };
export default apiClient; export default apiClient;
+10 -7
View File
@@ -26,6 +26,7 @@ import { projectsRoutes } from "./routes/projects";
import { gitProviderRoutes } from "./routes/git-providers"; import { gitProviderRoutes } from "./routes/git-providers";
import { storageDestinationRoutes } from "./routes/storage-destinations"; import { storageDestinationRoutes } from "./routes/storage-destinations";
import { commitHistoryRoutes } from "./routes/commit-history"; import { commitHistoryRoutes } from "./routes/commit-history";
import { startS3StorageRetentionScheduler } from "./services/s3-storage-retention.service";
const PORT = parseInt(process.env.PORT || "4000"); const PORT = parseInt(process.env.PORT || "4000");
const HOST = process.env.HOST || "0.0.0.0"; const HOST = process.env.HOST || "0.0.0.0";
@@ -111,7 +112,7 @@ async function start() {
await ensureDatabaseConnection(); await ensureDatabaseConnection();
setDefaultSecurityHeaders(); setDefaultSecurityHeaders();
// ── Plugins ────────────────────────────────────────── // ── Plugins ──────────────────────────────────────────
await app.register(cors, { await app.register(cors, {
origin: getCorsOrigins(), origin: getCorsOrigins(),
credentials: true, credentials: true,
@@ -142,7 +143,7 @@ async function start() {
await app.register(websocket); await app.register(websocket);
// ── Health check ────────────────────────────────────── // ── Health check ──────────────────────────────────────
app.get("/health", { config: { rateLimit: false } }, async () => ({ app.get("/health", { config: { rateLimit: false } }, async () => ({
status: "ok", status: "ok",
uptime: process.uptime(), uptime: process.uptime(),
@@ -150,7 +151,7 @@ async function start() {
version: process.env.NEXT_PUBLIC_VERSION || "unknown", version: process.env.NEXT_PUBLIC_VERSION || "unknown",
})); }));
// ── API Routes ──────────────────────────────────────── // ── API Routes ────────────────────────────────────────
const API_PREFIX = "/api/v1"; const API_PREFIX = "/api/v1";
await app.register(authRoutes, { prefix: `${API_PREFIX}/auth` }); await app.register(authRoutes, { prefix: `${API_PREFIX}/auth` });
@@ -182,7 +183,7 @@ async function start() {
prefix: `${API_PREFIX}/notifications`, prefix: `${API_PREFIX}/notifications`,
}); });
// ── Error handler ───────────────────────────────────── // ── Error handler ─────────────────────────────────────
app.setErrorHandler((error: FastifyError, _req, reply) => { app.setErrorHandler((error: FastifyError, _req, reply) => {
app.log.error(error); app.log.error(error);
const code = error.statusCode || 500; const code = error.statusCode || 500;
@@ -193,13 +194,14 @@ async function start() {
}); });
}); });
// ── Start ───────────────────────────────────────────── // ── Start ─────────────────────────────────────────────
try { try {
await app.listen({ port: PORT, host: HOST }); await app.listen({ port: PORT, host: HOST });
startS3StorageRetentionScheduler();
console.log( console.log(
`\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`, `\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`,
); );
console.log(`📋 Health Check: http://localhost:${PORT}/health\n`); console.log(`📋 Health Check: http://localhost:${PORT}/health\n`);
} catch (err) { } catch (err) {
const error = err as NodeJS.ErrnoException; const error = err as NodeJS.ErrnoException;
if (error.code === "EADDRINUSE") { if (error.code === "EADDRINUSE") {
@@ -225,3 +227,4 @@ if (require.main === module) {
} }
export { app, start }; export { app, start };
+254 -73
View File
@@ -1,19 +1,31 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import {
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
type HeadObjectCommandOutput,
} from "@aws-sdk/client-s3";
import { mkdtemp, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { posix as pathPosix } from "path"; import { posix as pathPosix } from "path";
import { randomUUID } from "crypto";
import { gunzipSync } from "zlib"; import { gunzipSync } from "zlib";
import { z } from "zod"; import { z } from "zod";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { decrypt } from "../lib/crypto";
import { authenticate, requireApiKeyPermission } from "../middleware/auth"; import { authenticate, requireApiKeyPermission } from "../middleware/auth";
import { auditLog } from "../services/audit.service"; import { auditLog } from "../services/audit.service";
import { dispatchRuntimeNotification } from "../services/notification.service"; import { dispatchRuntimeNotification } from "../services/notification.service";
import {
buildS3ObjectKey,
createS3Client,
} from "../services/s3-storage.service";
import * as ssh from "../services/ssh.service"; import * as ssh from "../services/ssh.service";
const backupsAccess = [authenticate, requireApiKeyPermission("write:backups")]; const backupsAccess = [authenticate, requireApiKeyPermission("write:backups")];
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
/** /**
* Parse "du -sb <path>" output and return size in MB * Parse "du -sb <path>" output and return size in MB
@@ -47,6 +59,7 @@ const DATABASE_IMAGE_HINTS = [
"valkey", "valkey",
]; ];
const DOCKER_VOLUMES_BASE_PATH = "/var/lib/docker/volumes"; const DOCKER_VOLUMES_BASE_PATH = "/var/lib/docker/volumes";
const BACKUP_DIR = "/var/backups/vps-panel";
const DOCKER_VOLUME_LIST_TIMEOUT_MS = 10_000; const DOCKER_VOLUME_LIST_TIMEOUT_MS = 10_000;
function dockerVolumeListTimeout() { function dockerVolumeListTimeout() {
@@ -56,6 +69,10 @@ function dockerVolumeListTimeout() {
}; };
} }
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
type DatabaseEngine = "POSTGRESQL" | "MYSQL" | "MARIADB" | "MONGODB" | "REDIS"; type DatabaseEngine = "POSTGRESQL" | "MYSQL" | "MARIADB" | "MONGODB" | "REDIS";
type DatabaseBackupPlan = { type DatabaseBackupPlan = {
@@ -77,6 +94,7 @@ type StorageDestinationRecord = {
bucket: string; bucket: string;
endpoint: string | null; endpoint: string | null;
additionalFlags: string[]; additionalFlags: string[];
objectPrefix?: string | null;
}; };
const prismaStorage = prisma as typeof prisma & { const prismaStorage = prisma as typeof prisma & {
@@ -168,26 +186,6 @@ async function readRemoteFileBase64(
return Buffer.from(payload, "base64"); return Buffer.from(payload, "base64");
} }
function createS3Client(destination: StorageDestinationRecord) {
if (!destination.secretAccessKeyEnc) {
throw new Error("Storage destination secret key is missing");
}
return new S3Client({
region: destination.region ?? "auto",
endpoint: destination.endpoint || undefined,
forcePathStyle:
Boolean(destination.endpoint) ||
destination.additionalFlags.some((flag) =>
flag.toLowerCase().includes("forcepathstyle=true"),
),
credentials: {
accessKeyId: destination.accessKeyId,
secretAccessKey: decrypt(destination.secretAccessKeyEnc),
},
});
}
async function uploadBackupToS3(args: { async function uploadBackupToS3(args: {
server: Parameters<typeof ssh.exec>[0]; server: Parameters<typeof ssh.exec>[0];
filePath: string; filePath: string;
@@ -196,22 +194,119 @@ async function uploadBackupToS3(args: {
}) { }) {
const body = await readRemoteFileBase64(args.server, args.filePath); const body = await readRemoteFileBase64(args.server, args.filePath);
const client = createS3Client(args.destination); const client = createS3Client(args.destination);
const objectKey = buildS3ObjectKey(args.destination, args.filename);
await client.send( const result = await client.send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: args.destination.bucket, Bucket: args.destination.bucket,
Key: args.filename, Key: objectKey,
Body: body, Body: body,
ContentType: "application/gzip", ContentType: "application/gzip",
}), }),
); );
const verification = await verifyUploadedS3Object({
client,
bucket: args.destination.bucket,
key: objectKey,
expectedSize: body.length,
uploadedEtag: result.ETag,
});
return { return {
bucket: args.destination.bucket, bucket: args.destination.bucket,
key: args.filename, key: objectKey,
etag: verification.etag,
}; };
} }
async function verifyUploadedS3Object(args: {
client: S3Client;
bucket: string;
key: string;
expectedSize: number;
uploadedEtag?: string;
}) {
let metadata: HeadObjectCommandOutput;
try {
metadata = await args.client.send(
new HeadObjectCommand({ Bucket: args.bucket, Key: args.key }),
);
} catch (error) {
throw new Error(
`S3 object verification failed for ${args.bucket}/${args.key}: ${getErrorMessage(error)}`,
);
}
if (typeof metadata.ContentLength !== "number") {
throw new Error(
`S3 object verification failed for ${args.bucket}/${args.key}: object size is unavailable`,
);
}
if (metadata.ContentLength !== args.expectedSize) {
throw new Error(
`S3 object verification failed for ${args.bucket}/${args.key}: expected ${args.expectedSize} bytes, received ${metadata.ContentLength} bytes`,
);
}
return {
etag: metadata.ETag ?? args.uploadedEtag ?? null,
size: metadata.ContentLength,
};
}
async function downloadBackupFromS3(
destination: StorageDestinationRecord,
bucket: string,
key: string,
) {
const response = await createS3Client(destination).send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
);
if (!response.Body) {
throw new Error("S3 object was returned without a body");
}
return Buffer.from(await response.Body.transformToByteArray());
}
async function writeRemoteFileFromBuffer(
server: Parameters<typeof ssh.exec>[0],
filePath: string,
content: Buffer,
) {
const tempDirectory = await mkdtemp(`${tmpdir()}/doktainer-restore-`);
const localPath = `${tempDirectory}/${randomUUID()}.backup`;
try {
await writeFile(localPath, content);
await ssh.execStrict(
server,
`mkdir -p ${escapeShellArg(pathPosix.dirname(filePath))}`,
);
await ssh.withIsolatedConnection(server, (connection) =>
connection.putFile(localPath, filePath),
);
} finally {
await rm(tempDirectory, { recursive: true, force: true });
}
}
async function removeRemoteBackupFile(
server: Parameters<typeof ssh.exec>[0],
filePath: string,
) {
try {
await ssh.execStrict(server, `rm -f ${escapeShellArg(filePath)}`);
} catch {
// The S3 copy is already durable; cleanup failure must not turn a
// successful upload into a failed backup.
}
}
function getBackupBaseName(name: string) { function getBackupBaseName(name: string) {
return name.replace(/[^a-zA-Z0-9_-]/g, "_") || "backup"; return name.replace(/[^a-zA-Z0-9_-]/g, "_") || "backup";
} }
@@ -455,7 +550,7 @@ function buildDatabaseBackupCommand(args: {
].join(" && "); ].join(" && ");
} }
// ─── Routes ────────────────────────────────────────────────────────────────── // ─── Routes ──────────────────────────────────────────────────────────────────
export async function backupsRoutes(app: FastifyInstance) { export async function backupsRoutes(app: FastifyInstance) {
app.get("/options", { preHandler: backupsAccess }, async (req, reply) => { app.get("/options", { preHandler: backupsAccess }, async (req, reply) => {
@@ -545,7 +640,7 @@ export async function backupsRoutes(app: FastifyInstance) {
}); });
}); });
// GET /backups — list backups (optionally filter by serverId) // GET /backups — list backups (optionally filter by serverId)
app.get("/", { preHandler: backupsAccess }, async (req, reply) => { app.get("/", { preHandler: backupsAccess }, async (req, reply) => {
const { serverId } = req.query as { serverId?: string }; const { serverId } = req.query as { serverId?: string };
@@ -605,20 +700,47 @@ export async function backupsRoutes(app: FastifyInstance) {
.send({ success: false, error: "Backup not found" }); .send({ success: false, error: "Backup not found" });
} }
if (!backup.filePath) {
return reply
.status(400)
.send({ success: false, error: "Backup has no file path recorded" });
}
try { try {
const content = await readRemoteFileBase64( let content: Buffer;
backup.server,
backup.filePath, if (backup.storageBucket && backup.storageKey) {
); const destination = backup.storageDestinationId
? await prismaStorage.userStorageDestination.findFirst({
where: {
id: backup.storageDestinationId,
userId: req.userId!,
organizationId: req.organizationId!,
},
})
: null;
if (!destination) {
return reply.status(400).send({
success: false,
error: "The S3 destination for this backup is no longer available",
});
}
content = await downloadBackupFromS3(
destination,
backup.storageBucket,
backup.storageKey,
);
} else {
if (!backup.filePath) {
return reply.status(400).send({
success: false,
error: "Backup has no local file or S3 object recorded",
});
}
content = await readRemoteFileBase64(backup.server, backup.filePath);
}
let payload = content; let payload = content;
let fileName = let fileName =
backup.filePath.split("/").pop() || backup.storageKey?.split("/").pop() ||
backup.filePath?.split("/").pop() ||
getDownloadArchiveName(backup.name, ".bak"); getDownloadArchiveName(backup.name, ".bak");
let contentType = "application/octet-stream"; let contentType = "application/octet-stream";
@@ -689,7 +811,7 @@ export async function backupsRoutes(app: FastifyInstance) {
}, },
); );
// POST /backups — trigger a new backup // POST /backups — trigger a new backup
app.post("/", { preHandler: backupsAccess }, async (req, reply) => { app.post("/", { preHandler: backupsAccess }, async (req, reply) => {
const body = z const body = z
.object({ .object({
@@ -790,6 +912,7 @@ export async function backupsRoutes(app: FastifyInstance) {
? `S3: ${storageDestination.name}` ? `S3: ${storageDestination.name}`
: body.data.target, : body.data.target,
filePath, filePath,
storageDestinationId: storageDestination?.id ?? null,
status: "RUNNING", status: "RUNNING",
}, },
}); });
@@ -858,7 +981,7 @@ export async function backupsRoutes(app: FastifyInstance) {
} }
case "FULL": case "FULL":
default: { default: {
// Backup common paths: /etc, /var/www, /home, /opt — skip /proc /sys /dev // Backup common paths: /etc, /var/www, /home, /opt — skip /proc /sys /dev
backupCmd = `tar czf ${filePath} --exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run --exclude=/tmp --one-file-system /etc /var/www /home /opt 2>/dev/null || true`; backupCmd = `tar czf ${filePath} --exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run --exclude=/tmp --one-file-system /etc /var/www /home /opt 2>/dev/null || true`;
break; break;
} }
@@ -866,21 +989,7 @@ export async function backupsRoutes(app: FastifyInstance) {
await ssh.execStrict(server, backupCmd); await ssh.execStrict(server, backupCmd);
let uploadedObject: { // Capture the local size before an S3-targeted backup is cleaned up.
bucket: string;
key: string;
} | null = null;
if (storageDestination) {
uploadedObject = await uploadBackupToS3({
server,
filePath,
filename,
destination: storageDestination,
});
}
// Get file size
const sizeOut = await ssh.execStrict( const sizeOut = await ssh.execStrict(
server, server,
`test -s ${escapeShellArg(filePath)} && wc -c < ${escapeShellArg(filePath)}`, `test -s ${escapeShellArg(filePath)} && wc -c < ${escapeShellArg(filePath)}`,
@@ -891,9 +1000,31 @@ export async function backupsRoutes(app: FastifyInstance) {
throw new Error("Backup file was created but is empty"); throw new Error("Backup file was created but is empty");
} }
let uploadedObject: {
bucket: string;
key: string;
etag: string | null;
} | null = null;
if (storageDestination) {
uploadedObject = await uploadBackupToS3({
server,
filePath,
filename,
destination: storageDestination,
});
await removeRemoteBackupFile(server, filePath);
}
await prisma.backup.update({ await prisma.backup.update({
where: { id: backup.id }, where: { id: backup.id },
data: { status: "COMPLETED", sizeMb }, data: {
status: "COMPLETED",
sizeMb,
storageBucket: uploadedObject?.bucket ?? null,
storageKey: uploadedObject?.key ?? null,
storageEtag: uploadedObject?.etag ?? null,
},
}); });
await auditLog({ await auditLog({
@@ -940,13 +1071,15 @@ export async function backupsRoutes(app: FastifyInstance) {
filePath, filePath,
storageBucket: uploadedObject?.bucket, storageBucket: uploadedObject?.bucket,
storageKey: uploadedObject?.key, storageKey: uploadedObject?.key,
storageEtag: uploadedObject?.etag,
}, },
}); });
} }
} catch (err: any) { } catch (err: unknown) {
const errorMessage = getErrorMessage(err);
await prisma.backup.update({ await prisma.backup.update({
where: { id: backup.id }, where: { id: backup.id },
data: { status: "FAILED", error: err.message }, data: { status: "FAILED", error: errorMessage },
}); });
await auditLog({ await auditLog({
@@ -956,7 +1089,7 @@ export async function backupsRoutes(app: FastifyInstance) {
action: "BACKUP_FAILED", action: "BACKUP_FAILED",
category: "SYSTEM", category: "SYSTEM",
level: "ERROR", level: "ERROR",
message: `Backup "${body.data.name}" failed on "${server.name}": ${err.message}`, message: `Backup "${body.data.name}" failed on "${server.name}": ${errorMessage}`,
}); });
if (req.organizationId) { if (req.organizationId) {
@@ -990,7 +1123,7 @@ export async function backupsRoutes(app: FastifyInstance) {
serverName: server.name, serverName: server.name,
serverIp: server.ip, serverIp: server.ip,
filePath, filePath,
error: err.message, error: errorMessage,
}, },
}); });
} }
@@ -1004,7 +1137,7 @@ export async function backupsRoutes(app: FastifyInstance) {
}); });
}); });
// POST /backups/:id/restore — trigger restore from backup file // POST /backups/:id/restore — trigger restore from backup file
app.post( app.post(
"/:id/restore", "/:id/restore",
{ preHandler: backupsAccess }, { preHandler: backupsAccess },
@@ -1025,30 +1158,72 @@ export async function backupsRoutes(app: FastifyInstance) {
error: "Only completed backups can be restored", error: "Only completed backups can be restored",
}); });
} }
if (!backup.filePath) { if (!backup.filePath && !(backup.storageBucket && backup.storageKey)) {
return reply return reply
.status(400) .status(400)
.send({ success: false, error: "Backup has no file path recorded" }); .send({
success: false,
error: "Backup has no local file or S3 object recorded",
});
} }
// Restore in background // Restore in background
setImmediate(async () => { setImmediate(async () => {
let hydratedFilePath: string | null = null;
try { try {
let restoreFilePath = backup.filePath;
if (backup.storageBucket && backup.storageKey) {
const destination = backup.storageDestinationId
? await prismaStorage.userStorageDestination.findFirst({
where: {
id: backup.storageDestinationId,
userId: req.userId!,
organizationId: req.organizationId!,
},
})
: null;
if (!destination) {
throw new Error(
"The S3 destination for this backup is no longer available",
);
}
restoreFilePath = `${BACKUP_DIR}/.doktainer-restore-${backup.id}.${
backup.type === "DATABASE" ? "sql.gz" : "tar.gz"
}`;
await writeRemoteFileFromBuffer(
backup.server,
restoreFilePath,
await downloadBackupFromS3(
destination,
backup.storageBucket,
backup.storageKey,
),
);
hydratedFilePath = restoreFilePath;
}
if (!restoreFilePath) {
throw new Error("Backup restore path is unavailable");
}
let restoreCmd: string; let restoreCmd: string;
switch (backup.type) { switch (backup.type) {
case "FULL": case "FULL":
case "VOLUME": case "VOLUME":
restoreCmd = `tar xzf ${backup.filePath} -C / 2>/dev/null || true`; restoreCmd = `tar xzf ${escapeShellArg(restoreFilePath)} -C / 2>/dev/null`;
break; break;
case "DATABASE": case "DATABASE":
default: default:
// Pipe unzipped dump back to psql — DBA should supervise this // Pipe unzipped dump back to psql — DBA should supervise this
restoreCmd = `zcat ${backup.filePath} | psql -U postgres 2>/dev/null || zcat ${backup.filePath} | mysql -u root 2>/dev/null || true`; restoreCmd = `zcat ${escapeShellArg(restoreFilePath)} | psql -U postgres 2>/dev/null || zcat ${escapeShellArg(restoreFilePath)} | mysql -u root 2>/dev/null`;
break; break;
} }
await ssh.exec(backup.server, restoreCmd); await ssh.execStrict(backup.server, restoreCmd);
await auditLog({ await auditLog({
userId: req.userId, userId: req.userId,
@@ -1059,7 +1234,8 @@ export async function backupsRoutes(app: FastifyInstance) {
level: "WARNING", level: "WARNING",
message: `Backup "${backup.name}" restored on "${backup.server.name}"`, message: `Backup "${backup.name}" restored on "${backup.server.name}"`,
}); });
} catch (err: any) { } catch (err: unknown) {
const errorMessage = getErrorMessage(err);
await auditLog({ await auditLog({
userId: req.userId, userId: req.userId,
organizationId: req.organizationId, organizationId: req.organizationId,
@@ -1067,8 +1243,12 @@ export async function backupsRoutes(app: FastifyInstance) {
action: "BACKUP_RESTORE_FAILED", action: "BACKUP_RESTORE_FAILED",
category: "SYSTEM", category: "SYSTEM",
level: "ERROR", level: "ERROR",
message: `Backup restore failed for "${backup.name}" on "${backup.server.name}": ${err.message}`, message: `Backup restore failed for "${backup.name}" on "${backup.server.name}": ${errorMessage}`,
}); });
} finally {
if (hydratedFilePath) {
await removeRemoteBackupFile(backup.server, hydratedFilePath);
}
} }
}); });
@@ -1076,7 +1256,7 @@ export async function backupsRoutes(app: FastifyInstance) {
}, },
); );
// DELETE /backups/:id — delete backup record and file on server // DELETE /backups/:id — delete backup record and file on server
app.delete("/:id", { preHandler: backupsAccess }, async (req, reply) => { app.delete("/:id", { preHandler: backupsAccess }, async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
@@ -1094,7 +1274,7 @@ export async function backupsRoutes(app: FastifyInstance) {
try { try {
await ssh.exec(backup.server, `rm -f ${backup.filePath}`); await ssh.exec(backup.server, `rm -f ${backup.filePath}`);
} catch { } catch {
// Ignore SSH errors — still delete the DB record // Ignore SSH errors — still delete the DB record
} }
} }
@@ -1113,7 +1293,7 @@ export async function backupsRoutes(app: FastifyInstance) {
return reply.send({ success: true, message: "Backup deleted" }); return reply.send({ success: true, message: "Backup deleted" });
}); });
// GET /backups/stats — aggregate stats for dashboard // GET /backups/stats — aggregate stats for dashboard
app.get("/stats", { preHandler: backupsAccess }, async (req, reply) => { app.get("/stats", { preHandler: backupsAccess }, async (req, reply) => {
const [total, completed, running, failed] = await Promise.all([ const [total, completed, running, failed] = await Promise.all([
prisma.backup.count({ prisma.backup.count({
@@ -1145,3 +1325,4 @@ export async function backupsRoutes(app: FastifyInstance) {
}); });
}); });
} }
+29
View File
@@ -54,6 +54,9 @@ const StorageDestinationSchema = z.object({
bucket: z.string().trim().min(1).max(128), bucket: z.string().trim().min(1).max(128),
endpoint: z.string().trim().url().max(2048).optional().or(z.literal("")), endpoint: z.string().trim().url().max(2048).optional().or(z.literal("")),
additionalFlags: z.array(z.string().trim().min(1).max(255)).max(12), additionalFlags: z.array(z.string().trim().min(1).max(255)).max(12),
retentionDays: z.number().int().min(1).max(3650).nullable().optional(),
maxBackupCount: z.number().int().min(1).max(10000).nullable().optional(),
objectPrefix: z.string().trim().max(255).optional().or(z.literal("")),
serverId: z.string().trim().max(64).optional().or(z.literal("")), serverId: z.string().trim().max(64).optional().or(z.literal("")),
}); });
@@ -78,6 +81,9 @@ type StorageDestinationRecord = {
bucket: string; bucket: string;
endpoint: string | null; endpoint: string | null;
additionalFlags: string[]; additionalFlags: string[];
retentionDays: number | null;
maxBackupCount: number | null;
objectPrefix: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
server?: { server?: {
@@ -114,6 +120,9 @@ function serializeDestination(destination: StorageDestinationRecord) {
bucket: destination.bucket, bucket: destination.bucket,
endpoint: destination.endpoint ?? "", endpoint: destination.endpoint ?? "",
additionalFlags: destination.additionalFlags, additionalFlags: destination.additionalFlags,
retentionDays: destination.retentionDays,
maxBackupCount: destination.maxBackupCount,
objectPrefix: destination.objectPrefix ?? "",
serverId: destination.serverId, serverId: destination.serverId,
targetServer: destination.server ?? null, targetServer: destination.server ?? null,
createdAt: destination.createdAt.toISOString(), createdAt: destination.createdAt.toISOString(),
@@ -133,6 +142,10 @@ function validateDestinationInput(
return "Secret access key is required"; return "Secret access key is required";
} }
if ((input.retentionDays || input.maxBackupCount) && !toTrimmedValue(input.objectPrefix)) {
return "Object prefix is required when retention or maximum backup count is enabled";
}
return null; return null;
} }
@@ -172,6 +185,9 @@ function buildDestinationWriteData(
region: toTrimmedValue(input.region), region: toTrimmedValue(input.region),
bucket: toTrimmedValue(input.bucket), bucket: toTrimmedValue(input.bucket),
endpoint: toTrimmedValue(input.endpoint) || null, endpoint: toTrimmedValue(input.endpoint) || null,
retentionDays: input.retentionDays ?? null,
maxBackupCount: input.maxBackupCount ?? null,
objectPrefix: toTrimmedValue(input.objectPrefix) || null,
additionalFlags: [ additionalFlags: [
...new Set( ...new Set(
input.additionalFlags.map((entry) => entry.trim()).filter(Boolean), input.additionalFlags.map((entry) => entry.trim()).filter(Boolean),
@@ -201,6 +217,9 @@ async function getDestinationById(
bucket: true, bucket: true,
endpoint: true, endpoint: true,
additionalFlags: true, additionalFlags: true,
retentionDays: true,
maxBackupCount: true,
objectPrefix: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
server: { server: {
@@ -244,6 +263,9 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
bucket: true, bucket: true,
endpoint: true, endpoint: true,
additionalFlags: true, additionalFlags: true,
retentionDays: true,
maxBackupCount: true,
objectPrefix: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
server: { server: {
@@ -322,6 +344,9 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
bucket: true, bucket: true,
endpoint: true, endpoint: true,
additionalFlags: true, additionalFlags: true,
retentionDays: true,
maxBackupCount: true,
objectPrefix: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
server: { server: {
@@ -442,6 +467,9 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
bucket: true, bucket: true,
endpoint: true, endpoint: true,
additionalFlags: true, additionalFlags: true,
retentionDays: true,
maxBackupCount: true,
objectPrefix: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
server: { server: {
@@ -616,3 +644,4 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
}, },
); );
} }
@@ -0,0 +1,137 @@
import prisma from "../lib/prisma";
import { deleteS3Objects, listS3Objects } from "./s3-storage.service";
const RETENTION_INTERVAL_MS = 15 * 60 * 1000;
type RetentionDestination = {
id: string;
userId: string;
organizationId: string;
name: string;
bucket: string;
endpoint: string | null;
region: string | null;
accessKeyId: string;
secretAccessKeyEnc: string | null;
additionalFlags: string[];
retentionDays: number | null;
maxBackupCount: number | null;
objectPrefix: string | null;
};
const prismaStorage = prisma as typeof prisma & {
userStorageDestination: {
findMany: (args: unknown) => Promise<RetentionDestination[]>;
};
};
let cleanupInProgress = false;
export function getExpiredObjectKeys(
objects: Array<{ key: string; lastModified: Date | null; size: number }>,
destination: RetentionDestination,
now = Date.now(),
) {
const keys = new Set<string>();
if (destination.retentionDays) {
const cutoff = now - destination.retentionDays * 24 * 60 * 60 * 1000;
for (const object of objects) {
if (object.lastModified && object.lastModified.getTime() < cutoff) {
keys.add(object.key);
}
}
}
if (destination.maxBackupCount) {
const remaining = objects
.filter((object) => !keys.has(object.key))
.sort(
(left, right) =>
(right.lastModified?.getTime() ?? 0) -
(left.lastModified?.getTime() ?? 0),
);
for (const object of remaining.slice(destination.maxBackupCount)) {
keys.add(object.key);
}
}
return [...keys];
}
async function cleanupDestination(destination: RetentionDestination) {
if (
!destination.objectPrefix ||
(!destination.retentionDays && !destination.maxBackupCount)
) {
return 0;
}
const objects = await listS3Objects(destination);
const keysToDelete = getExpiredObjectKeys(objects, destination);
if (keysToDelete.length === 0) return 0;
await deleteS3Objects(destination, keysToDelete);
await prisma.backup.updateMany({
where: {
storageDestinationId: destination.id,
storageKey: { in: keysToDelete },
},
data: {
storageBucket: null,
storageKey: null,
storageEtag: null,
error: "S3 object removed by retention policy",
},
});
return keysToDelete.length;
}
export async function runS3StorageRetentionCleanup() {
if (cleanupInProgress) return { skipped: true, deletedObjects: 0 };
cleanupInProgress = true;
try {
const destinations = await prismaStorage.userStorageDestination.findMany({
where: {
enabled: true,
objectPrefix: { not: null },
OR: [
{ retentionDays: { not: null } },
{ maxBackupCount: { not: null } },
],
},
});
let deletedObjects = 0;
for (const destination of destinations) {
try {
deletedObjects += await cleanupDestination(destination);
} catch (error) {
console.error(
`[S3 retention] Failed for destination ${destination.id}:`,
error,
);
}
}
return { skipped: false, deletedObjects };
} finally {
cleanupInProgress = false;
}
}
export function startS3StorageRetentionScheduler() {
const timer = setInterval(() => {
void runS3StorageRetentionCleanup();
}, RETENTION_INTERVAL_MS);
timer.unref?.();
void runS3StorageRetentionCleanup();
return () => clearInterval(timer);
}
+124
View File
@@ -0,0 +1,124 @@
import {
DeleteObjectsCommand,
ListObjectsV2Command,
S3Client,
type ObjectIdentifier,
} from "@aws-sdk/client-s3";
import { decrypt } from "../lib/crypto";
export interface S3StorageClientDestination {
accessKeyId: string;
secretAccessKeyEnc: string | null;
region: string | null;
endpoint: string | null;
additionalFlags: string[];
bucket: string;
objectPrefix?: string | null;
}
export function createS3Client(destination: S3StorageClientDestination) {
if (!destination.secretAccessKeyEnc) {
throw new Error("Storage destination secret key is missing");
}
return new S3Client({
region: destination.region ?? "auto",
endpoint: destination.endpoint || undefined,
forcePathStyle:
Boolean(destination.endpoint) ||
destination.additionalFlags.some((flag) =>
flag.toLowerCase().includes("forcepathstyle=true"),
),
credentials: {
accessKeyId: destination.accessKeyId,
secretAccessKey: decrypt(destination.secretAccessKeyEnc),
},
});
}
export function normalizeObjectPrefix(prefix: string | null | undefined) {
return (prefix ?? "")
.trim()
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, "")
.split("/")
.filter((segment) => segment && segment !== "." && segment !== "..")
.join("/");
}
export function buildS3ObjectKey(
destination: Pick<S3StorageClientDestination, "objectPrefix">,
filename: string,
) {
const prefix = normalizeObjectPrefix(destination.objectPrefix);
return prefix ? `${prefix}/${filename}` : filename;
}
export async function listS3Objects(
destination: S3StorageClientDestination,
) {
const client = createS3Client(destination);
const prefix = normalizeObjectPrefix(destination.objectPrefix);
const objects: Array<{
key: string;
lastModified: Date | null;
size: number;
}> = [];
let continuationToken: string | undefined;
do {
const response = await client.send(
new ListObjectsV2Command({
Bucket: destination.bucket,
Prefix: prefix ? `${prefix}/` : undefined,
ContinuationToken: continuationToken,
}),
);
for (const object of response.Contents ?? []) {
if (!object.Key) continue;
objects.push({
key: object.Key,
lastModified: object.LastModified ?? null,
size: object.Size ?? 0,
});
}
continuationToken = response.IsTruncated
? response.NextContinuationToken
: undefined;
} while (continuationToken);
return objects;
}
export async function deleteS3Objects(
destination: S3StorageClientDestination,
keys: string[],
) {
if (keys.length === 0) return;
const client = createS3Client(destination);
for (let offset = 0; offset < keys.length; offset += 1000) {
const identifiers: ObjectIdentifier[] = keys
.slice(offset, offset + 1000)
.map((key) => ({ Key: key }));
const response = await client.send(
new DeleteObjectsCommand({
Bucket: destination.bucket,
Delete: { Objects: identifiers, Quiet: true },
}),
);
if (response.Errors && response.Errors.length > 0) {
const failedKeys = response.Errors.map((error) => error.Key).filter(
(key): key is string => Boolean(key),
);
throw new Error(
`Failed to delete ${failedKeys.length || response.Errors.length} S3 object(s): ${failedKeys.join(", ")}`,
);
}
}
}
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getExpiredObjectKeys } from "../../src/server/services/s3-storage-retention.service";
const now = Date.parse("2026-07-15T00:00:00.000Z");
function destination(overrides: Record<string, unknown> = {}) {
return {
id: "destination-1",
userId: "user-1",
organizationId: "org-1",
name: "Backups",
bucket: "bucket",
endpoint: null,
region: "ap-southeast-1",
accessKeyId: "access-key",
secretAccessKeyEnc: "encrypted",
additionalFlags: [],
retentionDays: null,
maxBackupCount: null,
objectPrefix: "doktainer/backups",
...overrides,
};
}
test("retention removes objects older than the configured number of days", () => {
const keys = getExpiredObjectKeys(
[
{
key: "old.tar.gz",
lastModified: new Date("2026-07-01T00:00:00.000Z"),
size: 10,
},
{
key: "recent.tar.gz",
lastModified: new Date("2026-07-14T00:00:00.000Z"),
size: 10,
},
],
destination({ retentionDays: 7 }),
now,
);
assert.deepEqual(keys, ["old.tar.gz"]);
});
test("maximum backup count keeps the newest objects", () => {
const keys = getExpiredObjectKeys(
[
{
key: "oldest.tar.gz",
lastModified: new Date("2026-07-10T00:00:00.000Z"),
size: 10,
},
{
key: "newest.tar.gz",
lastModified: new Date("2026-07-14T00:00:00.000Z"),
size: 10,
},
{
key: "middle.tar.gz",
lastModified: new Date("2026-07-12T00:00:00.000Z"),
size: 10,
},
],
destination({ maxBackupCount: 2 }),
now,
);
assert.deepEqual(keys, ["oldest.tar.gz"]);
});