From eda41e51017d15386d95e75210cd593ac102f027 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 14 Nov 2025 13:12:39 +0100 Subject: [PATCH] Implement PBS authentication support for backup discovery - Add PBSStorageCredential model to database schema (fingerprint now required) - Create PBS credentials API router with CRUD operations - Add PBS login functionality to backup service before discovery - Create PBSCredentialsModal component for managing credentials - Integrate PBS credentials management into ServerStoragesModal - Update storage service to extract PBS IP and datastore info - Add helpful hint about finding fingerprint on PBS dashboard - Auto-accept fingerprint during login using stored credentials --- prisma/schema.prisma | 18 ++ src/app/_components/PBSCredentialsModal.tsx | 296 ++++++++++++++++++++ src/app/_components/ServerStoragesModal.tsx | 108 +++++-- src/server/api/root.ts | 2 + src/server/api/routers/pbsCredentials.ts | 153 ++++++++++ src/server/database-prisma.js | 56 ++++ src/server/database-prisma.ts | 63 +++++ src/server/services/backupService.ts | 91 ++++++ src/server/services/storageService.ts | 14 + 9 files changed, 772 insertions(+), 29 deletions(-) create mode 100644 src/app/_components/PBSCredentialsModal.tsx create mode 100644 src/server/api/routers/pbsCredentials.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30708b1..ec8a36e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,7 @@ model Server { key_generated Boolean? @default(false) installed_scripts InstalledScript[] backups Backup[] + pbs_credentials PBSStorageCredential[] @@map("servers") } @@ -115,3 +116,20 @@ model Backup { @@index([server_id]) @@map("backups") } + +model PBSStorageCredential { + id Int @id @default(autoincrement()) + server_id Int + storage_name String + pbs_ip String + pbs_datastore String + pbs_password String + pbs_fingerprint String + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + server Server @relation(fields: [server_id], references: [id], onDelete: Cascade) + + @@unique([server_id, storage_name]) + @@index([server_id]) + @@map("pbs_storage_credentials") +} diff --git a/src/app/_components/PBSCredentialsModal.tsx b/src/app/_components/PBSCredentialsModal.tsx new file mode 100644 index 0000000..e64e3e9 --- /dev/null +++ b/src/app/_components/PBSCredentialsModal.tsx @@ -0,0 +1,296 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from './ui/button'; +import { Lock, CheckCircle, AlertCircle } from 'lucide-react'; +import { useRegisterModal } from './modal/ModalStackProvider'; +import { api } from '~/trpc/react'; +import type { Storage } from '~/server/services/storageService'; + +interface PBSCredentialsModalProps { + isOpen: boolean; + onClose: () => void; + serverId: number; + serverName: string; + storage: Storage; +} + +export function PBSCredentialsModal({ + isOpen, + onClose, + serverId, + serverName, + storage +}: PBSCredentialsModalProps) { + const [pbsIp, setPbsIp] = useState(''); + const [pbsDatastore, setPbsDatastore] = useState(''); + const [pbsPassword, setPbsPassword] = useState(''); + const [pbsFingerprint, setPbsFingerprint] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // Extract PBS info from storage object + const pbsIpFromStorage = (storage as any).server || null; + const pbsDatastoreFromStorage = (storage as any).datastore || null; + + // Fetch existing credentials + const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery( + { serverId, storageName: storage.name }, + { enabled: isOpen } + ); + + // Initialize form with storage config values or existing credentials + useEffect(() => { + if (isOpen) { + if (credentialData?.success && credentialData.credential) { + // Load existing credentials + setPbsIp(credentialData.credential.pbs_ip); + setPbsDatastore(credentialData.credential.pbs_datastore); + setPbsPassword(''); // Don't show password + setPbsFingerprint(credentialData.credential.pbs_fingerprint || ''); + } else { + // Initialize with storage config values + setPbsIp(pbsIpFromStorage || ''); + setPbsDatastore(pbsDatastoreFromStorage || ''); + setPbsPassword(''); + setPbsFingerprint(''); + } + } + }, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]); + + const saveCredentials = api.pbsCredentials.saveCredentials.useMutation({ + onSuccess: () => { + void refetch(); + onClose(); + }, + onError: (error) => { + console.error('Failed to save PBS credentials:', error); + alert(`Failed to save credentials: ${error.message}`); + }, + }); + + const deleteCredentials = api.pbsCredentials.deleteCredentials.useMutation({ + onSuccess: () => { + void refetch(); + onClose(); + }, + onError: (error) => { + console.error('Failed to delete PBS credentials:', error); + alert(`Failed to delete credentials: ${error.message}`); + }, + }); + + useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!pbsIp || !pbsDatastore || !pbsFingerprint) { + alert('Please fill in all required fields (IP, Datastore, Fingerprint)'); + return; + } + + // Password is optional when updating existing credentials + setIsLoading(true); + try { + await saveCredentials.mutateAsync({ + serverId, + storageName: storage.name, + pbs_ip: pbsIp, + pbs_datastore: pbsDatastore, + pbs_password: pbsPassword || undefined, // Undefined means keep existing password + pbs_fingerprint: pbsFingerprint, + }); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async () => { + if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) { + return; + } + + setIsLoading(true); + try { + await deleteCredentials.mutateAsync({ + serverId, + storageName: storage.name, + }); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + const hasCredentials = credentialData?.success && credentialData.credential; + + return ( +
+
+ {/* Header */} +
+
+ +

+ PBS Credentials - {storage.name} +

+
+ +
+ + {/* Content */} +
+
+ {/* Storage Name (read-only) */} +
+ + +
+ + {/* PBS IP */} +
+ + setPbsIp(e.target.value)} + required + disabled={isLoading} + className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" + placeholder="e.g., 10.10.10.226" + /> +

