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
This commit is contained in:
@@ -42,6 +42,7 @@ model Server {
|
|||||||
key_generated Boolean? @default(false)
|
key_generated Boolean? @default(false)
|
||||||
installed_scripts InstalledScript[]
|
installed_scripts InstalledScript[]
|
||||||
backups Backup[]
|
backups Backup[]
|
||||||
|
pbs_credentials PBSStorageCredential[]
|
||||||
|
|
||||||
@@map("servers")
|
@@map("servers")
|
||||||
}
|
}
|
||||||
@@ -115,3 +116,20 @@ model Backup {
|
|||||||
@@index([server_id])
|
@@index([server_id])
|
||||||
@@map("backups")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
296
src/app/_components/PBSCredentialsModal.tsx
Normal file
296
src/app/_components/PBSCredentialsModal.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Lock className="h-6 w-6 text-primary" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">
|
||||||
|
PBS Credentials - {storage.name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Storage Name (read-only) */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Storage Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="storage-name"
|
||||||
|
value={storage.name}
|
||||||
|
disabled
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PBS IP */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
PBS Server IP <span className="text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="pbs-ip"
|
||||||
|
value={pbsIp}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
IP address of the Proxmox Backup Server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PBS Datastore */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
PBS Datastore <span className="text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="pbs-datastore"
|
||||||
|
value={pbsDatastore}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Name of the datastore on the PBS server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PBS Password */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Password {!hasCredentials && <span className="text-error">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="pbs-password"
|
||||||
|
value={pbsPassword}
|
||||||
|
onChange={(e) => 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"}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Password for root@pam user on PBS server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PBS Fingerprint */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Fingerprint <span className="text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="pbs-fingerprint"
|
||||||
|
value={pbsFingerprint}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
{hasCredentials && (
|
||||||
|
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-success" />
|
||||||
|
<span className="text-sm text-success font-medium">
|
||||||
|
Credentials are configured for this storage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
|
||||||
|
{hasCredentials && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full sm:w-auto order-3"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-4 w-4 mr-2" />
|
||||||
|
Delete Credentials
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full sm:w-auto order-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full sm:w-auto order-1"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from './ui/button';
|
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 { useRegisterModal } from './modal/ModalStackProvider';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
|
import { PBSCredentialsModal } from './PBSCredentialsModal';
|
||||||
import type { Storage } from '~/server/services/storageService';
|
import type { Storage } from '~/server/services/storageService';
|
||||||
|
|
||||||
interface ServerStoragesModalProps {
|
interface ServerStoragesModalProps {
|
||||||
@@ -21,11 +22,25 @@ export function ServerStoragesModal({
|
|||||||
serverName
|
serverName
|
||||||
}: ServerStoragesModalProps) {
|
}: ServerStoragesModalProps) {
|
||||||
const [forceRefresh, setForceRefresh] = useState(false);
|
const [forceRefresh, setForceRefresh] = useState(false);
|
||||||
|
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
|
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
|
||||||
{ serverId, forceRefresh },
|
{ serverId, forceRefresh },
|
||||||
{ enabled: isOpen }
|
{ 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<string, boolean>();
|
||||||
|
if (allCredentials?.success) {
|
||||||
|
allCredentials.credentials.forEach(c => {
|
||||||
|
credentialsMap.set(c.storage_name, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
|
useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
|
||||||
|
|
||||||
@@ -122,38 +137,62 @@ export function ServerStoragesModal({
|
|||||||
: 'border-border bg-card'
|
: 'border-border bg-card'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
||||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
{isBackupCapable && (
|
||||||
{isBackupCapable && (
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Backup
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
||||||
|
{storage.type}
|
||||||
|
</span>
|
||||||
|
{storage.type === 'pbs' && (
|
||||||
|
credentialsMap.has(storage.name) ? (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
||||||
<CheckCircle className="h-3 w-3" />
|
<CheckCircle className="h-3 w-3" />
|
||||||
Backup
|
Credentials Configured
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-warning/20 text-warning border border-warning/30 flex items-center gap-1">
|
||||||
{storage.type}
|
<AlertCircle className="h-3 w-3" />
|
||||||
</span>
|
Credentials Needed
|
||||||
</div>
|
</span>
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
)
|
||||||
<div>
|
)}
|
||||||
<span className="font-medium">Content:</span> {storage.content.join(', ')}
|
|
||||||
</div>
|
|
||||||
{storage.nodes && storage.nodes.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Object.entries(storage)
|
|
||||||
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
|
|
||||||
.map(([key, value]) => (
|
|
||||||
<div key={key}>
|
|
||||||
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Content:</span> {storage.content.join(', ')}
|
||||||
|
</div>
|
||||||
|
{storage.nodes && storage.nodes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.entries(storage)
|
||||||
|
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{storage.type === 'pbs' && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-border">
|
||||||
|
<Button
|
||||||
|
onClick={() => setSelectedPBSStorage(storage)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
{credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -171,6 +210,17 @@ export function ServerStoragesModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* PBS Credentials Modal */}
|
||||||
|
{selectedPBSStorage && (
|
||||||
|
<PBSCredentialsModal
|
||||||
|
isOpen={!!selectedPBSStorage}
|
||||||
|
onClose={() => setSelectedPBSStorage(null)}
|
||||||
|
serverId={serverId}
|
||||||
|
serverName={serverName}
|
||||||
|
storage={selectedPBSStorage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { installedScriptsRouter } from "~/server/api/routers/installedScripts";
|
|||||||
import { serversRouter } from "~/server/api/routers/servers";
|
import { serversRouter } from "~/server/api/routers/servers";
|
||||||
import { versionRouter } from "~/server/api/routers/version";
|
import { versionRouter } from "~/server/api/routers/version";
|
||||||
import { backupsRouter } from "~/server/api/routers/backups";
|
import { backupsRouter } from "~/server/api/routers/backups";
|
||||||
|
import { pbsCredentialsRouter } from "~/server/api/routers/pbsCredentials";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
servers: serversRouter,
|
servers: serversRouter,
|
||||||
version: versionRouter,
|
version: versionRouter,
|
||||||
backups: backupsRouter,
|
backups: backupsRouter,
|
||||||
|
pbsCredentials: pbsCredentialsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
153
src/server/api/routers/pbsCredentials.ts
Normal file
153
src/server/api/routers/pbsCredentials.ts
Normal file
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
@@ -361,6 +361,62 @@ class DatabaseServicePrisma {
|
|||||||
return grouped;
|
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() {
|
async close() {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,69 @@ class DatabaseServicePrisma {
|
|||||||
return grouped;
|
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() {
|
async close() {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,90 @@ class BackupService {
|
|||||||
return backups;
|
return backups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login to PBS using stored credentials
|
||||||
|
*/
|
||||||
|
async loginToPBS(server: Server, storage: Storage): Promise<boolean> {
|
||||||
|
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@<IP>:<DATASTORE>
|
||||||
|
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<void>((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<void>((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
|
* Discover PBS backups using proxmox-backup-client
|
||||||
*/
|
*/
|
||||||
@@ -300,6 +384,13 @@ class BackupService {
|
|||||||
const sshService = getSSHExecutionService();
|
const sshService = getSSHExecutionService();
|
||||||
const backups: BackupData[] = [];
|
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")
|
// Use storage name as repository name (e.g., "PBS1")
|
||||||
const repositoryName = storage.name;
|
const repositoryName = storage.name;
|
||||||
const command = `timeout 30 proxmox-backup-client snapshots host/${ctId} --repository ${repositoryName} 2>&1 || echo "PBS_ERROR"`;
|
const command = `timeout 30 proxmox-backup-client snapshots host/${ctId} --repository ${repositoryName} 2>&1 || echo "PBS_ERROR"`;
|
||||||
|
|||||||
@@ -182,6 +182,20 @@ class StorageService {
|
|||||||
return allStorages.filter(s => s.supportsBackup);
|
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
|
* Clear cache for a specific server
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user