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 */}
+
+
+
+ );
+}
+
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
*/