+ IP address of the Proxmox Backup Server +

+
+ + {/* PBS Datastore */} +
+ + setPbsDatastore(e.target.value)} + required + disabled={isLoading} + className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" + placeholder="e.g., NAS03-ISCSI-BACKUP" + /> +

+ Name of the datastore on the PBS server +

+
+ + {/* PBS Password */} +
+ + setPbsPassword(e.target.value)} + required={!hasCredentials} + disabled={isLoading} + className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" + placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"} + /> +

+ Password for root@pam user on PBS server +

+
+ + {/* PBS Fingerprint */} +
+ + setPbsFingerprint(e.target.value)} + required + disabled={isLoading} + className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border" + placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02" + /> +

+ Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button. +

+
+ + {/* Status indicator */} + {hasCredentials && ( +
+ + + Credentials are configured for this storage + +
+ )} + + {/* Action Buttons */} +
+ {hasCredentials && ( + + )} + + +
+
+
+
+
+ ); +} + diff --git a/src/app/_components/ServerStoragesModal.tsx b/src/app/_components/ServerStoragesModal.tsx index ffc9316..60d51a2 100644 --- a/src/app/_components/ServerStoragesModal.tsx +++ b/src/app/_components/ServerStoragesModal.tsx @@ -2,9 +2,10 @@ import { useState, useEffect } from 'react'; import { Button } from './ui/button'; -import { Database, RefreshCw, CheckCircle } from 'lucide-react'; +import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react'; import { useRegisterModal } from './modal/ModalStackProvider'; import { api } from '~/trpc/react'; +import { PBSCredentialsModal } from './PBSCredentialsModal'; import type { Storage } from '~/server/services/storageService'; interface ServerStoragesModalProps { @@ -21,11 +22,25 @@ export function ServerStoragesModal({ serverName }: ServerStoragesModalProps) { const [forceRefresh, setForceRefresh] = useState(false); + const [selectedPBSStorage, setSelectedPBSStorage] = useState(null); const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery( { serverId, forceRefresh }, { enabled: isOpen } ); + + // Fetch all PBS credentials for this server to show status indicators + const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery( + { serverId }, + { enabled: isOpen } + ); + + const credentialsMap = new Map(); + if (allCredentials?.success) { + allCredentials.credentials.forEach(c => { + credentialsMap.set(c.storage_name, true); + }); + } useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose }); @@ -122,38 +137,62 @@ export function ServerStoragesModal({ : 'border-border bg-card' }`} > -
-
-
-

{storage.name}

- {isBackupCapable && ( +
+
+

{storage.name}

+ {isBackupCapable && ( + + + Backup + + )} + + {storage.type} + + {storage.type === 'pbs' && ( + credentialsMap.has(storage.name) ? ( - Backup + Credentials Configured - )} - - {storage.type} - -
-
-
- Content: {storage.content.join(', ')} -
- {storage.nodes && storage.nodes.length > 0 && ( -
- Nodes: {storage.nodes.join(', ')} -
- )} - {Object.entries(storage) - .filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key)) - .map(([key, value]) => ( -
- {key.replace(/_/g, ' ')}: {String(value)} -
- ))} -
+ ) : ( + + + Credentials Needed + + ) + )}
+
+
+ Content: {storage.content.join(', ')} +
+ {storage.nodes && storage.nodes.length > 0 && ( +
+ Nodes: {storage.nodes.join(', ')} +
+ )} + {Object.entries(storage) + .filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key)) + .map(([key, value]) => ( +
+ {key.replace(/_/g, ' ')}: {String(value)} +
+ ))} +
+ {storage.type === 'pbs' && ( +
+ +
+ )}
); @@ -171,6 +210,17 @@ export function ServerStoragesModal({ )}
+ + {/* PBS Credentials Modal */} + {selectedPBSStorage && ( + setSelectedPBSStorage(null)} + serverId={serverId} + serverName={serverName} + storage={selectedPBSStorage} + /> + )} ); } diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 34767e0..44200ca 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -3,6 +3,7 @@ import { installedScriptsRouter } from "~/server/api/routers/installedScripts"; import { serversRouter } from "~/server/api/routers/servers"; import { versionRouter } from "~/server/api/routers/version"; import { backupsRouter } from "~/server/api/routers/backups"; +import { pbsCredentialsRouter } from "~/server/api/routers/pbsCredentials"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; /** @@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({ servers: serversRouter, version: versionRouter, backups: backupsRouter, + pbsCredentials: pbsCredentialsRouter, }); // export type definition of API diff --git a/src/server/api/routers/pbsCredentials.ts b/src/server/api/routers/pbsCredentials.ts new file mode 100644 index 0000000..167b489 --- /dev/null +++ b/src/server/api/routers/pbsCredentials.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; +import { createTRPCRouter, publicProcedure } from '~/server/api/trpc'; +import { getDatabase } from '~/server/database-prisma'; + +export const pbsCredentialsRouter = createTRPCRouter({ + // Get credentials for a specific storage + getCredentialsForStorage: publicProcedure + .input(z.object({ + serverId: z.number(), + storageName: z.string(), + })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const credential = await db.getPBSCredential(input.serverId, input.storageName); + + if (!credential) { + return { + success: false, + error: 'PBS credentials not found', + credential: null, + }; + } + + return { + success: true, + credential: { + id: credential.id, + server_id: credential.server_id, + storage_name: credential.storage_name, + pbs_ip: credential.pbs_ip, + pbs_datastore: credential.pbs_datastore, + pbs_fingerprint: credential.pbs_fingerprint, + // Don't return password for security + }, + }; + } catch (error) { + console.error('Error in getCredentialsForStorage:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch PBS credentials', + credential: null, + }; + } + }), + + // Get all PBS credentials for a server + getAllCredentialsForServer: publicProcedure + .input(z.object({ + serverId: z.number(), + })) + .query(async ({ input }) => { + try { + const db = getDatabase(); + const credentials = await db.getPBSCredentialsByServer(input.serverId); + + return { + success: true, + credentials: credentials.map(c => ({ + id: c.id, + server_id: c.server_id, + storage_name: c.storage_name, + pbs_ip: c.pbs_ip, + pbs_datastore: c.pbs_datastore, + pbs_fingerprint: c.pbs_fingerprint, + // Don't return password for security + })), + }; + } catch (error) { + console.error('Error in getAllCredentialsForServer:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch PBS credentials', + credentials: [], + }; + } + }), + + // Save/update PBS credentials + saveCredentials: publicProcedure + .input(z.object({ + serverId: z.number(), + storageName: z.string(), + pbs_ip: z.string(), + pbs_datastore: z.string(), + pbs_password: z.string().optional(), // Optional to allow updating without changing password + pbs_fingerprint: z.string(), + })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + + // If password is not provided, fetch existing credential to preserve password + let passwordToSave = input.pbs_password; + if (!passwordToSave) { + const existing = await db.getPBSCredential(input.serverId, input.storageName); + if (existing) { + passwordToSave = existing.pbs_password; + } else { + return { + success: false, + error: 'Password is required for new credentials', + }; + } + } + + await db.createOrUpdatePBSCredential({ + server_id: input.serverId, + storage_name: input.storageName, + pbs_ip: input.pbs_ip, + pbs_datastore: input.pbs_datastore, + pbs_password: passwordToSave, + pbs_fingerprint: input.pbs_fingerprint, + }); + + return { + success: true, + message: 'PBS credentials saved successfully', + }; + } catch (error) { + console.error('Error in saveCredentials:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save PBS credentials', + }; + } + }), + + // Delete PBS credentials + deleteCredentials: publicProcedure + .input(z.object({ + serverId: z.number(), + storageName: z.string(), + })) + .mutation(async ({ input }) => { + try { + const db = getDatabase(); + await db.deletePBSCredential(input.serverId, input.storageName); + + return { + success: true, + message: 'PBS credentials deleted successfully', + }; + } catch (error) { + console.error('Error in deleteCredentials:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete PBS credentials', + }; + } + }), +}); + diff --git a/src/server/database-prisma.js b/src/server/database-prisma.js index 8fd760d..951765f 100644 --- a/src/server/database-prisma.js +++ b/src/server/database-prisma.js @@ -361,6 +361,62 @@ class DatabaseServicePrisma { return grouped; } + // PBS Credentials CRUD operations + async createOrUpdatePBSCredential(credentialData) { + return await prisma.pBSStorageCredential.upsert({ + where: { + server_id_storage_name: { + server_id: credentialData.server_id, + storage_name: credentialData.storage_name, + }, + }, + update: { + pbs_ip: credentialData.pbs_ip, + pbs_datastore: credentialData.pbs_datastore, + pbs_password: credentialData.pbs_password, + pbs_fingerprint: credentialData.pbs_fingerprint, + updated_at: new Date(), + }, + create: { + server_id: credentialData.server_id, + storage_name: credentialData.storage_name, + pbs_ip: credentialData.pbs_ip, + pbs_datastore: credentialData.pbs_datastore, + pbs_password: credentialData.pbs_password, + pbs_fingerprint: credentialData.pbs_fingerprint, + }, + }); + } + + async getPBSCredential(serverId, storageName) { + return await prisma.pBSStorageCredential.findUnique({ + where: { + server_id_storage_name: { + server_id: serverId, + storage_name: storageName, + }, + }, + }); + } + + async getPBSCredentialsByServer(serverId) { + return await prisma.pBSStorageCredential.findMany({ + where: { server_id: serverId }, + orderBy: { storage_name: 'asc' }, + }); + } + + async deletePBSCredential(serverId, storageName) { + return await prisma.pBSStorageCredential.delete({ + where: { + server_id_storage_name: { + server_id: serverId, + storage_name: storageName, + }, + }, + }); + } + async close() { await prisma.$disconnect(); } diff --git a/src/server/database-prisma.ts b/src/server/database-prisma.ts index 7fbcade..471f623 100644 --- a/src/server/database-prisma.ts +++ b/src/server/database-prisma.ts @@ -417,6 +417,69 @@ class DatabaseServicePrisma { return grouped; } + // PBS Credentials CRUD operations + async createOrUpdatePBSCredential(credentialData: { + server_id: number; + storage_name: string; + pbs_ip: string; + pbs_datastore: string; + pbs_password: string; + pbs_fingerprint: string; + }) { + return await prisma.pBSStorageCredential.upsert({ + where: { + server_id_storage_name: { + server_id: credentialData.server_id, + storage_name: credentialData.storage_name, + }, + }, + update: { + pbs_ip: credentialData.pbs_ip, + pbs_datastore: credentialData.pbs_datastore, + pbs_password: credentialData.pbs_password, + pbs_fingerprint: credentialData.pbs_fingerprint, + updated_at: new Date(), + }, + create: { + server_id: credentialData.server_id, + storage_name: credentialData.storage_name, + pbs_ip: credentialData.pbs_ip, + pbs_datastore: credentialData.pbs_datastore, + pbs_password: credentialData.pbs_password, + pbs_fingerprint: credentialData.pbs_fingerprint, + }, + }); + } + + async getPBSCredential(serverId: number, storageName: string) { + return await prisma.pBSStorageCredential.findUnique({ + where: { + server_id_storage_name: { + server_id: serverId, + storage_name: storageName, + }, + }, + }); + } + + async getPBSCredentialsByServer(serverId: number) { + return await prisma.pBSStorageCredential.findMany({ + where: { server_id: serverId }, + orderBy: { storage_name: 'asc' }, + }); + } + + async deletePBSCredential(serverId: number, storageName: string) { + return await prisma.pBSStorageCredential.delete({ + where: { + server_id_storage_name: { + server_id: serverId, + storage_name: storageName, + }, + }, + }); + } + async close() { await prisma.$disconnect(); } diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 6a41994..3382cfe 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -293,6 +293,90 @@ class BackupService { return backups; } + /** + * Login to PBS using stored credentials + */ + async loginToPBS(server: Server, storage: Storage): Promise { + const db = getDatabase(); + const credential = await db.getPBSCredential(server.id, storage.name); + + if (!credential) { + console.log(`[BackupService] No PBS credentials found for storage ${storage.name}, skipping PBS discovery`); + return false; + } + + const sshService = getSSHExecutionService(); + const storageService = getStorageService(); + const pbsInfo = storageService.getPBSStorageInfo(storage); + + // Use IP and datastore from credentials (they override config if different) + const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip; + const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore; + + if (!pbsIp || !pbsDatastore) { + console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`); + return false; + } + + // Build login command + // Format: proxmox-backup-client login --repository root@pam@: + const repository = `root@pam@${pbsIp}:${pbsDatastore}`; + + // Auto-accept fingerprint using echo "y" + // Provide password via stdin + // proxmox-backup-client accepts password via stdin + const fullCommand = `echo -e "y\\n${credential.pbs_password}" | timeout 10 proxmox-backup-client login --repository ${repository} 2>&1`; + + console.log(`[BackupService] Logging into PBS: ${repository}`); + + let loginOutput = ''; + let loginSuccess = false; + + try { + await Promise.race([ + new Promise((resolve) => { + sshService.executeCommand( + server, + fullCommand, + (data: string) => { + loginOutput += data; + }, + (error: string) => { + console.log(`[BackupService] PBS login error: ${error}`); + resolve(); + }, + (exitCode: number) => { + loginSuccess = exitCode === 0; + if (loginSuccess) { + console.log(`[BackupService] Successfully logged into PBS: ${repository}`); + } else { + console.log(`[BackupService] PBS login failed with exit code ${exitCode}`); + console.log(`[BackupService] Login output: ${loginOutput}`); + } + resolve(); + } + ); + }), + new Promise((resolve) => { + setTimeout(() => { + console.log(`[BackupService] PBS login timeout`); + resolve(); + }, 15000); // 15 second timeout + }) + ]); + + // Check if login was successful (look for success indicators in output) + if (loginSuccess || loginOutput.includes('successfully') || loginOutput.includes('logged in')) { + return true; + } + + return false; + } catch (error) { + console.error(`[BackupService] Error during PBS login:`, error); + return false; + } + } + /** * Discover PBS backups using proxmox-backup-client */ @@ -300,6 +384,13 @@ class BackupService { const sshService = getSSHExecutionService(); const backups: BackupData[] = []; + // Login to PBS first + const loggedIn = await this.loginToPBS(server, storage); + if (!loggedIn) { + console.log(`[BackupService] Failed to login to PBS for storage ${storage.name}, skipping backup discovery`); + return backups; + } + // Use storage name as repository name (e.g., "PBS1") const repositoryName = storage.name; const command = `timeout 30 proxmox-backup-client snapshots host/${ctId} --repository ${repositoryName} 2>&1 || echo "PBS_ERROR"`; diff --git a/src/server/services/storageService.ts b/src/server/services/storageService.ts index 4365759..7157c42 100644 --- a/src/server/services/storageService.ts +++ b/src/server/services/storageService.ts @@ -182,6 +182,20 @@ class StorageService { return allStorages.filter(s => s.supportsBackup); } + /** + * Get PBS storage information (IP and datastore) from storage config + */ + getPBSStorageInfo(storage: Storage): { pbs_ip: string | null; pbs_datastore: string | null } { + if (storage.type !== 'pbs') { + return { pbs_ip: null, pbs_datastore: null }; + } + + return { + pbs_ip: (storage as any).server || null, + pbs_datastore: (storage as any).datastore || null, + }; + } + /** * Clear cache for a specific server */