Update scripts

This commit is contained in:
MacRimi
2026-04-12 20:32:34 +02:00
parent 4fa4bbb08b
commit 4843fae0eb
47 changed files with 8313 additions and 3014 deletions

View File

@@ -7,7 +7,7 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { Checkbox } from "./ui/checkbox" import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield } from "lucide-react" import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import Image from "next/image" import Image from "next/image"
@@ -21,6 +21,7 @@ export function Login({ onLogin }: LoginProps) {
const [totpCode, setTotpCode] = useState("") const [totpCode, setTotpCode] = useState("")
const [requiresTotp, setRequiresTotp] = useState(false) const [requiresTotp, setRequiresTotp] = useState(false)
const [rememberMe, setRememberMe] = useState(false) const [rememberMe, setRememberMe] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -161,14 +162,27 @@ export function Login({ onLogin }: LoginProps) {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="login-password" id="login-password"
type="password" type={showPassword ? "text" : "password"}
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base" className="pl-10 pr-10 text-base"
disabled={loading} disabled={loading}
autoComplete="current-password" autoComplete="current-password"
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
disabled={loading}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div> </div>
</div> </div>

View File

@@ -2,10 +2,11 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb } from "lucide-react" import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2 } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
interface DiskInfo { interface DiskInfo {
@@ -44,6 +45,8 @@ interface DiskInfo {
observations_count?: number observations_count?: number
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown' connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
removable?: boolean removable?: boolean
is_system_disk?: boolean
system_usage?: string[]
} }
interface DiskObservation { interface DiskObservation {
@@ -118,6 +121,7 @@ export function StorageOverview() {
const [detailsOpen, setDetailsOpen] = useState(false) const [detailsOpen, setDetailsOpen] = useState(false)
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([]) const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
const [loadingObservations, setLoadingObservations] = useState(false) const [loadingObservations, setLoadingObservations] = useState(false)
const [activeModalTab, setActiveModalTab] = useState<"overview" | "smart">("overview")
const fetchStorageData = async () => { const fetchStorageData = async () => {
try { try {
@@ -838,12 +842,18 @@ export function StorageOverview() {
> >
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
{/* Row 1: Device name and type badge */} {/* Row 1: Device name and type badge */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<h3 className="font-semibold">/dev/{disk.name}</h3> <h3 className="font-semibold">/dev/{disk.name}</h3>
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}> <Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
{getDiskTypeBadge(disk.name, disk.rotation_rate).label} {getDiskTypeBadge(disk.name, disk.rotation_rate).label}
</Badge> </Badge>
{disk.is_system_disk && (
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
<Server className="h-3 w-3" />
System
</Badge>
)}
</div> </div>
{/* Row 2: Model, temperature, and health status */} {/* Row 2: Model, temperature, and health status */}
@@ -930,6 +940,12 @@ export function StorageOverview() {
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}> <Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
{getDiskTypeBadge(disk.name, disk.rotation_rate).label} {getDiskTypeBadge(disk.name, disk.rotation_rate).label}
</Badge> </Badge>
{disk.is_system_disk && (
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
<Server className="h-3 w-3" />
System
</Badge>
)}
</div> </div>
{/* Row 2: Model, temperature, and health status */} {/* Row 2: Model, temperature, and health status */}
@@ -1187,9 +1203,12 @@ export function StorageOverview() {
)} )}
{/* Disk Details Dialog */} {/* Disk Details Dialog */}
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}> <Dialog open={detailsOpen} onOpenChange={(open) => {
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-y-auto"> setDetailsOpen(open)
<DialogHeader> if (!open) setActiveModalTab("overview")
}}>
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{selectedDisk?.connection_type === 'usb' ? ( {selectedDisk?.connection_type === 'usb' ? (
<Usb className="h-5 w-5 text-orange-400" /> <Usb className="h-5 w-5 text-orange-400" />
@@ -1200,10 +1219,47 @@ export function StorageOverview() {
{selectedDisk?.connection_type === 'usb' && ( {selectedDisk?.connection_type === 'usb' && (
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge> <Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
)} )}
{selectedDisk?.is_system_disk && (
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
<Server className="h-3 w-3" />
System
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription>Complete SMART information and health status</DialogDescription> <DialogDescription>
{selectedDisk?.model !== "Unknown" ? selectedDisk?.model : "Physical disk"} - {selectedDisk?.size_formatted}
</DialogDescription>
</DialogHeader> </DialogHeader>
{selectedDisk && (
{/* Tab Navigation */}
<div className="flex border-b border-border px-6">
<button
onClick={() => setActiveModalTab("overview")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeModalTab === "overview"
? "border-blue-500 text-blue-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Info className="h-4 w-4" />
Overview
</button>
<button
onClick={() => setActiveModalTab("smart")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeModalTab === "smart"
? "border-green-500 text-green-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Activity className="h-4 w-4" />
SMART Test
</button>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{selectedDisk && activeModalTab === "overview" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
@@ -1402,6 +1458,745 @@ export function StorageOverview() {
)} )}
</div> </div>
)} )}
{/* SMART Test Tab */}
{selectedDisk && activeModalTab === "smart" && (
<SmartTestTab disk={selectedDisk} />
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}
// SMART Test Tab Component
interface SmartTestTabProps {
disk: DiskInfo
}
interface SmartTestStatus {
status: 'idle' | 'running' | 'completed' | 'failed'
test_type?: string
progress?: number
result?: string
last_test?: {
type: string
status: string
timestamp: string
duration?: string
}
smart_data?: {
device: string
model: string
serial: string
firmware: string
smart_status: string
temperature: number
power_on_hours: number
attributes: Array<{
id: number
name: string
value: number
worst: number
threshold: number
raw_value: string
status: 'ok' | 'warning' | 'critical'
}>
}
}
function SmartTestTab({ disk }: SmartTestTabProps) {
const [testStatus, setTestStatus] = useState<SmartTestStatus>({ status: 'idle' })
const [loading, setLoading] = useState(true)
const [runningTest, setRunningTest] = useState<'short' | 'long' | null>(null)
const [showReport, setShowReport] = useState(false)
const [reportTab, setReportTab] = useState<'overview' | 'attributes' | 'history' | 'recommendations'>('overview')
// Fetch current SMART status on mount
useEffect(() => {
fetchSmartStatus()
}, [disk.name])
const fetchSmartStatus = async () => {
try {
setLoading(true)
const data = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
setTestStatus(data)
} catch {
setTestStatus({ status: 'idle' })
} finally {
setLoading(false)
}
}
const runSmartTest = async (testType: 'short' | 'long') => {
try {
setRunningTest(testType)
await fetchApi(`/api/storage/smart/${disk.name}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test_type: testType })
})
// Poll for status updates
const pollInterval = setInterval(async () => {
try {
const data = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
setTestStatus(data)
if (data.status !== 'running') {
clearInterval(pollInterval)
setRunningTest(null)
}
} catch {
clearInterval(pollInterval)
setRunningTest(null)
}
}, 5000)
} catch {
setRunningTest(null)
}
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading SMART data...</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Quick Actions */}
<div className="space-y-3">
<h4 className="font-semibold flex items-center gap-2">
<Play className="h-4 w-4" />
Run SMART Test
</h4>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
size="sm"
onClick={() => runSmartTest('short')}
disabled={runningTest !== null}
className="gap-2"
>
{runningTest === 'short' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Activity className="h-4 w-4" />
)}
Short Test (~2 min)
</Button>
<Button
variant="outline"
size="sm"
onClick={() => runSmartTest('long')}
disabled={runningTest !== null}
className="gap-2"
>
{runningTest === 'long' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Activity className="h-4 w-4" />
)}
Extended Test (background)
</Button>
<Button
variant="outline"
size="sm"
onClick={fetchSmartStatus}
disabled={runningTest !== null}
className="gap-2"
>
<Activity className="h-4 w-4" />
Refresh Status
</Button>
</div>
<p className="text-xs text-muted-foreground">
Short test takes ~2 minutes. Extended test runs in the background and can take several hours for large disks.
You will receive a notification when the test completes.
</p>
</div>
{/* Test Progress */}
{testStatus.status === 'running' && (
<div className="border rounded-lg p-4 bg-blue-500/5 border-blue-500/20">
<div className="flex items-center gap-3 mb-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
<div>
<p className="font-medium text-blue-500">
{testStatus.test_type === 'short' ? 'Short' : 'Extended'} test in progress
</p>
<p className="text-xs text-muted-foreground">
Please wait while the test completes...
</p>
</div>
</div>
{testStatus.progress !== undefined && (
<Progress value={testStatus.progress} className="h-2 [&>div]:bg-blue-500" />
)}
</div>
)}
{/* Last Test Result */}
{testStatus.last_test && (
<div className="space-y-3">
<h4 className="font-semibold flex items-center gap-2">
<FileText className="h-4 w-4" />
Last Test Result
</h4>
<div className={`border rounded-lg p-4 ${
testStatus.last_test.status === 'passed'
? 'bg-green-500/5 border-green-500/20'
: 'bg-red-500/5 border-red-500/20'
}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{testStatus.last_test.status === 'passed' ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="font-medium">
{testStatus.last_test.type === 'short' ? 'Short' : 'Extended'} Test - {' '}
{testStatus.last_test.status === 'passed' ? 'Passed' : 'Failed'}
</span>
</div>
<Badge className={testStatus.last_test.status === 'passed'
? 'bg-green-500/10 text-green-500 border-green-500/20'
: 'bg-red-500/10 text-red-500 border-red-500/20'
}>
{testStatus.last_test.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Completed</p>
<p className="font-medium">{testStatus.last_test.timestamp}</p>
</div>
{testStatus.last_test.duration && (
<div>
<p className="text-muted-foreground">Duration</p>
<p className="font-medium">{testStatus.last_test.duration}</p>
</div>
)}
</div>
</div>
</div>
)}
{/* SMART Attributes Summary */}
{testStatus.smart_data?.attributes && testStatus.smart_data.attributes.length > 0 && (
<div className="space-y-3">
<h4 className="font-semibold flex items-center gap-2">
<Activity className="h-4 w-4" />
SMART Attributes
</h4>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-12 gap-2 p-3 bg-muted/30 text-xs font-medium text-muted-foreground">
<div className="col-span-1">ID</div>
<div className="col-span-5">Attribute</div>
<div className="col-span-2 text-center">Value</div>
<div className="col-span-2 text-center">Worst</div>
<div className="col-span-2 text-center">Status</div>
</div>
<div className="divide-y divide-border max-h-[200px] overflow-y-auto">
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => (
<div key={attr.id} className="grid grid-cols-12 gap-2 p-3 text-sm items-center">
<div className="col-span-1 text-muted-foreground">{attr.id}</div>
<div className="col-span-5 truncate" title={attr.name}>{attr.name}</div>
<div className="col-span-2 text-center font-mono">{attr.value}</div>
<div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>
<div className="col-span-2 text-center">
{attr.status === 'ok' ? (
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />
) : attr.status === 'warning' ? (
<AlertTriangle className="h-4 w-4 text-yellow-500 mx-auto" />
) : (
<XCircle className="h-4 w-4 text-red-500 mx-auto" />
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* View Full Report Button */}
<div className="pt-4 border-t">
<Button
variant="outline"
className="w-full gap-2"
onClick={() => setShowReport(true)}
>
<FileText className="h-4 w-4" />
View Full SMART Report
</Button>
<p className="text-xs text-muted-foreground text-center mt-2">
Generate a comprehensive professional report with detailed analysis and recommendations.
</p>
</div>
{/* Full SMART Report Dialog */}
<Dialog open={showReport} onOpenChange={setShowReport}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
SMART Health Report: /dev/{disk.name}
</DialogTitle>
<DialogDescription>
Comprehensive analysis of disk health, SMART attributes, and recommendations
</DialogDescription>
</DialogHeader>
{/* Report Tabs */}
<div className="flex border-b border-border px-6 overflow-x-auto">
<button
onClick={() => setReportTab('overview')}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
reportTab === 'overview'
? "border-blue-500 text-blue-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Info className="h-4 w-4" />
Overview
</button>
<button
onClick={() => setReportTab('attributes')}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
reportTab === 'attributes'
? "border-purple-500 text-purple-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Activity className="h-4 w-4" />
Attributes
</button>
<button
onClick={() => setReportTab('history')}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
reportTab === 'history'
? "border-amber-500 text-amber-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Clock className="h-4 w-4" />
History
</button>
<button
onClick={() => setReportTab('recommendations')}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
reportTab === 'recommendations'
? "border-green-500 text-green-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<CheckCircle2 className="h-4 w-4" />
Recommendations
</button>
</div>
{/* Report Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{/* Overview Tab */}
{reportTab === 'overview' && (
<div className="space-y-6">
{/* Health Score Card */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className={`border rounded-lg p-4 ${
testStatus.smart_status === 'passed'
? 'bg-green-500/5 border-green-500/20'
: testStatus.smart_status === 'failed'
? 'bg-red-500/5 border-red-500/20'
: 'bg-yellow-500/5 border-yellow-500/20'
}`}>
<p className="text-sm text-muted-foreground mb-1">Overall Health</p>
<div className="flex items-center gap-2">
{testStatus.smart_status === 'passed' ? (
<CheckCircle2 className="h-6 w-6 text-green-500" />
) : testStatus.smart_status === 'failed' ? (
<XCircle className="h-6 w-6 text-red-500" />
) : (
<AlertTriangle className="h-6 w-6 text-yellow-500" />
)}
<span className="text-xl font-bold capitalize">
{testStatus.smart_status || 'Unknown'}
</span>
</div>
</div>
<div className="border rounded-lg p-4 bg-muted/20">
<p className="text-sm text-muted-foreground mb-1">Temperature</p>
<div className="flex items-center gap-2">
<Thermometer className="h-6 w-6 text-blue-500" />
<span className="text-xl font-bold">
{disk.temperature > 0 ? `${disk.temperature}°C` : 'N/A'}
</span>
</div>
</div>
<div className="border rounded-lg p-4 bg-muted/20">
<p className="text-sm text-muted-foreground mb-1">Power On Time</p>
<div className="flex items-center gap-2">
<Clock className="h-6 w-6 text-purple-500" />
<span className="text-xl font-bold">
{disk.power_on_hours ? `${disk.power_on_hours.toLocaleString()}h` : 'N/A'}
</span>
</div>
</div>
</div>
{/* Executive Summary */}
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-3 flex items-center gap-2">
<FileText className="h-4 w-4" />
Executive Summary
</h4>
<div className="prose prose-sm prose-invert max-w-none">
<p className="text-muted-foreground leading-relaxed">
{testStatus.smart_status === 'passed' ? (
<>
This disk is operating within normal parameters. All SMART attributes are within acceptable thresholds,
indicating good health. The disk has been powered on for approximately{' '}
<span className="text-foreground font-medium">
{disk.power_on_hours ? `${Math.round(disk.power_on_hours / 24)} days` : 'an unknown period'}
</span>{' '}
and is currently operating at{' '}
<span className="text-foreground font-medium">{disk.temperature || 'N/A'}°C</span>.
{disk.reallocated_sectors === 0 && disk.pending_sectors === 0
? ' No bad sectors have been detected.'
: disk.reallocated_sectors && disk.reallocated_sectors > 0
? ` ${disk.reallocated_sectors} sectors have been reallocated, which may indicate early signs of wear.`
: ''}
</>
) : testStatus.smart_status === 'failed' ? (
<>
<span className="text-red-400 font-medium">Warning: This disk has failed SMART health assessment.</span>{' '}
One or more critical SMART attributes have exceeded their failure threshold.
It is strongly recommended to backup all data immediately and consider replacing this disk.
{disk.reallocated_sectors && disk.reallocated_sectors > 0
? ` The disk has ${disk.reallocated_sectors} reallocated sectors, indicating physical media degradation.`
: ''}
</>
) : (
<>
The disk health status could not be fully determined. Some SMART attributes may be showing warning signs.
It is recommended to run a full SMART self-test and monitor the disk closely.
</>
)}
</p>
</div>
</div>
{/* Key Metrics */}
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-3">Key Metrics</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Model</p>
<p className="font-medium">{disk.model || 'Unknown'}</p>
</div>
<div>
<p className="text-muted-foreground">Serial</p>
<p className="font-medium font-mono text-xs">{disk.serial?.replace(/\\x[0-9a-fA-F]{2}/g, '') || 'Unknown'}</p>
</div>
<div>
<p className="text-muted-foreground">Capacity</p>
<p className="font-medium">{disk.size_formatted || 'Unknown'}</p>
</div>
<div>
<p className="text-muted-foreground">Power Cycles</p>
<p className="font-medium">{disk.power_cycles?.toLocaleString() || 'N/A'}</p>
</div>
<div>
<p className="text-muted-foreground">Reallocated Sectors</p>
<p className={`font-medium ${disk.reallocated_sectors && disk.reallocated_sectors > 0 ? 'text-yellow-500' : ''}`}>
{disk.reallocated_sectors ?? 0}
</p>
</div>
<div>
<p className="text-muted-foreground">Pending Sectors</p>
<p className={`font-medium ${disk.pending_sectors && disk.pending_sectors > 0 ? 'text-yellow-500' : ''}`}>
{disk.pending_sectors ?? 0}
</p>
</div>
<div>
<p className="text-muted-foreground">CRC Errors</p>
<p className={`font-medium ${disk.crc_errors && disk.crc_errors > 0 ? 'text-yellow-500' : ''}`}>
{disk.crc_errors ?? 0}
</p>
</div>
<div>
<p className="text-muted-foreground">Disk Type</p>
<p className="font-medium">
{disk.name.startsWith('nvme') ? 'NVMe' : !disk.rotation_rate || disk.rotation_rate === 0 ? 'SSD' : 'HDD'}
</p>
</div>
</div>
</div>
</div>
)}
{/* Attributes Tab */}
{reportTab === 'attributes' && (
<div className="space-y-4">
<div className="border rounded-lg p-4 bg-muted/20">
<h4 className="font-semibold mb-2">Understanding SMART Attributes</h4>
<p className="text-sm text-muted-foreground">
SMART (Self-Monitoring, Analysis and Reporting Technology) attributes are sensors built into hard drives and SSDs.
Each attribute has a current value, a worst recorded value, and a threshold. When the current value drops below the threshold,
the attribute is considered failed. Values typically decrease from 100 (or 200/253 on some drives) as the attribute degrades.
</p>
</div>
{testStatus.smart_data?.attributes && testStatus.smart_data.attributes.length > 0 ? (
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-12 gap-2 p-3 bg-muted/30 text-xs font-medium text-muted-foreground">
<div className="col-span-1">ID</div>
<div className="col-span-4">Attribute Name</div>
<div className="col-span-2 text-center">Value</div>
<div className="col-span-2 text-center">Worst</div>
<div className="col-span-2 text-center">Threshold</div>
<div className="col-span-1 text-center">Status</div>
</div>
<div className="divide-y divide-border max-h-[400px] overflow-y-auto">
{testStatus.smart_data.attributes.map((attr) => (
<div key={attr.id} className={`grid grid-cols-12 gap-2 p-3 text-sm items-center ${
attr.status === 'critical' ? 'bg-red-500/5' : attr.status === 'warning' ? 'bg-yellow-500/5' : ''
}`}>
<div className="col-span-1 text-muted-foreground font-mono">{attr.id}</div>
<div className="col-span-4">
<p className="truncate font-medium" title={attr.name}>{attr.name.replace(/_/g, ' ')}</p>
<p className="text-xs text-muted-foreground">Raw: {attr.raw_value}</p>
</div>
<div className="col-span-2 text-center font-mono font-medium">{attr.value}</div>
<div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>
<div className="col-span-2 text-center font-mono text-muted-foreground">{attr.threshold}</div>
<div className="col-span-1 text-center">
{attr.status === 'ok' ? (
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />
) : attr.status === 'warning' ? (
<AlertTriangle className="h-4 w-4 text-yellow-500 mx-auto" />
) : (
<XCircle className="h-4 w-4 text-red-500 mx-auto" />
)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>No SMART attribute data available.</p>
<p className="text-sm mt-1">Run a SMART test to collect attribute data.</p>
</div>
)}
</div>
)}
{/* History Tab */}
{reportTab === 'history' && (
<div className="space-y-4">
{testStatus.last_test ? (
<div className={`border rounded-lg p-4 ${
testStatus.last_test.status === 'passed'
? 'bg-green-500/5 border-green-500/20'
: 'bg-red-500/5 border-red-500/20'
}`}>
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold flex items-center gap-2">
{testStatus.last_test.status === 'passed' ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
Last Test Result
</h4>
<Badge className={testStatus.last_test.status === 'passed'
? 'bg-green-500/10 text-green-500 border-green-500/20'
: 'bg-red-500/10 text-red-500 border-red-500/20'
}>
{testStatus.last_test.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Test Type</p>
<p className="font-medium capitalize">{testStatus.last_test.type}</p>
</div>
<div>
<p className="text-muted-foreground">Completed</p>
<p className="font-medium">{testStatus.last_test.timestamp}</p>
</div>
</div>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Clock className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>No test history available.</p>
<p className="text-sm mt-1">Run a SMART self-test to see results here.</p>
</div>
)}
<div className="border rounded-lg p-4 bg-muted/20">
<h4 className="font-semibold mb-2">About Self-Tests</h4>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
<strong className="text-foreground">Short Test (~2 minutes):</strong> Performs a quick check of the disk&apos;s
basic functionality including read/seek tests on a small portion of the disk surface.
</p>
<p>
<strong className="text-foreground">Extended Test (hours):</strong> Performs a comprehensive surface scan
of the entire disk. Duration depends on disk size - typically 1-2 hours per TB.
</p>
</div>
</div>
</div>
)}
{/* Recommendations Tab */}
{reportTab === 'recommendations' && (
<div className="space-y-4">
{/* Status-based recommendations */}
{testStatus.smart_status === 'passed' && (
<div className="border rounded-lg p-4 bg-green-500/5 border-green-500/20">
<div className="flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-semibold text-green-500">Disk is Healthy</h4>
<p className="text-sm text-muted-foreground mt-1">
All SMART attributes are within normal ranges. Continue with regular monitoring.
</p>
</div>
</div>
</div>
)}
{testStatus.smart_status === 'failed' && (
<div className="border rounded-lg p-4 bg-red-500/5 border-red-500/20">
<div className="flex items-start gap-3">
<XCircle className="h-5 w-5 text-red-500 mt-0.5" />
<div>
<h4 className="font-semibold text-red-500">Critical: Disk Replacement Recommended</h4>
<p className="text-sm text-muted-foreground mt-1">
This disk has failed SMART health assessment. Backup all data immediately and plan for disk replacement.
</p>
</div>
</div>
</div>
)}
{/* Conditional recommendations */}
<div className="space-y-3">
<h4 className="font-semibold">Recommendations</h4>
{(disk.reallocated_sectors ?? 0) > 0 && (
<div className="border rounded-lg p-3 bg-yellow-500/5 border-yellow-500/20">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 mt-0.5" />
<div>
<p className="font-medium">Reallocated Sectors Detected</p>
<p className="text-sm text-muted-foreground mt-1">
{disk.reallocated_sectors} sectors have been reallocated. This indicates the disk has found and
remapped bad sectors. Monitor this value - if it increases rapidly, consider replacing the disk.
</p>
</div>
</div>
</div>
)}
{(disk.pending_sectors ?? 0) > 0 && (
<div className="border rounded-lg p-3 bg-yellow-500/5 border-yellow-500/20">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 mt-0.5" />
<div>
<p className="font-medium">Pending Sectors Detected</p>
<p className="text-sm text-muted-foreground mt-1">
{disk.pending_sectors} sectors are pending reallocation. These sectors may be unreadable.
Run an extended self-test to force reallocation attempts.
</p>
</div>
</div>
</div>
)}
{disk.temperature > 55 && (
<div className="border rounded-lg p-3 bg-yellow-500/5 border-yellow-500/20">
<div className="flex items-start gap-3">
<Thermometer className="h-5 w-5 text-yellow-500 mt-0.5" />
<div>
<p className="font-medium">Elevated Temperature</p>
<p className="text-sm text-muted-foreground mt-1">
Current temperature ({disk.temperature}°C) is above optimal. Improve airflow or reduce disk activity.
Sustained high temperatures can reduce disk lifespan.
</p>
</div>
</div>
</div>
)}
{(disk.power_on_hours ?? 0) > 35000 && (
<div className="border rounded-lg p-3 bg-blue-500/5 border-blue-500/20">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-500 mt-0.5" />
<div>
<p className="font-medium">High Power-On Hours</p>
<p className="text-sm text-muted-foreground mt-1">
This disk has been running for {Math.round((disk.power_on_hours ?? 0) / 8760)} years.
While still operational, consider planning for replacement as disks typically have a 3-5 year lifespan.
</p>
</div>
</div>
</div>
)}
{/* General best practices */}
<div className="border rounded-lg p-4 bg-muted/20 mt-6">
<h4 className="font-semibold mb-3">Best Practices</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Run a short SMART test monthly to catch early issues</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Run an extended test quarterly for comprehensive verification</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Maintain regular backups - SMART can detect some failures but not all</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Keep disk temperatures below 50°C for optimal lifespan</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>Replace disks proactively after 4-5 years of heavy use</span>
</li>
</ul>
</div>
</div>
</div>
)}
</div>
{/* Report Footer */}
<div className="border-t px-6 py-4 flex justify-between items-center bg-muted/10">
<p className="text-xs text-muted-foreground">
Report generated by ProxMenux Monitor
</p>
<Button variant="outline" size="sm" onClick={() => window.print()} className="gap-2">
<FileText className="h-4 w-4" />
Print Report
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>

View File

@@ -1784,6 +1784,270 @@ def is_disk_removable(disk_name):
return False return False
def _is_system_mount(mountpoint):
"""Check if mountpoint is a critical system path (matching bash scripts logic)."""
system_mounts = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '/etc',
'/lib', '/lib64', '/run', '/proc', '/sys')
if mountpoint in system_mounts:
return True
# Also check if it's under these paths
for prefix in ('/usr/', '/var/', '/lib/', '/lib64/'):
if mountpoint.startswith(prefix):
return True
return False
def _get_zfs_root_pool():
"""Get the ZFS pool containing the root filesystem, if any (matches bash _get_zfs_root_pool)."""
try:
result = subprocess.run(['df', '/'], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
if len(lines) >= 2:
root_fs = lines[1].split()[0]
# A ZFS dataset looks like "rpool/ROOT/pve-1" — not /dev/
if not root_fs.startswith('/dev/') and '/' in root_fs:
return root_fs.split('/')[0]
except Exception:
pass
return None
def _resolve_zfs_entry(entry):
"""
Resolve a ZFS device entry to a base disk name.
Handles: /dev/paths, by-id names, short kernel names (matches bash _resolve_zfs_entry).
"""
path = None
try:
if entry.startswith('/dev/'):
path = os.path.realpath(entry)
elif os.path.exists(f'/dev/disk/by-id/{entry}'):
path = os.path.realpath(f'/dev/disk/by-id/{entry}')
elif os.path.exists(f'/dev/{entry}'):
path = os.path.realpath(f'/dev/{entry}')
if path:
# Get parent disk (base disk without partition number)
result = subprocess.run(
['lsblk', '-no', 'PKNAME', path],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
# If no parent, path is the base disk itself
return os.path.basename(path)
except Exception:
pass
return None
def get_system_disks():
"""
Detect which physical disks are used by the system (Proxmox).
Returns a dict mapping disk names to their system usage info:
{
'sda': {'is_system': True, 'usage': ['root', 'boot']},
'nvme0n1': {'is_system': True, 'usage': ['zfs:rpool']},
}
Detects (matching logic from disk-passthrough.sh, format-disk.sh, disk_host.sh):
- Root filesystem (/) and critical system mounts (/boot, /usr, /var, etc.)
- Boot partition (/boot, /boot/efi)
- Active swap partitions
- ZFS pools (especially root pool - these disks are critical)
- LVM physical volumes (especially 'pve' volume group)
- RAID members (mdadm)
- Disks with Proxmox partition labels
"""
system_disks = {}
def add_usage(disk_name, usage_type):
"""Helper to add a usage type to a disk."""
if not disk_name:
return
# Normalize disk name (strip partition numbers to get base disk)
base_disk = disk_name
if disk_name and disk_name[-1].isdigit():
if 'nvme' in disk_name or 'mmcblk' in disk_name:
# NVMe/eMMC: nvme0n1p1 -> nvme0n1
if 'p' in disk_name:
base_disk = disk_name.rsplit('p', 1)[0]
else:
# SATA/SAS: sda1 -> sda
base_disk = disk_name.rstrip('0123456789')
if base_disk not in system_disks:
system_disks[base_disk] = {'is_system': True, 'usage': []}
if usage_type not in system_disks[base_disk]['usage']:
system_disks[base_disk]['usage'].append(usage_type)
# Get ZFS root pool first (critical - these disks should never be touched)
zfs_root_pool = _get_zfs_root_pool()
try:
# 1. Check mounted filesystems for system-critical mounts
with open('/proc/mounts', 'r') as f:
for line in f:
parts = line.split()
if len(parts) < 2:
continue
device, mountpoint = parts[0], parts[1]
# Skip non-block devices
if not device.startswith('/dev/'):
continue
disk_name = device.replace('/dev/', '').replace('mapper/', '')
# Identify system mountpoints (expanded from bash _is_system_mount)
if _is_system_mount(mountpoint):
if mountpoint == '/':
add_usage(disk_name, 'root')
elif mountpoint.startswith('/boot'):
add_usage(disk_name, 'boot')
else:
add_usage(disk_name, 'system')
except Exception:
pass
try:
# 2. Check active swap partitions
result = subprocess.run(
['swapon', '--noheadings', '--raw', '--show=NAME'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
device = line.strip()
if device.startswith('/dev/'):
disk_name = device.replace('/dev/', '')
add_usage(disk_name, 'swap')
except Exception:
# Fallback to /proc/swaps
try:
with open('/proc/swaps', 'r') as f:
lines = f.readlines()[1:] # Skip header
for line in lines:
parts = line.split()
if len(parts) >= 1:
device = parts[0]
if device.startswith('/dev/'):
disk_name = device.replace('/dev/', '').replace('mapper/', '')
add_usage(disk_name, 'swap')
except Exception:
pass
try:
# 3. Check ZFS pools using zpool list -v -H (matches bash _build_pool_disks)
result = subprocess.run(
['zpool', 'list', '-v', '-H'],
capture_output=True, text=True, timeout=8
)
if result.returncode == 0:
current_pool = None
for line in result.stdout.split('\n'):
if not line.strip():
continue
parts = line.split()
if not parts:
continue
# First column is pool name or device
entry = parts[0]
# Skip metadata entries
if entry in ('-', 'mirror', 'raidz', 'raidz1', 'raidz2', 'raidz3', 'spare', 'log', 'cache'):
continue
# Check if this is a pool name (pools have no leading whitespace)
if not line.startswith('\t') and not line.startswith(' '):
current_pool = entry
continue
# This is a device entry - resolve it
base_disk = _resolve_zfs_entry(entry)
if base_disk:
pool_label = current_pool or 'unknown'
# Mark root pool specially
if zfs_root_pool and pool_label == zfs_root_pool:
add_usage(base_disk, f'zfs:{pool_label} (root)')
else:
add_usage(base_disk, f'zfs:{pool_label}')
except Exception:
pass
try:
# 4. Check LVM physical volumes
result = subprocess.run(
['pvs', '--noheadings', '-o', 'pv_name,vg_name'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split()
if len(parts) >= 2:
pv_device = parts[0]
vg_name = parts[1]
if pv_device.startswith('/dev/'):
# Resolve to real path
try:
real_path = os.path.realpath(pv_device)
disk_name = os.path.basename(real_path)
except Exception:
disk_name = pv_device.replace('/dev/', '')
# Proxmox typically uses 'pve' volume group
if 'pve' in vg_name.lower():
add_usage(disk_name, f'lvm:{vg_name} (pve)')
else:
add_usage(disk_name, f'lvm:{vg_name}')
except Exception:
pass
try:
# 5. Check active RAID arrays
if os.path.exists('/proc/mdstat'):
with open('/proc/mdstat', 'r') as f:
content = f.read()
if 'active' in content:
# Parse mdstat for active arrays
for line in content.split('\n'):
if 'active raid' in line:
# Extract device names from line like "md0 : active raid1 sda1[0] sdb1[1]"
parts = line.split()
for part in parts:
if '[' in part:
dev = part.split('[')[0]
add_usage(dev, 'raid')
except Exception:
pass
try:
# 6. Check for disks with Proxmox/system partition labels
result = subprocess.run(
['lsblk', '-o', 'NAME,PARTLABEL', '-n', '-l'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
parts = line.split(None, 1)
if len(parts) >= 2:
disk_name = parts[0]
partlabel = parts[1].lower() if len(parts) > 1 else ''
# Proxmox-specific and system partition labels
system_labels = ['pve', 'proxmox', 'bios', 'esp', 'efi', 'boot', 'grub']
if any(label in partlabel for label in system_labels):
add_usage(disk_name, 'system-partition')
except Exception:
pass
return system_disks
def get_storage_info(): def get_storage_info():
"""Get storage and disk information""" """Get storage and disk information"""
try: try:
@@ -1802,6 +2066,9 @@ def get_storage_info():
physical_disks = {} physical_disks = {}
total_disk_size_bytes = 0 total_disk_size_bytes = 0
# Get system disk information (disks used by Proxmox)
system_disks = get_system_disks()
try: try:
# List all block devices # List all block devices
result = subprocess.run(['lsblk', '-b', '-d', '-n', '-o', 'NAME,SIZE,TYPE'], result = subprocess.run(['lsblk', '-b', '-d', '-n', '-o', 'NAME,SIZE,TYPE'],
@@ -1843,6 +2110,11 @@ def get_storage_info():
conn_type = get_disk_connection_type(disk_name) conn_type = get_disk_connection_type(disk_name)
removable = is_disk_removable(disk_name) removable = is_disk_removable(disk_name)
# Check if this disk is used by the system
sys_info = system_disks.get(disk_name, {})
is_system_disk = sys_info.get('is_system', False)
system_usage = sys_info.get('usage', [])
physical_disks[disk_name] = { physical_disks[disk_name] = {
'name': disk_name, 'name': disk_name,
'size': disk_size_kb, # In KB for formatMemory() in Storage Summary 'size': disk_size_kb, # In KB for formatMemory() in Storage Summary
@@ -1866,6 +2138,8 @@ def get_storage_info():
'ssd_life_left': smart_data.get('ssd_life_left'), 'ssd_life_left': smart_data.get('ssd_life_left'),
'connection_type': conn_type, 'connection_type': conn_type,
'removable': removable, 'removable': removable,
'is_system_disk': is_system_disk,
'system_usage': system_usage,
} }
except Exception as e: except Exception as e:
@@ -6075,6 +6349,322 @@ def api_proxmox_storage():
"""Get Proxmox storage information""" """Get Proxmox storage information"""
return jsonify(get_proxmox_storage()) return jsonify(get_proxmox_storage())
# ─── SMART Disk Testing API ───────────────────────────────────────────────────
SMART_DIR = '/usr/local/share/proxmenux/smart'
def _is_nvme(disk_name):
"""Check if disk is NVMe."""
return disk_name.startswith('nvme')
def _get_smart_json_path(disk_name):
"""Get path to SMART JSON file for a disk."""
return os.path.join(SMART_DIR, f"{disk_name}.json")
def _ensure_smart_tools():
"""Check if SMART tools are installed."""
has_smartctl = shutil.which('smartctl') is not None
has_nvme = shutil.which('nvme') is not None
return {'smartctl': has_smartctl, 'nvme': has_nvme}
def _parse_smart_attributes(output_lines):
"""Parse SMART attributes from smartctl output."""
attributes = []
in_attrs = False
for line in output_lines:
if 'ID#' in line and 'ATTRIBUTE_NAME' in line:
in_attrs = True
continue
if in_attrs:
if not line.strip():
break
parts = line.split()
if len(parts) >= 10 and parts[0].isdigit():
attr_id = int(parts[0])
attr_name = parts[1]
value = int(parts[3]) if parts[3].isdigit() else 0
worst = int(parts[4]) if parts[4].isdigit() else 0
threshold = int(parts[5]) if parts[5].isdigit() else 0
raw_value = parts[9] if len(parts) > 9 else ''
# Determine status
status = 'ok'
if threshold > 0 and value <= threshold:
status = 'critical'
elif threshold > 0 and value <= threshold + 10:
status = 'warning'
attributes.append({
'id': attr_id,
'name': attr_name,
'value': value,
'worst': worst,
'threshold': threshold,
'raw_value': raw_value,
'status': status
})
return attributes
@app.route('/api/storage/smart/<disk_name>', methods=['GET'])
@require_auth
def api_smart_status(disk_name):
"""Get SMART status and data for a specific disk."""
try:
# Validate disk name (security)
if not re.match(r'^[a-zA-Z0-9]+$', disk_name):
return jsonify({'error': 'Invalid disk name'}), 400
device = f'/dev/{disk_name}'
if not os.path.exists(device):
return jsonify({'error': 'Device not found'}), 404
tools = _ensure_smart_tools()
result = {
'status': 'idle',
'tools_installed': tools
}
# Check if tools are available
is_nvme = _is_nvme(disk_name)
if is_nvme and not tools['nvme']:
result['error'] = 'nvme-cli not installed'
return jsonify(result)
if not is_nvme and not tools['smartctl']:
result['error'] = 'smartmontools not installed'
return jsonify(result)
# Check for existing JSON file (from previous test)
json_path = _get_smart_json_path(disk_name)
if os.path.exists(json_path):
try:
with open(json_path, 'r') as f:
saved_data = json.load(f)
result['saved_data'] = saved_data
result['saved_timestamp'] = os.path.getmtime(json_path)
except (json.JSONDecodeError, IOError):
pass
# Get current SMART status
if is_nvme:
# NVMe: Check for running test
proc = subprocess.run(
['nvme', 'self-test-log', device],
capture_output=True, text=True, timeout=10
)
if 'in progress' in proc.stdout.lower():
result['status'] = 'running'
result['test_type'] = 'nvme'
# Get smart-log data
proc = subprocess.run(
['nvme', 'smart-log', device],
capture_output=True, text=True, timeout=10
)
if proc.returncode == 0:
lines = proc.stdout.strip().split('\n')
smart_data = {}
for line in lines:
if ':' in line:
key, value = line.split(':', 1)
smart_data[key.strip().lower().replace(' ', '_')] = value.strip()
result['smart_data'] = smart_data
# Check health
crit_warn = smart_data.get('critical_warning', '0')
result['smart_status'] = 'passed' if crit_warn == '0' else 'warning'
else:
# SATA/SAS: Check for running test
proc = subprocess.run(
['smartctl', '-c', device],
capture_output=True, text=True, timeout=10
)
if 'Self-test routine in progress' in proc.stdout or '% of test remaining' in proc.stdout:
result['status'] = 'running'
# Extract progress percentage
match = re.search(r'(\d+)% of test remaining', proc.stdout)
if match:
result['progress'] = 100 - int(match.group(1))
# Get SMART health
proc = subprocess.run(
['smartctl', '-H', device],
capture_output=True, text=True, timeout=10
)
if 'PASSED' in proc.stdout:
result['smart_status'] = 'passed'
elif 'FAILED' in proc.stdout:
result['smart_status'] = 'failed'
else:
result['smart_status'] = 'unknown'
# Get SMART attributes
proc = subprocess.run(
['smartctl', '-A', device],
capture_output=True, text=True, timeout=10
)
if proc.returncode == 0:
attrs = _parse_smart_attributes(proc.stdout.split('\n'))
result['smart_data'] = {'attributes': attrs}
# Get self-test log for last test result
proc = subprocess.run(
['smartctl', '-l', 'selftest', device],
capture_output=True, text=True, timeout=10
)
if proc.returncode == 0:
lines = proc.stdout.split('\n')
for line in lines:
if line.startswith('# ') or line.startswith('# '):
parts = line.split()
if len(parts) >= 5:
test_type = 'short' if 'Short' in line else 'long' if 'Extended' in line or 'Long' in line else 'unknown'
test_status = 'passed' if 'Completed without error' in line else 'failed'
result['last_test'] = {
'type': test_type,
'status': test_status,
'timestamp': ' '.join(parts[-5:-2]) if len(parts) > 5 else 'unknown'
}
break
return jsonify(result)
except subprocess.TimeoutExpired:
return jsonify({'error': 'Command timeout'}), 504
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/storage/smart/<disk_name>/test', methods=['POST'])
@require_auth
def api_smart_run_test(disk_name):
"""Start a SMART self-test on a disk."""
try:
# Validate disk name (security)
if not re.match(r'^[a-zA-Z0-9]+$', disk_name):
return jsonify({'error': 'Invalid disk name'}), 400
device = f'/dev/{disk_name}'
if not os.path.exists(device):
return jsonify({'error': 'Device not found'}), 404
data = request.get_json() or {}
test_type = data.get('test_type', 'short')
if test_type not in ('short', 'long'):
return jsonify({'error': 'Invalid test type. Use "short" or "long"'}), 400
tools = _ensure_smart_tools()
is_nvme = _is_nvme(disk_name)
# Ensure SMART directory exists
os.makedirs(SMART_DIR, exist_ok=True)
json_path = _get_smart_json_path(disk_name)
if is_nvme:
if not tools['nvme']:
return jsonify({'error': 'nvme-cli not installed'}), 400
# NVMe: self-test-code 1=short, 2=long
code = 1 if test_type == 'short' else 2
proc = subprocess.run(
['nvme', 'device-self-test', device, f'--self-test-code={code}'],
capture_output=True, text=True, timeout=30
)
if proc.returncode != 0:
return jsonify({'error': f'Failed to start test: {proc.stderr}'}), 500
# For long test, start background monitor
if test_type == 'long':
subprocess.Popen(
f'''
while nvme device-self-test {device} --self-test-code=0 2>/dev/null | grep -qi 'in progress'; do
sleep 60
done
nvme smart-log -o json {device} > {json_path} 2>/dev/null
''',
shell=True, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
else:
if not tools['smartctl']:
return jsonify({'error': 'smartmontools not installed'}), 400
test_flag = '-t short' if test_type == 'short' else '-t long'
proc = subprocess.run(
['smartctl'] + test_flag.split() + [device],
capture_output=True, text=True, timeout=30
)
if proc.returncode not in (0, 4): # 4 = test started successfully
return jsonify({'error': f'Failed to start test: {proc.stderr}'}), 500
# For long test, start background monitor
if test_type == 'long':
subprocess.Popen(
f'''
while smartctl -c {device} 2>/dev/null | grep -qiE 'Self-test routine in progress|[1-9][0-9]?% of test remaining'; do
sleep 60
done
smartctl --json=c {device} > {json_path} 2>/dev/null
''',
shell=True, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
return jsonify({
'success': True,
'test_type': test_type,
'device': device,
'message': f'{test_type.capitalize()} test started on {device}'
})
except subprocess.TimeoutExpired:
return jsonify({'error': 'Command timeout'}), 504
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/storage/smart/tools', methods=['GET'])
@require_auth
def api_smart_tools_status():
"""Check if SMART tools are installed."""
tools = _ensure_smart_tools()
return jsonify(tools)
@app.route('/api/storage/smart/tools/install', methods=['POST'])
@require_auth
def api_smart_tools_install():
"""Install SMART tools (smartmontools and nvme-cli)."""
try:
data = request.get_json() or {}
packages = data.get('packages', ['smartmontools', 'nvme-cli'])
results = {}
for pkg in packages:
if pkg not in ('smartmontools', 'nvme-cli'):
results[pkg] = {'success': False, 'error': 'Invalid package name'}
continue
# Update apt cache and install
proc = subprocess.run(
['apt-get', 'install', '-y', pkg],
capture_output=True, text=True, timeout=120
)
results[pkg] = {
'success': proc.returncode == 0,
'output': proc.stdout if proc.returncode == 0 else proc.stderr
}
return jsonify(results)
except subprocess.TimeoutExpired:
return jsonify({'error': 'Installation timeout'}), 504
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── END SMART API ────────────────────────────────────────────────────────────
@app.route('/api/network', methods=['GET']) @app.route('/api/network', methods=['GET'])
@require_auth @require_auth
def api_network(): def api_network():

View File

@@ -678,6 +678,59 @@ TEMPLATES = {
'group': 'storage', 'group': 'storage',
'default_enabled': True, 'default_enabled': True,
}, },
'smart_test_complete': {
'title': '{hostname}: SMART test completed — {device}',
'body': 'SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}',
'label': 'SMART test completed',
'group': 'storage',
'default_enabled': True,
},
'smart_test_failed': {
'title': '{hostname}: SMART test FAILED — {device}',
'body': 'SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}',
'label': 'SMART test FAILED',
'group': 'storage',
'default_enabled': True,
},
# ── GPU / PCIe passthrough events ──
'gpu_mode_switch': {
'title': '{hostname}: GPU mode changed to {new_mode}',
'body': (
'GPU passthrough mode has been switched.\n'
'GPU: {gpu_name} ({gpu_pci})\n'
'Previous mode: {old_mode}\n'
'New mode: {new_mode}\n'
'{details}'
),
'label': 'GPU mode switched',
'group': 'hardware',
'default_enabled': True,
},
'gpu_passthrough_blocked': {
'title': '{hostname}: {guest_type} {guest_id} blocked at startup',
'body': (
'PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\n'
'Reason: {reason}\n'
'{details}'
),
'label': 'GPU passthrough blocked',
'group': 'hardware',
'default_enabled': True,
},
'pci_passthrough_conflict': {
'title': '{hostname}: PCIe device conflict detected',
'body': (
'A PCIe device is assigned to multiple guests.\n'
'Device: {device_pci}\n'
'Conflicting guests: {guest_list}\n'
'Action required: Stop one of the guests or reassign the device.'
),
'label': 'PCIe device conflict',
'group': 'hardware',
'default_enabled': True,
},
'load_high': { 'load_high': {
'title': '{hostname}: High system load — {value}', 'title': '{hostname}: High system load — {value}',
'body': 'System load average is {value} on {cores} cores.\n{details}', 'body': 'System load average is {value} on {cores} cores.\n{details}',
@@ -1203,6 +1256,7 @@ CATEGORY_EMOJI = {
'services': '\u2699\uFE0F', # gear 'services': '\u2699\uFE0F', # gear
'health': '\U0001FA7A', # stethoscope 'health': '\U0001FA7A', # stethoscope
'updates': '\U0001F504', # counterclockwise arrows (update) 'updates': '\U0001F504', # counterclockwise arrows (update)
'hardware': '\U0001F3AE', # video game controller (GPU/PCIe hardware)
'other': '\U0001F4E8', # incoming envelope 'other': '\U0001F4E8', # incoming envelope
} }
@@ -1275,6 +1329,10 @@ EVENT_EMOJI = {
'proxmenux_update': '\U0001F195', # NEW 'proxmenux_update': '\U0001F195', # NEW
# AI # AI
'ai_model_migrated': '\U0001F504', # arrows counterclockwise (refresh/update) 'ai_model_migrated': '\U0001F504', # arrows counterclockwise (refresh/update)
# GPU / PCIe
'gpu_mode_switch': '\U0001F3AE', # video game controller (represents GPU)
'gpu_passthrough_blocked': '\U0001F6AB', # prohibited sign (blocked)
'pci_passthrough_conflict': '\u26A0\uFE0F', # warning triangle (conflict)
} }
# Decorative field-level icons for body text enrichment # Decorative field-level icons for body text enrichment

View File

@@ -0,0 +1,385 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux - Disk Operations Helpers
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : MIT
# Version : 1.0
# Last Updated: 11/04/2026
# ==========================================================
# Shared low-level disk operations: wipe, partition, format.
# Consumed by format-disk.sh, disk_host.sh and future scripts.
#
# Output variables (set by helpers, read by callers):
# DOH_CREATED_PARTITION — partition path set by doh_create_partition()
# DOH_PARTITION_ERROR_DETAIL — error detail set by doh_create_partition()
# ==========================================================
if [[ -n "${__PROXMENUX_DISK_OPS_HELPERS__}" ]]; then
return 0
fi
__PROXMENUX_DISK_OPS_HELPERS__=1
# shellcheck disable=SC2034 # these are output variables read by callers (format-disk.sh, disk_host.sh)
DOH_CREATED_PARTITION=""
DOH_PARTITION_ERROR_DETAIL=""
DOH_FORMAT_ERROR_DETAIL=""
DOH_WIPE_ERROR_DETAIL=""
# Internal: print progress lines only when explicitly enabled by caller.
# Enabled with: export DOH_SHOW_PROGRESS=1
_doh_progress() {
[[ "${DOH_SHOW_PROGRESS:-0}" == "1" ]] || return 0
echo -e "${TAB}${YW}${HOLD}$*${CL}"
}
# Internal: collect command stdout with timeout protection (best-effort).
# Usage: _doh_collect_cmd <seconds> <cmd> [args...]
_doh_collect_cmd() {
local seconds="$1"
shift
if command -v timeout >/dev/null 2>&1; then
timeout --kill-after=2 "${seconds}s" "$@" 2>/dev/null || true
else
"$@" 2>/dev/null || true
fi
}
# Internal: run a command with a timeout, suppressing all output including
# the bash "Killed" job notification that leaks when --kill-after re-raises
# SIGKILL. Plain SIGTERM is not enough for processes stuck in kernel D-state
# (uninterruptible I/O wait on a busy ZFS/LVM disk), so --kill-after=2 is
# needed. The notification is suppressed by temporarily redirecting the
# current shell's stderr with exec before the call and restoring it after.
# Usage: _doh_run_quick_cmd <seconds> <cmd> [args...]
_doh_run_quick_cmd() {
local seconds="$1"
shift
if command -v timeout >/dev/null 2>&1; then
local _saved_stderr
exec {_saved_stderr}>&2 2>/dev/null
timeout --kill-after=2 "${seconds}s" "$@" >/dev/null 2>&1
local rc=$?
exec 2>&"${_saved_stderr}" {_saved_stderr}>&-
return $rc
fi
"$@" >/dev/null 2>&1
}
# Internal: unmount all ZFS datasets then export (or destroy) any ZFS pools
# whose vdevs live on <disk>. Called at the very start of doh_wipe_disk so
# ZFS fully releases the device before wipefs/sgdisk/partprobe touch it.
# If the pool is still held after export, processes on it will be in D-state
# and --kill-after in _doh_run_quick_cmd handles the force-kill.
_doh_release_zfs_pools() {
local disk="$1"
command -v zpool >/dev/null 2>&1 || return 0
local pool_name dev resolved base parent
while read -r pool_name; do
[[ -z "$pool_name" ]] && continue
local found=false
while read -r dev; do
[[ -z "$dev" ]] && continue
if [[ "$dev" == /dev/* ]]; then
resolved=$(readlink -f "$dev" 2>/dev/null)
elif [[ -e "/dev/disk/by-id/$dev" ]]; then
resolved=$(readlink -f "/dev/disk/by-id/$dev" 2>/dev/null)
elif [[ -e "/dev/$dev" ]]; then
resolved=$(readlink -f "/dev/$dev" 2>/dev/null)
else
continue
fi
[[ -z "$resolved" ]] && continue
base=$(lsblk -no PKNAME "$resolved" 2>/dev/null)
parent="${base:+/dev/$base}"
[[ -z "$parent" ]] && parent="$resolved"
if [[ "$parent" == "$disk" || "$resolved" == "$disk" ]]; then
found=true; break
fi
done < <(_doh_collect_cmd 12 zpool list -v -H "$pool_name" | awk '{print $1}' | \
grep -v '^-' | grep -v '^mirror' | grep -v '^raidz' | \
grep -v "^${pool_name}$")
if $found; then
_doh_progress "- Releasing active ZFS pool: $pool_name"
# Unmount all datasets (reverse order: deepest first)
if command -v zfs >/dev/null 2>&1; then
while read -r ds; do
[[ -z "$ds" ]] && continue
timeout 10s zfs unmount -f "$ds" >/dev/null 2>&1 || true
done < <(_doh_collect_cmd 10 zfs list -H -o name -r "$pool_name" | sort -r)
fi
# Export the pool so the kernel releases the block device
timeout 30s zpool export -f "$pool_name" >/dev/null 2>&1 || true
# Wait for udev to finish processing the device release
udevadm settle --timeout=5 >/dev/null 2>&1 || true
sleep 1
fi
done < <(_doh_collect_cmd 8 zpool list -H -o name)
}
# Internal: run a partitioning command with timeout, appending combined output to a file.
# Usage: _doh_part_cmd <seconds> <outfile> <cmd> [args...]
_doh_part_cmd() {
local secs="$1" outfile="$2"
shift 2
if command -v timeout >/dev/null 2>&1; then
timeout --kill-after=3 "${secs}s" "$@" >>"$outfile" 2>&1
else
"$@" >>"$outfile" 2>&1
fi
}
# doh_wipe_disk <disk>
# Unmounts all partitions, deactivates swap, wipes all filesystem metadata
# and partition tables (wipefs + sgdisk + dd first/last 16 MiB).
# Never fails — all sub-commands run with "|| true".
doh_wipe_disk() {
local disk="$1"
local node mountpoint total_sectors seek_sectors discard_max base
DOH_WIPE_ERROR_DETAIL=""
_doh_progress "[1/8] Preparing disk $disk"
# Optional heavy release flow (disabled by default to avoid hangs in busy hosts).
if [[ "${DOH_ENABLE_STACK_RELEASE:-0}" == "1" ]]; then
# Release any ZFS pools using this disk so the kernel lets go of it
_doh_release_zfs_pools "$disk"
# Deactivate any LVM VGs backed by this disk
if command -v vgchange >/dev/null 2>&1; then
local pv rp vg
while read -r pv; do
rp=$(readlink -f "$pv" 2>/dev/null)
base=$(lsblk -no PKNAME "${rp:-$pv}" 2>/dev/null)
if [[ "/dev/${base}" == "$disk" || "$rp" == "$disk" ]]; then
vg=$(_doh_collect_cmd 8 pvs --noheadings -o vg_name "${rp:-$pv}" | xargs)
[[ -n "$vg" ]] && _doh_run_quick_cmd 8 vgchange -an "$vg" || true
fi
done < <(_doh_collect_cmd 8 pvs --noheadings -o pv_name | xargs -r -n1)
fi
fi
# Unmount all partitions
_doh_progress "[2/8] Unmounting partitions"
while read -r node mountpoint; do
[[ -z "$node" || -z "$mountpoint" ]] && continue
_doh_run_quick_cmd 8 umount -f "$node" || true
done < <(lsblk -lnpo NAME,MOUNTPOINT "$disk" 2>/dev/null | awk 'NR>1 && $2!="" {print $1" "$2}')
# Deactivate swap
_doh_progress "[3/8] Disabling swap signatures"
while read -r node; do
[[ -z "$node" ]] && continue
_doh_run_quick_cmd 8 swapoff "$node" || true
done < <(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR>1 {print $1}')
# Wipe filesystem signatures and RAID superblocks on every node
_doh_progress "[4/8] Removing filesystem/RAID signatures"
while read -r node; do
[[ -z "$node" ]] && continue
_doh_run_quick_cmd 10 wipefs -a -f "$node" || true
if command -v mdadm >/dev/null 2>&1; then
_doh_run_quick_cmd 8 mdadm --zero-superblock --force "$node" || true
fi
done < <(lsblk -lnpo NAME "$disk" 2>/dev/null)
# Zap partition table
_doh_progress "[5/8] Resetting partition table"
_doh_run_quick_cmd 12 sgdisk --zap-all "$disk" || true
# TRIM/discard if device supports it
_doh_progress "[6/8] Attempting discard/TRIM when supported"
discard_max=$(lsblk -dn -o DISC-MAX "$disk" 2>/dev/null | xargs)
if [[ -n "$discard_max" && "$discard_max" != "0B" && "$discard_max" != "0" ]]; then
_doh_run_quick_cmd 15 blkdiscard -f "$disk" || true
fi
# Zero first 16 MiB (destroys partition table / filesystem headers)
_doh_progress "[7/8] Zeroing first metadata region"
_doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=1M count=16 conv=fsync status=none || true
# Zero last 16 MiB (destroys backup GPT header)
_doh_progress "[8/8] Zeroing backup GPT region"
total_sectors=$(blockdev --getsz "$disk" 2>/dev/null || echo 0)
if [[ "$total_sectors" =~ ^[0-9]+$ ]] && (( total_sectors > 32768 )); then
seek_sectors=$(( total_sectors - 32768 ))
_doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=512 seek="$seek_sectors" count=32768 conv=fsync status=none || true
fi
udevadm settle --timeout=10 >/dev/null 2>&1 || true
_doh_run_quick_cmd 8 partprobe "$disk" || true
sleep 1
}
# doh_create_partition <disk>
# Creates a single GPT partition spanning the whole disk.
# Tries parted → sgdisk → sfdisk in order; stops at first success.
#
# On success: sets DOH_CREATED_PARTITION to the new partition path, returns 0.
# On failure: sets DOH_PARTITION_ERROR_DETAIL with tool diagnostics, returns 1.
doh_create_partition() {
local disk="$1"
local created=false tmp_out err_snippet
DOH_CREATED_PARTITION=""
DOH_PARTITION_ERROR_DETAIL=""
_doh_run_quick_cmd 5 blockdev --setrw "$disk" || true
# --- attempt 1: parted ---
if command -v parted >/dev/null 2>&1; then
tmp_out=$(mktemp)
if _doh_part_cmd 15 "$tmp_out" parted -s -f "$disk" mklabel gpt; then
if _doh_part_cmd 20 "$tmp_out" parted -s -f "$disk" mkpart primary 1MiB 100%; then
created=true
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="parted mkpart: ${err_snippet:-no details}"$'\n'
fi
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="parted mklabel: ${err_snippet:-no details}"$'\n'
fi
rm -f "$tmp_out"
else
DOH_PARTITION_ERROR_DETAIL+="parted command not found"$'\n'
fi
# --- attempt 2: sgdisk ---
if [[ "$created" != "true" ]] && command -v sgdisk >/dev/null 2>&1; then
tmp_out=$(mktemp)
_doh_run_quick_cmd 10 sgdisk --zap-all "$disk" || true
# sgdisk does not accept "1MiB" notation — use sector 2048 (= 1 MiB at 512 B/sector)
if _doh_part_cmd 20 "$tmp_out" sgdisk -o -n 1:2048:0 -t 1:8300 "$disk"; then
created=true
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="sgdisk create: ${err_snippet:-no details}"$'\n'
fi
rm -f "$tmp_out"
elif [[ "$created" != "true" ]]; then
DOH_PARTITION_ERROR_DETAIL+="sgdisk command not found"$'\n'
fi
# --- attempt 3: sfdisk ---
if [[ "$created" != "true" ]] && command -v sfdisk >/dev/null 2>&1; then
tmp_out=$(mktemp)
local sfdisk_ok=1
if command -v timeout >/dev/null 2>&1; then
printf 'label: gpt\n,;\n' | timeout --kill-after=3 20s sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1
sfdisk_ok=$?
else
printf 'label: gpt\n,;\n' | sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1
sfdisk_ok=$?
fi
if [[ $sfdisk_ok -eq 0 ]]; then
created=true
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="sfdisk create: ${err_snippet:-no details}"$'\n'
fi
rm -f "$tmp_out"
elif [[ "$created" != "true" ]]; then
DOH_PARTITION_ERROR_DETAIL+="sfdisk command not found"$'\n'
fi
[[ "$created" == "true" ]] || return 1
# Wait for the kernel to expose the new partition node
udevadm settle --timeout=10 >/dev/null 2>&1 || true
_doh_run_quick_cmd 8 partprobe "$disk" || true
local part
for _ in {1..15}; do
sleep 0.3
part=$(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR==2{print; exit}')
if [[ -n "$part" && -b "$part" ]]; then
DOH_CREATED_PARTITION="$part"
return 0
fi
done
# Fallback: derive partition name from disk path (handles NVMe p-suffix)
local fallback
if [[ "$disk" =~ [0-9]$ ]]; then
fallback="${disk}p1"
else
fallback="${disk}1"
fi
if [[ -b "$fallback" ]]; then
DOH_CREATED_PARTITION="$fallback"
return 0
fi
DOH_PARTITION_ERROR_DETAIL+="partition node not detected after table refresh"$'\n'
return 1
}
# doh_format_partition <partition> <filesystem> [label] [zfs_pool_name] [zfs_mountpoint]
#
# Formats <partition> with <filesystem>.
# label : optional FS label for ext4/xfs/btrfs (ignored for ZFS)
# zfs_pool_name : required when filesystem=zfs; defaults to label if empty
# zfs_mountpoint : ZFS pool mountpoint (default: "none" — no automatic mount)
#
# On failure: sets DOH_FORMAT_ERROR_DETAIL with tool diagnostics.
# Returns 0 on success, 1 on failure.
doh_format_partition() {
local partition="$1"
local filesystem="$2"
local label="${3:-}"
local zfs_pool="${4:-}"
local zfs_mountpoint="${5:-none}"
local tmp_out rc=1
DOH_FORMAT_ERROR_DETAIL=""
tmp_out=$(mktemp)
case "$filesystem" in
ext4)
if [[ -n "$label" ]]; then
mkfs.ext4 -F -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
else
mkfs.ext4 -F "$partition" >"$tmp_out" 2>&1; rc=$?
fi
;;
xfs)
if [[ -n "$label" ]]; then
mkfs.xfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
else
mkfs.xfs -f "$partition" >"$tmp_out" 2>&1; rc=$?
fi
;;
exfat)
mkfs.exfat "$partition" >"$tmp_out" 2>&1; rc=$?
;;
btrfs)
if [[ -n "$label" ]]; then
mkfs.btrfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
else
mkfs.btrfs -f "$partition" >"$tmp_out" 2>&1; rc=$?
fi
;;
zfs)
[[ -z "$zfs_pool" ]] && zfs_pool="${label:-pool}"
zpool labelclear -f "$partition" >/dev/null 2>&1 || true
zpool create -f -o ashift=12 \
-O compression=lz4 -O atime=off -O xattr=sa -O acltype=posixacl \
-m "$zfs_mountpoint" "$zfs_pool" "$partition" >"$tmp_out" 2>&1
rc=$?
;;
*)
echo "Unknown filesystem: $filesystem" >"$tmp_out"
rc=1
;;
esac
if [[ $rc -ne 0 ]]; then
DOH_FORMAT_ERROR_DETAIL=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
fi
rm -f "$tmp_out"
return $rc
}

View File

@@ -222,9 +222,9 @@ attach_proxmenux_gpu_guard_to_vm() {
fi fi
if qm set "$vmid" --hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then if qm set "$vmid" --hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then
_gpu_guard_msg_ok "GPU guard hook attached to VM ${vmid}" _gpu_guard_msg_ok "PCIe passthrough guard attached to VM ${vmid}"
else else
_gpu_guard_msg_warn "Could not attach GPU guard hook to VM ${vmid}. Ensure 'local' storage supports snippets." _gpu_guard_msg_warn "Could not attach PCIe passthrough guard to VM ${vmid}. Ensure 'local' storage supports snippets."
fi fi
} }
@@ -239,9 +239,9 @@ attach_proxmenux_gpu_guard_to_lxc() {
fi fi
if pct set "$ctid" -hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then if pct set "$ctid" -hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then
_gpu_guard_msg_ok "GPU guard hook attached to LXC ${ctid}" _gpu_guard_msg_ok "PCIe passthrough guard attached to LXC ${ctid}"
else else
_gpu_guard_msg_warn "Could not attach GPU guard hook to LXC ${ctid}. Ensure 'local' storage supports snippets." _gpu_guard_msg_warn "Could not attach PCIe passthrough guard to LXC ${ctid}. Ensure 'local' storage supports snippets."
fi fi
} }

View File

@@ -15,6 +15,66 @@ function _array_contains() {
return 1 return 1
} }
function _vm_boot_order_add_unique() {
local arr_name="$1"
shift
local -n arr_ref="$arr_name"
local entry
for entry in "$@"; do
[[ -z "$entry" ]] && continue
_array_contains "$entry" "${arr_ref[@]}" || arr_ref+=("$entry")
done
}
function _vm_boot_order_join() {
local -a unique_entries=()
local entry
for entry in "$@"; do
[[ -z "$entry" ]] && continue
_array_contains "$entry" "${unique_entries[@]}" || unique_entries+=("$entry")
done
[[ ${#unique_entries[@]} -gt 0 ]] || return 0
local joined
joined=$(IFS=';'; echo "${unique_entries[*]}")
echo "$joined"
}
function _vm_boot_order_hostpci_entries_for_pcis() {
local vmid="$1"
shift
local cfg
cfg=$(qm config "$vmid" 2>/dev/null || true)
[[ -n "$cfg" ]] || return 0
local -a hostpci_entries=()
local pci bdf bdf_re slot_base slot_re line entry
for pci in "$@"; do
[[ -n "$pci" ]] || continue
bdf="${pci#0000:}"
bdf_re="${bdf//./\\.}"
line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${bdf_re}([,[:space:]]|$)" <<< "$cfg" | head -n1)
if [[ -z "$line" ]]; then
slot_base="${bdf%.*}"
slot_re="${slot_base//./\\.}"
line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${slot_re}(\\.[0-7])?([,[:space:]]|$)" <<< "$cfg" | head -n1)
fi
[[ -n "$line" ]] || continue
entry="${line%%:*}"
_array_contains "$entry" "${hostpci_entries[@]}" || hostpci_entries+=("$entry")
done
printf '%s\n' "${hostpci_entries[@]}"
}
function _vmids_scope_key() {
[[ "$#" -eq 0 ]] && { echo ""; return 0; }
printf '%s\n' "$@" | awk 'NF' | sort -u | paste -sd',' -
}
function _refresh_host_storage_cache() { function _refresh_host_storage_cache() {
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
SWAP_DISKS=$(swapon --noheadings --raw --show=NAME 2>/dev/null) SWAP_DISKS=$(swapon --noheadings --raw --show=NAME 2>/dev/null)
@@ -23,17 +83,24 @@ function _refresh_host_storage_cache() {
ZFS_DISKS="" ZFS_DISKS=""
local zfs_raw entry path base_disk local zfs_raw entry path base_disk
zfs_raw=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') zfs_raw=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror' | grep -v '^raidz')
for entry in $zfs_raw; do for entry in $zfs_raw; do
path="" path=""
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then if [[ "$entry" == /dev/* ]]; then
[[ -e "/dev/disk/by-id/$entry" ]] && path=$(readlink -f "/dev/disk/by-id/$entry") path=$(readlink -f "$entry" 2>/dev/null)
elif [[ "$entry" == /dev/* ]]; then elif [[ -e "/dev/disk/by-id/$entry" ]]; then
path="$entry" path=$(readlink -f "/dev/disk/by-id/$entry" 2>/dev/null)
elif [[ -e "/dev/$entry" ]]; then
path=$(readlink -f "/dev/$entry" 2>/dev/null)
fi fi
if [[ -n "$path" ]]; then if [[ -n "$path" ]]; then
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
[[ -n "$base_disk" ]] && ZFS_DISKS+="/dev/$base_disk"$'\n' if [[ -n "$base_disk" ]]; then
ZFS_DISKS+="/dev/$base_disk"$'\n'
else
# Whole-disk vdev — path is already the resolved disk itself
ZFS_DISKS+="$path"$'\n'
fi
fi fi
done done
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u) ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
@@ -77,7 +144,7 @@ function _disk_is_host_system_used() {
DISK_USAGE_REASON="$(translate "Disk is part of host LVM")" DISK_USAGE_REASON="$(translate "Disk is part of host LVM")"
return 0 return 0
fi fi
if [[ -n "$ZFS_DISKS" && "$ZFS_DISKS" == *"$disk"* ]]; then if [[ -n "$ZFS_DISKS" ]] && grep -qFx "$disk" <<< "$ZFS_DISKS"; then
DISK_USAGE_REASON="$(translate "Disk is part of a host ZFS pool")" DISK_USAGE_REASON="$(translate "Disk is part of a host ZFS pool")"
return 0 return 0
fi fi
@@ -86,23 +153,181 @@ function _disk_is_host_system_used() {
function _disk_used_in_guest_configs() { function _disk_used_in_guest_configs() {
local disk="$1" local disk="$1"
local real_path local real_path escaped
real_path=$(readlink -f "$disk" 2>/dev/null) real_path=$(readlink -f "$disk" 2>/dev/null)
if [[ -n "$real_path" ]] && grep -Fq "$real_path" <<< "$CONFIG_DATA"; then # Use boundary matching: path must be followed by comma, whitespace, or EOL
return 0 # This prevents /dev/sdb from falsely matching /dev/sdb1 or /dev/sdb2
if [[ -n "$real_path" ]]; then
escaped="${real_path//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then
return 0
fi
fi fi
local symlink local symlink symlink_escaped
for symlink in /dev/disk/by-id/*; do for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue [[ -e "$symlink" ]] || continue
if [[ "$(readlink -f "$symlink")" == "$real_path" ]] && grep -Fq "$symlink" <<< "$CONFIG_DATA"; then [[ "$(readlink -f "$symlink")" == "$real_path" ]] || continue
symlink_escaped="${symlink//./\\.}"
if grep -qE "${symlink_escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then
return 0 return 0
fi fi
done done
return 1 return 1
} }
# Returns 0 if the disk is referenced in a RUNNING VM or CT config.
# Mirrors _disk_used_in_guest_configs but checks guest status per-file.
function _disk_used_in_running_guest() {
local disk="$1"
local real_path
real_path=$(readlink -f "$disk" 2>/dev/null)
local -a aliases=()
[[ -n "$disk" ]] && aliases+=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local conf vmid alias escaped
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
vmid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
if qm status "$vmid" 2>/dev/null | grep -q "status: running"; then
return 0
fi
fi
done
done
local ctid
for conf in /etc/pve/lxc/*.conf; do
[[ -f "$conf" ]] || continue
ctid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
if pct status "$ctid" 2>/dev/null | grep -q "status: running"; then
return 0
fi
fi
done
done
return 1
}
# Prints "VM:VMID" or "CT:CTID" for each stopped guest that references the disk.
function _disk_guest_ids() {
local disk="$1"
local real_path
real_path=$(readlink -f "$disk" 2>/dev/null)
local -a aliases=()
[[ -n "$disk" ]] && aliases+=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local conf vmid alias escaped
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
vmid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
echo "VM:$vmid"
break
fi
done
done
local ctid
for conf in /etc/pve/lxc/*.conf; do
[[ -f "$conf" ]] || continue
ctid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
echo "CT:$ctid"
break
fi
done
done
}
# Print the slot names (e.g. sata0, scsi1) in a VM config that reference the disk.
function _find_disk_slots_in_vm() {
local vmid="$1"
local disk="$2"
local real_path conf
real_path=$(readlink -f "$disk" 2>/dev/null)
conf="/etc/pve/qemu-server/${vmid}.conf"
[[ -f "$conf" ]] || return
local -a aliases=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local key rest alias escaped
while IFS=: read -r key rest; do
key=$(echo "$key" | xargs)
[[ "$key" =~ ^(scsi|sata|ide|virtio)[0-9]+$ ]] || continue
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then
echo "$key"
break
fi
done
done < "$conf"
}
# Print the mp names (e.g. mp0, mp1) in a CT config that reference the disk.
function _find_disk_slots_in_ct() {
local ctid="$1"
local disk="$2"
local real_path conf
real_path=$(readlink -f "$disk" 2>/dev/null)
conf="/etc/pve/lxc/${ctid}.conf"
[[ -f "$conf" ]] || return
local -a aliases=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local key rest alias escaped
while IFS=: read -r key rest; do
key=$(echo "$key" | xargs)
[[ "$key" =~ ^mp[0-9]+$ ]] || continue
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then
echo "$key"
break
fi
done
done < "$conf"
}
function _controller_block_devices() { function _controller_block_devices() {
local pci_full="$1" local pci_full="$1"
local pci_root="/sys/bus/pci/devices/$pci_full" local pci_root="/sys/bus/pci/devices/$pci_full"
@@ -137,6 +362,14 @@ function _vm_is_q35() {
[[ "$machine_line" == *q35* ]] [[ "$machine_line" == *q35* ]]
} }
function _vm_storage_register_vfio_iommu_tool() {
local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json"
command -v jq >/dev/null 2>&1 || return 0
[[ -f "$tools_json" ]] || echo "{}" > "$tools_json"
jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \
&& mv "$tools_json.tmp" "$tools_json" || true
}
function _vm_storage_enable_iommu_cmdline() { function _vm_storage_enable_iommu_cmdline() {
local cpu_vendor iommu_param local cpu_vendor iommu_param
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
@@ -175,18 +408,28 @@ function _vm_storage_ensure_iommu_or_offer() {
local reboot_policy="${VM_STORAGE_IOMMU_REBOOT_POLICY:-ask_now}" local reboot_policy="${VM_STORAGE_IOMMU_REBOOT_POLICY:-ask_now}"
if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then
_vm_storage_register_vfio_iommu_tool
return 0 return 0
fi fi
if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \ if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \
[[ -d /sys/kernel/iommu_groups ]] && \ [[ -d /sys/kernel/iommu_groups ]] && \
[[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then [[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then
_vm_storage_register_vfio_iommu_tool
return 0 return 0
fi fi
# Wizard flow: if IOMMU was already configured in this run and reboot is pending, # Dedup: if IOMMU was already configured/announced in this wizard run, skip prompt
# allow the user to continue planning storage selections without re-prompting. if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
if [[ "$reboot_policy" == "defer" && "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then return 0
fi
# Detect if another script already wrote IOMMU params (e.g. GPU script ran first)
if grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \
grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then
_vm_storage_register_vfio_iommu_tool
VM_STORAGE_IOMMU_PENDING_REBOOT=1
export VM_STORAGE_IOMMU_PENDING_REBOOT
return 0 return 0
fi fi
@@ -206,6 +449,8 @@ function _vm_storage_ensure_iommu_or_offer() {
return 1 return 1
fi fi
_vm_storage_register_vfio_iommu_tool
if [[ "$reboot_policy" == "defer" ]]; then if [[ "$reboot_policy" == "defer" ]]; then
VM_STORAGE_IOMMU_PENDING_REBOOT=1 VM_STORAGE_IOMMU_PENDING_REBOOT=1
export VM_STORAGE_IOMMU_PENDING_REBOOT export VM_STORAGE_IOMMU_PENDING_REBOOT
@@ -230,47 +475,71 @@ function _vm_storage_confirm_controller_passthrough_risk() {
local vmid="${1:-}" local vmid="${1:-}"
local vm_name="${2:-}" local vm_name="${2:-}"
local title="${3:-Controller + NVMe}" local title="${3:-Controller + NVMe}"
local ui_mode="${4:-auto}" # wizard | standalone | auto
local vm_label="" local vm_label=""
if [[ -n "$vmid" ]]; then if [[ -n "$vmid" ]]; then
vm_label="$vmid" vm_label="$vmid"
[[ -n "$vm_name" ]] && vm_label="${vm_label} (${vm_name})" [[ -n "$vm_name" ]] && vm_label="${vm_label} (${vm_name})"
fi fi
local msg
msg="$(translate "Important compatibility notice")\n\n"
msg+="$(translate "Not all motherboards support physical Controller/NVMe passthrough to VMs reliably, especially systems with old platforms or limited BIOS/UEFI firmware.")\n\n"
msg+="$(translate "On some systems, the VM may fail to start or the host may freeze when the VM boots.")\n\n"
local reinforce_limited_firmware="no" local reinforce_limited_firmware="no"
local bios_date bios_year current_year cpu_model local bios_date bios_year current_year bios_age cpu_model risk_detail=""
bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null) bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null)
bios_year=$(echo "$bios_date" | grep -oE '[0-9]{4}' | tail -n1) bios_year=$(echo "$bios_date" | grep -oE '[0-9]{4}' | tail -n1)
current_year=$(date +%Y 2>/dev/null) current_year=$(date +%Y 2>/dev/null)
if [[ -n "$bios_year" && -n "$current_year" ]]; then if [[ -n "$bios_year" && -n "$current_year" ]]; then
if (( current_year - bios_year >= 7 )); then bios_age=$(( current_year - bios_year ))
if (( bios_age >= 7 )); then
reinforce_limited_firmware="yes" reinforce_limited_firmware="yes"
risk_detail="$(translate "BIOS from") ${bios_year} (${bios_age} $(translate "years old")) — $(translate "older firmware may increase passthrough instability")"
fi fi
fi fi
cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs) cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs)
if echo "$cpu_model" | grep -qiE 'J4[0-9]{3}|J3[0-9]{3}|N4[0-9]{3}|N3[0-9]{3}|Apollo Lake'; then if echo "$cpu_model" | grep -qiE 'J4[0-9]{3}|J3[0-9]{3}|N4[0-9]{3}|N3[0-9]{3}|Apollo Lake'; then
reinforce_limited_firmware="yes" reinforce_limited_firmware="yes"
[[ -z "$risk_detail" ]] && risk_detail="$(translate "Low-power CPU platform"): ${cpu_model}"
fi fi
if [[ "$reinforce_limited_firmware" == "yes" ]]; then if [[ "$ui_mode" == "auto" ]]; then
msg+="$(translate "Detected risk factor: this host may use an older or limited firmware platform, which increases passthrough instability risk.")\n\n" if [[ "${PROXMENUX_UI_MODE:-}" == "wizard" || "${WIZARD_CALL:-false}" == "true" ]]; then
ui_mode="wizard"
else
ui_mode="standalone"
fi
fi fi
if [[ -n "$vm_label" ]]; then local height=20
msg+="$(translate "Target VM"): ${vm_label}\n\n" [[ "$reinforce_limited_firmware" == "yes" ]] && height=23
fi
msg+="$(translate "If this happens after assignment"):\n"
msg+=" - $(translate "Power cycle the host if it is frozen.")\n"
msg+=" - $(translate "Remove the hostpci controller/NVMe entries from the VM config file.")\n"
msg+=" /etc/pve/qemu-server/${vmid:-<VMID>}.conf\n"
msg+=" - $(translate "Start the VM again without that passthrough device.")\n\n"
msg+="$(translate "Do you want to continue with this assignment?")"
whiptail --title "$title" --yesno "$msg" 21 96 if [[ "$ui_mode" == "wizard" ]]; then
# whiptail: plain text (no color codes)
local msg
[[ -n "$vm_label" ]] && msg+="$(translate "Target VM"): ${vm_label}\n\n"
msg+="$(translate "Controller/NVMe passthrough — compatibility notice")\n\n"
msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n"
msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n"
if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then
msg+="\n$(translate "Detected risk factor"): ${risk_detail}\n"
fi
msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-<VMID>}.conf\n"
msg+="\n$(translate "Do you want to continue?")"
whiptail --title "$title" --yesno "$msg" $height 96
else
# dialog: colored format matching add_controller_nvme_vm.sh
local msg
[[ -n "$vm_label" ]] && msg+="\n\Zb$(translate "Target VM"): ${vm_label}\Zn\n"
msg+="\n\Zb\Z4⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\Zn\n\n"
msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n"
msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n"
if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then
msg+="\n\Z1$(translate "Detected risk factor"): ${risk_detail}\Zn\n"
fi
msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-<VMID>}.conf\n"
msg+="\n\Zb$(translate "Do you want to continue?")\Zn"
dialog --backtitle "ProxMenux" --colors \
--title "$title" \
--yesno "$msg" $height 96
fi
} }
function _shorten_text() { function _shorten_text() {
@@ -284,6 +553,30 @@ function _shorten_text() {
fi fi
} }
function _pci_storage_display_name() {
local pci_full="$1"
local raw_line name_part
raw_line=$(lspci -nn -s "${pci_full#0000:}" 2>/dev/null | sed 's/^[^ ]* //')
if [[ -z "$raw_line" ]]; then
translate "Unknown storage controller"
return 0
fi
# Prefer the right side after class prefix (e.g. "...: Vendor Model ...").
name_part="${raw_line#*: }"
[[ "$name_part" == "$raw_line" ]] && name_part="$raw_line"
# Remove noisy suffixes while keeping the meaningful model name.
name_part="${name_part%% (rev *}"
name_part=$(echo "$name_part" | sed -E 's/\[[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\]//g')
name_part=$(echo "$name_part" | sed -E 's/ Technology Inc\.?//g; s/ Corporation//g; s/ Co\., Ltd\.?//g')
name_part=$(echo "$name_part" | sed -E 's/[[:space:]]+/ /g; s/^ +| +$//g')
[[ -z "$name_part" ]] && name_part="$raw_line"
echo "$name_part"
}
function _pci_slot_base() { function _pci_slot_base() {
local pci_full="$1" local pci_full="$1"
local slot local slot

View File

@@ -28,6 +28,7 @@ LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
TOOLS_JSON="$BASE_DIR/installed_tools.json"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
@@ -190,12 +191,80 @@ _vm_switch_action_label() {
esac esac
} }
_gpu_register_vfio_iommu_tool() {
command -v jq >/dev/null 2>&1 || return 0
[[ -f "$TOOLS_JSON" ]] || echo "{}" > "$TOOLS_JSON"
jq '.vfio_iommu=true' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" \
&& mv "$TOOLS_JSON.tmp" "$TOOLS_JSON" || true
}
_set_wizard_result() { _set_wizard_result() {
local result="$1" local result="$1"
[[ -z "${GPU_WIZARD_RESULT_FILE:-}" ]] && return 0 [[ -z "${GPU_WIZARD_RESULT_FILE:-}" ]] && return 0
printf '%s\n' "$result" >"$GPU_WIZARD_RESULT_FILE" 2>/dev/null || true printf '%s\n' "$result" >"$GPU_WIZARD_RESULT_FILE" 2>/dev/null || true
} }
# ==========================================================
# UI wrapper helpers — dialog in standalone, whiptail in wizard
# ==========================================================
# Strips dialog color sequences (\Zb, \Z1, \Zn, etc.) from a string
_strip_colors() {
printf '%s' "$1" | sed 's/\\Z[0-9a-zA-Z]//g'
}
# Msgbox: dialog in standalone mode, whiptail in wizard mode
_pmx_msgbox() {
local title="$1" msg="$2" h="${3:-10}" w="${4:-72}"
if [[ "$WIZARD_CALL" == "true" ]]; then
whiptail --backtitle "ProxMenux" --title "$title" \
--msgbox "$(_strip_colors "$msg")" "$h" "$w"
else
dialog --backtitle "ProxMenux" --colors \
--title "$title" --msgbox "$msg" "$h" "$w"
fi
}
# Yesno: dialog in standalone mode, whiptail in wizard mode
# Returns 0 for yes, 1 for no (same as dialog/whiptail)
_pmx_yesno() {
local title="$1" msg="$2" h="${3:-10}" w="${4:-72}"
if [[ "$WIZARD_CALL" == "true" ]]; then
whiptail --backtitle "ProxMenux" --title "$title" \
--yesno "$(_strip_colors "$msg")" "$h" "$w"
else
dialog --backtitle "ProxMenux" --colors \
--title "$title" --yesno "$msg" "$h" "$w"
fi
return $?
}
# Menu: dialog in standalone mode, whiptail in wizard mode
# Accepts optional --default-item VALUE before title
# Usage: _pmx_menu [--default-item VAL] title msg h w list_h item desc ...
_pmx_menu() {
local -a extra_opts=()
while [[ "${1:-}" == --* ]]; do
case "$1" in
--default-item) extra_opts+=("--default-item" "$2"); shift 2 ;;
*) shift ;;
esac
done
local title="$1" msg="$2" h="$3" w="$4" lh="$5"
shift 5
if [[ "$WIZARD_CALL" == "true" ]]; then
whiptail --backtitle "ProxMenux" "${extra_opts[@]}" \
--title "$title" \
--menu "$(_strip_colors "$msg")" "$h" "$w" "$lh" \
"$@" 3>&1 1>&2 2>&3
else
dialog --backtitle "ProxMenux" --colors "${extra_opts[@]}" \
--title "$title" \
--menu "$msg" "$h" "$w" "$lh" \
"$@" 2>&1 >/dev/tty
fi
return $?
}
_file_has_exact_line() { _file_has_exact_line() {
local line="$1" local line="$1"
local file="$2" local file="$2"
@@ -398,18 +467,16 @@ ensure_selected_gpu_not_already_in_target_vm() {
TARGET_VM_ALREADY_HAS_GPU=true TARGET_VM_ALREADY_HAS_GPU=true
local popup_title local popup_title
popup_title=$(_get_vm_run_title) popup_title=$(_get_vm_run_title)
dialog --backtitle "ProxMenux" \ _pmx_msgbox "${popup_title}" \
--title "${popup_title}" \ "\n$(translate 'The selected GPU is already assigned to this VM, but the host is not currently using vfio-pci for this device.')\n\n$(translate 'Current driver'): ${current_driver}\n\n$(translate 'The script will continue to restore VM passthrough mode on the host and reuse existing hostpci entries.')" \
--msgbox "\n$(translate 'The selected GPU is already assigned to this VM, but the host is not currently using vfio-pci for this device.')\n\n$(translate 'Current driver'): ${current_driver}\n\n$(translate 'The script will continue to restore VM passthrough mode on the host and reuse existing hostpci entries.')" \
13 78 13 78
return 0 return 0
fi fi
# Single GPU system: nothing else to choose # Single GPU system: nothing else to choose
if [[ $GPU_COUNT -le 1 ]]; then if [[ $GPU_COUNT -le 1 ]]; then
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'GPU Already Added')" \
--title "$(translate 'GPU Already Added')" \ "\n$(translate 'The selected GPU is already assigned to this VM.')\n\n$(translate 'No changes are required.')" \
--msgbox "\n$(translate 'The selected GPU is already assigned to this VM.')\n\n$(translate 'No changes are required.')" \
9 66 9 66
exit 0 exit 0
fi fi
@@ -428,22 +495,18 @@ ensure_selected_gpu_not_already_in_target_vm() {
done done
if [[ $available -eq 0 ]]; then if [[ $available -eq 0 ]]; then
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'All GPUs Already Assigned')" \
--title "$(translate 'All GPUs Already Assigned')" \ "\n$(translate 'All detected GPUs are already assigned to this VM.')\n\n$(translate 'No additional GPU can be added.')" \
--msgbox "\n$(translate 'All detected GPUs are already assigned to this VM.')\n\n$(translate 'No additional GPU can be added.')" \
10 70 10 70
exit 0 exit 0
fi fi
local choice local choice
local -a clear_opt=() choice=$(_pmx_menu \
[[ "$WIZARD_CALL" != "true" ]] && clear_opt+=(--clear) "$(translate 'GPU Already Assigned to This VM')" \
choice=$(dialog "${clear_opt[@]}" --backtitle "ProxMenux" --colors \ "\n$(translate 'The selected GPU is already present in this VM. Select another GPU to continue:')" \
--title "$(translate 'GPU Already Assigned to This VM')" \
--menu "\n$(translate 'The selected GPU is already present in this VM. Select another GPU to continue:')" \
18 82 10 \ 18 82 10 \
"${menu_items[@]}" \ "${menu_items[@]}") || exit 0
2>&1 >/dev/tty) || exit 0
SELECTED_GPU="${ALL_GPU_TYPES[$choice]}" SELECTED_GPU="${ALL_GPU_TYPES[$choice]}"
SELECTED_GPU_PCI="${ALL_GPU_PCIS[$choice]}" SELECTED_GPU_PCI="${ALL_GPU_PCIS[$choice]}"
@@ -492,9 +555,8 @@ detect_host_gpus() {
if [[ $GPU_COUNT -eq 0 ]]; then if [[ $GPU_COUNT -eq 0 ]]; then
_set_wizard_result "no_gpu" _set_wizard_result "no_gpu"
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'No GPU Detected')" \
--title "$(translate 'No GPU Detected')" \ "\n$(translate 'No compatible GPU was detected on this host.')" 8 60
--msgbox "\n$(translate 'No compatible GPU was detected on this host.')" 8 60
exit 0 exit 0
fi fi
@@ -506,7 +568,16 @@ detect_host_gpus() {
# Phase 1 — Step 2: Check IOMMU, offer to enable it # Phase 1 — Step 2: Check IOMMU, offer to enable it
# ========================================================== # ==========================================================
check_iommu_enabled() { check_iommu_enabled() {
# Dedup: if IOMMU was already configured by another script in this wizard run, skip prompt
if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
IOMMU_PENDING_REBOOT=true
HOST_CONFIG_CHANGED=true
_gpu_register_vfio_iommu_tool
return 0
fi
if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then
_gpu_register_vfio_iommu_tool
return 0 return 0
fi fi
@@ -519,9 +590,11 @@ check_iommu_enabled() {
if [[ "$configured_next_boot" == "true" ]]; then if [[ "$configured_next_boot" == "true" ]]; then
IOMMU_PENDING_REBOOT=true IOMMU_PENDING_REBOOT=true
HOST_CONFIG_CHANGED=true HOST_CONFIG_CHANGED=true
dialog --backtitle "ProxMenux" \ _gpu_register_vfio_iommu_tool
--title "$(translate 'IOMMU Pending Reboot')" \ VM_STORAGE_IOMMU_PENDING_REBOOT=1
--msgbox "\n$(translate 'IOMMU is already configured for next boot, but it is not active yet.')\n\n$(translate 'GPU passthrough configuration will continue now and will become effective after host reboot.')" \ export VM_STORAGE_IOMMU_PENDING_REBOOT
_pmx_msgbox "$(translate 'IOMMU Pending Reboot')" \
"\n$(translate 'IOMMU is already configured for next boot, but it is not active yet.')\n\n$(translate 'GPU passthrough configuration will continue now and will become effective after host reboot.')" \
11 78 11 78
return 0 return 0
fi fi
@@ -533,9 +606,7 @@ check_iommu_enabled() {
msg+="$(translate 'Note: A system reboot will be required after enabling IOMMU.')\n" msg+="$(translate 'Note: A system reboot will be required after enabling IOMMU.')\n"
msg+="$(translate 'Configuration will continue now and be effective after reboot.')" msg+="$(translate 'Configuration will continue now and be effective after reboot.')"
dialog --backtitle "ProxMenux" \ _pmx_yesno "$(translate 'IOMMU Required')" "$msg" 15 72
--title "$(translate 'IOMMU Required')" \
--yesno "$msg" 15 72
local response=$? local response=$?
[[ "$WIZARD_CALL" != "true" ]] && clear [[ "$WIZARD_CALL" != "true" ]] && clear
@@ -553,6 +624,9 @@ check_iommu_enabled() {
fi fi
IOMMU_PENDING_REBOOT=true IOMMU_PENDING_REBOOT=true
HOST_CONFIG_CHANGED=true HOST_CONFIG_CHANGED=true
_gpu_register_vfio_iommu_tool
VM_STORAGE_IOMMU_PENDING_REBOOT=1
export VM_STORAGE_IOMMU_PENDING_REBOOT
echo echo
msg_success "$(translate 'IOMMU configured. GPU passthrough setup will continue now and will be effective after reboot.')" msg_success "$(translate 'IOMMU configured. GPU passthrough setup will continue now and will be effective after reboot.')"
echo echo
@@ -632,14 +706,11 @@ select_gpu() {
done done
local choice local choice
local -a clear_opt=() choice=$(_pmx_menu \
[[ "$WIZARD_CALL" != "true" ]] && clear_opt+=(--clear) "$(translate 'Select GPU for VM Passthrough')" \
choice=$(dialog "${clear_opt[@]}" --backtitle "ProxMenux" --colors \ "\n$(translate 'Select the GPU to pass through to the VM:')" \
--title "$(translate 'Select GPU for VM Passthrough')" \
--menu "\n$(translate 'Select the GPU to pass through to the VM:')" \
18 82 10 \ 18 82 10 \
"${menu_items[@]}" \ "${menu_items[@]}") || exit 0
2>&1 >/dev/tty) || exit 0
SELECTED_GPU="${ALL_GPU_TYPES[$choice]}" SELECTED_GPU="${ALL_GPU_TYPES[$choice]}"
SELECTED_GPU_PCI="${ALL_GPU_PCIS[$choice]}" SELECTED_GPU_PCI="${ALL_GPU_PCIS[$choice]}"
@@ -665,9 +736,7 @@ warn_single_gpu() {
msg+="$(translate 'Make sure you have SSH or Web UI access before rebooting.')\n\n" msg+="$(translate 'Make sure you have SSH or Web UI access before rebooting.')\n\n"
msg+="$(translate 'Do you want to continue?')" msg+="$(translate 'Do you want to continue?')"
dialog --backtitle "ProxMenux" --colors \ _pmx_yesno "$(translate 'Single GPU Warning')" "$msg" 22 76
--title "$(translate 'Single GPU Warning')" \
--yesno "$msg" 22 76
[[ $? -ne 0 ]] && exit 0 [[ $? -ne 0 ]] && exit 0
} }
@@ -765,9 +834,7 @@ check_intel_vm_compatibility() {
msg+="$(translate 'This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.')\n\n" msg+="$(translate 'This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.')\n\n"
msg+="$(translate 'Recommended: use GPU with LXC workloads instead of VM passthrough on this hardware.')" msg+="$(translate 'Recommended: use GPU with LXC workloads instead of VM passthrough on this hardware.')"
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'Blocked GPU ID')" "$msg" 20 84
--title "$(translate 'Blocked GPU ID')" \
--msgbox "$msg" 20 84
exit 0 exit 0
fi fi
@@ -782,9 +849,7 @@ check_intel_vm_compatibility() {
msg+="$(translate 'This state has a high probability of VM startup/reset failures.')\n\n" msg+="$(translate 'This state has a high probability of VM startup/reset failures.')\n\n"
msg+="\Zb$(translate 'Configuration has been stopped to prevent an unusable VM state.')\Zn" msg+="\Zb$(translate 'Configuration has been stopped to prevent an unusable VM state.')\Zn"
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'High-Risk GPU Power State')" "$msg" 20 80
--title "$(translate 'High-Risk GPU Power State')" \
--msgbox "$msg" 20 80
exit 0 exit 0
fi fi
@@ -800,9 +865,7 @@ check_intel_vm_compatibility() {
msg+="$(translate 'startup/restart errors are likely.')\n\n" msg+="$(translate 'startup/restart errors are likely.')\n\n"
msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn" msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn"
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'Reset Capability Blocked')" "$msg" 20 80
--title "$(translate 'Reset Capability Blocked')" \
--msgbox "$msg" 20 80
exit 0 exit 0
fi fi
@@ -818,9 +881,7 @@ check_intel_vm_compatibility() {
msg+="$(translate 'start/restart failures and reset instability.')\n\n" msg+="$(translate 'start/restart failures and reset instability.')\n\n"
msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn" msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn"
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'Reset Capability Blocked')" "$msg" 20 80
--title "$(translate 'Reset Capability Blocked')" \
--msgbox "$msg" 20 80
exit 0 exit 0
fi fi
@@ -834,9 +895,7 @@ check_intel_vm_compatibility() {
msg+="$(translate 'Passthrough may work, but startup/restart reliability is not guaranteed.')\n\n" msg+="$(translate 'Passthrough may work, but startup/restart reliability is not guaranteed.')\n\n"
msg+="$(translate 'Do you want to continue anyway?')" msg+="$(translate 'Do you want to continue anyway?')"
dialog --backtitle "ProxMenux" --colors \ _pmx_yesno "$(translate 'Reset Capability Warning')" "$msg" 18 78
--title "$(translate 'Reset Capability Warning')" \
--yesno "$msg" 18 78
[[ $? -ne 0 ]] && exit 0 [[ $? -ne 0 ]] && exit 0
fi fi
} }
@@ -872,9 +931,7 @@ check_gpu_vm_compatibility() {
msg+="$(translate 'Potential QEMU startup/assertion failures')\n\n" msg+="$(translate 'Potential QEMU startup/assertion failures')\n\n"
msg+="\Zb$(translate 'Configuration has been stopped to prevent an unusable VM state.')\Zn" msg+="\Zb$(translate 'Configuration has been stopped to prevent an unusable VM state.')\Zn"
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'High-Risk GPU Power State')" "$msg" 22 80
--title "$(translate 'High-Risk GPU Power State')" \
--msgbox "$msg" 22 80
exit 0 exit 0
fi fi
@@ -903,9 +960,7 @@ check_gpu_vm_compatibility() {
msg+=" — QEMU IRQ assertion failure → VM does not start\n\n" msg+=" — QEMU IRQ assertion failure → VM does not start\n\n"
msg+="\Zb$(translate 'Configuration has been stopped to prevent leaving the VM in an unusable state.')\Zn" msg+="\Zb$(translate 'Configuration has been stopped to prevent leaving the VM in an unusable state.')\Zn"
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'Incompatible GPU for VM Passthrough')" "$msg" 26 80
--title "$(translate 'Incompatible GPU for VM Passthrough')" \
--msgbox "$msg" 26 80
exit 0 exit 0
fi fi
@@ -922,9 +977,7 @@ check_gpu_vm_compatibility() {
msg+="$(translate 'for this policy and may fail after first use or on subsequent VM starts.')\n\n" msg+="$(translate 'for this policy and may fail after first use or on subsequent VM starts.')\n\n"
msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn" msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn"
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'Reset Capability Blocked')" "$msg" 20 80
--title "$(translate 'Reset Capability Blocked')" \
--msgbox "$msg" 20 80
exit 0 exit 0
fi fi
@@ -939,9 +992,7 @@ check_gpu_vm_compatibility() {
msg+="$(translate 'Passthrough may fail depending on hardware/firmware implementation.')\n\n" msg+="$(translate 'Passthrough may fail depending on hardware/firmware implementation.')\n\n"
msg+="$(translate 'Do you want to continue anyway?')" msg+="$(translate 'Do you want to continue anyway?')"
dialog --backtitle "ProxMenux" --colors \ _pmx_yesno "$(translate 'Reset Capability Warning')" "$msg" 18 78
--title "$(translate 'Reset Capability Warning')" \
--yesno "$msg" 18 78
[[ $? -ne 0 ]] && exit 0 [[ $? -ne 0 ]] && exit 0
fi fi
} }
@@ -965,16 +1016,14 @@ analyze_iommu_group() {
did=$(cat "/sys/bus/pci/devices/${pci_full}/device" 2>/dev/null | sed 's/0x//') did=$(cat "/sys/bus/pci/devices/${pci_full}/device" 2>/dev/null | sed 's/0x//')
[[ -n "$vid" && -n "$did" ]] && IOMMU_VFIO_IDS+=("${vid}:${did}") [[ -n "$vid" && -n "$did" ]] && IOMMU_VFIO_IDS+=("${vid}:${did}")
dialog --backtitle "ProxMenux" --colors \ _pmx_msgbox "$(translate 'IOMMU Group Pending')" \
--title "$(translate 'IOMMU Group Pending')" \ "\n$(translate 'IOMMU groups are not available yet because reboot is pending.')\n\n$(translate 'The script will preconfigure the selected GPU now and finalize hardware binding after reboot.')\n\n$(translate 'Selected GPU function'):\n • ${pci_full}" \
--msgbox "\n$(translate 'IOMMU groups are not available yet because reboot is pending.')\n\n$(translate 'The script will preconfigure the selected GPU now and finalize hardware binding after reboot.')\n\n$(translate 'Selected GPU function'):\n • ${pci_full}" \
14 82 14 82
return 0 return 0
fi fi
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'IOMMU Group Error')" \
--title "$(translate 'IOMMU Group Error')" \ "\n$(translate 'Could not determine the IOMMU group for the selected GPU.')\n\n$(translate 'Make sure IOMMU is properly enabled and the system has been rebooted after activation.')" \
--msgbox "\n$(translate 'Could not determine the IOMMU group for the selected GPU.')\n\n$(translate 'Make sure IOMMU is properly enabled and the system has been rebooted after activation.')" \
10 72 10 72
exit 1 exit 1
fi fi
@@ -1016,19 +1065,6 @@ analyze_iommu_group() {
[[ "$dev" != "$pci_full" ]] && extra_devices=$((extra_devices + 1)) [[ "$dev" != "$pci_full" ]] && extra_devices=$((extra_devices + 1))
done done
local msg
msg="$(translate 'IOMMU Group'): ${IOMMU_GROUP}\n\n"
msg+="$(translate 'The following devices will all be passed to the VM') "
msg+="($(translate 'IOMMU isolation rule')):\n\n"
msg+="${display_lines}"
if [[ $extra_devices -gt 0 ]]; then
msg+="\n\Z1$(translate 'All devices in the same IOMMU group must be passed together.')\Zn"
fi
dialog --backtitle "ProxMenux" --colors \
--title "$(translate 'IOMMU Group') ${IOMMU_GROUP}" \
--msgbox "\n${msg}" 22 82
} }
detect_optional_gpu_audio() { detect_optional_gpu_audio() {
@@ -1078,9 +1114,8 @@ select_vm() {
VM_NAME=$(qm config "$SELECTED_VMID" 2>/dev/null | grep "^name:" | awk '{print $2}') VM_NAME=$(qm config "$SELECTED_VMID" 2>/dev/null | grep "^name:" | awk '{print $2}')
return 0 return 0
fi fi
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'Invalid VMID')" \
--title "$(translate 'Invalid VMID')" \ "\n$(translate 'The preselected VMID does not exist on this host:') ${PRESELECT_VMID}" 9 72
--msgbox "\n$(translate 'The preselected VMID does not exist on this host:') ${PRESELECT_VMID}" 9 72
exit 1 exit 1
fi fi
@@ -1097,19 +1132,17 @@ select_vm() {
done < <(qm list 2>/dev/null) done < <(qm list 2>/dev/null)
if [[ ${#menu_items[@]} -eq 0 ]]; then if [[ ${#menu_items[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'No VMs Found')" \
--title "$(translate 'No VMs Found')" \ "\n$(translate 'No Virtual Machines found on this system.')\n\n$(translate 'Create a VM first (machine type q35 + UEFI BIOS), then run this option again.')" \
--msgbox "\n$(translate 'No Virtual Machines found on this system.')\n\n$(translate 'Create a VM first (machine type q35 + UEFI BIOS), then run this option again.')" \
10 68 10 68
exit 0 exit 0
fi fi
SELECTED_VMID=$(dialog --backtitle "ProxMenux" \ SELECTED_VMID=$(_pmx_menu \
--title "$(translate 'Select Virtual Machine')" \ "$(translate 'Select Virtual Machine')" \
--menu "\n$(translate 'Select the VM to add the GPU to:')" \ "\n$(translate 'Select the VM to add the GPU to:')" \
20 72 12 \ 20 72 12 \
"${menu_items[@]}" \ "${menu_items[@]}") || exit 0
2>&1 >/dev/tty) || exit 0
VM_NAME=$(qm config "$SELECTED_VMID" 2>/dev/null | grep "^name:" | awk '{print $2}') VM_NAME=$(qm config "$SELECTED_VMID" 2>/dev/null | grep "^name:" | awk '{print $2}')
} }
@@ -1138,9 +1171,7 @@ check_vm_machine_type() {
msg+="$(translate 'BIOS: OVMF (UEFI)')\n" msg+="$(translate 'BIOS: OVMF (UEFI)')\n"
msg+="$(translate 'Storage controller: VirtIO SCSI')" msg+="$(translate 'Storage controller: VirtIO SCSI')"
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'Incompatible Machine Type')" "$msg" 20 78
--title "$(translate 'Incompatible Machine Type')" \
--msgbox "$msg" 20 78
exit 0 exit 0
} }
@@ -1210,13 +1241,11 @@ check_switch_mode() {
msg+="\Z1\Zb$(translate 'Start on boot enabled (onboot=1)'): ${onboot_count}\Zn\n" msg+="\Z1\Zb$(translate 'Start on boot enabled (onboot=1)'): ${onboot_count}\Zn\n"
msg+="\n\Z1$(translate 'After this LXC → VM switch, reboot the host so the new binding state is applied cleanly.')\Zn" msg+="\n\Z1$(translate 'After this LXC → VM switch, reboot the host so the new binding state is applied cleanly.')\Zn"
action_choice=$(dialog --backtitle "ProxMenux" --colors \ action_choice=$(_pmx_menu --default-item "2" \
--title "$(translate 'GPU Used in LXC Containers')" \ "$(translate 'GPU Used in LXC Containers')" \
--default-item "2" \ "$msg" 25 96 8 \
--menu "$msg" 25 96 8 \
"1" "$(translate 'Keep GPU in LXC config (disable Start on boot)')" \ "1" "$(translate 'Keep GPU in LXC config (disable Start on boot)')" \
"2" "$(translate 'Remove GPU from LXC config (keep Start on boot)')" \ "2" "$(translate 'Remove GPU from LXC config (keep Start on boot)')") || exit 0
2>&1 >/dev/tty) || exit 0
case "$action_choice" in case "$action_choice" in
1) LXC_SWITCH_ACTION="keep_gpu_disable_onboot" ;; 1) LXC_SWITCH_ACTION="keep_gpu_disable_onboot" ;;
@@ -1254,9 +1283,7 @@ check_switch_mode() {
msg+=" Hardware Graphics → Add GPU to VM\n" msg+=" Hardware Graphics → Add GPU to VM\n"
msg+="$(translate 'to move the GPU safely.')" msg+="$(translate 'to move the GPU safely.')"
dialog --backtitle "ProxMenux" \ _pmx_msgbox "$(translate 'GPU Busy in Running VM')" "$msg" 16 78
--title "$(translate 'GPU Busy in Running VM')" \
--msgbox "$msg" 16 78
exit 0 exit 0
fi fi
@@ -1292,13 +1319,11 @@ check_switch_mode() {
msg+="$(translate 'Choose conflict policy for the source VM:')" msg+="$(translate 'Choose conflict policy for the source VM:')"
local vm_action_choice local vm_action_choice
vm_action_choice=$(dialog --clear --backtitle "ProxMenux" --colors \ vm_action_choice=$(_pmx_menu --default-item "1" \
--title "$(translate 'GPU Already Assigned to Another VM')" \ "$(translate 'GPU Already Assigned to Another VM')" \
--default-item "1" \ "$msg" 24 84 8 \
--menu "$msg" 24 98 8 \
"1" "$(translate 'Keep GPU in source VM config (disable Start on boot if enabled)')" \ "1" "$(translate 'Keep GPU in source VM config (disable Start on boot if enabled)')" \
"2" "$(translate 'Remove GPU from source VM config (keep Start on boot)')" \ "2" "$(translate 'Remove GPU from source VM config (keep Start on boot)')") || exit 0
2>&1 >/dev/tty) || exit 0
case "$vm_action_choice" in case "$vm_action_choice" in
1) SWITCH_VM_ACTION="keep_gpu_disable_onboot" ;; 1) SWITCH_VM_ACTION="keep_gpu_disable_onboot" ;;
@@ -1376,9 +1401,7 @@ confirm_summary() {
local run_title local run_title
run_title=$(_get_vm_run_title) run_title=$(_get_vm_run_title)
dialog --clear --backtitle "ProxMenux" --colors \ _pmx_yesno "${run_title}" "$msg" 28 78
--title "${run_title}" \
--yesno "$msg" 28 78
[[ $? -ne 0 ]] && exit 0 [[ $? -ne 0 ]] && exit 0
} }
@@ -1724,7 +1747,7 @@ cleanup_vm_config() {
local pci_slot="${SELECTED_GPU_PCI#0000:}" local pci_slot="${SELECTED_GPU_PCI#0000:}"
pci_slot="${pci_slot%.*}" # 01:00 pci_slot="${pci_slot%.*}" # 01:00
if [[ "$VM_SWITCH_ACTION" == "keep_gpu_disable_onboot" ]]; then if [[ "$SWITCH_VM_ACTION" == "keep_gpu_disable_onboot" ]]; then
msg_info "$(translate 'Keeping GPU in source VM config') ${SWITCH_VM_SRC}..." msg_info "$(translate 'Keeping GPU in source VM config') ${SWITCH_VM_SRC}..."
if _vm_onboot_enabled "$SWITCH_VM_SRC"; then if _vm_onboot_enabled "$SWITCH_VM_SRC"; then
if qm set "$SWITCH_VM_SRC" -onboot 0 >>"$LOG_FILE" 2>&1; then if qm set "$SWITCH_VM_SRC" -onboot 0 >>"$LOG_FILE" 2>&1; then
@@ -1916,7 +1939,6 @@ main() {
if [[ "$WIZARD_CALL" == "true" ]]; then if [[ "$WIZARD_CALL" == "true" ]]; then
echo echo
else else
clear
show_proxmenux_logo show_proxmenux_logo
msg_title "${run_title}" msg_title "${run_title}"
fi fi

View File

@@ -0,0 +1,160 @@
#!/bin/bash
# ==========================================================
# ProxMenux - GPU/TPU Manual CLI Guide
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : GPL-3.0
# Version : 1.0
# Last Updated: 07/04/2026
# ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
GREEN=$'\033[0;32m'
NC=$'\033[0m'
_cl() {
# _cl <num> <display_cmd> <description>
# Prints a numbered command line with fixed-column alignment (separator at col 52).
local num="$1" disp="$2" desc="$3"
local pad=$((47 - ${#disp}))
[[ $pad -lt 1 ]] && pad=1
local spaces
spaces=$(printf '%*s' "$pad" '')
printf " %2d) %s%s%s%s - %s\n" "$num" "$GREEN" "$disp" "$NC" "$spaces" "$desc"
}
while true; do
clear
show_proxmenux_logo
msg_title "$(translate "GPU/TPU - Manual CLI Guide")"
echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}"
echo
_cl 1 "lspci -nn | grep -iE 'VGA|3D|Display'" "$(translate 'Detect GPUs in host')"
_cl 2 "lspci -nnk | grep -A3 -Ei 'VGA|3D'" "$(translate 'Show GPU kernel driver in use')"
_cl 3 "cat /proc/cmdline" "$(translate 'Check kernel params (IOMMU flags)')"
_cl 4 "dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'" "$(translate 'Inspect passthrough/kernel events')"
_cl 5 "find /sys/kernel/iommu_groups -type l" "$(translate 'List IOMMU group mapping')"
_cl 6 "lsmod | grep -E 'vfio|nvidia|amdgpu|apex'" "$(translate 'Check loaded GPU/TPU modules')"
_cl 7 "grep -R \"vfio-pci|blacklist\" /etc/modprobe.d" "$(translate 'Review passthrough config files')"
_cl 8 "nvidia-smi" "$(translate 'Check NVIDIA driver and devices')"
_cl 9 "qm config <vmid> | grep 'hostpci|bios'" "$(translate 'Check VM passthrough settings')"
_cl 10 "pct config <ctid> | grep 'dev|lxc.cgroup2'" "$(translate 'Check LXC GPU/TPU mapping')"
_cl 11 "ls -l /dev/dri /dev/kfd /dev/nvidia*" "$(translate 'Inspect host device nodes')"
_cl 12 "qm set <vmid> --hostpci<slot> <BDF>,pcie=1" "[T] $(translate 'Assign GPU PCI function to VM')"
_cl 13 "qm set <vmid> -delete hostpci<slot>" "[T] $(translate 'Remove passthrough device from VM')"
_cl 14 "qm set <vmid> -onboot 0" "[T] $(translate 'Disable autostart on conflicting VM')"
_cl 15 "sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'" "[T] $(translate 'Enable IOMMU in GRUB or ZFS boot')"
_cl 16 "update-initramfs -u && proxmox-boot-tool" "[T] $(translate 'Apply boot/initramfs changes')"
_cl 17 "lsusb | grep Coral ; lspci | grep Unichip" "$(translate 'Check Coral USB/M.2 detection')"
echo -e " ${DEF} 0) $(translate 'Back to previous menu or Esc + Enter')${CL}"
echo
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}"
read -r user_input
if [[ "$user_input" == $'\x1b' ]]; then
break
fi
mode="exec"
case "$user_input" in
1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;;
2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;;
3) cmd="cat /proc/cmdline" ;;
4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;;
5) cmd="find /sys/kernel/iommu_groups -type l" ;;
6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;;
7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;;
8) cmd="nvidia-smi" ;;
9)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"
read -r vmid
cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'"
;;
10)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"
read -r ctid
cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'"
;;
11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;;
12)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf
cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1"
mode="template"
;;
13)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
cmd="qm set $vmid -delete hostpci${slot}"
mode="template"
;;
14)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
cmd="qm set $vmid -onboot 0"
mode="template"
;;
15)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor
case "$cpu_vendor" in
amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;;
*) iommu_param="intel_iommu=on iommu=pt" ;;
esac
case "$boot_type" in
zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;;
*) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;;
esac
mode="template"
;;
16)
cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)"
mode="template"
;;
17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;;
0) break ;;
*)
if [[ -n "$user_input" ]]; then
cmd="$user_input"
else
continue
fi
;;
esac
if [[ "$mode" == "template" ]]; then
echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n"
echo "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
continue
fi
echo -e "\n${GREEN}> $cmd${NC}\n"
bash -c "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
done

View File

@@ -57,6 +57,33 @@ detect_nvidia_gpus() {
fi fi
} }
check_gpu_not_in_vm_passthrough() {
local dev vendor driver vfio_list=""
for dev in /sys/bus/pci/devices/*; do
vendor=$(cat "$dev/vendor" 2>/dev/null)
[[ "$vendor" != "0x10de" ]] && continue
if [[ -L "$dev/driver" ]]; then
driver=$(basename "$(readlink "$dev/driver")")
if [[ "$driver" == "vfio-pci" ]]; then
vfio_list+="$(basename "$dev")\n"
fi
fi
done
[[ -z "$vfio_list" ]] && return 0
local msg
msg="\n$(translate "One or more NVIDIA GPUs are currently configured for VM passthrough (vfio-pci):")\n\n"
msg+="${vfio_list}\n"
msg+="$(translate "Installing host drivers while the GPU is assigned to a VM could break passthrough and destabilize the system.")\n\n"
msg+="$(translate "To install host drivers, first remove the GPU from VM passthrough configuration and reboot.")"
dialog --backtitle "ProxMenux" \
--title "$(translate "GPU in VM Passthrough Mode")" \
--msgbox "$msg" 16 78
exit 0
}
detect_driver_status() { detect_driver_status() {
CURRENT_DRIVER_INSTALLED=false CURRENT_DRIVER_INSTALLED=false
CURRENT_DRIVER_VERSION="" CURRENT_DRIVER_VERSION=""
@@ -842,6 +869,7 @@ main() {
detect_nvidia_gpus detect_nvidia_gpus
detect_driver_status detect_driver_status
check_gpu_not_in_vm_passthrough
if ! $NVIDIA_GPU_PRESENT; then if ! $NVIDIA_GPU_PRESENT; then
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \ dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \

View File

@@ -23,6 +23,37 @@ load_language
initialize_cache initialize_cache
# ============================================================
# GPU passthrough guard — block update when GPU is in VM passthrough mode
# ============================================================
check_gpu_not_in_vm_passthrough() {
local dev vendor driver vfio_list=""
for dev in /sys/bus/pci/devices/*; do
vendor=$(cat "$dev/vendor" 2>/dev/null)
[[ "$vendor" != "0x10de" ]] && continue
if [[ -L "$dev/driver" ]]; then
driver=$(basename "$(readlink "$dev/driver")")
if [[ "$driver" == "vfio-pci" ]]; then
vfio_list+="$(basename "$dev")\n"
fi
fi
done
[[ -z "$vfio_list" ]] && return 0
local msg
msg="\n$(translate "One or more NVIDIA GPUs are currently configured for VM passthrough (vfio-pci):")\n\n"
msg+="${vfio_list}\n"
msg+="$(translate "Updating host drivers while the GPU is assigned to a VM could break passthrough and destabilize the system.")\n\n"
msg+="$(translate "To update host drivers, first remove the GPU from VM passthrough configuration and reboot.")"
dialog --backtitle "ProxMenux" \
--title "$(translate "GPU in VM Passthrough Mode")" \
--msgbox "$msg" 16 78
exit 0
}
# ============================================================ # ============================================================
# Host NVIDIA state detection # Host NVIDIA state detection
# ============================================================ # ============================================================
@@ -436,13 +467,25 @@ show_current_state_dialog() {
# Restart prompt # Restart prompt
# ============================================================ # ============================================================
restart_prompt() { restart_prompt() {
if whiptail --title "$(translate 'NVIDIA Update')" --yesno \ echo
"$(translate 'The host driver update requires a reboot to take effect. Reboot now?')" 10 70; then msg_success "$(translate 'NVIDIA driver update completed.')"
msg_warn "$(translate 'Restarting the server...')" echo
msg_info "$(translate 'Removing no longer required packages and purging old cached updates...')"
apt-get -y autoremove >/dev/null 2>&1
apt-get -y autoclean >/dev/null 2>&1
msg_ok "$(translate 'Cleanup finished.')"
echo -e "${TAB}${BL}Log: ${LOG_FILE}${CL}"
echo
if whiptail --title "$(translate 'Reboot Required')" \
--yesno "$(translate 'The host driver update requires a reboot to take effect. Do you want to restart now?')" 10 70; then
msg_success "$(translate 'Press Enter to continue...')"
read -r
msg_warn "$(translate 'Rebooting the system...')"
reboot reboot
else else
msg_success "$(translate 'Update complete. Please reboot the server manually.')" msg_info2 "$(translate 'You can reboot later manually.')"
msg_success "$(translate 'Completed. Press Enter to return to menu...')" msg_success "$(translate 'Press Enter to continue...')"
read -r read -r
fi fi
} }
@@ -455,6 +498,7 @@ main() {
: >"$LOG_FILE" : >"$LOG_FILE"
# ---- Phase 1: dialogs ---- # ---- Phase 1: dialogs ----
check_gpu_not_in_vm_passthrough
detect_host_nvidia detect_host_nvidia
show_current_state_dialog show_current_state_dialog
select_target_version select_target_version

View File

@@ -888,6 +888,48 @@ apply_vm_action_for_lxc_mode() {
done done
} }
_register_iommu_tool() {
local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json"
command -v jq >/dev/null 2>&1 || return 0
[[ -f "$tools_json" ]] || echo "{}" > "$tools_json"
jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \
&& mv "$tools_json.tmp" "$tools_json" || true
}
_enable_iommu_cmdline() {
local cpu_vendor
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
local iommu_param
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
iommu_param="intel_iommu=on"
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
iommu_param="amd_iommu=on"
else
return 1
fi
local cmdline_file="/etc/kernel/cmdline"
local grub_file="/etc/default/grub"
if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then
if ! grep -q "$iommu_param" "$cmdline_file"; then
cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file"
proxmox-boot-tool refresh >>"$LOG_FILE" 2>&1 || true
fi
elif [[ -f "$grub_file" ]]; then
if ! grep -q "$iommu_param" "$grub_file"; then
cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file"
update-grub >>"$LOG_FILE" 2>&1 || true
fi
else
return 1
fi
return 0
}
switch_to_vm_mode() { switch_to_vm_mode() {
detect_affected_lxc_for_selected detect_affected_lxc_for_selected
prompt_lxc_action_for_vm_mode prompt_lxc_action_for_vm_mode
@@ -897,6 +939,25 @@ switch_to_vm_mode() {
apply_lxc_action_for_vm_mode apply_lxc_action_for_vm_mode
msg_info "$(translate 'Configuring host for GPU -> VM mode...')" msg_info "$(translate 'Configuring host for GPU -> VM mode...')"
if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then
_register_iommu_tool
msg_ok "$(translate 'IOMMU is already active on this system')" | tee -a "$screen_capture"
elif grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \
grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then
_register_iommu_tool
HOST_CONFIG_CHANGED=true
msg_ok "$(translate 'IOMMU already configured in kernel parameters')" | tee -a "$screen_capture"
else
if _enable_iommu_cmdline; then
_register_iommu_tool
HOST_CONFIG_CHANGED=true
msg_ok "$(translate 'IOMMU kernel parameters configured')" | tee -a "$screen_capture"
else
msg_warn "$(translate 'Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.')" | tee -a "$screen_capture"
fi
fi
_add_vfio_modules _add_vfio_modules
msg_ok "$(translate 'VFIO modules configured in /etc/modules')" | tee -a "$screen_capture" msg_ok "$(translate 'VFIO modules configured in /etc/modules')" | tee -a "$screen_capture"
_configure_iommu_options _configure_iommu_options
@@ -1011,6 +1072,45 @@ switch_to_lxc_mode() {
fi fi
} }
# ==========================================================
# Send notification when GPU mode switch completes
# ==========================================================
_send_gpu_mode_notification() {
local new_mode="$1"
local old_mode="$2"
local notify_script="/usr/bin/notification_manager.py"
[[ ! -f "$notify_script" ]] && return 0
local hostname_short
hostname_short=$(hostname -s)
# Build GPU list for notification
local gpu_list=""
local idx
for idx in "${SELECTED_GPU_IDX[@]}"; do
gpu_list+="${ALL_GPU_NAMES[$idx]} (${ALL_GPU_PCIS[$idx]}), "
done
gpu_list="${gpu_list%, }"
local mode_label details
if [[ "$new_mode" == "vm" ]]; then
mode_label="GPU -> VM (VFIO passthrough)"
details="GPU(s) ready for VM passthrough. A host reboot may be required."
else
mode_label="GPU -> LXC (native driver)"
details="GPU(s) available for LXC containers with native drivers."
fi
python3 "$notify_script" --action send-raw --severity INFO \
--title "${hostname_short}: GPU mode changed to ${mode_label}" \
--message "GPU passthrough mode switched.
GPU(s): ${gpu_list}
Previous: ${old_mode}
New: ${mode_label}
${details}" 2>/dev/null || true
}
confirm_plan() { confirm_plan() {
local msg mode_line local msg mode_line
if [[ "$TARGET_MODE" == "vm" ]]; then if [[ "$TARGET_MODE" == "vm" ]]; then
@@ -1079,12 +1179,22 @@ main() {
_set_title _set_title
echo echo
# Determine old mode before switch for notification
local old_mode_label
if [[ "$CURRENT_MODE" == "vm" ]]; then
old_mode_label="GPU -> VM (VFIO)"
else
old_mode_label="GPU -> LXC (native)"
fi
if [[ "$TARGET_MODE" == "vm" ]]; then if [[ "$TARGET_MODE" == "vm" ]]; then
switch_to_vm_mode switch_to_vm_mode
msg_success "$(translate 'GPU switch complete: VM mode prepared.')" msg_success "$(translate 'GPU switch complete: VM mode prepared.')"
_send_gpu_mode_notification "vm" "$old_mode_label"
else else
switch_to_lxc_mode switch_to_lxc_mode
msg_success "$(translate 'GPU switch complete: LXC mode prepared.')" msg_success "$(translate 'GPU switch complete: LXC mode prepared.')"
_send_gpu_mode_notification "lxc" "$old_mode_label"
fi fi
final_summary final_summary

View File

@@ -816,6 +816,48 @@ apply_vm_action_for_lxc_mode() {
# ========================================================== # ==========================================================
# Switch Mode Functions # Switch Mode Functions
# ========================================================== # ==========================================================
_register_iommu_tool() {
local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json"
command -v jq >/dev/null 2>&1 || return 0
[[ -f "$tools_json" ]] || echo "{}" > "$tools_json"
jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \
&& mv "$tools_json.tmp" "$tools_json" || true
}
_enable_iommu_cmdline() {
local cpu_vendor
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
local iommu_param
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
iommu_param="intel_iommu=on"
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
iommu_param="amd_iommu=on"
else
return 1
fi
local cmdline_file="/etc/kernel/cmdline"
local grub_file="/etc/default/grub"
if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then
if ! grep -q "$iommu_param" "$cmdline_file"; then
cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file"
proxmox-boot-tool refresh >>"$LOG_FILE" 2>&1 || true
fi
elif [[ -f "$grub_file" ]]; then
if ! grep -q "$iommu_param" "$grub_file"; then
cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file"
update-grub >>"$LOG_FILE" 2>&1 || true
fi
else
return 1
fi
return 0
}
switch_to_vm_mode() { switch_to_vm_mode() {
detect_affected_lxc_for_selected detect_affected_lxc_for_selected
prompt_lxc_action_for_vm_mode prompt_lxc_action_for_vm_mode
@@ -825,6 +867,25 @@ switch_to_vm_mode() {
apply_lxc_action_for_vm_mode apply_lxc_action_for_vm_mode
msg_info "$(translate 'Configuring host for GPU -> VM mode...')" msg_info "$(translate 'Configuring host for GPU -> VM mode...')"
if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then
_register_iommu_tool
msg_ok "$(translate 'IOMMU is already active on this system')" | tee -a "$screen_capture"
elif grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \
grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then
_register_iommu_tool
HOST_CONFIG_CHANGED=true
msg_ok "$(translate 'IOMMU already configured in kernel parameters')" | tee -a "$screen_capture"
else
if _enable_iommu_cmdline; then
_register_iommu_tool
HOST_CONFIG_CHANGED=true
msg_ok "$(translate 'IOMMU kernel parameters configured')" | tee -a "$screen_capture"
else
msg_warn "$(translate 'Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.')" | tee -a "$screen_capture"
fi
fi
_add_vfio_modules _add_vfio_modules
msg_ok "$(translate 'VFIO modules configured in /etc/modules')" | tee -a "$screen_capture" msg_ok "$(translate 'VFIO modules configured in /etc/modules')" | tee -a "$screen_capture"
_configure_iommu_options _configure_iommu_options
@@ -986,6 +1047,39 @@ final_summary() {
# ========================================================== # ==========================================================
# Parse Arguments (supports both CLI args and env vars) # Parse Arguments (supports both CLI args and env vars)
# ==========================================================
# Send notification when GPU mode switch completes
# ==========================================================
_send_gpu_mode_notification() {
local new_mode="$1"
local gpu_name="$2"
local gpu_pci="$3"
local old_mode="$4"
local notify_script="/usr/bin/notification_manager.py"
[[ ! -f "$notify_script" ]] && return 0
local hostname_short
hostname_short=$(hostname -s)
local mode_label details
if [[ "$new_mode" == "vm" ]]; then
mode_label="GPU -> VM (VFIO passthrough)"
details="GPU is now ready for VM passthrough. A host reboot may be required."
else
mode_label="GPU -> LXC (native driver)"
details="GPU is now available for LXC containers with native drivers."
fi
python3 "$notify_script" --action send-raw --severity INFO \
--title "${hostname_short}: GPU mode changed to ${mode_label}" \
--message "GPU passthrough mode switched.
GPU: ${gpu_name} (${gpu_pci})
Previous: ${old_mode}
New: ${mode_label}
${details}" 2>/dev/null || true
}
# ========================================================== # ==========================================================
parse_arguments() { parse_arguments() {
# First, check combined parameter (format: "SLOT|MODE") # First, check combined parameter (format: "SLOT|MODE")
@@ -1066,13 +1160,28 @@ main() {
_set_title _set_title
echo echo
# Determine old mode before switch for notification
local old_mode_label
if [[ "$CURRENT_MODE" == "vm" ]]; then
old_mode_label="GPU -> VM (VFIO)"
else
old_mode_label="GPU -> LXC (native)"
fi
# Get GPU info for notification
local gpu_idx="${SELECTED_GPU_IDX[0]}"
local gpu_name="${ALL_GPU_NAMES[$gpu_idx]}"
local gpu_pci="${ALL_GPU_PCIS[$gpu_idx]}"
# Execute the switch # Execute the switch
if [[ "$TARGET_MODE" == "vm" ]]; then if [[ "$TARGET_MODE" == "vm" ]]; then
switch_to_vm_mode switch_to_vm_mode
msg_success "$(translate 'GPU switch complete: VM mode prepared.')" msg_success "$(translate 'GPU switch complete: VM mode prepared.')"
_send_gpu_mode_notification "vm" "$gpu_name" "$gpu_pci" "$old_mode_label"
else else
switch_to_lxc_mode switch_to_lxc_mode
msg_success "$(translate 'GPU switch complete: LXC mode prepared.')" msg_success "$(translate 'GPU switch complete: LXC mode prepared.')"
_send_gpu_mode_notification "lxc" "$gpu_name" "$gpu_pci" "$old_mode_label"
fi fi
final_summary final_summary

View File

@@ -303,7 +303,7 @@ show_storage_commands() {
15) cmd="lvs" ;; 15) cmd="lvs" ;;
16) cmd="cat /etc/pve/storage.cfg" ;; 16) cmd="cat /etc/pve/storage.cfg" ;;
17) cmd="pvesm status" ;; 17) cmd="pvesm status" ;;
19) 18)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter storage ID: ')${CL}" echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter storage ID: ')${CL}"
read -r store read -r store
cmd="pvesm list $store" cmd="pvesm list $store"
@@ -591,42 +591,116 @@ show_update_commands() {
# =============================================================== # ===============================================================
# 06 GPU Passthrough Commands # 06 GPU/TPU Passthrough Commands
# =============================================================== # ===============================================================
show_gpu_commands() { show_gpu_commands() {
while true; do while true; do
clear clear
echo -e "${YELLOW}$(translate 'GPU Passthrough Commands')${NC}" echo -e "${YELLOW}$(translate 'GPU/TPU Passthrough Commands')${NC}"
echo "------------------------------------------------" echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}"
echo -e " 1) ${GREEN}lspci -nn | grep -i nvidia${NC} - $(translate 'List NVIDIA PCI devices')" echo "------------------------------------------------------------"
echo -e " 2) ${GREEN}lspci -nn | grep -i vga${NC} - $(translate 'List all VGA compatible devices')" echo -e " 1) ${GREEN}lspci -nn | grep -iE 'VGA|3D|Display'${NC} - $(translate 'Detect GPUs in host')"
echo -e " 3) ${GREEN}dmesg | grep -i vfio${NC} - $(translate 'Check VFIO module messages')" echo -e " 2) ${GREEN}lspci -nnk | grep -A3 -Ei 'VGA|3D'${NC} - $(translate 'Show GPU kernel driver in use')"
echo -e " 4) ${GREEN}cat /etc/modprobe.d/vfio.conf${NC} - $(translate 'Review VFIO passthrough configuration')" echo -e " 3) ${GREEN}cat /proc/cmdline${NC} - $(translate 'Check kernel params (IOMMU flags)')"
echo -e " 5) ${GREEN}update-initramfs -u${NC} - $(translate 'Apply initramfs changes (VFIO)')" echo -e " 4) ${GREEN}dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'${NC} - $(translate 'Inspect passthrough/kernel events')"
echo -e " 6) ${GREEN}cat /etc/default/grub${NC} - $(translate 'Review GRUB options for IOMMU')" echo -e " 5) ${GREEN}find /sys/kernel/iommu_groups -type l${NC} - $(translate 'List IOMMU group mapping')"
echo -e " 7) ${GREEN}update-grub${NC} - $(translate 'Apply GRUB changes')" echo -e " 6) ${GREEN}lsmod | grep -E 'vfio|nvidia|amdgpu|apex'${NC} - $(translate 'Check loaded GPU/TPU modules')"
echo -e " 7) ${GREEN}grep -R \"vfio-pci|blacklist\" /etc/modprobe.d${NC} - $(translate 'Review passthrough config files')"
echo -e " 8) ${GREEN}nvidia-smi${NC} - $(translate 'Check NVIDIA driver and devices')"
echo -e " 9) ${GREEN}qm config <vmid> | grep 'hostpci|bios'${NC} - [T] $(translate 'Check VM passthrough settings')"
echo -e "10) ${GREEN}pct config <ctid> | grep 'dev|lxc.cgroup2'${NC} - [T] $(translate 'Check LXC GPU/TPU mapping')"
echo -e "11) ${GREEN}ls -l /dev/dri /dev/kfd /dev/nvidia*${NC} - $(translate 'Inspect host device nodes')"
echo -e "12) ${GREEN}qm set <vmid> --hostpci<slot> <BDF>,pcie=1${NC} - [T] $(translate 'Assign GPU PCI function to VM')"
echo -e "13) ${GREEN}qm set <vmid> -delete hostpci<slot>${NC} - [T] $(translate 'Remove passthrough device from VM')"
echo -e "14) ${GREEN}qm set <vmid> -onboot 0${NC} - [T] $(translate 'Disable autostart on conflicting VM')"
echo -e "15) ${GREEN}sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'${NC} - [T] $(translate 'Enable IOMMU in GRUB or ZFS boot')"
echo -e "16) ${GREEN}update-initramfs -u && proxmox-boot-tool${NC} - [T] $(translate 'Apply boot/initramfs changes')"
echo -e "17) ${GREEN}lsusb | grep Coral ; lspci | grep Unichip${NC} - $(translate 'Check Coral USB/M.2 detection')"
echo -e " ${DEF}0) $(translate ' Back to previous menu or Esc + Enter')" echo -e " ${DEF}0) $(translate ' Back to previous menu or Esc + Enter')"
echo echo
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}" echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}"
read -r user_input read -r user_input
# Check for Esc key press
if [[ "$user_input" == $'\x1b' ]]; then if [[ "$user_input" == $'\x1b' ]]; then
break break
fi fi
mode="exec"
case "$user_input" in case "$user_input" in
1) cmd="lspci -nn | grep -i nvidia" ;; 1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;;
2) cmd="lspci -nn | grep -i vga" ;; 2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;;
3) cmd="dmesg | grep -i vfio" ;; 3) cmd="cat /proc/cmdline" ;;
4) cmd="cat /etc/modprobe.d/vfio.conf" ;; 4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;;
5) cmd="update-initramfs -u" ;; 5) cmd="find /sys/kernel/iommu_groups -type l" ;;
6) cmd="cat /etc/default/grub" ;; 6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;;
7) cmd="update-grub" ;; 7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;;
8) cmd="nvidia-smi" ;;
9)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"
read -r vmid
cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'"
;;
10)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"
read -r ctid
cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'"
;;
11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;;
12)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf
cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1"
mode="template"
;;
13)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
cmd="qm set $vmid -delete hostpci${slot}"
mode="template"
;;
14)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
cmd="qm set $vmid -onboot 0"
mode="template"
;;
15)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor
case "$cpu_vendor" in
amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;;
*) iommu_param="intel_iommu=on iommu=pt" ;;
esac
case "$boot_type" in
zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;;
*) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;;
esac
mode="template"
;;
16)
cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)"
mode="template"
;;
17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;;
0) break ;; 0) break ;;
*) cmd="$user_input" ;; *)
if [[ -n "$user_input" ]]; then
cmd="$user_input"
else
continue
fi
;;
esac esac
if [[ "$mode" == "template" ]]; then
echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n"
echo "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
continue
fi
echo -e "\n${GREEN}> $cmd${NC}\n" echo -e "\n${GREEN}> $cmd${NC}\n"
bash -c "$cmd" bash -c "$cmd"
echo echo
@@ -913,7 +987,7 @@ show_tools_commands() {
while true; do while true; do
OPTION=$(dialog --stdout \ OPTION=$(dialog --stdout \
--title "$(translate 'Help and Info')" \ --title "$(translate 'Help and Info')" \
--menu "\n$(translate 'Select a category of useful commands:')" 20 70 9 \ --menu "$(translate 'Select a category of useful commands:')" 20 70 9 \
1 "$(translate 'Useful System Commands')" \ 1 "$(translate 'Useful System Commands')" \
2 "$(translate 'VM and CT Management Commands')" \ 2 "$(translate 'VM and CT Management Commands')" \
3 "$(translate 'Storage and Disks Commands')" \ 3 "$(translate 'Storage and Disks Commands')" \

View File

@@ -134,14 +134,21 @@ function start_vm_configuration() {
while true; do while true; do
VM_STORAGE_IOMMU_PENDING_REBOOT=0 VM_STORAGE_IOMMU_PENDING_REBOOT=0
OS_TYPE=$(dialog --backtitle "ProxMenux" \ WIZARD_CONFLICT_POLICY=""
WIZARD_CONFLICT_SCOPE=""
export WIZARD_CONFLICT_POLICY WIZARD_CONFLICT_SCOPE
OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "Select System Type")" \ --title "$(translate "Select System Type")" \
--menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \ --menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \
1 "$(translate "Create") VM System NAS" \ 1 "$(translate "Create") VM System NAS" \
2 "$(translate "Create") VM System Windows" \ 2 "$(translate "Create") VM System Windows" \
3 "$(translate "Create") VM System Linux" \ 3 "$(translate "Create") VM System Linux" \
"" "" \
"" "\Z4──────────────────────────────────────────────────\Zn" \
"" "" \
4 "$(translate "Create") VM System macOS (OSX-PROXMOX)" \ 4 "$(translate "Create") VM System macOS (OSX-PROXMOX)" \
5 "$(translate "Create") VM System Others (based Linux)" \ 5 "$(translate "Create") VM System Others (based Linux)" \
"" "" \
6 "$(translate "Return to Main Menu")" \ 6 "$(translate "Return to Main Menu")" \
3>&1 1>&2 2>&3) 3>&1 1>&2 2>&3)

View File

@@ -27,20 +27,24 @@ initialize_cache
while true; do while true; do
OPTION=$(dialog --colors --backtitle "ProxMenux" \ OPTION=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "GPUs and Coral-TPU Menu")" \ --title "$(translate "GPUs and Coral-TPU Menu")" \
--menu "\n$(translate "Select an option:")" 25 80 15 \ --menu "\n$(translate "Select an option:")" 26 78 18 \
"" "\Z4──────────────────────── HOST ─────────────────────────\Zn" \ "" "\Z4──────────────────────── HOST ─────────────────────────\Zn" \
"1" "$(translate "Install NVIDIA Drivers on Host")" \ "1" "$(translate "Install NVIDIA Drivers on Host")" \
"2" "$(translate "Update NVIDIA Drivers (Host + LXC)")" \ "2" "$(translate "Update NVIDIA Drivers (Host + LXC)")" \
"3" "$(translate "Install/Update Coral TPU on Host")" \ "3" "$(translate "Install/Update Coral TPU on Host")" \
"" "" \
"" "\Z4──────────────────────── LXC ──────────────────────────\Zn" \ "" "\Z4──────────────────────── LXC ──────────────────────────\Zn" \
"4" "$(translate "Add GPU to LXC (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \ "4" "$(translate "Add GPU to LXC (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \
"5" "$(translate "Add Coral TPU to LXC")" \ "5" "$(translate "Add Coral TPU to LXC")" \
"" "" \
"" "\Z4──────────────────────── VM ───────────────────────────\Zn" \ "" "\Z4──────────────────────── VM ───────────────────────────\Zn" \
"6" "$(translate "Add GPU to VM (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \ "6" "$(translate "Add GPU to VM (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \
"" "" \ "" "" \
"" "\Z4──────────────────── SWICHT MODE ───────────────────────\Zn" \ "" "\Z4──────────────────── SWICHT MODE ───────────────────────\Zn" \
"7" "$(translate "Switch GPU Mode (VM <-> LXC)")" \ "7" "$(translate "Switch GPU Mode (VM <-> LXC)")" \
"" "" \ "" "" \
"" "\Z4────────────────────── Utilities ───────────────────────\Zn" \
"8" "$(translate "Manual CLI Guide (GPU/TPU)")" \
"0" "$(translate "Return to Main Menu")" \ "0" "$(translate "Return to Main Menu")" \
2>&1 >/dev/tty 2>&1 >/dev/tty
) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; } ) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; }
@@ -67,6 +71,9 @@ while true; do
7) 7)
bash "$LOCAL_SCRIPTS/gpu_tpu/switch_gpu_mode.sh" bash "$LOCAL_SCRIPTS/gpu_tpu/switch_gpu_mode.sh"
;; ;;
8)
bash "$LOCAL_SCRIPTS/gpu_tpu/gpu-tpu-manual-guide.sh"
;;
0) 0)
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
;; ;;

View File

@@ -94,12 +94,12 @@ show_menu() {
dialog --clear \ dialog --clear \
--backtitle "ProxMenux" \ --backtitle "ProxMenux" \
--title "$(translate "$menu_title")" \ --title "$(translate "$menu_title")" \
--menu "$(translate "Select an option:")" 20 70 11 \ --menu "\n$(translate "Select an option:")" 20 70 11 \
1 "$(translate "Settings post-install Proxmox")" \ 1 "$(translate "Settings post-install Proxmox")" \
2 "$(translate "Hardware: GPUs and Coral-TPU")" \ 2 "$(translate "Hardware: GPUs and Coral-TPU")" \
3 "$(translate "Create VM from template or script")" \ 3 "$(translate "Create VM from template or script")" \
4 "$(translate "Disk and Storage Manager")" \ 4 "$(translate "Disk Manager")" \
5 "$(translate "Mount and Share Manager")" \ 5 "$(translate "Storage & Share Manager")" \
6 "$(translate "Proxmox VE Helper Scripts")" \ 6 "$(translate "Proxmox VE Helper Scripts")" \
7 "$(translate "Network Management")" \ 7 "$(translate "Network Management")" \
8 "$(translate "Security")" \ 8 "$(translate "Security")" \

View File

@@ -398,7 +398,7 @@ while true; do
SELECTED_IDX=$(dialog --backtitle "ProxMenux" \ SELECTED_IDX=$(dialog --backtitle "ProxMenux" \
--title "Proxmox VE Helper-Scripts" \ --title "Proxmox VE Helper-Scripts" \
--menu "$(translate "Select a category or search for scripts:"):" \ --menu "$(translate "Select a category or search for scripts:"):" \
20 70 14 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || { 22 75 15 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
dialog --clear --title "ProxMenux" \ dialog --clear --title "ProxMenux" \
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70 --msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
@@ -440,7 +440,7 @@ while true; do
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \ SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \ --title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \
--menu "$(translate "Choose a script to execute:"):" \ --menu "$(translate "Choose a script to execute:"):" \
20 70 14 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break 22 75 15 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}" SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}"
run_script_by_slug "$SCRIPT_SELECTED" run_script_by_slug "$SCRIPT_SELECTED"

View File

@@ -26,13 +26,12 @@ initialize_cache
security_menu() { security_menu() {
while true; do while true; do
local menu_text local menu_text
menu_text="\n$(translate 'Security tools for hardening and auditing your Proxmox VE system.')\n\n" menu_text+="\n$(translate 'Select an option:')"
menu_text+="$(translate 'Select an option:')"
local OPTION local OPTION
OPTION=$(dialog --backtitle "ProxMenux" \ OPTION=$(dialog --backtitle "ProxMenux" \
--title "$(translate "$SCRIPT_TITLE")" \ --title "$(translate "$SCRIPT_TITLE")" \
--menu "$menu_text" 18 70 4 \ --menu "$menu_text" 20 70 10 \
"1" "$(translate 'Fail2Ban - Intrusion Prevention')" \ "1" "$(translate 'Fail2Ban - Intrusion Prevention')" \
"2" "$(translate 'Lynis - Security Audit')" \ "2" "$(translate 'Lynis - Security Audit')" \
3>&1 1>&2 2>&3) || OPTION="0" 3>&1 1>&2 2>&3) || OPTION="0"

View File

@@ -26,21 +26,22 @@ initialize_cache
while true; do while true; do
OPTION=$(dialog --colors --backtitle "ProxMenux" \ OPTION=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "Mount and Share Manager")" \ --title "$(translate "Storage & Share Manager")" \
--menu "\n$(translate "Select an option:")" 25 80 15 \ --menu "\n$(translate "Select an option:")" 26 78 17 \
"" "\Z4──────────────────────── HOST ─────────────────────────\Zn" \ "" "\Z4──────────────────────── HOST ─────────────────────────\Zn" \
"1" "$(translate "Configure NFS shared on Host")" \ "1" "$(translate "Configure NFS shared on Host")" \
"2" "$(translate "Configure Samba shared on Host")" \ "2" "$(translate "Configure Samba shared on Host")" \
"3" "$(translate "Configure Local Shared on Host")" \ "3" "$(translate "Configure Local Shared on Host")" \
"9" "$(translate "Add Local Disk as Proxmox Storage")" \ "4" "$(translate "Add Local Disk as Proxmox Storage")" \
"10" "$(translate "Add iSCSI Target as Proxmox Storage")" \ "5" "$(translate "Add iSCSI Target as Proxmox Storage")" \
"" "\Z4──────────────────────── LXC ─────────────────────────\Zn" \
"4" "$(translate "Configure LXC Mount Points (Host ↔ Container)")" \
"" "" \ "" "" \
"5" "$(translate "Configure NFS Client in LXC (only privileged)")" \ "" "\Z4──────────────────────── LXC ─────────────────────────\Zn" \
"6" "$(translate "Configure Samba Client in LXC (only privileged)")" \ "6" "$(translate "Configure LXC Mount Points (Host ↔ Container)")" \
"7" "$(translate "Configure NFS Server in LXC (only privileged)")" \ "" "" \
"8" "$(translate "configure Samba Server in LXC (only privileged)")" \ "7" "$(translate "Configure NFS Client in LXC (only privileged)")" \
"8" "$(translate "Configure Samba Client in LXC (only privileged)")" \
"9" "$(translate "Configure NFS Server in LXC (only privileged)")" \
"10" "$(translate "configure Samba Server in LXC (only privileged)")" \
"" "" \ "" "" \
"h" "$(translate "Help & Info (commands)")" \ "h" "$(translate "Help & Info (commands)")" \
"0" "$(translate "Return to Main Menu")" \ "0" "$(translate "Return to Main Menu")" \
@@ -62,25 +63,25 @@ while true; do
3) 3)
bash "$LOCAL_SCRIPTS/share/local-shared-manager.sh" bash "$LOCAL_SCRIPTS/share/local-shared-manager.sh"
;; ;;
9) 4)
bash "$LOCAL_SCRIPTS/share/disk_host.sh" bash "$LOCAL_SCRIPTS/share/disk_host.sh"
;; ;;
10) 5)
bash "$LOCAL_SCRIPTS/share/iscsi_host.sh" bash "$LOCAL_SCRIPTS/share/iscsi_host.sh"
;; ;;
4) 6)
bash "$LOCAL_SCRIPTS/share/lxc-mount-manager_minimal.sh" bash "$LOCAL_SCRIPTS/share/lxc-mount-manager_minimal.sh"
;; ;;
5) 7)
bash "$LOCAL_SCRIPTS/share/nfs_client.sh" bash "$LOCAL_SCRIPTS/share/nfs_client.sh"
;; ;;
6) 8)
bash "$LOCAL_SCRIPTS/share/samba_client.sh" bash "$LOCAL_SCRIPTS/share/samba_client.sh"
;; ;;
7) 9)
bash "$LOCAL_SCRIPTS/share/nfs_lxc_server.sh" bash "$LOCAL_SCRIPTS/share/nfs_lxc_server.sh"
;; ;;
8) 10)
bash "$LOCAL_SCRIPTS/share/samba_lxc_server.sh" bash "$LOCAL_SCRIPTS/share/samba_lxc_server.sh"
;; ;;
h) h)

View File

@@ -6,7 +6,7 @@
# Copyright : (c) 2024 MacRimi # Copyright : (c) 2024 MacRimi
# License : GPL-3.0 # License : GPL-3.0
# Version : 2.0 # Version : 2.0
# Last Updated: 06/04/2026 # Last Updated: 07/04/2026
# ========================================================== # ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -30,15 +30,21 @@ initialize_cache
while true; do while true; do
OPTION=$(dialog --colors --backtitle "ProxMenux" \ OPTION=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "Disk and Storage Manager Menu")" \ --title "$(translate "Disk Manager")" \
--menu "\n$(translate "Select an option:")" 24 84 14 \ --menu "\n$(translate "Select an option:")" 24 78 16 \
"" "\Z4──────────────────────── VM ───────────────────────────\Zn" \ "" "\Z4──────────────────────── VM ───────────────────────────\Zn" \
"1" "$(translate "Import Disk to VM")" \ "1" "$(translate "Import Disk to VM")" \
"2" "$(translate "Import Disk Image to VM")" \ "2" "$(translate "Import Disk Image to VM")" \
"3" "$(translate "Add Controller or NVMe PCIe to VM")" \ "3" "$(translate "Add Controller or NVMe PCIe to VM")" \
"" "" \
"" "\Z4──────────────────────── LXC ──────────────────────────\Zn" \ "" "\Z4──────────────────────── LXC ──────────────────────────\Zn" \
"4" "$(translate "Import Disk to LXC")" \ "4" "$(translate "Import Disk to LXC")" \
"" "" \ "" "" \
"" "\Z4────────────────────── Utilities ───────────────────────\Zn" \
"5" "$(translate "Format / Wipe Physical Disk (Safe)")" \
"6" "$(translate "SMART Disk Health & Test")" \
"7" "$(translate "Manual CLI Guide (Disk and Storage Manager)")" \
"" "" \
"0" "$(translate "Return to Main Menu")" \ "0" "$(translate "Return to Main Menu")" \
2>&1 >/dev/tty 2>&1 >/dev/tty
) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; } ) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; }
@@ -56,6 +62,15 @@ while true; do
4) 4)
bash "$LOCAL_SCRIPTS/storage/disk-passthrough_ct.sh" bash "$LOCAL_SCRIPTS/storage/disk-passthrough_ct.sh"
;; ;;
5)
bash "$LOCAL_SCRIPTS/storage/format-disk.sh"
;;
6)
bash "$LOCAL_SCRIPTS/storage/smart-disk-test.sh"
;;
7)
bash "$LOCAL_SCRIPTS/storage/disk-storage-manual-guide.sh"
;;
0) 0)
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
;; ;;

View File

@@ -26,12 +26,14 @@ initialize_cache
while true; do while true; do
OPTION=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "Utilities Menu")" \ OPTION=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "Utilities Menu")" \
--menu "$(translate "Select an option:")" 20 70 8 \ --menu "\n$(translate "Select an option:")" 20 70 11 \
"1" "$(translate "UUp Dump ISO creator Custom")" \ "1" "$(translate "UUp Dump ISO creator Custom")" \
"2" "$(translate "System Utilities Installer")" \ "2" "$(translate "System Utilities Installer")" \
"3" "$(translate "Proxmox System Update")" \ "3" "$(translate "Proxmox System Update")" \
"4" "$(translate "Upgrade PVE 8 to PVE 9")" \ "4" "$(translate "Upgrade PVE 8 to PVE 9")" \
"5" "$(translate "Return to Main Menu")" \ "5" "$(translate "Export VM to OVA or OVF")" \
"6" "$(translate "Import VM from OVA or OVF")" \
"7" "$(translate "Return to Main Menu")" \
2>&1 >/dev/tty) 2>&1 >/dev/tty)
case $OPTION in case $OPTION in
@@ -76,8 +78,20 @@ initialize_cache
return return
fi fi
;; ;;
5) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;; 5)
bash "$LOCAL_SCRIPTS/utilities/export_vm_ova_ovf.sh"
if [ $? -ne 0 ]; then
return
fi
;;
6)
bash "$LOCAL_SCRIPTS/utilities/import_vm_ova_ovf.sh"
if [ $? -ne 0 ]; then
return
fi
;;
7) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;;
*) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;; *) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;;
esac esac
done done

View File

@@ -6,8 +6,8 @@
# Author : MacRimi # Author : MacRimi
# Copyright : (c) 2024 MacRimi # Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.5 # Version : 1.6
# Last Updated: 04/08/2025 # Last Updated: 07/04/2026
# ========================================================== # ==========================================================
# Configuration ============================================ # Configuration ============================================
@@ -29,11 +29,14 @@ show_command() {
local command="$3" local command="$3"
local note="$4" local note="$4"
local command_extra="$5" local command_extra="$5"
echo -e "${BGN}${step}.${CL} ${BL}${description}${CL}" echo -e " ${DARK_GRAY}────────────────────────────────────────────────${CL}"
echo -e " ${BGN}${step}.${CL} ${description}"
echo ""
while IFS= read -r line; do
echo -e "${TAB}${line}"
done <<< "$(echo -e "$command")"
echo "" echo ""
echo -e "${TAB}${command}"
echo -e
[[ -n "$note" ]] && echo -e "${TAB}${DARK_GRAY}${note}${CL}" [[ -n "$note" ]] && echo -e "${TAB}${DARK_GRAY}${note}${CL}"
[[ -n "$command_extra" ]] && echo -e "${TAB}${YW}${command_extra}${CL}" [[ -n "$command_extra" ]] && echo -e "${TAB}${YW}${command_extra}${CL}"
echo "" echo ""
@@ -43,10 +46,10 @@ show_how_to_enter_lxc() {
clear clear
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "How to Access an LXC Terminal from Proxmox Host")" msg_title "$(translate "How to Access an LXC Terminal from Proxmox Host")"
msg_info2 "$(translate "Use these commands on your Proxmox host to access an LXC container's terminal:")" msg_info2 "$(translate "Use these commands on your Proxmox host to access an LXC container's terminal:")"
echo -e echo -e
show_command "1" \ show_command "1" \
"$(translate "Get a list of all your containers:")" \ "$(translate "Get a list of all your containers:")" \
"pct list" \ "pct list" \
@@ -54,93 +57,203 @@ show_how_to_enter_lxc() {
"" ""
show_command "2" \ show_command "2" \
"$(translate "Enter the container's terminal")" \ "$(translate "Enter the container terminal:")" \
"pct enter ${CUS}<container-id>${CL}" \ "pct enter ${CUS}<container-id>${CL}" \
"$(translate "Replace <container-id> with the actual ID.")"\ "$(translate "Replace <container-id> with the actual ID.")" \
"$(translate "For example: pct enter 101")" "$(translate "For example: pct enter 101")"
show_command "3" \ show_command "3" \
"$(translate "To exit the container's terminal, press:")" \ "$(translate "Exit the container terminal:")" \
"CTRL + D" \ "exit" \
"" \ "$(translate "Or press CTRL + D")" \
"" ""
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
} }
show_host_mount_resources_help() { show_host_storage_help() {
clear clear
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "Mount Remote Resources on Proxmox Host")" msg_title "$(translate "Host Storage (NFS / Samba via Proxmox)")"
msg_info2 "$(translate "How to mount NFS and Samba shares directly on the Proxmox host. Proxmox already has the necessary tools installed.")"
echo -e
echo -e "${BOLD}${BL}=== MOUNT NFS SHARE ===${CL}" msg_info2 "$(translate "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.")"
msg_info2 "$(translate "This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).")"
echo -e echo -e
echo -e "${BOLD}${BL}=== NFS AS PROXMOX STORAGE ===${CL}"
echo -e
show_command "1" \ show_command "1" \
"$(translate "Create mount point:")" \ "$(translate "Add NFS storage:")" \
"mkdir -p ${CUS}/mnt/nfs_share${CL}" \ "pvesm add nfs ${CUS}<storage-id>${CL} --server ${CUS}<nfs-server-ip>${CL} --export ${CUS}</export/path>${CL} --content ${CUS}import,backup,iso,vztmpl,images,snippets${CL}" \
"$(translate "Replace with your preferred path.")" \ "$(translate "Use content types according to your use case.")" \
"" "$(translate "Example: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup")"
show_command "2" \ show_command "2" \
"$(translate "Mount NFS share:")" \ "$(translate "List configured storages:")" \
"mount -t nfs ${CUS}192.168.1.100${CL}:${CUS}/path/to/share${CL} ${CUS}/mnt/nfs_share${CL}" \ "pvesm status" \
"$(translate "Replace IP and paths with your values.")" \ "$(translate "Shows status and type (nfs/cifs/dir/iscsi...).")" \
"" ""
show_command "3" \ show_command "3" \
"$(translate "Make permanent (optional):")" \ "$(translate "Remove NFS storage:")" \
"echo '${CUS}192.168.1.100${CL}:${CUS}/path/to/share${CL} ${CUS}/mnt/nfs_share${CL} nfs4 rw,hard,intr,_netdev,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0' >> /etc/fstab" \ "pvesm remove ${CUS}<storage-id>${CL}" \
"$(translate "_netdev waits for network before mounting.")" \ "$(translate "Only removes storage definition, not remote data.")" \
"" ""
echo -e "${BOLD}${BL}=== MOUNT SAMBA SHARE ===${CL}" echo -e "${BOLD}${BL}=== SAMBA/CIFS AS PROXMOX STORAGE ===${CL}"
echo -e echo -e
show_command "4" \ show_command "4" \
"$(translate "Create mount point:")" \ "$(translate "Add CIFS storage:")" \
"mkdir -p ${CUS}/mnt/samba_share${CL}" \ "pvesm add cifs ${CUS}<storage-id>${CL} --server ${CUS}<samba-server-ip>${CL} --share ${CUS}<share-name>${CL} --username ${CUS}<user>${CL} --password ${CUS}<pass>${CL} --content ${CUS}import,backup,iso,vztmpl,images,snippets${CL}" \
"$(translate "Replace with your preferred path.")" \ "$(translate "For guest shares add: --options guest")" \
"" ""
show_command "5" \ show_command "5" \
"$(translate "Mount Samba share:")" \ "$(translate "Inspect storage config block:")" \
"mount -t cifs //${CUS}192.168.1.100${CL}/${CUS}sharename${CL} ${CUS}/mnt/samba_share${CL} -o username=${CUS}user${CL}" \ "sed -n '/^${CUS}<storage-id>${CL}:/,/^[^ ]/p' /etc/pve/storage.cfg" \
"$(translate "You will be prompted for password. Replace IP, share and user.")" \ "$(translate "Useful to verify options/content after script execution.")" \
"" ""
show_command "6" \ show_command "6" \
"$(translate "Make permanent (optional):")" \ "$(translate "Remove CIFS storage:")" \
"echo '//${CUS}192.168.1.100${CL}/${CUS}sharename${CL} ${CUS}/mnt/samba_share${CL} cifs username=${CUS}user${CL},password=${CUS}pass${CL},_netdev 0 0' >> /etc/fstab" \ "pvesm remove ${CUS}<storage-id>${CL}" \
"$(translate "Replace with your credentials.")" \ "" \
"" ""
echo -e "${BOLD}${BL}=== CREATE LOCAL DIRECTORY ===${CL}" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")"
read -r
}
show_local_share_help() {
clear
show_proxmenux_logo
msg_title "$(translate "Local Shared Directory on Host")"
msg_info2 "$(translate "Equivalent manual flow used by Local Shared Manager.")"
msg_info2 "$(translate "No group creation required — uses world-writable sticky bit permissions.")"
echo -e echo -e
show_command "7" \ show_command "1" \
"$(translate "Create directory:")" \ "$(translate "Create shared directory:")" \
"mkdir -p ${CUS}/mnt/local_share${CL}" \ "mkdir -p ${CUS}/mnt/shared${CL}" \
"$(translate "Creates a local directory on Proxmox host.")" \ "$(translate "Choose any host path you want to share with CTs.")" \
"" ""
show_command "8" \ show_command "2" \
"$(translate "Set permissions:")" \ "$(translate "Set ownership and permissions:")" \
"chmod 755 ${CUS}/mnt/local_share${CL}" \ "chown root:root ${CUS}/mnt/shared${CL}\nchmod 1777 ${CUS}/mnt/shared${CL}" \
"$(translate "Sets basic read/write permissions.")" \ "$(translate "1777 = sticky bit + rwx for all. No shared group needed.")" \
"" ""
show_command "9" \ show_command "3" \
"$(translate "Verify mounts:")" \ "$(translate "Optional: apply default ACL so new files inherit permissions:")" \
"df -h" \ "setfacl -R -m d:u::rwx,d:g::rwx,d:o::rwx,m::rwx ${CUS}/mnt/shared${CL}" \
"$(translate "Shows all mounted filesystems.")" \ "$(translate "Requires acl package. Skip if setfacl is not available.")" \
"" ""
show_command "4" \
"$(translate "Optional: register this path as Proxmox dir storage:")" \
"pvesm add dir ${CUS}<storage-id>${CL} --path ${CUS}/mnt/shared${CL} --content ${CUS}backup,iso,vztmpl,snippets${CL}" \
"$(translate "Use images only if the directory is on suitable storage.")" \
""
echo -e ""
msg_success "$(translate "Press Enter to return to menu...")"
read -r
}
show_disk_host_help() {
clear
show_proxmenux_logo
msg_title "$(translate "Add Local Disk as Proxmox Storage")"
msg_info2 "$(translate "Equivalent manual flow of disk_host.sh: partition, format, mount, persist, register in Proxmox.")"
echo -e
show_command "1" \
"$(translate "Identify candidate disk (never use system disk):")" \
"lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL" \
"$(translate "Example target: /dev/sdb")" \
""
show_command "2" \
"$(translate "Wipe old signatures and partition table (DESTRUCTIVE):")" \
"wipefs -a ${CUS}/dev/sdb${CL}\nsgdisk --zap-all ${CUS}/dev/sdb${CL}" \
"$(translate "This erases existing metadata.")" \
""
show_command "3" \
"$(translate "Create GPT and one partition:")" \
"parted -s ${CUS}/dev/sdb${CL} mklabel gpt\nparted -s ${CUS}/dev/sdb${CL} mkpart primary 0% 100%" \
"" \
""
show_command "4" \
"$(translate "Format partition:")" \
"mkfs.ext4 -F ${CUS}/dev/sdb1${CL}\n# or\nmkfs.xfs -f ${CUS}/dev/sdb1${CL}" \
"" \
""
show_command "5" \
"$(translate "Mount and persist with UUID:")" \
"mkdir -p ${CUS}/mnt/disk-sdb${CL}\nmount ${CUS}/dev/sdb1${CL} ${CUS}/mnt/disk-sdb${CL}\nblkid ${CUS}/dev/sdb1${CL}\n# Add UUID line to /etc/fstab" \
"$(translate "Using UUID is recommended over /dev/sdX.")" \
""
show_command "6" \
"$(translate "Register mount path in Proxmox:")" \
"pvesm add dir ${CUS}<storage-id>${CL} --path ${CUS}/mnt/disk-sdb${CL} --content ${CUS}images,backup${CL}" \
"" \
""
echo -e ""
msg_success "$(translate "Press Enter to return to menu...")"
read -r
}
show_iscsi_host_help() {
clear
show_proxmenux_logo
msg_title "$(translate "Add iSCSI Target as Proxmox Storage")"
msg_info2 "$(translate "Equivalent manual flow of iscsi_host.sh.")"
echo -e
show_command "1" \
"$(translate "Install and start iSCSI initiator:")" \
"apt-get update && apt-get install -y open-iscsi\nsystemctl enable --now iscsid" \
"" \
""
show_command "2" \
"$(translate "Discover targets on portal:")" \
"iscsiadm -m discovery -t sendtargets -p ${CUS}<portal-ip>:3260${CL}" \
"$(translate "This returns available IQNs.")" \
""
show_command "3" \
"$(translate "Add iSCSI storage in Proxmox:")" \
"pvesm add iscsi ${CUS}<storage-id>${CL} --portal ${CUS}<portal-ip>:3260${CL} --target ${CUS}<target-iqn>${CL} --content images" \
"$(translate "Content is usually images for VM block devices.")" \
""
show_command "4" \
"$(translate "Verify iSCSI sessions and storage status:")" \
"iscsiadm -m session\npvesm status" \
"" \
""
show_command "5" \
"$(translate "Remove iSCSI storage definition:")" \
"pvesm remove ${CUS}<storage-id>${CL}" \
"" \
""
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
@@ -149,42 +262,42 @@ show_host_mount_resources_help() {
show_host_to_lxc_mount_help() { show_host_to_lxc_mount_help() {
clear clear
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "Mount Host Directory to LXC Container")" msg_title "$(translate "Host Directory to LXC Mount Point")"
msg_info2 "$(translate "How to mount a Proxmox host directory into an LXC container. Execute these commands on the Proxmox host.")" msg_info2 "$(translate "Current script uses native bind mounts with pct set -mpX.")"
echo -e msg_info2 "$(translate "Safe design: no automatic ACL/ownership mutation on host or CT.")"
echo -e
show_command "1" \ show_command "1" \
"$(translate "Add mount point to container:")" \ "$(translate "List containers:")" \
"pct set ${CUS}<container-id>${CL} -mp0 ${CUS}/host/directory${CL},mp=${CUS}/container/path${CL},backup=0,shared=1" \ "pct list" \
"$(translate "Replace container-id, host directory and container path.")" \ "" \
"$(translate "Example: pct set 101 -mp0 /mnt/shared,mp=/mnt/shared,,backup=0,shared=1")" ""
show_command "2" \ show_command "2" \
"$(translate "Restart container:")" \ "$(translate "Add bind mount to container:")" \
"pct reboot ${CUS}<container-id>${CL}" \ "pct set ${CUS}<ctid>${CL} -mp0 ${CUS}/host/path${CL},mp=${CUS}/container/path${CL},backup=0,shared=1" \
"$(translate "Required to activate the mount point.")" \ "$(translate "Use mp1/mp2/... for extra mount points.")" \
"" ""
show_command "3" \ show_command "3" \
"$(translate "Verify mount inside container:")" \ "$(translate "Check resulting config:")" \
"pct enter ${CUS}<container-id>${CL} "pct config ${CUS}<ctid>${CL} | grep '^mp'" \
df -h | grep ${CUS}/container/path${CL}" \ "" \
"$(translate "Check if the directory is mounted.")" \
"" ""
show_command "4" \ show_command "4" \
"$(translate "Remove mount point (if needed):")" \ "$(translate "Remove mount point:")" \
"pct set ${CUS}<container-id>${CL} --delete mp0" \ "pct set ${CUS}<ctid>${CL} --delete mp0" \
"$(translate "Removes the mount point. Use mp1, mp2, etc. for other mounts.")" \ "" \
"" ""
echo -e "${BOR}" show_command "5" \
echo -e "${BOLD}$(translate "Notes:")${CL}" "$(translate "Verify inside container:")" \
echo -e "${TAB}${BGN}$(translate "Mount indices:")${CL} ${BL}Use mp0, mp1, mp2, etc. for multiple mounts${CL}" "pct enter ${CUS}<ctid>${CL}\ndf -h" \
echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}May need adjustment depending on directory type${CL}" "$(translate "Confirm the mount path is visible.")" \
echo -e "${TAB}${BGN}$(translate "Container types:")${CL} ${BL}Works with both privileged and unprivileged containers${CL}" ""
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
@@ -193,67 +306,41 @@ show_host_to_lxc_mount_help() {
show_nfs_server_help() { show_nfs_server_help() {
clear clear
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "NFS Server Installation")" msg_title "$(translate "NFS Server in LXC (Privileged)")"
msg_info2 "$(translate "How to install and configure an NFS server in an LXC container.")" msg_warn "$(translate "Use a privileged LXC for NFS server/client workflows.")"
echo -e echo -e
show_command "1" \ show_command "1" \
"$(translate "Update and install packages:")" \ "$(translate "Install server packages inside CT:")" \
"apt-get update && apt-get install -y nfs-kernel-server" \ "apt-get update && apt-get install -y nfs-kernel-server nfs-common rpcbind" \
"" \ "" \
"" ""
show_command "2" \ show_command "2" \
"$(translate "Create export directory:")" \ "$(translate "Create export directory:")" \
"mkdir -p ${CUS}/mnt/nfs_export${CL}" \ "mkdir -p ${CUS}/mnt/nfs_export${CL}\nchmod 755 ${CUS}/mnt/nfs_export${CL}" \
"$(translate "Replace with your preferred path.")" \ "" \
"" ""
show_command "3" \ show_command "3" \
"$(translate "Set directory permissions:")" \ "$(translate "Add export rule:")" \
"chmod 755 ${CUS}/mnt/nfs_export${CL}" \
"" \
""
show_command "4.1" \
"$(translate "Configure exports (safe root_squash):")" \
"echo '${CUS}/mnt/nfs_export${CL} ${CUS}192.168.1.0/24${CL}(rw,sync,no_subtree_check,root_squash)' >> /etc/exports" \ "echo '${CUS}/mnt/nfs_export${CL} ${CUS}192.168.1.0/24${CL}(rw,sync,no_subtree_check,root_squash)' >> /etc/exports" \
"$(translate "Replace directory path and network range.")" \ "$(translate "Adjust network/CIDR to your environment.")" \
"" ""
show_command "4.2" \ show_command "4" \
"$(translate "Or Configure exports (map all users):")" \ "$(translate "Apply and restart services:")" \
"echo '${CUS}/mnt/nfs_export${CL} ${CUS}192.168.1.0/24${CL}(rw,sync,no_subtree_check,all_squash,anonuid=0,anongid=0)' >> /etc/exports" \ "exportfs -ra\nsystemctl restart rpcbind nfs-kernel-server\nsystemctl enable rpcbind nfs-kernel-server" \
"$(translate "Replace directory path and network range.")" \ "" \
"" ""
show_command "5" \ show_command "5" \
"$(translate "Apply configuration:")" \ "$(translate "Verify active exports:")" \
"exportfs -ra" \
"" \
""
show_command "6" \
"$(translate "Start and enable service:")" \
"systemctl restart nfs-kernel-server
systemctl enable nfs-kernel-server" \
"" \
""
show_command "7" \
"$(translate "Verify exports:")" \
"showmount -e localhost" \ "showmount -e localhost" \
"$(translate "Shows available NFS exports.")" \ "" \
"" ""
echo -e "${BOR}"
echo -e "${BOLD}$(translate "Export Options:")${CL}"
echo -e "${TAB}${BGN}$(translate "rw:")${CL} ${BL}Read-write access${CL}"
echo -e "${TAB}${BGN}$(translate "sync:")${CL} ${BL}Synchronous writes${CL}"
echo -e "${TAB}${BGN}$(translate "no_subtree_check:")${CL} ${BL}Improves performance${CL}"
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
@@ -262,67 +349,47 @@ show_nfs_server_help() {
show_samba_server_help() { show_samba_server_help() {
clear clear
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "Samba Server Installation")" msg_title "$(translate "Samba Server in LXC (Privileged)")"
msg_info2 "$(translate "How to install and configure a Samba server in an LXC container.")" msg_warn "$(translate "Use a privileged LXC for Samba client/server workflows.")"
echo -e echo -e
show_command "1" \ show_command "1" \
"$(translate "Update and install packages:")" \ "$(translate "Install Samba inside CT:")" \
"apt-get update && apt-get install -y samba" \ "apt-get update && apt-get install -y samba samba-common-bin acl" \
"" \ "" \
"" ""
show_command "2" \ show_command "2" \
"$(translate "Create share directory:")" \ "$(translate "Create share directory:")" \
"mkdir -p ${CUS}/mnt/samba_share${CL}" \ "mkdir -p ${CUS}/mnt/samba_share${CL}\nchmod 755 ${CUS}/mnt/samba_share${CL}" \
"$(translate "Replace with your preferred path.")" \ "" \
"" ""
show_command "3" \ show_command "3" \
"$(translate "Set directory permissions:")" \
"chmod 755 ${CUS}/mnt/samba_share${CL}" \
"" \
""
show_command "4" \
"$(translate "Create Samba user:")" \ "$(translate "Create Samba user:")" \
"adduser ${CUS}sambauser${CL} "adduser ${CUS}sambauser${CL}\nsmbpasswd -a ${CUS}sambauser${CL}" \
smbpasswd -a ${CUS}sambauser${CL}" \
"$(translate "Replace with your username. You'll be prompted for password.")" \
""
show_command "5" \
"$(translate "Configure share:")" \
"cat >> /etc/samba/smb.conf << EOF
[shared]
comment = Shared folder
path = ${CUS}/mnt/samba_share${CL}
read only = no
browseable = yes
valid users = ${CUS}sambauser${CL}
EOF" \
"$(translate "Replace path and username.")" \
""
show_command "6" \
"$(translate "Restart and enable service:")" \
"systemctl restart smbd
systemctl enable smbd" \
"" \ "" \
"" ""
show_command "7" \ show_command "4" \
"$(translate "Test configuration:")" \ "$(translate "Add share block in /etc/samba/smb.conf:")" \
"smbclient -L localhost -U ${CUS}sambauser${CL}" \ "cat >> /etc/samba/smb.conf << 'EOF'\n[shared]\n path = /mnt/samba_share\n browseable = yes\n read only = no\n valid users = sambauser\nEOF" \
"$(translate "Lists available shares. You'll be prompted for password.")" \ "" \
"" ""
echo -e "${BOR}" show_command "5" \
echo -e "${BOLD}$(translate "Connection Examples:")${CL}" "$(translate "Restart and enable Samba:")" \
echo -e "${TAB}${BGN}$(translate "Windows:")${CL} ${YW}\\\\<server-ip>\\shared${CL}" "systemctl restart smbd\nsystemctl enable smbd" \
echo -e "${TAB}${BGN}$(translate "Linux:")${CL} ${YW}smbclient //server-ip/shared -U sambauser${CL}" "" \
""
show_command "6" \
"$(translate "Test share visibility:")" \
"smbclient -L localhost -U ${CUS}sambauser${CL}" \
"" \
""
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
@@ -331,47 +398,41 @@ EOF" \
show_nfs_client_help() { show_nfs_client_help() {
clear clear
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "NFS Client Configuration")" msg_title "$(translate "NFS Client in LXC (Privileged)")"
msg_info2 "$(translate "How to configure an NFS client in an LXC container.")" msg_warn "$(translate "Current NFS client script supports privileged LXC only.")"
echo -e echo -e
show_command "1" \ show_command "1" \
"$(translate "Update and install packages:")" \ "$(translate "Install NFS client packages inside CT:")" \
"apt-get update && apt-get install -y nfs-common" \ "apt-get update && apt-get install -y nfs-common" \
"" \ "" \
"" ""
show_command "2" \ show_command "2" \
"$(translate "Create mount point:")" \ "$(translate "Create mount point:")" \
"mkdir -p ${CUS}/mnt/nfsmount${CL}" \ "mkdir -p ${CUS}/mnt/nfs_share${CL}" \
"$(translate "Replace with your preferred path.")" \ "" \
"" ""
show_command "3" \ show_command "3" \
"$(translate "Mount NFS share:")" \ "$(translate "Mount NFS share:")" \
"mount -t nfs ${CUS}192.168.1.100${CL}:${CUS}/mnt/nfs_export${CL} ${CUS}/mnt/nfsmount${CL}" \ "mount -t nfs ${CUS}<server-ip>:/export/path${CL} ${CUS}/mnt/nfs_share${CL}" \
"$(translate "Replace server IP and paths.")" \ "$(translate "Adjust options if needed (vers=4,hard,timeo,...).")" \
"" ""
show_command "4" \ show_command "4" \
"$(translate "Test access:")" \ "$(translate "Persist mount in CT /etc/fstab (optional):")" \
"ls -la ${CUS}/mnt/nfsmount${CL}" \ "echo '${CUS}<server-ip>:/export/path${CL} ${CUS}/mnt/nfs_share${CL} nfs defaults,_netdev,x-systemd.automount,noauto 0 0' >> /etc/fstab" \
"$(translate "Verify you can access the mounted share.")" \ "" \
"" ""
show_command "5" \ show_command "5" \
"$(translate "Make permanent (optional):")" \
"echo '${CUS}192.168.1.100${CL}:${CUS}/path/to/share${CL} ${CUS}/mnt/nfs_share${CL} nfs4 rw,hard,intr,_netdev,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0' >> /etc/fstab" \
"$(translate "Replace with your server IP and paths.")" \
""
show_command "6" \
"$(translate "Verify mount:")" \ "$(translate "Verify mount:")" \
"df -h | grep nfs" \ "mount | grep nfs\ndf -h | grep nfs" \
"$(translate "Shows NFS mounts.")" \ "" \
"" ""
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
@@ -380,63 +441,47 @@ show_nfs_client_help() {
show_samba_client_help() { show_samba_client_help() {
clear clear
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "Samba Client Configuration")" msg_title "$(translate "Samba Client in LXC (Privileged)")"
msg_info2 "$(translate "How to configure a Samba client in an LXC container.")" msg_warn "$(translate "Current Samba client script supports privileged LXC only.")"
echo -e echo -e
show_command "1" \ show_command "1" \
"$(translate "Update and install packages:")" \ "$(translate "Install CIFS client packages inside CT:")" \
"apt-get update && apt-get install -y cifs-utils" \ "apt-get update && apt-get install -y cifs-utils" \
"" \ "" \
"" ""
show_command "2" \ show_command "2" \
"$(translate "Create mount point:")" \ "$(translate "Create mount point:")" \
"mkdir -p ${CUS}/mnt/sambamount${CL}" \ "mkdir -p ${CUS}/mnt/samba_share${CL}" \
"$(translate "Replace with your preferred path.")" \ "" \
"" ""
show_command "3" \ show_command "3" \
"$(translate "Mount Samba share:")" \ "$(translate "Create credentials file (recommended):")" \
"mount -t cifs //${CUS}192.168.1.100${CL}/${CUS}shared${CL} ${CUS}/mnt/sambamount${CL} -o username=${CUS}sambauser${CL}" \ "cat > /etc/samba/credentials/proxmenux.cred << 'EOF'\nusername=${CUS}<user>${CL}\npassword=${CUS}<pass>${CL}\nEOF\nchmod 600 /etc/samba/credentials/proxmenux.cred" \
"$(translate "Replace server IP, share name and username. You'll be prompted for password.")" \ "" \
"" ""
show_command "4" \ show_command "4" \
"$(translate "Test access:")" \ "$(translate "Mount CIFS share:")" \
"ls -la ${CUS}/mnt/sambamount${CL}" \ "mount -t cifs //${CUS}<server-ip>/<share>${CL} ${CUS}/mnt/samba_share${CL} -o credentials=/etc/samba/credentials/proxmenux.cred,iocharset=utf8,file_mode=0664,dir_mode=0775" \
"$(translate "Verify you can access the mounted share.")" \ "" \
"" ""
show_command "5" \ show_command "5" \
"$(translate "Create credentials file (optional):")" \ "$(translate "Persist mount in CT /etc/fstab (optional):")" \
"cat > /etc/samba/credentials << EOF "echo '//${CUS}<server-ip>/<share>${CL} ${CUS}/mnt/samba_share${CL} cifs credentials=/etc/samba/credentials/proxmenux.cred,_netdev,x-systemd.automount,noauto 0 0' >> /etc/fstab" \
username=${CUS}sambauser${CL} "" \
password=${CUS}your_password${CL}
EOF
chmod 600 /etc/samba/credentials" \
"$(translate "Secure way to store credentials.")" \
"" ""
show_command "6" \ show_command "6" \
"$(translate "Mount with credentials file:")" \ "$(translate "Verify mount:")" \
"mount -t cifs //${CUS}192.168.1.100${CL}/${CUS}shared${CL} ${CUS}/mnt/sambamount${CL} -o credentials=/etc/samba/credentials" \ "mount -t cifs\ndf -h | grep cifs" \
"$(translate "No password prompt needed.")" \ "" \
"" ""
show_command "7" \
"$(translate "Make permanent (optional):")" \
"echo '//${CUS}192.168.1.100${CL}/${CUS}shared${CL} ${CUS}/mnt/sambamount${CL} cifs credentials=/etc/samba/credentials,_netdev 0 0' >> /etc/fstab" \
"$(translate "Replace with your values.")" \
""
show_command "8" \
"$(translate "Verify mount:")" \
"df -h | grep cifs" \
"$(translate "Shows CIFS/Samba mounts.")" \
""
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
@@ -445,28 +490,35 @@ chmod 600 /etc/samba/credentials" \
show_help_menu() { show_help_menu() {
while true; do while true; do
CHOICE=$(dialog --title "$(translate "Help & Information")" \ CHOICE=$(dialog --title "$(translate "Help & Information")" \
--menu "$(translate "Select help topic:")" 24 80 14 \ --menu "$(translate "Select help topic:")" 24 90 14 \
"0" "$(translate "How to Access an LXC Terminal")" \ "0" "$(translate "How to Access an LXC Terminal")" \
"1" "$(translate "Mount Remote Resources on Proxmox Host")" \ "1" "$(translate "Host NFS/Samba as Proxmox Storage (pvesm)")" \
"2" "$(translate "Mount Host Directory to LXC Container")" \ "2" "$(translate "Local Shared Directory on Host")" \
"3" "$(translate "NFS Server Installation")" \ "3" "$(translate "Add Local Disk as Proxmox Storage")" \
"4" "$(translate "Samba Server Installation")" \ "4" "$(translate "Add iSCSI Target as Proxmox Storage")" \
"5" "$(translate "NFS Client Configuration")" \ "5" "$(translate "Mount Host Directory to LXC Container")" \
"6" "$(translate "Samba Client Configuration")" \ "6" "$(translate "NFS Client in LXC (privileged)")" \
"7" "$(translate "Return to Main Menu")" \ "7" "$(translate "Samba Client in LXC (privileged)")" \
"8" "$(translate "NFS Server in LXC (privileged)")" \
"9" "$(translate "Samba Server in LXC (privileged)")" \
"10" "$(translate "Return to Share Menu")" \
3>&1 1>&2 2>&3) 3>&1 1>&2 2>&3)
case $CHOICE in case "$CHOICE" in
0) show_how_to_enter_lxc ;; 0) show_how_to_enter_lxc ;;
1) show_host_mount_resources_help ;; 1) show_host_storage_help ;;
2) show_host_to_lxc_mount_help ;; 2) show_local_share_help ;;
3) show_nfs_server_help ;; 3) show_disk_host_help ;;
4) show_samba_server_help ;; 4) show_iscsi_host_help ;;
5) show_nfs_client_help ;; 5) show_host_to_lxc_mount_help ;;
6) show_samba_client_help ;; 6) show_nfs_client_help ;;
7) return ;; 7) show_samba_client_help ;;
8) show_nfs_server_help ;;
9) show_samba_server_help ;;
10) return ;;
*) return ;; *) return ;;
esac esac
done done
} }
show_help_menu show_help_menu

File diff suppressed because it is too large Load Diff

View File

@@ -55,7 +55,6 @@ ensure_iscsi_tools() {
fi fi
if ! systemctl is-active --quiet iscsid 2>/dev/null; then if ! systemctl is-active --quiet iscsid 2>/dev/null; then
msg_info "$(translate "Starting iSCSI daemon...")"
systemctl start iscsid 2>/dev/null || true systemctl start iscsid 2>/dev/null || true
fi fi
} }
@@ -65,10 +64,9 @@ ensure_iscsi_tools() {
# ========================================================== # ==========================================================
select_iscsi_portal() { select_iscsi_portal() {
ISCSI_PORTAL=$(whiptail --inputbox \ ISCSI_PORTAL=$(dialog --backtitle "ProxMenux" --title "$(translate "iSCSI Portal")" --inputbox \
"$(translate "Enter iSCSI target portal IP or hostname:")\n\n$(translate "Examples:")\n 192.168.1.100\n 192.168.1.100:3260\n nas.local" \ "$(translate "Enter iSCSI target portal IP or hostname:")\n\n$(translate "Examples:")\n 192.168.1.100\n 192.168.1.100:3260\n nas.local" \
14 65 \ 14 65 3>&1 1>&2 2>&3)
--title "$(translate "iSCSI Portal")" 3>&1 1>&2 2>&3)
[[ $? -ne 0 || -z "$ISCSI_PORTAL" ]] && return 1 [[ $? -ne 0 || -z "$ISCSI_PORTAL" ]] && return 1
# Normalise: if no port specified, add default 3260 # Normalise: if no port specified, add default 3260

View File

@@ -6,82 +6,236 @@
# Copyright : (c) 2024 MacRimi # Copyright : (c) 2024 MacRimi
# License : MIT # License : MIT
# Version : 1.0 # Version : 1.0
# Last Updated: $(date +%d/%m/%Y) # Last Updated: 08/04/2026
# ========================================================== # ==========================================================
# Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE" source "$UTILS_FILE"
fi fi
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func" SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
SHARE_COMMON_LOADED=false msg_error "$(translate "Could not load shared functions. Script cannot continue.")"
else exit 1
SHARE_COMMON_LOADED=true
fi fi
load_language load_language
initialize_cache initialize_cache
if ! command -v pveversion >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
--msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60
exit 1
fi
# ========================================================== # ==========================================================
create_shared_directory() { lsm_apply_multi_unpriv_permissions() {
SHARED_DIR=$(pmx_select_host_mount_point "$(translate "Select Shared Directory Location")" "/mnt/shared") local dir="$1"
[[ -z "$SHARED_DIR" ]] && return
[[ -z "$dir" || ! -d "$dir" ]] && return 1
if [[ -d "$SHARED_DIR" ]]; then # root:root ownership — no new group needed.
if ! whiptail --yesno "$(translate "Directory already exists. Continue with permission setup?")" 10 70 --title "$(translate "Directory Exists")"; then chown root:root "$dir" 2>/dev/null || true
return
# 1777 = sticky bit (prevents cross-container file deletion) + world-rwx.
# Unprivileged LXC UIDs (100000+) appear as 'others' on the host,
# so 'o+rwx' is what grants them read+write access.
chmod 1777 "$dir" 2>/dev/null || true
# Ensure existing content is readable/writable regardless of UID mapping.
chmod -R a+rwX "$dir" 2>/dev/null || true
find "$dir" -type d -exec chmod 1777 {} + 2>/dev/null || true
if command -v setfacl >/dev/null 2>&1; then
# Remove restrictive ACLs and enforce permissive inheritance for new files.
setfacl -b -R "$dir" 2>/dev/null || true
setfacl -R -m u::rwx,g::rwx,o::rwx,m::rwx "$dir" 2>/dev/null || true
setfacl -R -m d:u::rwx,d:g::rwx,d:o::rwx,d:m::rwx "$dir" 2>/dev/null || true
fi
return 0
}
# Returns a free name like /mnt/shared, /mnt/shared2, /mnt/shared3 …
lsm_next_free_name() {
local base="${1:-shared}"
local candidate="/mnt/$base"
[[ ! -d "$candidate" ]] && echo "$candidate" && return
local n=2
while [[ -d "/mnt/${base}${n}" ]]; do
((n++))
done
echo "/mnt/${base}${n}"
}
lsm_list_mnt_folders() {
show_proxmenux_logo
msg_title "$(translate "Folders in /mnt")"
echo "=================================================="
if [[ ! -d /mnt ]] || [[ -z "$(ls -A /mnt 2>/dev/null)" ]]; then
echo ""
echo -e "${TAB}$(translate "No folders found in /mnt.")"
else
local found=false
while IFS= read -r dir; do
[[ ! -d "$dir" ]] && continue
found=true
local perms owner
perms=$(stat -c "%a" "$dir" 2>/dev/null)
owner=$(stat -c "%U:%G" "$dir" 2>/dev/null)
echo ""
echo -e "${TAB}${BGN}$(translate "Directory:")${CL} ${BL}$dir${CL}"
echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}${perms} $(stat -c "(%A)" "$dir" 2>/dev/null)${CL}"
echo -e "${TAB}${BGN}$(translate "Owner:")${CL} ${BL}${owner}${CL}"
done < <(find /mnt -mindepth 1 -maxdepth 1 -type d | sort)
if [[ "$found" = false ]]; then
echo ""
echo -e "${TAB}$(translate "No folders found in /mnt.")"
fi fi
fi fi
echo ""
echo "=================================================="
echo ""
SHARE_GROUP=$(pmx_choose_or_create_group "sharedfiles") || return 1 # Summary of /mnt available space
SHARE_GID=$(pmx_ensure_host_group "$SHARE_GROUP" 101000) || return 1 if mountpoint -q /mnt 2>/dev/null || [[ -d /mnt ]]; then
local mnt_avail mnt_total
mnt_avail=$(df -h /mnt 2>/dev/null | awk 'NR==2{print $4}')
if command -v setfacl >/dev/null 2>&1; then mnt_total=$(df -h /mnt 2>/dev/null | awk 'NR==2{print $2}')
setfacl -k /mnt 2>/dev/null || true if [[ -n "$mnt_avail" ]]; then
setfacl -b /mnt 2>/dev/null || true echo -e "${TAB}${BGN}$(translate "Available space in /mnt:")${CL} ${BL}${mnt_avail} $(translate "of") ${mnt_total}${CL}"
fi echo ""
chmod 755 /mnt 2>/dev/null || true fi
pmx_prepare_host_shared_dir "$SHARED_DIR" "$SHARE_GROUP" || return 1
if command -v setfacl >/dev/null 2>&1; then
setfacl -b -R "$SHARED_DIR" 2>/dev/null || true
fi fi
msg_success "$(translate "Press Enter to return to menu...")"
read -r
}
chown root:"$SHARE_GROUP" "$SHARED_DIR" # Result is stored in LSM_SELECTED_MOUNT_POINT (not echoed) to avoid subshell issues
chmod 2775 "$SHARED_DIR" LSM_SELECTED_MOUNT_POINT=""
pmx_share_map_set "$SHARED_DIR" "$SHARE_GROUP" lsm_select_host_mount_point_dialog() {
local title="${1:-$(translate "Select Shared Directory Location")}"
local base_name="${2:-shared}"
local choice folder_name result mount_point
LSM_SELECTED_MOUNT_POINT=""
# Auto-suggest a free name in /mnt
local suggested
suggested=$(lsm_next_free_name "$base_name")
while true; do
choice=$(dialog --backtitle "ProxMenux" \
--title "$title" \
--menu "\n$(translate "Where do you want the host folder?")" 16 72 4 \
"1" "$(translate "Create new folder in /mnt")" \
"2" "$(translate "Enter custom path")" \
"3" "$(translate "View existing folders in /mnt")" \
"4" "$(translate "Cancel")" \
3>&1 1>&2 2>&3) || return 1
case "$choice" in
1)
folder_name=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Folder Name")" \
--inputbox "\n$(translate "Enter folder name for /mnt:")" 10 70 "$(basename "$suggested")" \
3>&1 1>&2 2>&3) || continue
[[ -z "$folder_name" ]] && continue
mount_point="/mnt/$folder_name"
# Only warn if the user manually typed an existing name
if [[ -d "$mount_point" ]]; then
if ! dialog --backtitle "ProxMenux" --title "$(translate "Directory Exists")" \
--yesno "\n$(translate "Directory already exists. Continue with permission setup?")" 8 70; then
continue
fi
fi
;;
2)
result=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Custom Path")" \
--inputbox "\n$(translate "Enter full path:")" 10 80 "" \
3>&1 1>&2 2>&3) || continue
[[ -z "$result" ]] && continue
mount_point="$result"
if [[ -d "$mount_point" ]]; then
if ! dialog --backtitle "ProxMenux" --title "$(translate "Directory Exists")" \
--yesno "\n$(translate "Directory already exists. Continue with permission setup?")" 8 70; then
continue
fi
fi
;;
3)
lsm_list_mnt_folders
# Refresh suggestion after viewing
suggested=$(lsm_next_free_name "$base_name")
continue
;;
4) return 1 ;;
*) continue ;;
esac
if [[ ! "$mount_point" =~ ^/ ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Invalid Path")" \
--msgbox "\n$(translate "Path must be absolute (start with /).")" 8 60
continue
fi
LSM_SELECTED_MOUNT_POINT="$mount_point"
return 0
done
}
create_shared_directory() {
lsm_select_host_mount_point_dialog "$(translate "Select Shared Directory Location")" "shared"
[[ -z "$LSM_SELECTED_MOUNT_POINT" ]] && return
SHARED_DIR="$LSM_SELECTED_MOUNT_POINT"
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "Create Shared Directory")" msg_title "$(translate "Create Shared Directory")"
if ! mkdir -p "$SHARED_DIR" 2>/dev/null; then
msg_error "$(translate "Failed to create directory:") $SHARED_DIR"
echo ""
msg_success "$(translate "Press Enter to continue...")"
read -r
return 1
fi
msg_ok "$(translate "Directory created:") $SHARED_DIR"
lsm_apply_multi_unpriv_permissions "$SHARED_DIR"
pmx_share_map_set "$SHARED_DIR" "open"
echo -e "" echo -e ""
echo -e "${TAB}${BOLD}$(translate "Shared Directory Created:")${CL}" echo -e "${TAB}${BOLD}$(translate "Shared Directory Ready:")${CL}"
echo -e "${TAB}${BGN}$(translate "Directory:")${CL} ${BL}$SHARED_DIR${CL}" echo -e "${TAB}${BGN}$(translate "Directory:")${CL} ${BL}$SHARED_DIR${CL}"
echo -e "${TAB}${BGN}$(translate "Group:")${CL} ${BL}$SHARE_GROUP (GID: $SHARE_GID)${CL}" echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}1777 (rwxrwxrwt)${CL}"
echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}2775 (rwxrwsr-x)${CL}" echo -e "${TAB}${BGN}$(translate "Owner:")${CL} ${BL}root:root${CL}"
echo -e "${TAB}${BGN}$(translate "Owner:")${CL} ${BL}root:$SHARE_GROUP${CL}" echo -e "${TAB}${BGN}$(translate "Access profile:")${CL} ${BL}$(translate "Compatible with privileged and unprivileged LXC containers")${CL}"
echo -e "${TAB}${BGN}$(translate "ACL Status:")${CL} ${BL}$(translate "Cleaned and set for POSIX inheritance")${CL}" echo -e "${TAB}${BGN}$(translate "ACL Status:")${CL} ${BL}$(translate "Open rwx + default inheritance for new files")${CL}"
echo -e "" echo -e ""
msg_success "$(translate "Press Enter to return to menu...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r

View File

@@ -229,15 +229,13 @@ select_host_directory_unified() {
return 1 return 1
fi fi
# Warn about CIFS Proxmox-GUI storage (read-only limitation) # Store the storage type as a global so the main flow can act on it later.
# We don't block the user here — the active fix happens after we know the container type.
LMM_HOST_DIR_TYPE="local"
if detect_problematic_storage "$result" "Proxmox-Storage" "CIFS/SMB"; then if detect_problematic_storage "$result" "Proxmox-Storage" "CIFS/SMB"; then
dialog --clear --title "$(translate "CIFS Storage Notice")" --yesno "\ LMM_HOST_DIR_TYPE="cifs"
$(translate "This directory is a CIFS storage managed by Proxmox.")\n\n\ elif detect_problematic_storage "$result" "Proxmox-Storage" "NFS"; then
$(translate "CIFS storage configured through Proxmox GUI applies restrictive permissions.")\n\ LMM_HOST_DIR_TYPE="nfs"
$(translate "LXC containers can usually READ but may NOT be able to WRITE.")\n\n\
$(translate "For write access, use 'Add Samba Share as Proxmox Storage' option instead.")\n\n\
$(translate "Do you want to continue anyway?")" 14 80 3>&1 1>&2 2>&3
[[ $? -ne 0 ]] && return 1
fi fi
echo "$result" echo "$result"
@@ -314,7 +312,7 @@ select_container_mount_point() {
fi fi
# Check if path is already used as a mount point in this CT # Check if path is already used as a mount point in this CT
if pct config "$ctid" 2>/dev/null | grep -q "mp=.*$mount_point"; then if pct config "$ctid" 2>/dev/null | grep -qE "mp=${mount_point}(,|$)"; then
whiptail --msgbox "$(translate "This path is already used as a mount point in this container.")" 8 70 whiptail --msgbox "$(translate "This path is already used as a mount point in this container.")" 8 70
continue continue
fi fi
@@ -364,7 +362,7 @@ add_bind_mount() {
fi fi
# Check if this host path is already mounted in this CT # Check if this host path is already mounted in this CT
if pct config "$ctid" 2>/dev/null | grep -q "^mp[0-9]*:.*${host_path},"; then if pct config "$ctid" 2>/dev/null | grep -qF " ${host_path},"; then
msg_warn "$(translate "Mount already exists for this path in container") $ctid" msg_warn "$(translate "Mount already exists for this path in container") $ctid"
return 1 return 1
fi fi
@@ -555,6 +553,199 @@ $(translate "Proceed with removal")?"
read -r read -r
} }
# ==========================================================
# ACTIVE FIXES FOR NETWORK STORAGE (CIFS / NFS)
# These functions act on problems instead of just warning about them.
# ==========================================================
lmm_fix_cifs_access() {
local host_dir="$1"
local is_unprivileged="$2"
# CIFS mounted by Proxmox GUI uses uid=0/gid=0 by default (root only).
# The fix: remount with uid/gid that the LXC can access.
# We detect the current mount options and propose a corrected remount.
local mount_src mount_opts
mount_src=$(findmnt -n -o SOURCE --target "$host_dir" 2>/dev/null)
mount_opts=$(findmnt -n -o OPTIONS --target "$host_dir" 2>/dev/null)
if [[ -z "$mount_src" ]]; then
dialog --backtitle "ProxMenux" \
--title "$(translate "CIFS Mount Not Found")" \
--msgbox "$(translate "Could not detect the CIFS mount for this directory. Try accessing it manually.")" 8 70
return 0
fi
# Determine which uid/gid to use
local target_uid target_gid
if [[ "$is_unprivileged" == "1" ]]; then
# Unprivileged LXC: container root (UID 0) maps to host UID 100000.
# Use file_mode/dir_mode 0777 + uid=0/gid=0 — CIFS maps them to everyone.
target_uid=0
target_gid=0
else
target_uid=0
target_gid=0
fi
# Build new options: strip existing uid/gid/file_mode/dir_mode, add ours
local new_opts
new_opts=$(echo "$mount_opts" | sed -E \
's/(^|,)(uid|gid|file_mode|dir_mode)=[^,]*//g' | \
sed 's/^,//')
new_opts="${new_opts},uid=${target_uid},gid=${target_gid},file_mode=0777,dir_mode=0777"
new_opts="${new_opts/#,/}"
if dialog --backtitle "ProxMenux" \
--title "$(translate "Fix CIFS Permissions")" \
--yesno \
"$(translate "This CIFS share is mounted with restrictive permissions.")\n\n\
$(translate "ProxMenux can remount it with open permissions so any LXC can read and write.")\n\n\
$(translate "Current mount options:")\n${mount_opts}\n\n\
$(translate "New mount options to apply:")\n${new_opts}\n\n\
$(translate "Apply fix now? (The share will be briefly remounted)")" \
18 84 3>&1 1>&2 2>&3; then
msg_info "$(translate "Remounting CIFS share with open permissions...")"
if umount "$host_dir" 2>/dev/null && \
mount -t cifs "$mount_src" "$host_dir" -o "$new_opts" 2>/dev/null; then
msg_ok "$(translate "CIFS share remounted — LXC containers can now read and write")"
# Update fstab if the mount is there
if grep -qF "$host_dir" /etc/fstab 2>/dev/null; then
sed -i "s|^\(${mount_src}[[:space:]].*${host_dir}.*cifs[[:space:]]\).*|\1${new_opts} 0 0|" /etc/fstab 2>/dev/null || true
msg_ok "$(translate "/etc/fstab updated — permissions will persist after reboot")"
fi
else
msg_warn "$(translate "Could not remount automatically. Try manually or check credentials.")"
fi
fi
}
lmm_fix_nfs_access() {
local host_dir="$1"
local is_unprivileged="$2"
local uid_shift="${3:-100000}"
# NFS: the host cannot override server-side permissions.
# BUT: if the server exports with root_squash (default), we can check
# if no_root_squash or all_squash is possible, and guide the user.
# What we CAN do on the host: apply a sticky+open directory as a cache layer
# if the NFS mount allows it.
local mount_src mount_opts
mount_src=$(findmnt -n -o SOURCE --target "$host_dir" 2>/dev/null)
mount_opts=$(findmnt -n -o OPTIONS --target "$host_dir" 2>/dev/null)
# Try to detect if we can write to the NFS share as root
local can_write=false
local testfile="${host_dir}/.proxmenux_write_test_$$"
if touch "$testfile" 2>/dev/null; then
rm -f "$testfile" 2>/dev/null
can_write=true
fi
local server_hint=""
if [[ -n "$mount_src" ]]; then
server_hint="${mount_src%%:*}"
fi
if [[ "$can_write" == "true" && "$is_unprivileged" == "1" ]]; then
# Root on host CAN write to NFS, but unprivileged LXC UIDs (100000+)
# will be squashed by the NFS server. We can set a world-writable sticky
# dir on the share itself so the container can write to it.
if dialog --backtitle "ProxMenux" \
--title "$(translate "Fix NFS Access for Unprivileged LXC")" \
--yesno \
"$(translate "NFS server export is writable from the host, but unprivileged LXC containers use mapped UIDs (${uid_shift}+) which the NFS server will squash.")\n\n\
$(translate "ProxMenux can apply open permissions on this NFS directory from the host so the container can read and write:")\n\n\
$(translate " chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)")\n\n\
$(translate "Note: this only works if the NFS server does NOT use 'all_squash' for root.")\n\
$(translate "If it still fails, the NFS server export options must be changed on the server.")\n\n\
$(translate "Apply fix now?")" \
18 84 3>&1 1>&2 2>&3; then
if chmod 1777 "$host_dir" 2>/dev/null; then
msg_ok "$(translate "NFS directory permissions set — containers should now be able to write")"
else
msg_warn "$(translate "chmod failed — NFS server may be restricting changes from root")"
fi
if command -v setfacl >/dev/null 2>&1; then
setfacl -m o::rwx "$host_dir" 2>/dev/null || true
setfacl -m d:o::rwx "$host_dir" 2>/dev/null || true
fi
fi
elif [[ "$can_write" == "false" ]]; then
# Even root cannot write — NFS server is fully restrictive
local server_msg=""
[[ -n "$server_hint" ]] && server_msg="\n$(translate "NFS server:"): ${server_hint}"
dialog --backtitle "ProxMenux" \
--title "$(translate "NFS Access Restricted")" \
--msgbox \
"$(translate "This NFS share is fully restricted — even the host root cannot write to it.")\n\
${server_msg}\n\n\
$(translate "ProxMenux cannot override NFS server-side permissions from the host.")\n\n\
$(translate "To allow LXC write access, change the NFS export on the server to include:")\n\n\
$(translate " no_root_squash") $(translate "(if only privileged LXCs need write access)")\n\
$(translate " all_squash,anonuid=65534,anongid=65534") $(translate "(for unprivileged LXCs)")\n\n\
$(translate "You can still mount this share for READ-ONLY access.")" \
20 84 3>&1 1>&2 2>&3
fi
}
# ==========================================================
# HOST PERMISSION CHECK (host-side only, never touches the container)
# ==========================================================
lmm_offer_host_permissions() {
local host_dir="$1"
local is_unprivileged="$2"
# Privileged containers: UID 0 inside = UID 0 on host — always accessible
[[ "$is_unprivileged" != "1" ]] && return 0
# Check if 'others' already have r+x (minimum to traverse and read)
local stat_perms others_bits
stat_perms=$(stat -c "%a" "$host_dir" 2>/dev/null) || return 0
others_bits=$(( 8#${stat_perms} & 7 ))
# Check ACLs first if available (takes precedence over mode bits)
if command -v getfacl >/dev/null 2>&1; then
if getfacl -p "$host_dir" 2>/dev/null | grep -q "^other::.*r.*x"; then
return 0 # ACL already grants others r+x or better
fi
fi
# 5 = r-x (bits: r=4, x=1). If already r+x or rwx we're fine.
(( (others_bits & 5) == 5 )) && return 0
# Permissions are insufficient — offer to fix HOST directory only
local current_perms
current_perms=$(stat -c "%A" "$host_dir" 2>/dev/null)
if dialog --backtitle "ProxMenux" \
--title "$(translate "Unprivileged Container Access")" \
--yesno \
"$(translate "The host directory may not be accessible from an unprivileged container.")\n\n\
$(translate "Unprivileged containers map their UIDs to high host UIDs (e.g. 100000+), which appear as 'others' on the host filesystem.")\n\n\
$(translate "Current permissions:"): ${current_perms}\n\n\
$(translate "Apply read+write access for 'others' on the host directory?")\n\n\
$(translate "(Only the host directory is modified. Nothing inside the container is changed.")" \
16 80 3>&1 1>&2 2>&3; then
chmod o+rwx "$host_dir" 2>/dev/null || true
if command -v setfacl >/dev/null 2>&1; then
setfacl -m o::rwx "$host_dir" 2>/dev/null || true
setfacl -m d:o::rwx "$host_dir" 2>/dev/null || true
fi
msg_ok "$(translate "Host directory permissions updated — unprivileged containers can now access it")"
fi
}
# ========================================================== # ==========================================================
# MAIN FUNCTION — ADD MOUNT # MAIN FUNCTION — ADD MOUNT
# ========================================================== # ==========================================================
@@ -577,7 +768,7 @@ mount_host_directory_minimal() {
# Step 4: Get container type info (for display only) # Step 4: Get container type info (for display only)
local uid_shift container_type_display local uid_shift container_type_display
uid_shift=$(awk -F: '/^lxc.idmap.*u 0/ {print $5}' "/etc/pve/lxc/${container_id}.conf" 2>/dev/null | head -1) uid_shift=$(awk '/^lxc.idmap.*u 0/ {print $5}' "/etc/pve/lxc/${container_id}.conf" 2>/dev/null | head -1)
local is_unprivileged local is_unprivileged
is_unprivileged=$(grep "^unprivileged:" "/etc/pve/lxc/${container_id}.conf" 2>/dev/null | awk '{print $2}') is_unprivileged=$(grep "^unprivileged:" "/etc/pve/lxc/${container_id}.conf" 2>/dev/null | awk '{print $2}')
if [[ "$is_unprivileged" == "1" ]]; then if [[ "$is_unprivileged" == "1" ]]; then
@@ -588,7 +779,13 @@ mount_host_directory_minimal() {
uid_shift="0" uid_shift="0"
fi fi
# Step 5: Confirmation # Step 5: Active fix for network storage (before confirmation, while we know container type)
case "${LMM_HOST_DIR_TYPE:-local}" in
cifs) lmm_fix_cifs_access "$host_dir" "$is_unprivileged" ;;
nfs) lmm_fix_nfs_access "$host_dir" "$is_unprivileged" "$uid_shift" ;;
esac
# Step 6: Confirmation
local confirm_msg local confirm_msg
confirm_msg="$(translate "Mount Configuration Summary:") confirm_msg="$(translate "Mount Configuration Summary:")
@@ -597,17 +794,12 @@ $(translate "Host Directory"): $host_dir
$(translate "Container Mount Point"): $ct_mount_point $(translate "Container Mount Point"): $ct_mount_point
$(translate "IMPORTANT NOTES:") $(translate "IMPORTANT NOTES:")
- $(translate "Host directory permissions and ownership are NOT modified") - $(translate "Nothing inside the container is modified")
- $(translate "Container filesystem is NOT modified") - $(if [[ "$is_unprivileged" == "1" ]]; then
- $(translate "If access fails after mounting, adjust permissions manually:") translate "Host directory access for unprivileged containers has been prepared above"
else
$(if [[ "$is_unprivileged" == "1" ]]; then translate "Privileged container — host root maps directly, no permission changes needed"
echo " # Allow container UID ${uid_shift}+ to access host dir:" fi)
echo " setfacl -m u:${uid_shift}:rwx \"$host_dir\""
echo " setfacl -d:m u:${uid_shift}:rwx \"$host_dir\""
else
echo " chmod 755 \"$host_dir\""
fi)
$(translate "Proceed")?" $(translate "Proceed")?"
@@ -621,7 +813,7 @@ $(translate "Proceed")?"
msg_ok "$(translate "Host directory:") $host_dir" msg_ok "$(translate "Host directory:") $host_dir"
msg_ok "$(translate "Container mount point:") $ct_mount_point" msg_ok "$(translate "Container mount point:") $ct_mount_point"
# Step 6: Add bind mount (the ONLY operation that changes anything) # Step 7: Add bind mount
if ! add_bind_mount "$container_id" "$host_dir" "$ct_mount_point"; then if ! add_bind_mount "$container_id" "$host_dir" "$ct_mount_point"; then
echo "" echo ""
msg_success "$(translate "Press Enter to continue...")" msg_success "$(translate "Press Enter to continue...")"
@@ -629,27 +821,25 @@ $(translate "Proceed")?"
return 1 return 1
fi fi
# Step 7: Summary with permission hints # Step 8: Host permission check for local dirs (only if not already handled above for CIFS/NFS)
if [[ "${LMM_HOST_DIR_TYPE:-local}" == "local" ]]; then
lmm_offer_host_permissions "$host_dir" "$is_unprivileged"
fi
# Step 9: Summary
echo "" echo ""
echo -e "${TAB}${BOLD}$(translate "Mount Added Successfully:")${CL}" echo -e "${TAB}${BOLD}$(translate "Mount Added Successfully:")${CL}"
echo -e "${TAB}${BGN}$(translate "Container:")${CL} ${BL}$container_id${CL}" echo -e "${TAB}${BGN}$(translate "Container:")${CL} ${BL}$container_id${CL}"
echo -e "${TAB}${BGN}$(translate "Host Directory:")${CL} ${BL}$host_dir${CL}" echo -e "${TAB}${BGN}$(translate "Host Directory:")${CL} ${BL}$host_dir${CL}"
echo -e "${TAB}${BGN}$(translate "Mount Point:")${CL} ${BL}$ct_mount_point${CL}" echo -e "${TAB}${BGN}$(translate "Mount Point:")${CL} ${BL}$ct_mount_point${CL}"
if [[ "$is_unprivileged" == "1" ]]; then
echo -e "${TAB}${YW}$(translate "Unprivileged container — UID offset:") ${uid_shift}${CL}"
else
echo -e "${TAB}${DGN}$(translate "Privileged container — direct root access")${CL}"
fi
echo "" echo ""
if [[ "$is_unprivileged" == "1" ]]; then # Step 10: Offer restart
local mapped_uid="$uid_shift"
echo -e "${TAB}${YW}$(translate "UNPRIVILEGED container — UID mapping active:")${CL}"
echo -e "${TAB} $(translate "Container UID 0")$(translate "Host UID") $mapped_uid"
echo -e "${TAB} $(translate "If access fails, run on the host:")"
echo -e "${TAB} ${DGN}setfacl -m u:${mapped_uid}:rwx \"$host_dir\"${CL}"
echo -e "${TAB} ${DGN}setfacl -d:m u:${mapped_uid}:rwx \"$host_dir\"${CL}"
else
echo -e "${TAB}${DGN}$(translate "PRIVILEGED container — direct UID mapping")${CL}"
echo -e "${TAB} $(translate "Ensure") $host_dir $(translate "is accessible by root (chmod 755 or wider)")"
fi
# Step 8: Offer restart
echo "" echo ""
if whiptail --yesno "$(translate "Restart container to activate mount?")" 8 60; then if whiptail --yesno "$(translate "Restart container to activate mount?")" 8 60; then
msg_info "$(translate "Restarting container...")" msg_info "$(translate "Restarting container...")"

View File

@@ -253,7 +253,7 @@ add_proxmox_nfs_storage() {
fi fi
msg_ok "$(translate "Storage ID is available")" msg_ok "$(translate "Storage ID is available")"
msg_info "$(translate "NFS storage adding in progress...")"
if pvesm_output=$(pvesm add nfs "$storage_id" \ if pvesm_output=$(pvesm add nfs "$storage_id" \
--server "$server" \ --server "$server" \
--export "$export" \ --export "$export" \

View File

@@ -14,6 +14,7 @@ LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
TOOLS_JSON="$BASE_DIR/installed_tools.json"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
@@ -51,22 +52,45 @@ SELECTED_VMID=""
SELECTED_VM_NAME="" SELECTED_VM_NAME=""
declare -a SELECTED_CONTROLLER_PCIS=() declare -a SELECTED_CONTROLLER_PCIS=()
IOMMU_PENDING_REBOOT=0 IOMMU_PENDING_REBOOT=0
IOMMU_ALREADY_ACTIVE=0
NEED_HOOK_SYNC=false
WIZARD_CONFLICT_POLICY=""
WIZARD_CONFLICT_SCOPE=""
set_title() { set_title() {
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "Add Controller or NVMe PCIe to VM")" msg_title "$(translate "Add Controller or NVMe PCIe to VM")"
} }
ensure_tools_json() {
[[ -f "$TOOLS_JSON" ]] || echo "{}" > "$TOOLS_JSON"
}
register_tool() {
local tool="$1"
local state="$2"
command -v jq >/dev/null 2>&1 || return 0
ensure_tools_json
jq --arg t "$tool" --argjson v "$state" \
'.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" \
&& mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
}
register_vfio_iommu_tool() {
register_tool "vfio_iommu" true || true
}
enable_iommu_cmdline() { enable_iommu_cmdline() {
local silent="${1:-}"
local cpu_vendor iommu_param local cpu_vendor iommu_param
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
iommu_param="intel_iommu=on" iommu_param="intel_iommu=on"
msg_info "$(translate "Intel CPU detected")" [[ "$silent" != "silent" ]] && msg_info "$(translate "Intel CPU detected")"
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
iommu_param="amd_iommu=on" iommu_param="amd_iommu=on"
msg_info "$(translate "AMD CPU detected")" [[ "$silent" != "silent" ]] && msg_info "$(translate "AMD CPU detected")"
else else
msg_error "$(translate "Unknown CPU vendor. Cannot determine IOMMU parameter.")" msg_error "$(translate "Unknown CPU vendor. Cannot determine IOMMU parameter.")"
return 1 return 1
@@ -76,22 +100,22 @@ enable_iommu_cmdline() {
local grub_file="/etc/default/grub" local grub_file="/etc/default/grub"
if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then
if ! grep -q "$iommu_param" "$cmdline_file"; then if ! grep -q "$iommu_param" "$cmdline_file" || ! grep -q "iommu=pt" "$cmdline_file"; then
cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)" cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file" sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file"
proxmox-boot-tool refresh >/dev/null 2>&1 || true proxmox-boot-tool refresh >/dev/null 2>&1 || true
msg_ok "$(translate "IOMMU parameters added to /etc/kernel/cmdline")" [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to /etc/kernel/cmdline")"
else else
msg_ok "$(translate "IOMMU already configured in /etc/kernel/cmdline")" [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU already configured in /etc/kernel/cmdline")"
fi fi
elif [[ -f "$grub_file" ]]; then elif [[ -f "$grub_file" ]]; then
if ! grep -q "$iommu_param" "$grub_file"; then if ! grep -q "$iommu_param" "$grub_file" || ! grep -q "iommu=pt" "$grub_file"; then
cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)" cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file" sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file"
update-grub >/dev/null 2>&1 || true update-grub >/dev/null 2>&1 || true
msg_ok "$(translate "IOMMU parameters added to GRUB")" [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to GRUB")"
else else
msg_ok "$(translate "IOMMU already configured in GRUB")" [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU already configured in GRUB")"
fi fi
else else
msg_error "$(translate "Neither /etc/kernel/cmdline nor /etc/default/grub found.")" msg_error "$(translate "Neither /etc/kernel/cmdline nor /etc/default/grub found.")"
@@ -101,24 +125,29 @@ enable_iommu_cmdline() {
check_iommu_or_offer_enable() { check_iommu_or_offer_enable() {
if [[ "${IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then if [[ "${IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
register_vfio_iommu_tool
return 0 return 0
fi fi
if grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \ if grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \
grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then
IOMMU_PENDING_REBOOT=1 IOMMU_PENDING_REBOOT=1
msg_warn "$(translate "IOMMU is configured for next boot, but not active yet.")" register_vfio_iommu_tool
msg_info2 "$(translate "Controller/NVMe assignment can continue now and will be effective after reboot.")"
return 0 return 0
fi fi
if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then
IOMMU_ALREADY_ACTIVE=1
register_vfio_iommu_tool
return 0 return 0
fi fi
if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \ if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \
[[ -d /sys/kernel/iommu_groups ]] && \ [[ -d /sys/kernel/iommu_groups ]] && \
[[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then [[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then
IOMMU_ALREADY_ACTIVE=1
register_vfio_iommu_tool
return 0 return 0
fi fi
@@ -133,13 +162,11 @@ check_iommu_or_offer_enable() {
--title "$(translate "IOMMU Required")" \ --title "$(translate "IOMMU Required")" \
--yesno "$msg" 15 74 --yesno "$msg" 15 74
local response=$? local response=$?
clear
[[ $response -ne 0 ]] && return 1 [[ $response -ne 0 ]] && return 1
set_title set_title
msg_title "$(translate "Enabling IOMMU")" msg_title "$(translate "Enabling IOMMU")"
echo
if ! enable_iommu_cmdline; then if ! enable_iommu_cmdline; then
echo echo
msg_error "$(translate "Failed to configure IOMMU automatically.")" msg_error "$(translate "Failed to configure IOMMU automatically.")"
@@ -148,37 +175,36 @@ check_iommu_or_offer_enable() {
return 1 return 1
fi fi
echo register_vfio_iommu_tool
msg_success "$(translate "IOMMU configured. Reboot required before using Controller/NVMe passthrough.")" IOMMU_PENDING_REBOOT=1
echo
if whiptail --title "$(translate "Reboot Required")" \
--yesno "$(translate "Do you want to reboot now?")" 10 64; then
msg_warn "$(translate "Rebooting the system...")"
reboot
else
IOMMU_PENDING_REBOOT=1
msg_warn "$(translate "Reboot postponed by user.")"
msg_info2 "$(translate "You can continue assigning Controller/NVMe now, but reboot the host before starting the VM.")"
msg_success "$(translate "Press Enter to continue...")"
read -r
fi
return 0 return 0
} }
select_target_vm() { select_target_vm() {
local -a vm_menu=() local -a vm_menu=()
local line vmid vmname vmstatus vm_machine status_label local line vmid vmname vmstatus vm_machine status_label
local max_name_len=0 padded_name
while IFS= read -r line; do
vmid=$(awk '{print $1}' <<< "$line")
vmname=$(awk '{print $2}' <<< "$line")
[[ -z "$vmid" || "$vmid" == "VMID" ]] && continue
[[ -f "/etc/pve/qemu-server/${vmid}.conf" ]] || continue
[[ ${#vmname} -gt $max_name_len ]] && max_name_len=${#vmname}
done < <(qm list 2>/dev/null)
while IFS= read -r line; do while IFS= read -r line; do
vmid=$(awk '{print $1}' <<< "$line") vmid=$(awk '{print $1}' <<< "$line")
vmname=$(awk '{print $2}' <<< "$line") vmname=$(awk '{print $2}' <<< "$line")
vmstatus=$(awk '{print $3}' <<< "$line") vmstatus=$(awk '{print $3}' <<< "$line")
[[ -z "$vmid" || "$vmid" == "VMID" ]] && continue [[ -z "$vmid" || "$vmid" == "VMID" ]] && continue
[[ -f "/etc/pve/qemu-server/${vmid}.conf" ]] || continue
vm_machine=$(qm config "$vmid" 2>/dev/null | awk -F': ' '/^machine:/ {print $2}') vm_machine=$(qm config "$vmid" 2>/dev/null | awk -F': ' '/^machine:/ {print $2}')
[[ -z "$vm_machine" ]] && vm_machine="unknown" [[ -z "$vm_machine" ]] && vm_machine="unknown"
status_label="${vmstatus}, ${vm_machine}" status_label="${vmstatus}, ${vm_machine}"
vm_menu+=("$vmid" "${vmname} [${status_label}]") printf -v padded_name "%-${max_name_len}s" "$vmname"
vm_menu+=("$vmid" "${padded_name} [${status_label}]")
done < <(qm list 2>/dev/null) done < <(qm list 2>/dev/null)
if [[ ${#vm_menu[@]} -eq 0 ]]; then if [[ ${#vm_menu[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" \ dialog --backtitle "ProxMenux" \
@@ -221,6 +247,10 @@ validate_vm_requirements() {
} }
select_controller_nvme() { select_controller_nvme() {
# Show progress during potentially slow PCIe + disk detection
set_title
msg_info "$(translate "Analyzing system for available PCIe storage devices...")"
_refresh_host_storage_cache _refresh_host_storage_cache
local -a menu_items=() local -a menu_items=()
@@ -251,12 +281,19 @@ select_controller_nvme() {
_array_contains "$disk" "${controller_disks[@]}" || controller_disks+=("$disk") _array_contains "$disk" "${controller_disks[@]}" || controller_disks+=("$disk")
done < <(_controller_block_devices "$pci_full") done < <(_controller_block_devices "$pci_full")
# blocked_reasons: system disk OR disk in RUNNING guest → hide controller
# warn_reasons: disk in STOPPED guest only → show with ⚠ but allow selection
local -a blocked_reasons=() local -a blocked_reasons=()
local -a warn_reasons=()
for disk in "${controller_disks[@]}"; do for disk in "${controller_disks[@]}"; do
if _disk_is_host_system_used "$disk"; then if _disk_is_host_system_used "$disk"; then
blocked_reasons+=("${disk} (${DISK_USAGE_REASON})") blocked_reasons+=("${disk} (${DISK_USAGE_REASON})")
elif _disk_used_in_guest_configs "$disk"; then elif _disk_used_in_guest_configs "$disk"; then
blocked_reasons+=("${disk} ($(translate "In use by VM/LXC config"))") if _disk_used_in_running_guest "$disk"; then
blocked_reasons+=("${disk} ($(translate "In use by running VM/LXC — stop it first"))")
else
warn_reasons+=("$disk")
fi
fi fi
done done
@@ -266,21 +303,30 @@ select_controller_nvme() {
continue continue
fi fi
local short_name local short_name display_name
short_name=$(_shorten_text "$name" 42) display_name=$(_pci_storage_display_name "$pci_full")
short_name=$(_shorten_text "$display_name" 56)
local assigned_suffix="" local assigned_suffix=""
if [[ -n "$(_pci_assigned_vm_ids "$pci_full" "$SELECTED_VMID" 2>/dev/null | head -1)" ]]; then if [[ -n "$(_pci_assigned_vm_ids "$pci_full" "$SELECTED_VMID" 2>/dev/null | head -1)" ]]; then
assigned_suffix=" | $(translate "Assigned to VM")" assigned_suffix=" | $(translate "Assigned to VM")"
fi fi
controller_desc="${short_name}${assigned_suffix}" # Warn if some disks are referenced in stopped VM/CT configs
local warn_suffix=""
if [[ ${#warn_reasons[@]} -gt 0 ]]; then
warn_suffix=" ⚠"
fi
controller_desc="${short_name}${assigned_suffix}${warn_suffix}"
state="off" state="off"
menu_items+=("$pci_full" "$controller_desc" "$state") menu_items+=("$pci_full" "$controller_desc" "$state")
safe_count=$((safe_count + 1)) safe_count=$((safe_count + 1))
done < <(ls -d /sys/bus/pci/devices/* 2>/dev/null | sort) done < <(ls -d /sys/bus/pci/devices/* 2>/dev/null | sort)
stop_spinner
if [[ "$safe_count" -eq 0 ]]; then if [[ "$safe_count" -eq 0 ]]; then
local msg local msg
if [[ "$hidden_target_count" -gt 0 && "$blocked_count" -eq 0 ]]; then if [[ "$hidden_target_count" -gt 0 && "$blocked_count" -eq 0 ]]; then
@@ -318,29 +364,100 @@ select_controller_nvme() {
return 1 return 1
fi fi
if declare -F _vm_storage_confirm_controller_passthrough_risk >/dev/null 2>&1; then
if ! _vm_storage_confirm_controller_passthrough_risk "$SELECTED_VMID" "$SELECTED_VM_NAME" "$(translate "Controller + NVMe")"; then
return 1
fi
fi
return 0 return 0
} }
confirm_summary() { _prompt_raw_disk_conflict_policy() {
local msg local disk="$1"
msg="\n$(translate "The following devices will be added to VM") ${SELECTED_VMID} (${SELECTED_VM_NAME}):\n\n" shift
local pci info local -a guest_ids=("$@")
for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do local msg gid gtype gid_num gname gstatus
info=$(lspci -nn -s "${pci#0000:}" 2>/dev/null | sed 's/^[^ ]* //')
msg+=" - ${pci}${info:+ (${info})}\n"
done
msg+="\n$(translate "Do you want to continue?")"
dialog --backtitle "ProxMenux" --colors \ msg="$(translate "Disk") ${disk} $(translate "is referenced in the following stopped VM(s)/CT(s):")\\n\\n"
for gid in "${guest_ids[@]}"; do
gtype="${gid%%:*}"; gid_num="${gid##*:}"
if [[ "$gtype" == "VM" ]]; then
gname=$(_vm_name_by_id "$gid_num")
gstatus=$(qm status "$gid_num" 2>/dev/null | awk '{print $2}')
msg+=" - VM $gid_num ($gname) [${gstatus}]\\n"
else
gname=$(pct config "$gid_num" 2>/dev/null | awk '/^hostname:/ {print $2}')
[[ -z "$gname" ]] && gname="CT-$gid_num"
gstatus=$(pct status "$gid_num" 2>/dev/null | awk '{print $2}')
msg+=" - CT $gid_num ($gname) [${gstatus}]\\n"
fi
done
msg+="\\n$(translate "Choose action:")"
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Disk Reference Conflict")" \
--menu "$msg" 22 84 3 \
"1" "$(translate "Disable onboot on affected VM(s)/CT(s)")" \
"2" "$(translate "Remove disk references from affected VM(s)/CT(s) config")" \
"3" "$(translate "Skip — leave as-is")" \
2>&1 >/dev/tty) || { echo "skip"; return; }
case "$choice" in
1) echo "disable_onboot" ;;
2) echo "remove_refs" ;;
*) echo "skip" ;;
esac
}
confirm_summary() {
# ── Risk detection ─────────────────────────────────────────────────────────
local reinforce_limited_firmware="no"
local bios_date bios_year current_year bios_age cpu_model risk_detail=""
bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null)
bios_year=$(echo "$bios_date" | grep -oE '[0-9]{4}' | tail -n1)
current_year=$(date +%Y 2>/dev/null)
if [[ -n "$bios_year" && -n "$current_year" ]]; then
bios_age=$(( current_year - bios_year ))
if (( bios_age >= 7 )); then
reinforce_limited_firmware="yes"
risk_detail="$(translate "BIOS from") ${bios_year} (${bios_age} $(translate "years old")) — $(translate "older firmware may increase passthrough instability")"
fi
fi
cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs)
if echo "$cpu_model" | grep -qiE 'J4[0-9]{3}|J3[0-9]{3}|N4[0-9]{3}|N3[0-9]{3}|Apollo Lake'; then
reinforce_limited_firmware="yes"
[[ -z "$risk_detail" ]] && risk_detail="$(translate "Low-power CPU platform"): ${cpu_model}"
fi
# ── Build unified message ──────────────────────────────────────────────────
local msg pci display_name
msg="\n"
# Devices to add
msg+="\Zb$(translate "Devices to add to VM") ${SELECTED_VMID} (${SELECTED_VM_NAME}):\Zn\n"
for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do
display_name=$(_pci_storage_display_name "$pci")
msg+=" \Zb•\Zn ${pci} ${display_name}\n"
done
msg+="\n"
# Compatibility notice (always shown)
msg+="\Zb\Z4⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\Zn\n\n"
msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n"
msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n"
# Detected risk (only when applicable)
if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then
msg+="\n\Z1$(translate "Detected risk factor"): ${risk_detail}\Zn\n"
fi
msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${SELECTED_VMID}.conf\n"
msg+="\n\Zb$(translate "Do you want to continue?")\Zn"
local height=22
[[ "$reinforce_limited_firmware" == "yes" ]] && height=25
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Confirm Controller + NVMe Assignment")" \ --title "$(translate "Confirm Controller + NVMe Assignment")" \
--yesno "$msg" 18 90 --yesno "$msg" $height 90; then
[[ $? -ne 0 ]] && return 1 return 1
fi
return 0 return 0
} }
@@ -349,7 +466,7 @@ prompt_controller_conflict_policy() {
shift shift
local -a source_vms=("$@") local -a source_vms=("$@")
local msg vmid vm_name st ob local msg vmid vm_name st ob
msg="$(translate "Selected device is already assigned to other VM(s):")\n\n" msg="\n$(translate "Selected device is already assigned to other VM(s):")\n\n"
for vmid in "${source_vms[@]}"; do for vmid in "${source_vms[@]}"; do
vm_name=$(_vm_name_by_id "$vmid") vm_name=$(_vm_name_by_id "$vmid")
st="stopped"; _vm_status_is_running "$vmid" && st="running" st="stopped"; _vm_status_is_running "$vmid" && st="running"
@@ -359,11 +476,13 @@ prompt_controller_conflict_policy() {
msg+="\n$(translate "Choose action for this controller/NVMe:")" msg+="\n$(translate "Choose action for this controller/NVMe:")"
local choice local choice
choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Controller/NVMe Conflict Policy")" \
--menu "$msg" 20 80 10 \
"1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \
"2" "$(translate "Move to target VM (remove from source VM config)")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \
"3" "$(translate "Skip this device")" \ "3" "$(translate "Skip this device")" \
3>&1 1>&2 2>&3) || { echo "skip"; return; } 2>&1 >/dev/tty) || { echo "skip"; return; }
case "$choice" in case "$choice" in
1) echo "keep_disable_onboot" ;; 1) echo "keep_disable_onboot" ;;
@@ -372,17 +491,143 @@ prompt_controller_conflict_policy() {
esac esac
} }
# ── DIALOG PHASE: resolve all conflicts before terminal ───────────────────────
resolve_disk_conflicts() {
local -a new_pci_list=()
local pci vmid action slot_base scope_key has_running
# ── hostpci conflicts: controller already assigned to another VM ──────────
for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do
local -a source_vms=()
mapfile -t source_vms < <(_pci_assigned_vm_ids "$pci" "$SELECTED_VMID" 2>/dev/null)
if [[ ${#source_vms[@]} -eq 0 ]]; then
new_pci_list+=("$pci")
continue
fi
has_running=false
for vmid in "${source_vms[@]}"; do
if _vm_status_is_running "$vmid"; then
has_running=true
dialog --backtitle "ProxMenux" \
--title "$(translate "Device In Use")" \
--msgbox "\n$(translate "Controller") $pci $(translate "is in use by running VM") $vmid.\n\n$(translate "Stop it first and run this option again.")" \
10 72
break
fi
done
$has_running && continue
scope_key=$(printf '%s,' "${source_vms[@]}")
if [[ -n "$WIZARD_CONFLICT_POLICY" && "$WIZARD_CONFLICT_SCOPE" == "$scope_key" ]]; then
action="$WIZARD_CONFLICT_POLICY"
else
action=$(prompt_controller_conflict_policy "$pci" "${source_vms[@]}")
WIZARD_CONFLICT_POLICY="$action"
WIZARD_CONFLICT_SCOPE="$scope_key"
fi
case "$action" in
keep_disable_onboot)
for vmid in "${source_vms[@]}"; do
_vm_onboot_is_enabled "$vmid" && qm set "$vmid" -onboot 0 >/dev/null 2>&1
done
NEED_HOOK_SYNC=true
new_pci_list+=("$pci")
;;
move_remove_source)
slot_base=$(_pci_slot_base "$pci")
for vmid in "${source_vms[@]}"; do
_remove_pci_slot_from_vm_config "$vmid" "$slot_base"
done
new_pci_list+=("$pci")
;;
*) ;; # skip — do not add to new_pci_list
esac
done
SELECTED_CONTROLLER_PCIS=("${new_pci_list[@]}")
if [[ ${#SELECTED_CONTROLLER_PCIS[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" \
--title "$(translate "Controller + NVMe")" \
--msgbox "\n$(translate "No controllers remaining after conflict resolution.")" 8 64
return 1
fi
# ── Raw disk passthrough conflicts ───────────────────────────────────────
local raw_disk_policy="" raw_disk_scope=""
for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do
local -a cdisks=()
while IFS= read -r disk; do
[[ -z "$disk" ]] && continue
_array_contains "$disk" "${cdisks[@]}" || cdisks+=("$disk")
done < <(_controller_block_devices "$pci")
for disk in "${cdisks[@]}"; do
_disk_used_in_guest_configs "$disk" || continue
_disk_used_in_running_guest "$disk" && continue
local -a guest_ids=()
mapfile -t guest_ids < <(_disk_guest_ids "$disk")
[[ ${#guest_ids[@]} -eq 0 ]] && continue
local gscope gaction
gscope=$(printf '%s,' "${guest_ids[@]}")
if [[ -n "$raw_disk_policy" && "$raw_disk_scope" == "$gscope" ]]; then
gaction="$raw_disk_policy"
else
gaction=$(_prompt_raw_disk_conflict_policy "$disk" "${guest_ids[@]}")
raw_disk_policy="$gaction"
raw_disk_scope="$gscope"
fi
local gid gtype gid_num slot
case "$gaction" in
disable_onboot)
for gid in "${guest_ids[@]}"; do
gtype="${gid%%:*}"; gid_num="${gid##*:}"
if [[ "$gtype" == "VM" ]]; then
_vm_onboot_is_enabled "$gid_num" && qm set "$gid_num" -onboot 0 >/dev/null 2>&1
else
grep -qE '^onboot:\s*1' "/etc/pve/lxc/$gid_num.conf" 2>/dev/null && \
pct set "$gid_num" -onboot 0 >/dev/null 2>&1
fi
done
;;
remove_refs)
for gid in "${guest_ids[@]}"; do
gtype="${gid%%:*}"; gid_num="${gid##*:}"
if [[ "$gtype" == "VM" ]]; then
while IFS= read -r slot; do
[[ -z "$slot" ]] && continue
qm set "$gid_num" -delete "$slot" >/dev/null 2>&1
done < <(_find_disk_slots_in_vm "$gid_num" "$disk")
else
while IFS= read -r slot; do
[[ -z "$slot" ]] && continue
pct set "$gid_num" -delete "$slot" >/dev/null 2>&1
done < <(_find_disk_slots_in_ct "$gid_num" "$disk")
fi
done
;;
esac
done
done
return 0
}
apply_assignment() { apply_assignment() {
: >"$LOG_FILE" : >"$LOG_FILE"
set_title set_title
echo
msg_info "$(translate "Applying Controller/NVMe passthrough to VM") ${SELECTED_VMID}..." msg_info "$(translate "Applying Controller/NVMe passthrough to VM") ${SELECTED_VMID}..."
msg_ok "$(translate "Target VM validated") (${SELECTED_VM_NAME} / ${SELECTED_VMID})" msg_ok "$(translate "Target VM validated") (${SELECTED_VM_NAME} / ${SELECTED_VMID})"
msg_ok "$(translate "Selected devices"): ${#SELECTED_CONTROLLER_PCIS[@]}" msg_ok "$(translate "Selected devices"): ${#SELECTED_CONTROLLER_PCIS[@]}"
local hostpci_idx=0 local hostpci_idx=0
msg_info "$(translate "Calculating next available hostpci slot...")"
if declare -F _pci_next_hostpci_index >/dev/null 2>&1; then if declare -F _pci_next_hostpci_index >/dev/null 2>&1; then
hostpci_idx=$(_pci_next_hostpci_index "$SELECTED_VMID" 2>/dev/null || echo 0) hostpci_idx=$(_pci_next_hostpci_index "$SELECTED_VMID" 2>/dev/null || echo 0)
else else
@@ -392,10 +637,8 @@ apply_assignment() {
hostpci_idx=$((hostpci_idx + 1)) hostpci_idx=$((hostpci_idx + 1))
done done
fi fi
msg_ok "$(translate "Next available hostpci slot"): hostpci${hostpci_idx}"
local pci bdf assigned_count=0 local pci bdf assigned_count=0
local need_hook_sync=false
for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do
bdf="${pci#0000:}" bdf="${pci#0000:}"
if declare -F _pci_function_assigned_to_vm >/dev/null 2>&1; then if declare -F _pci_function_assigned_to_vm >/dev/null 2>&1; then
@@ -408,50 +651,11 @@ apply_assignment() {
continue continue
fi fi
local -a source_vms=() local display_name
mapfile -t source_vms < <(_pci_assigned_vm_ids "$pci" "$SELECTED_VMID" 2>/dev/null) display_name=$(_pci_storage_display_name "$pci")
if [[ ${#source_vms[@]} -gt 0 ]]; then msg_info "$(translate "Adding") ${display_name} (${pci}) → hostpci${hostpci_idx}..."
local has_running=false vmid action slot_base if qm set "$SELECTED_VMID" "--hostpci${hostpci_idx}" "${pci},pcie=1" >>"$LOG_FILE" 2>&1; then
for vmid in "${source_vms[@]}"; do msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx}${pci})"
if _vm_status_is_running "$vmid"; then
has_running=true
msg_warn "$(translate "Controller/NVMe is in use by running VM") ${vmid} ($(translate "stop source VM first"))"
fi
done
if $has_running; then
continue
fi
action=$(prompt_controller_conflict_policy "$pci" "${source_vms[@]}")
case "$action" in
keep_disable_onboot)
for vmid in "${source_vms[@]}"; do
if _vm_onboot_is_enabled "$vmid"; then
if qm set "$vmid" -onboot 0 >>"$LOG_FILE" 2>&1; then
msg_warn "$(translate "Start on boot disabled for VM") ${vmid}"
fi
fi
done
need_hook_sync=true
;;
move_remove_source)
slot_base=$(_pci_slot_base "$pci")
for vmid in "${source_vms[@]}"; do
if _remove_pci_slot_from_vm_config "$vmid" "$slot_base"; then
msg_ok "$(translate "Controller/NVMe removed from source VM") ${vmid} (${pci})"
fi
done
;;
*)
msg_info2 "$(translate "Skipped device"): ${pci}"
continue
;;
esac
fi
if qm set "$SELECTED_VMID" --hostpci${hostpci_idx} "${pci},pcie=1" >>"$LOG_FILE" 2>&1; then
msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx} -> ${pci})"
assigned_count=$((assigned_count + 1)) assigned_count=$((assigned_count + 1))
hostpci_idx=$((hostpci_idx + 1)) hostpci_idx=$((hostpci_idx + 1))
else else
@@ -459,33 +663,50 @@ apply_assignment() {
fi fi
done done
if $need_hook_sync && declare -F sync_proxmenux_gpu_guard_hooks >/dev/null 2>&1; then if $NEED_HOOK_SYNC && declare -F sync_proxmenux_gpu_guard_hooks >/dev/null 2>&1; then
ensure_proxmenux_gpu_guard_hookscript ensure_proxmenux_gpu_guard_hookscript
sync_proxmenux_gpu_guard_hooks sync_proxmenux_gpu_guard_hooks
msg_ok "$(translate "VM hook guard synced for shared controller/NVMe protection")" msg_ok "$(translate "VM hook guard synced for shared controller/NVMe protection")"
fi fi
echo echo ""
echo -e "${TAB}${BL}Log: ${LOG_FILE}${CL}" echo -e "${TAB}${BL}Log: ${LOG_FILE}${CL}"
if [[ "$assigned_count" -gt 0 ]]; then if [[ "$assigned_count" -gt 0 ]]; then
msg_success "$(translate "Completed. Controller/NVMe passthrough configured for VM") ${SELECTED_VMID}." msg_ok "$(translate "Completed.") $assigned_count $(translate "device(s) added to VM") ${SELECTED_VMID}."
if [[ "${IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
msg_warn "$(translate "IOMMU was configured during this run. Reboot the host before starting the VM.")"
fi
else else
msg_warn "$(translate "No new Controller/NVMe entries were added.")" msg_warn "$(translate "No new Controller/NVMe entries were added.")"
fi fi
if [[ "${IOMMU_ALREADY_ACTIVE:-0}" == "1" ]]; then
msg_ok "$(translate "IOMMU is enabled on the system")"
elif [[ "${IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")"
echo ""
if whiptail --title "$(translate "Reboot Required")" \
--yesno "\n$(translate "IOMMU has been enabled on this system. A reboot is required to apply the changes. Reboot now?")" 11 64; then
msg_success "$(translate "Press Enter to continue...")"
read -r
msg_warn "$(translate "Rebooting the system...")"
reboot
else
msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")"
msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")"
fi
fi
echo ""
msg_success "$(translate "Press Enter to continue...")" msg_success "$(translate "Press Enter to continue...")"
read -r read -r
} }
main() { main() {
select_target_vm || exit 0 export WIZARD_CONFLICT_POLICY
export WIZARD_CONFLICT_SCOPE
select_target_vm || exit 0
validate_vm_requirements || exit 0 validate_vm_requirements || exit 0
select_controller_nvme || exit 0 select_controller_nvme || exit 0
confirm_summary || exit 0 resolve_disk_conflicts || exit 0
clear confirm_summary || exit 0
apply_assignment apply_assignment
} }

View File

@@ -6,8 +6,8 @@
# Author : MacRimi # Author : MacRimi
# Copyright : (c) 2024 MacRimi # Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0 # Version : 1.2
# Last Updated: 28/01/2025 # Last Updated: 12/04/2026
# ========================================================== # ==========================================================
# Description: # Description:
# This script allows users to assign physical disks to existing # This script allows users to assign physical disks to existing
@@ -20,6 +20,7 @@
# - Ensures that disks are not already assigned to active VMs. # - Ensures that disks are not already assigned to active VMs.
# - Warns about disk sharing between multiple VMs to avoid data corruption. # - Warns about disk sharing between multiple VMs to avoid data corruption.
# - Configures the selected disks for the VM and verifies the assignment. # - Configures the selected disks for the VM and verifies the assignment.
# - Prefers persistent /dev/disk/by-id paths for assignment when available.
# #
# The goal of this script is to simplify the process of assigning # The goal of this script is to simplify the process of assigning
# physical disks to Proxmox VMs, reducing manual configurations # physical disks to Proxmox VMs, reducing manual configurations
@@ -28,134 +29,181 @@
# Configuration ============================================ # Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
# shellcheck source=/dev/null
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE" source "$UTILS_FILE"
fi fi
# shellcheck source=/dev/null
if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then
source "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh"
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then
source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh"
fi
BACKTITLE="ProxMenux"
UI_MENU_H=20
UI_MENU_W=84
UI_MENU_LIST_H=10
UI_SHORT_MENU_H=16
UI_SHORT_MENU_W=72
UI_SHORT_MENU_LIST_H=6
UI_MSG_H=10
UI_MSG_W=72
UI_YESNO_H=18
UI_YESNO_W=86
UI_RESULT_H=18
UI_RESULT_W=86
load_language load_language
initialize_cache initialize_cache
# ==========================================================
if ! command -v pveversion >/dev/null 2>&1; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60
exit 1
fi
# ==========================================================
get_disk_info() { get_disk_info() {
local disk=$1 local disk=$1
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs) local model size
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs) model=$(lsblk -dn -o MODEL "$disk" | xargs)
echo "$MODEL" "$SIZE" size=$(lsblk -dn -o SIZE "$disk" | xargs)
[[ -z "$model" ]] && model="Unknown"
printf '%s\t%s\n' "$model" "$size"
}
get_all_disk_paths() {
local disk="$1"
local real_path
real_path=$(readlink -f "$disk" 2>/dev/null)
[[ -n "$disk" ]] && echo "$disk"
[[ -n "$real_path" ]] && echo "$real_path"
local link
for link in /dev/disk/by-id/* /dev/disk/by-path/*; do
[[ -e "$link" ]] || continue
[[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue
echo "$link"
done | sort -u
}
get_preferred_disk_path() {
local disk="$1"
local real_path
real_path=$(readlink -f "$disk" 2>/dev/null)
[[ -z "$real_path" ]] && { echo "$disk"; return 0; }
local best="" best_score=99999
local link name score
for link in /dev/disk/by-id/*; do
[[ -e "$link" ]] || continue
[[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue
name=$(basename "$link")
[[ "$name" == *-part* ]] && continue
case "$name" in
ata-*|scsi-*|nvme-*) score=100 ;;
wwn-*) score=200 ;;
*) score=300 ;;
esac
score=$((score + ${#name}))
if (( score < best_score )); then
best="$link"
best_score=$score
fi
done
if [[ -n "$best" ]]; then
echo "$best"
else
echo "$disk"
fi
}
disk_referenced_in_config() {
local config_text="$1"
local disk="$2"
local alias
while read -r alias; do
[[ -z "$alias" ]] && continue
if grep -Fq "$alias" <<< "$config_text"; then
return 0
fi
done < <(get_all_disk_paths "$disk")
return 1
} }
# ── DIALOG PHASE ──────────────────────────────────────────────────────────────
VM_LIST=$(qm list | awk 'NR>1 {print $1, $2}') VM_LIST=$(qm list | awk 'NR>1 {print $1, $2}')
if [ -z "$VM_LIST" ]; then if [ -z "$VM_LIST" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VMs available in the system.")" 8 40 dialog --backtitle "$BACKTITLE" \
--title "$(translate "Error")" \
--msgbox "$(translate "No VMs available in the system.")" $UI_MSG_H $UI_MSG_W
exit 1 exit 1
fi fi
# shellcheck disable=SC2086
VMID=$(whiptail --title "$(translate "Select VM")" --menu "$(translate "Select the VM to which you want to add disks:")" 15 60 8 $VM_LIST 3>&1 1>&2 2>&3) VMID=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "Select VM")" \
--menu "$(translate "Select the VM to which you want to add disks:")" $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
$VM_LIST \
2>&1 >/dev/tty)
if [ -z "$VMID" ]; then if [ -z "$VMID" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VM was selected.")" 8 40 dialog --backtitle "$BACKTITLE" \
--title "$(translate "Error")" \
--msgbox "$(translate "No VM was selected.")" $UI_MSG_H $UI_MSG_W
exit 1 exit 1
fi fi
VMID=$(echo "$VMID" | tr -d '"') VMID=$(echo "$VMID" | tr -d '"')
clear
show_proxmenux_logo
echo -e
msg_title "$(translate "Import Disk to VM")"
echo -e
msg_ok "$(translate "VM selected successfully.")"
VM_STATUS=$(qm status "$VMID" | awk '{print $2}') VM_STATUS=$(qm status "$VMID" | awk '{print $2}')
if [ "$VM_STATUS" == "running" ]; then if [ "$VM_STATUS" == "running" ]; then
whiptail --title "$(translate "Warning")" --msgbox "$(translate "The VM is powered on. Turn it off before adding disks.")" 12 60 dialog --backtitle "$BACKTITLE" \
--title "$(translate "Warning")" \
--msgbox "$(translate "The VM is powered on. Turn it off before adding disks.")" $UI_MSG_H $UI_MSG_W
exit 1 exit 1
fi fi
########################################## # ── TERMINAL PHASE 1: detect disks ────────────────────────────────────────────
show_proxmenux_logo
msg_title "$(translate "Import Disk to VM")"
msg_ok "$(translate "VM $VMID selected successfully.")"
msg_info "$(translate "Detecting available disks...")" msg_info "$(translate "Detecting available disks...")"
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}') _refresh_host_storage_cache
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') VM_CONFIG=$(qm config "$VMID" 2>/dev/null | grep -vE '^\s*#|^description:')
ZFS_DISKS=""
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
for entry in $ZFS_RAW; do
path=""
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
if [ -e "/dev/disk/by-id/$entry" ]; then
path=$(readlink -f "/dev/disk/by-id/$entry")
fi
elif [[ "$entry" == /dev/* ]]; then
path="$entry"
fi
if [ -n "$path" ]; then
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
if [ -n "$base_disk" ]; then
ZFS_DISKS+="/dev/$base_disk"$'\n'
fi
fi
done
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
is_disk_in_use() {
local disk="$1"
while read -r part fstype; do
case "$fstype" in
zfs_member|linux_raid_member)
return 0 ;;
esac
if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then
return 0
fi
done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2)
if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then
return 0
fi
return 1
}
FREE_DISKS=() FREE_DISKS=()
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u)
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
while read -r DISK; do while read -r DISK; do
[[ "$DISK" =~ /dev/zd ]] && continue [[ "$DISK" =~ /dev/zd ]] && continue
INFO=($(get_disk_info "$DISK")) IFS=$'\t' read -r MODEL SIZE < <(get_disk_info "$DISK")
MODEL="${INFO[@]::${#INFO[@]}-1}"
SIZE="${INFO[-1]}"
LABEL="" LABEL=""
SHOW_DISK=true SHOW_DISK=true
IS_MOUNTED=false IS_MOUNTED=false
IS_RAID=false IS_RAID=false
IS_ZFS=false IS_ZFS=false
@@ -175,46 +223,34 @@ while read -r DISK; do
IS_MOUNTED=true IS_MOUNTED=true
fi fi
USED_BY="" USED_BY=""
REAL_PATH=$(readlink -f "$DISK") if _disk_used_in_guest_configs "$DISK"; then
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")" USED_BY="$(translate "In use")"
else
for SYMLINK in /dev/disk/by-id/*; do
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
break
fi
fi
done
fi fi
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
if grep -q "active raid" /proc/mdstat; then if grep -q "active raid" /proc/mdstat; then
SHOW_DISK=false SHOW_DISK=false
fi fi
fi fi
if $IS_ZFS; then if $IS_ZFS; then
SHOW_DISK=false SHOW_DISK=false
fi fi
# Catch whole-disk ZFS vdevs with no partitions (e.g. bare NVMe ZFS)
# The tail -n +2 trick misses them; ZFS_DISKS from _refresh_host_storage_cache covers them.
if [[ -n "$ZFS_DISKS" ]] && \
{ grep -qFx "$DISK" <<< "$ZFS_DISKS" || \
{ [[ -n "$REAL_PATH" ]] && grep -qFx "$REAL_PATH" <<< "$ZFS_DISKS"; }; }; then
SHOW_DISK=false
fi
if $IS_MOUNTED; then if $IS_MOUNTED; then
SHOW_DISK=false SHOW_DISK=false
fi fi
if disk_referenced_in_config "$VM_CONFIG" "$DISK"; then
if qm config "$VMID" | grep -vE '^\s*#|^description:' | grep -q "$DISK"; then
SHOW_DISK=false SHOW_DISK=false
fi fi
@@ -229,144 +265,188 @@ while read -r DISK; do
fi fi
done < <(lsblk -dn -e 7,11 -o PATH) done < <(lsblk -dn -e 7,11 -o PATH)
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
cleanup stop_spinner
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this VM.")" 8 40 dialog --backtitle "$BACKTITLE" \
clear --title "$(translate "Error")" \
--msgbox "$(translate "No disks available for this VM.")" $UI_MSG_H $UI_MSG_W
exit 1 exit 1
fi fi
stop_spinner
msg_ok "$(translate "Available disks detected.")" msg_ok "$(translate "Available disks detected.")"
# ── DIALOG PHASE: select disks + interface ────────────────────────────────────
######################################################
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1) MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
TOTAL_WIDTH=$((MAX_WIDTH + 20)) TOTAL_WIDTH=$((MAX_WIDTH + 20))
if [ $TOTAL_WIDTH -lt $UI_MENU_W ]; then
if [ $TOTAL_WIDTH -lt 50 ]; then TOTAL_WIDTH=$UI_MENU_W
TOTAL_WIDTH=50 fi
if [ $TOTAL_WIDTH -gt 116 ]; then
TOTAL_WIDTH=116
fi fi
SELECTED=$(dialog --backtitle "$BACKTITLE" \
SELECTED=$(whiptail --title "$(translate "Select Disks")" --checklist \ --title "$(translate "Select Disks")" \
"$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) --checklist "\n$(translate "Select the disks you want to add:")" $UI_MENU_H $TOTAL_WIDTH $UI_MENU_LIST_H \
"${FREE_DISKS[@]}" \
2>&1 >/dev/tty)
if [ -z "$SELECTED" ]; then if [ -z "$SELECTED" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64 dialog --backtitle "$BACKTITLE" \
clear --title "$(translate "Error")" \
--msgbox "$(translate "No disks were selected.")" $UI_MSG_H $UI_MSG_W
exit 1 exit 1
fi fi
msg_ok "$(translate "Disks selected successfully.")" INTERFACE=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "Interface Type")" \
--menu "$(translate "Select the interface type for all disks:")" $UI_SHORT_MENU_H $UI_SHORT_MENU_W $UI_SHORT_MENU_LIST_H \
INTERFACE=$(whiptail --title "$(translate "Interface Type")" --menu "$(translate "Select the interface type for all disks:")" 15 40 4 \ "sata" "$(translate "Add as SATA")" \
"sata" "$(translate "Add as SATA")" \ "scsi" "$(translate "Add as SCSI")" \
"scsi" "$(translate "Add as SCSI")" \
"virtio" "$(translate "Add as VirtIO")" \ "virtio" "$(translate "Add as VirtIO")" \
"ide" "$(translate "Add as IDE")" 3>&1 1>&2 2>&3) "ide" "$(translate "Add as IDE")" \
2>&1 >/dev/tty)
if [ -z "$INTERFACE" ]; then if [ -z "$INTERFACE" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No interface type was selected for the disks.")" 8 40 dialog --backtitle "$BACKTITLE" \
clear --title "$(translate "Error")" \
--msgbox "$(translate "No interface type was selected for the disks.")" $UI_MSG_H $UI_MSG_W
exit 1 exit 1
fi fi
msg_ok "$(translate "Interface type selected: $INTERFACE")"
DISKS_ADDED=0 # ── DIALOG PHASE: per-disk pre-check ──────────────────────────────────────────
ERROR_MESSAGES="" declare -a DISK_LIST=()
SUCCESS_MESSAGES="" declare -a DISK_DESCRIPTIONS=()
declare -a DISK_ASSIGNED_TOS=()
declare -a NVME_SKIPPED=()
msg_info "$(translate "Processing selected disks...")"
for DISK in $SELECTED; do for DISK in $SELECTED; do
DISK=$(echo "$DISK" | tr -d '"') DISK="${DISK//\"/}"
DISK_INFO=$(get_disk_info "$DISK") DISK_INFO=$(get_disk_info "$DISK")
ASSIGNED_TO="" ASSIGNED_TO=""
RUNNING_VMS="" RUNNING_VMS=""
RUNNING_CTS="" RUNNING_CTS=""
while read -r VM_ID VM_NAME; do while read -r VM_ID VM_NAME; do
if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then VM_CONFIG_RAW=$(qm config "$VM_ID" 2>/dev/null)
if [[ "$VM_ID" =~ ^[0-9]+$ ]] && disk_referenced_in_config "$VM_CONFIG_RAW" "$DISK"; then
ASSIGNED_TO+="VM $VM_ID $VM_NAME\n" ASSIGNED_TO+="VM $VM_ID $VM_NAME\n"
VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}') VM_STATUS_CHK=$(qm status "$VM_ID" | awk '{print $2}')
if [ "$VM_STATUS" == "running" ]; then [[ "$VM_STATUS_CHK" == "running" ]] && RUNNING_VMS+="VM $VM_ID $VM_NAME\n"
RUNNING_VMS+="VM $VM_ID $VM_NAME\n"
fi
fi fi
done < <(qm list | awk 'NR>1 {print $1, $2}') done < <(qm list | awk 'NR>1 {print $1, $2}')
while read -r CT_ID CT_NAME; do while read -r CT_ID CT_NAME; do
if [[ "$CT_ID" =~ ^[0-9]+$ ]] && pct config "$CT_ID" | grep -q "$DISK"; then CT_CONFIG_RAW=$(pct config "$CT_ID" 2>/dev/null)
if [[ "$CT_ID" =~ ^[0-9]+$ ]] && disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"; then
ASSIGNED_TO+="CT $CT_ID $CT_NAME\n" ASSIGNED_TO+="CT $CT_ID $CT_NAME\n"
CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}') CT_STATUS_CHK=$(pct status "$CT_ID" | awk '{print $2}')
if [ "$CT_STATUS" == "running" ]; then [[ "$CT_STATUS_CHK" == "running" ]] && RUNNING_CTS+="CT $CT_ID $CT_NAME\n"
RUNNING_CTS+="CT $CT_ID $CT_NAME\n"
fi
fi fi
done < <(pct list | awk 'NR>1 {print $1, $2}') done < <(pct list | awk 'NR>1 {print $1, $3}')
if [ -n "$RUNNING_VMS" ] || [ -n "$RUNNING_CTS" ]; then if [ -n "$RUNNING_VMS" ] || [ -n "$RUNNING_CTS" ]; then
ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is currently in use by the following running VM(s) or CT(s):")\\n$RUNNING_VMS$RUNNING_CTS\\n\\n$(translate "You cannot add this disk while the VM or CT is running.")\\n$(translate "Please shut it down first and run this script again to add the disk.")\\n\\n" dialog --backtitle "$BACKTITLE" \
--title "$(translate "Disk In Use")" \
--msgbox "$(translate "The disk") $DISK_INFO $(translate "is in use by the following running VM(s) or CT(s):")\\n$RUNNING_VMS$RUNNING_CTS\\n\\n$(translate "Stop them first and run this script again.")" $UI_RESULT_H $UI_RESULT_W
continue continue
fi fi
if [ -n "$ASSIGNED_TO" ]; then if [ -n "$ASSIGNED_TO" ]; then
cleanup if ! dialog --backtitle "$BACKTITLE" \
whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70 --title "$(translate "Disk Already Assigned")" \
if [ $? -ne 0 ]; then --yesno "\n\n$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" $UI_YESNO_H $UI_YESNO_W; then
sleep 1 continue
exec "$0"
fi fi
fi fi
# NVMe: suggest PCIe passthrough for better performance
if [[ "$DISK" =~ /dev/nvme ]] || \
[[ "$(lsblk -dn -o TRAN "$DISK" 2>/dev/null | xargs)" == "nvme" ]]; then
NVME_CHOICE=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "NVMe Disk Detected")" \
--default-item "disk" \
--menu "\n$(translate "Adding this NVMe as a PCIe device (via 'Add Controller or NVMe PCIe to VM') gives better performance.")\n\n$(translate "How do you want to add it?")" \
$UI_YESNO_H $UI_YESNO_W 2 \
"disk" "$(translate "Add as disk (standard)")" \
"pci" "$(translate "Skip — I will add it as PCIe device")" \
2>&1 >/dev/tty)
if [[ "$NVME_CHOICE" == "pci" ]]; then
NVME_SKIPPED+=("$DISK")
continue
fi
fi
DISK_LIST+=("$DISK")
DISK_DESCRIPTIONS+=("$DISK_INFO")
DISK_ASSIGNED_TOS+=("$ASSIGNED_TO")
done
if [ "${#DISK_LIST[@]}" -eq 0 ]; then
show_proxmenux_logo
msg_title "$(translate "Import Disk to VM")"
msg_warn "$(translate "No disks were configured for processing.")"
echo ""
msg_success "$(translate "Press Enter to return to menu...")"
read -r
exit 0
fi
# ── TERMINAL PHASE: execute all disk operations ───────────────────────────────
show_proxmenux_logo
msg_title "$(translate "Import Disk to VM")"
msg_ok "$(translate "VM $VMID selected successfully.")"
msg_ok "$(translate "Disks to process:") ${#DISK_LIST[@]}"
for i in "${!DISK_LIST[@]}"; do
IFS=$'\t' read -r _desc_model _desc_size <<< "${DISK_DESCRIPTIONS[$i]}"
echo -e "${TAB}${BL}${DISK_LIST[$i]} $_desc_model $_desc_size${CL}"
done
if [[ ${#NVME_SKIPPED[@]} -gt 0 ]]; then
echo ""
msg_warn "$(translate "NVMe skipped (to add as PCIe use 'Add Controller or NVMe PCIe to VM'):")"
for _nvme in "${NVME_SKIPPED[@]}"; do
echo -e "${TAB}${BL}${_nvme}${CL}"
done
fi
echo ""
msg_ok "$(translate "Interface type:") $INTERFACE"
echo ""
DISKS_ADDED=0
for i in "${!DISK_LIST[@]}"; do
DISK="${DISK_LIST[$i]}"
ASSIGNED_TO="${DISK_ASSIGNED_TOS[$i]}"
IFS=$'\t' read -r _model _size <<< "${DISK_DESCRIPTIONS[$i]}"
INDEX=0 INDEX=0
while qm config "$VMID" | grep -q "${INTERFACE}${INDEX}"; do while qm config "$VMID" | grep -q "${INTERFACE}${INDEX}"; do
((INDEX++)) ((INDEX++))
done done
RESULT=$(qm set "$VMID" -${INTERFACE}${INDEX} "$DISK" 2>&1) ASSIGN_PATH=$(get_preferred_disk_path "$DISK")
msg_info "$(translate "Adding") $_model $_size $(translate "as") ${INTERFACE}${INDEX}..."
if [ $? -eq 0 ]; then if RESULT=$(qm set "$VMID" "-${INTERFACE}${INDEX}" "$ASSIGN_PATH" 2>&1); then
MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to VM") $VMID." msg_ok "$(translate "Disk added as") ${INTERFACE}${INDEX} $(translate "using") $ASSIGN_PATH"
if [ -n "$ASSIGNED_TO" ]; then [[ -n "$ASSIGNED_TO" ]] && msg_warn "$(translate "WARNING: This disk is also assigned to:") $(echo -e "$ASSIGNED_TO" | tr '\n' ' ')"
MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following VM(s):")\\n$ASSIGNED_TO"
MESSAGE+="\\n$(translate "Make sure not to start VMs that share this disk at the same time to avoid data corruption.")"
fi
SUCCESS_MESSAGES+="$MESSAGE\\n\\n"
((DISKS_ADDED++)) ((DISKS_ADDED++))
else else
ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to VM") $VMID.\\n$(translate "Error:") $RESULT\\n\\n" msg_error "$(translate "Could not add") $_model $_size: $RESULT"
fi fi
done done
msg_ok "$(translate "Disk processing completed.")" echo ""
if [ "$DISKS_ADDED" -gt 0 ]; then
msg_ok "$(translate "Completed.") $DISKS_ADDED $(translate "disk(s) added to VM") $VMID."
else
if [ -n "$SUCCESS_MESSAGES" ]; then msg_warn "$(translate "No disks were added.")"
MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l)
whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70
fi fi
msg_success "$(translate "Press Enter to return to menu...")"
if [ -n "$ERROR_MESSAGES" ]; then read -r
whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70
fi
exit 0 exit 0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Disk and Storage Manager Manual CLI Guide
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : GPL-3.0
# Version : 1.0
# Last Updated: 07/04/2026
# ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
GREEN=$'\033[0;32m'
NC=$'\033[0m'
_cl() {
local num="$1" disp="$2" desc="$3"
local pad=$((47 - ${#disp}))
[[ $pad -lt 1 ]] && pad=1
local spaces
spaces=$(printf '%*s' "$pad" '')
printf " %2d) %s%s%s%s - %s\n" "$num" "$GREEN" "$disp" "$NC" "$spaces" "$desc"
}
while true; do
clear
show_proxmenux_logo
msg_title "$(translate "Disk and Storage Manager - Manual CLI Guide")"
echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}"
echo
_cl 1 "lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL" "$(translate 'Inspect disks before any action')"
_cl 2 "ls -lh /dev/disk/by-id/" "$(translate 'Identify persistent disk paths')"
_cl 3 "qm list && pct list" "$(translate 'List VM/CT IDs to operate on')"
_cl 4 "qm config <vmid> | grep 'sata|scsi|hostpci'" "$(translate 'Check VM disk/PCI slots')"
_cl 5 "pvesm status -content images" "$(translate 'List storages valid for image import')"
_cl 6 "qm importdisk <vmid> <image_path> <storage>" "[T] $(translate 'Import disk image to VM')"
_cl 7 "qm set <vmid> --<iface><slot> <imported-disk>" "[T] $(translate 'Attach imported disk to VM')"
_cl 8 "qm set <vmid> --boot order=<iface><slot>" "[T] $(translate 'Set VM boot order')"
_cl 9 "lspci -nn | grep -Ei 'SATA|RAID|NVMe'" "$(translate 'Detect controller/NVMe BDF')"
_cl 10 "find /sys/kernel/iommu_groups -type l | grep BDF" "$(translate 'Verify IOMMU group for PCI device')"
_cl 11 "qm set <vmid> --hostpci<slot> <BDF>,pcie=1" "[T] $(translate 'Assign controller/NVMe passthrough')"
_cl 12 "pct config <ctid> | grep '^mp'" "$(translate 'Check container mount points')"
_cl 13 "pct set <ctid> -mp<slot> <disk>,mp=<path>" "[T] $(translate 'Add disk to LXC container')"
_cl 14 "wipefs -a -f /dev/sdX && sgdisk --zap-all /dev/sdX" "[T] $(translate 'Clean disk metadata')"
_cl 15 "parted -s /dev/sdX mklabel gpt mkpart primary" "[T] $(translate 'Create GPT partition')"
_cl 16 "mkfs.ext4 -F /dev/sdX1 (or mkfs.xfs / mkfs.btrfs)" "[T] $(translate 'Format filesystem')"
_cl 17 "pvesm status && zpool status" "$(translate 'Final storage health/status check')"
echo -e " ${DEF} 0) $(translate 'Back to previous menu or Esc + Enter')${CL}"
echo
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}"
read -r user_input
if [[ "$user_input" == $'\x1b' ]]; then
break
fi
mode="exec"
case "$user_input" in
1) cmd="lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL" ;;
2) cmd="ls -lh /dev/disk/by-id/" ;;
3) cmd="qm list && pct list" ;;
4)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"
read -r vmid
cmd="qm config $vmid | grep -E '^(sata|scsi|virtio|ide|hostpci|boot:)'"
;;
5) cmd="pvesm status -content images" ;;
6)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter image full path: ')${CL}"; read -r image_path
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter target storage: ')${CL}"; read -r storage
cmd="qm importdisk $vmid $image_path $storage"
mode="template"
;;
7)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter interface (sata/scsi/virtio/ide): ')${CL}"; read -r iface
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter slot number (e.g. 0): ')${CL}"; read -r slot
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter imported disk reference (e.g. local-lvm:vm-100-disk-0): ')${CL}"; read -r imported_disk
cmd="qm set $vmid --${iface}${slot} $imported_disk"
mode="template"
;;
8)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter boot target (e.g. scsi0, sata0, ide0): ')${CL}"; read -r boot_target
cmd="qm set $vmid --boot order=$boot_target"
mode="template"
;;
9) cmd="lspci -nn | grep -Ei 'SATA|RAID|Non-Volatile|NVMe'" ;;
10)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:04:00.0): ')${CL}"
read -r bdf
cmd="find /sys/kernel/iommu_groups -type l | grep $bdf"
;;
11)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot number (e.g. 0): ')${CL}"; read -r slot
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:04:00.0): ')${CL}"; read -r bdf
cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1"
mode="template"
;;
12)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"
read -r ctid
cmd="pct config $ctid | grep '^mp'"
;;
13)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"; read -r ctid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter mp slot number (e.g. 0): ')${CL}"; read -r mpslot
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter disk or partition path (prefer /dev/disk/by-id/...): ')${CL}"; read -r disk_part
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter mount point in CT (e.g. /mnt/data): ')${CL}"; read -r mount_point
cmd="pct set $ctid -mp${mpslot} ${disk_part},mp=${mount_point},backup=0,ro=0"
mode="template"
;;
14)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter target disk (e.g. /dev/sdX): ')${CL}"
read -r disk
cmd="wipefs -a -f $disk && sgdisk --zap-all $disk"
mode="template"
;;
15)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter target disk (e.g. /dev/sdX): ')${CL}"
read -r disk
cmd="parted -s -f $disk mklabel gpt mkpart primary 1MiB 100%"
mode="template"
;;
16)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter partition path (e.g. /dev/sdX1): ')${CL}"; read -r part
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter filesystem (ext4/xfs/btrfs): ')${CL}"; read -r fs
case "$fs" in
ext4) cmd="mkfs.ext4 -F $part" ;;
xfs) cmd="mkfs.xfs -f $part" ;;
btrfs) cmd="mkfs.btrfs -f $part" ;;
*) cmd="mkfs.ext4 -F $part" ;;
esac
mode="template"
;;
17) cmd="pvesm status && zpool status" ;;
0) break ;;
*)
if [[ -n "$user_input" ]]; then
cmd="$user_input"
else
continue
fi
;;
esac
if [[ "$mode" == "template" ]]; then
echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n"
echo "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
continue
fi
echo -e "\n${GREEN}> $cmd${NC}\n"
bash -c "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
done

File diff suppressed because it is too large Load Diff

View File

@@ -6,24 +6,14 @@
# Author : MacRimi # Author : MacRimi
# Copyright : (c) 2024 MacRimi # Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.1 # Version : 1.3
# Last Updated: 29/05/2025 # Last Updated: 12/04/2026
# ========================================================== # ==========================================================
# Description: # Description:
# This script automates the process of importing disk images into Proxmox VE virtual machines (VMs), # Imports disk images (.img, .qcow2, .vmdk, .raw) into Proxmox VE VMs.
# making it easy to attach pre-existing disk files without manual configuration. # Supports the default system ISO directory and custom paths.
# # All user decisions are collected in Phase 1 (dialogs) before
# Before running the script, ensure that disk images are available in /var/lib/vz/template/images/. # any operation is executed in Phase 2 (terminal output).
# The script scans this directory for compatible formats (.img, .qcow2, .vmdk, .raw) and lists the available files.
#
# Using an interactive menu, you can:
# - Select a VM to attach the imported disk.
# - Choose one or multiple disk images for import.
# - Pick a storage volume in Proxmox for disk placement.
# - Assign a suitable interface (SATA, SCSI, VirtIO, or IDE).
# - Enable optional settings like SSD emulation or bootable disk configuration.
#
# Once completed, the script ensures the selected images are correctly attached and ready to use.
# ========================================================== # ==========================================================
# Configuration ============================================ # Configuration ============================================
@@ -31,256 +21,340 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env" VENV_PATH="/opt/googletrans-env"
BACKTITLE="ProxMenux"
UI_MENU_H=20
UI_MENU_W=84
UI_MENU_LIST_H=10
UI_SHORT_MENU_H=16
UI_SHORT_MENU_W=72
UI_SHORT_MENU_LIST_H=6
UI_MSG_H=10
UI_MSG_W=72
UI_YESNO_H=10
UI_YESNO_W=72
UI_RESULT_H=14
UI_RESULT_W=86
# shellcheck source=/dev/null
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE" [[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
load_language load_language
initialize_cache initialize_cache
# Configuration ============================================ # Configuration ============================================
_get_default_images_dir() {
detect_image_dir() { for dir in /var/lib/vz/template/iso /var/lib/vz/template/images; do
for store in $(pvesm status -content images | awk 'NR>1 {print $1}'); do [[ -d "$dir" ]] && echo "$dir" && return 0
done
local store path
for store in $(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1}'); do
path=$(pvesm path "${store}:template" 2>/dev/null) path=$(pvesm path "${store}:template" 2>/dev/null)
if [[ -d "$path" ]]; then [[ -d "$path" ]] && echo "$path" && return 0
for ext in raw img qcow2 vmdk; do
if compgen -G "$path/*.$ext" > /dev/null; then
echo "$path"
return 0
fi
done
for sub in images iso; do
dir="$path/$sub"
if [[ -d "$dir" ]]; then
for ext in raw img qcow2 vmdk; do
if compgen -G "$dir/*.$ext" > /dev/null; then
echo "$dir"
return 0
fi
done
fi
done
fi
done done
for fallback in /var/lib/vz/template/images /var/lib/vz/template/iso; do echo "/var/lib/vz/template/iso"
if [[ -d "$fallback" ]]; then
for ext in raw img qcow2 vmdk; do
if compgen -G "$fallback/*.$ext" > /dev/null; then
echo "$fallback"
return 0
fi
done
fi
done
return 1
} }
IMAGES_DIR=$(detect_image_dir) # ==========================================================
if [[ -z "$IMAGES_DIR" ]]; then # PHASE 1 — SELECTION
dialog --title "$(translate 'No Images Found')" \ # All dialogs run here. No execution, no show_proxmenux_logo.
--msgbox "$(translate 'Could not find any directory containing disk images')\n\n$(translate 'Make sure there is at least one file with extension .img, .qcow2, .vmdk or .raw')" 15 60 # ==========================================================
# ── Step 1: Select VM ─────────────────────────────────────
VM_OPTIONS=()
while read -r vmid vmname _rest; do
VM_OPTIONS+=("$vmid" "${vmname:-VM-$vmid}")
done < <(qm list 2>/dev/null | awk 'NR>1')
stop_spinner
if [[ ${#VM_OPTIONS[@]} -eq 0 ]]; then
dialog --backtitle "$BACKTITLE" \
--title "$(translate 'No VMs Found')" \
--msgbox "\n$(translate 'No VMs available in the system.')" \
$UI_MSG_H $UI_MSG_W
exit 1 exit 1
fi fi
IMAGES=$(ls -A "$IMAGES_DIR" | grep -E "\.(img|qcow2|vmdk|raw)$") VMID=$(dialog --backtitle "$BACKTITLE" \
if [ -z "$IMAGES" ]; then --title "$(translate 'Select VM')" \
dialog --title "$(translate 'No Disk Images Found')" \ --menu "$(translate 'Select the VM where you want to import the disk image:')" \
--msgbox "$(translate 'No compatible disk images found in:')\n\n$IMAGES_DIR\n\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" 15 60 $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
exit 1 "${VM_OPTIONS[@]}" \
fi 2>&1 >/dev/tty)
[[ -z "$VMID" ]] && exit 0
# 1. Select VM
msg_info "$(translate 'Getting VM list')"
VM_LIST=$(qm list | awk 'NR>1 {print $1" "$2}')
if [ -z "$VM_LIST" ]; then
msg_error "$(translate 'No VMs available in the system')"
exit 1
fi
msg_ok "$(translate 'VM list obtained')"
VMID=$(whiptail --title "$(translate 'Select VM')" --menu "$(translate 'Select the VM where you want to import the disk image:')" 15 60 8 $VM_LIST 3>&1 1>&2 2>&3)
if [ -z "$VMID" ]; then
exit 1
fi
# 2. Select storage volume
msg_info "$(translate 'Getting storage volumes')"
STORAGE_LIST=$(pvesm status -content images | awk 'NR>1 {print $1}')
if [ -z "$STORAGE_LIST" ]; then
msg_error "$(translate 'No storage volumes available')"
exit 1
fi
msg_ok "$(translate 'Storage volumes obtained')"
# ── Step 2: Select storage ────────────────────────────────
STORAGE_OPTIONS=() STORAGE_OPTIONS=()
while read -r storage; do while read -r storage type _rest; do
STORAGE_OPTIONS+=("$storage" "") STORAGE_OPTIONS+=("$storage" "$type")
done <<< "$STORAGE_LIST" done < <(pvesm status -content images 2>/dev/null | awk 'NR>1')
stop_spinner
STORAGE=$(whiptail --title "$(translate 'Select Storage')" --menu "$(translate 'Select the storage volume for disk import:')" 15 60 8 "${STORAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3) if [[ ${#STORAGE_OPTIONS[@]} -eq 0 ]]; then
dialog --backtitle "$BACKTITLE" \
--title "$(translate 'No Storage Found')" \
--msgbox "\n$(translate 'No storage volumes available for disk images.')" \
$UI_MSG_H $UI_MSG_W
exit 1
fi
if [ -z "$STORAGE" ]; then if [[ ${#STORAGE_OPTIONS[@]} -eq 2 ]]; then
# Only one storage available — auto-select it
exit 1 STORAGE="${STORAGE_OPTIONS[0]}"
else
STORAGE=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Select Storage')" \
--menu "$(translate 'Select the storage volume for disk import:')" \
$UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
"${STORAGE_OPTIONS[@]}" \
2>&1 >/dev/tty)
[[ -z "$STORAGE" ]] && exit 0
fi fi
# ── Step 3: Select image source directory ────────────────
ISO_DIR="/var/lib/vz/template/iso"
# 3. Select disk images DIR_CHOICE=$(dialog --backtitle "$BACKTITLE" \
msg_info "$(translate 'Scanning disk images')" --title "$(translate 'Image Source Directory')" \
if [ -z "$IMAGES" ]; then --menu "$(translate 'Select the directory containing disk images:')" \
msg_warn "$(translate 'No compatible disk images found in') $IMAGES_DIR" $UI_SHORT_MENU_H $UI_MENU_W $UI_SHORT_MENU_LIST_H \
exit 0 "$ISO_DIR" "$(translate 'Default ISO directory')" \
"custom" "$(translate 'Custom path...')" \
2>&1 >/dev/tty)
[[ -z "$DIR_CHOICE" ]] && exit 0
if [[ "$DIR_CHOICE" == "custom" ]]; then
IMAGES_DIR=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Custom Directory')" \
--inputbox "\n$(translate 'Enter the full path to the directory containing disk images:')\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" \
10 $UI_RESULT_W "" \
2>&1 >/dev/tty)
[[ -z "$IMAGES_DIR" ]] && exit 0
else
IMAGES_DIR="$ISO_DIR"
fi fi
msg_ok "$(translate 'Disk images found')"
if [[ ! -d "$IMAGES_DIR" ]]; then
dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Directory Not Found')" \
--msgbox "\n$(translate 'The specified directory does not exist:')\n\n$IMAGES_DIR" \
$UI_MSG_H $UI_MSG_W
exit 1
fi
IMAGES=$(find "$IMAGES_DIR" -maxdepth 1 -type f \
\( -name "*.img" -o -name "*.qcow2" -o -name "*.vmdk" -o -name "*.raw" \) \
-printf '%f\n' 2>/dev/null | sort)
if [[ -z "$IMAGES" ]]; then
dialog --backtitle "$BACKTITLE" \
--title "$(translate 'No Disk Images Found')" \
--msgbox "\n$(translate 'No compatible disk images found in:')\n\n$IMAGES_DIR\n\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" \
$UI_RESULT_H $UI_RESULT_W
exit 1
fi
# ── Step 4: Select images ─────────────────────────────────
IMAGE_OPTIONS=() IMAGE_OPTIONS=()
while read -r img; do while IFS= read -r img; do
IMAGE_OPTIONS+=("$img" "" "OFF") IMAGE_OPTIONS+=("$img" "" "OFF")
done <<< "$IMAGES" done <<< "$IMAGES"
SELECTED_IMAGES=$(whiptail --title "$(translate 'Select Disk Images')" --checklist "$(translate 'Select the disk images to import:')" 20 60 10 "${IMAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3) SELECTED_IMAGES_STR=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Select Disk Images')" \
--checklist "$(translate 'Select one or more disk images to import:')" \
$UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
"${IMAGE_OPTIONS[@]}" \
2>&1 >/dev/tty)
[[ -z "$SELECTED_IMAGES_STR" ]] && exit 0
if [ -z "$SELECTED_IMAGES" ]; then eval "declare -a SELECTED_ARRAY=($SELECTED_IMAGES_STR)"
exit 1
# ── Step 5: Per-image options ─────────────────────────────
declare -a IMG_NAMES=()
declare -a IMG_INTERFACES=()
declare -a IMG_SSD_OPTIONS=()
declare -a IMG_BOOTABLE=()
for IMAGE in "${SELECTED_ARRAY[@]}"; do
IMAGE="${IMAGE//\"/}"
INTERFACE=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Interface Type')$IMAGE" \
--default-item "scsi" \
--menu "$(translate 'Select the interface type for:') $IMAGE" \
$UI_SHORT_MENU_H $UI_SHORT_MENU_W $UI_SHORT_MENU_LIST_H \
"scsi" "SCSI $(translate '(recommended)')" \
"virtio" "VirtIO" \
"sata" "SATA" \
"ide" "IDE" \
2>&1 >/dev/tty)
[[ -z "$INTERFACE" ]] && continue
SSD_OPTION=""
if [[ "$INTERFACE" != "virtio" ]]; then
if dialog --backtitle "$BACKTITLE" \
--title "$(translate 'SSD Emulation')$IMAGE" \
--yesno "\n$(translate 'Enable SSD emulation for this disk?')" \
$UI_YESNO_H $UI_YESNO_W; then
SSD_OPTION=",ssd=1"
fi
fi
BOOTABLE="no"
if dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Boot Disk')$IMAGE" \
--yesno "\n$(translate 'Set this disk as the primary boot disk?')" \
$UI_YESNO_H $UI_YESNO_W; then
BOOTABLE="yes"
fi
IMG_NAMES+=("$IMAGE")
IMG_INTERFACES+=("$INTERFACE")
IMG_SSD_OPTIONS+=("$SSD_OPTION")
IMG_BOOTABLE+=("$BOOTABLE")
done
if [[ ${#IMG_NAMES[@]} -eq 0 ]]; then
exit 0
fi fi
# ==========================================================
# PHASE 2 — EXECUTION
# show_proxmenux_logo appears here exactly once.
# No dialogs from this point on.
# ==========================================================
# 4. Import each selected image show_proxmenux_logo
for IMAGE in $SELECTED_IMAGES; do msg_title "$(translate 'Import Disk Image to VM')"
VM_NAME=$(qm config "$VMID" 2>/dev/null | awk '/^name:/ {print $2}')
msg_ok "$(translate 'VM:') ${VM_NAME:-VM-$VMID} (${VMID})"
msg_ok "$(translate 'Storage:') $STORAGE"
msg_ok "$(translate 'Image directory:') $IMAGES_DIR"
msg_ok "$(translate 'Images to import:') ${#IMG_NAMES[@]}"
echo ""
IMAGE=$(echo "$IMAGE" | tr -d '"') PROCESSED=0
FAILED=0
for i in "${!IMG_NAMES[@]}"; do
IMAGE="${IMG_NAMES[$i]}"
INTERFACE="${IMG_INTERFACES[$i]}"
SSD_OPTION="${IMG_SSD_OPTIONS[$i]}"
BOOTABLE="${IMG_BOOTABLE[$i]}"
FULL_PATH="$IMAGES_DIR/$IMAGE"
INTERFACE=$(whiptail --title "$(translate 'Interface Type')" --menu "$(translate 'Select the interface type for the image:') $IMAGE" 15 40 4 \ if [[ ! -f "$FULL_PATH" ]]; then
"sata" "SATA" \ msg_error "$(translate 'Image file not found:') $FULL_PATH"
"scsi" "SCSI" \ FAILED=$((FAILED + 1))
"virtio" "VirtIO" \ continue
"ide" "IDE" 3>&1 1>&2 2>&3) fi
if [ -z "$INTERFACE" ]; then # Snapshot of unused entries before import for reliable detection
msg_error "$(translate 'No interface type selected for') $IMAGE" BEFORE_UNUSED=$(qm config "$VMID" 2>/dev/null | grep -E '^unused[0-9]+:' || true)
continue
TEMP_STATUS_FILE=$(mktemp)
TEMP_DISK_FILE=$(mktemp)
msg_info "$(translate 'Importing') $IMAGE..."
(
qm importdisk "$VMID" "$FULL_PATH" "$STORAGE" 2>&1
echo $? > "$TEMP_STATUS_FILE"
) | while IFS= read -r line; do
if [[ "$line" =~ [0-9]+\.[0-9]+% ]]; then
echo -ne "\r${TAB}${BL}$(translate 'Importing') ${IMAGE}${CL} ${BASH_REMATCH[0]} "
fi
if echo "$line" | grep -qiF "successfully imported disk"; then
echo "$line" | sed -n "s/.*successfully imported disk as '\\([^']*\\)'.*/\\1/p" > "$TEMP_DISK_FILE"
fi
done
echo -ne "\n"
IMPORT_STATUS=$(cat "$TEMP_STATUS_FILE" 2>/dev/null)
rm -f "$TEMP_STATUS_FILE"
[[ -z "$IMPORT_STATUS" ]] && IMPORT_STATUS=1
if [[ "$IMPORT_STATUS" -ne 0 ]]; then
msg_error "$(translate 'Failed to import') $IMAGE"
rm -f "$TEMP_DISK_FILE"
FAILED=$((FAILED + 1))
continue
fi
msg_ok "$(translate 'Image imported:') $IMAGE"
# Primary: parse disk name from qm importdisk output
IMPORTED_DISK=$(cat "$TEMP_DISK_FILE" 2>/dev/null | xargs)
rm -f "$TEMP_DISK_FILE"
# Fallback: compare unused entries before/after import
if [[ -z "$IMPORTED_DISK" ]]; then
AFTER_UNUSED=$(qm config "$VMID" 2>/dev/null | grep -E '^unused[0-9]+:' || true)
NEW_LINE=$(comm -13 \
<(echo "$BEFORE_UNUSED" | sort) \
<(echo "$AFTER_UNUSED" | sort) | head -1)
if [[ -n "$NEW_LINE" ]]; then
IMPORTED_DISK=$(echo "$NEW_LINE" | cut -d':' -f2- | xargs)
fi
fi
if [[ -z "$IMPORTED_DISK" ]]; then
msg_error "$(translate 'Could not identify the imported disk in VM config')"
FAILED=$((FAILED + 1))
continue
fi
# Find the unusedN key that holds this disk (needed to clean it up after assignment)
IMPORTED_ID=$(qm config "$VMID" 2>/dev/null | grep -F "$IMPORTED_DISK" | cut -d':' -f1 | head -1)
# Find next available slot for the chosen interface
LAST_SLOT=$(qm config "$VMID" 2>/dev/null | grep -oE "^${INTERFACE}[0-9]+" | grep -oE '[0-9]+' | sort -n | tail -1)
if [[ -z "$LAST_SLOT" ]]; then
NEXT_SLOT=0
else
NEXT_SLOT=$((LAST_SLOT + 1))
fi
msg_info "$(translate 'Configuring disk as') ${INTERFACE}${NEXT_SLOT}..."
if qm set "$VMID" "--${INTERFACE}${NEXT_SLOT}" "${IMPORTED_DISK}${SSD_OPTION}" >/dev/null 2>&1; then
msg_ok "$(translate 'Disk configured as') ${INTERFACE}${NEXT_SLOT}${SSD_OPTION:+ (SSD)}"
# Remove the unusedN entry now that the disk is properly assigned
if [[ -n "$IMPORTED_ID" ]]; then
qm set "$VMID" -delete "$IMPORTED_ID" >/dev/null 2>&1
fi fi
FULL_PATH="$IMAGES_DIR/$IMAGE" if [[ "$BOOTABLE" == "yes" ]]; then
msg_info "$(translate 'Setting boot order...')"
if qm set "$VMID" --boot "order=${INTERFACE}${NEXT_SLOT}" >/dev/null 2>&1; then
msg_info "$(translate 'Importing image:')" msg_ok "$(translate 'Boot order set to') ${INTERFACE}${NEXT_SLOT}"
else
msg_error "$(translate 'Could not set boot order for') ${INTERFACE}${NEXT_SLOT}"
TEMP_DISK_FILE=$(mktemp) fi
qm importdisk "$VMID" "$FULL_PATH" "$STORAGE" 2>&1 | while read -r line; do
if [[ "$line" =~ transferred ]]; then
PERCENT=$(echo "$line" | grep -oP "\d+\.\d+(?=%)")
echo -ne "\r${TAB}${BL}-$(translate 'Importing image:') $IMAGE-${CL} ${PERCENT}%"
elif [[ "$line" =~ successfully\ imported\ disk ]]; then
echo "$line" | grep -oP "(?<=successfully imported disk ').*(?=')" > "$TEMP_DISK_FILE"
fi
done
echo -ne "\n"
IMPORT_STATUS=${PIPESTATUS[0]}
if [ $IMPORT_STATUS -eq 0 ]; then
msg_ok "$(translate 'Image imported successfully')"
IMPORTED_DISK=$(cat "$TEMP_DISK_FILE")
rm -f "$TEMP_DISK_FILE"
if [ -z "$IMPORTED_DISK" ]; then
STORAGE_TYPE=$(pvesm status -storage "$STORAGE" | awk 'NR>1 {print $2}')
if [[ "$STORAGE_TYPE" == "btrfs" || "$STORAGE_TYPE" == "dir" || "$STORAGE_TYPE" == "nfs" ]]; then
UNUSED_LINE=$(qm config "$VMID" | grep -E '^unused[0-9]+:')
IMPORTED_ID=$(echo "$UNUSED_LINE" | cut -d: -f1)
IMPORTED_DISK=$(echo "$UNUSED_LINE" | cut -d: -f2- | xargs)
else
IMPORTED_DISK=$(qm config "$VMID" | grep -E 'unused[0-9]+' | tail -1 | cut -d: -f2- | xargs)
IMPORTED_ID=$(qm config "$VMID" | grep -E 'unused[0-9]+' | tail -1 | cut -d: -f1)
fi
fi
if [ -n "$IMPORTED_DISK" ]; then
EXISTING_DISKS=$(qm config "$VMID" | grep -oP "${INTERFACE}\d+" | sort -n)
if [ -z "$EXISTING_DISKS" ]; then
NEXT_SLOT=0
else
LAST_SLOT=$(echo "$EXISTING_DISKS" | tail -n1 | sed "s/${INTERFACE}//")
NEXT_SLOT=$((LAST_SLOT + 1))
fi
if [ "$INTERFACE" != "virtio" ]; then
if (whiptail --title "$(translate 'SSD Emulation')" --yesno "$(translate 'Do you want to use SSD emulation for this disk?')" 10 60); then
SSD_OPTION=",ssd=1"
else
SSD_OPTION=""
fi
else
SSD_OPTION=""
fi
msg_info "$(translate 'Configuring disk')"
if qm set "$VMID" --${INTERFACE}${NEXT_SLOT} "$IMPORTED_DISK${SSD_OPTION}" &>/dev/null; then
msg_ok "$(translate 'Image') $IMAGE $(translate 'configured as') ${INTERFACE}${NEXT_SLOT}"
if [[ -n "$IMPORTED_ID" ]]; then
qm set "$VMID" -delete "$IMPORTED_ID" >/dev/null 2>&1
fi
if (whiptail --title "$(translate 'Make Bootable')" --yesno "$(translate 'Do you want to make this disk bootable?')" 10 60); then
msg_info "$(translate 'Configuring disk as bootable')"
if qm set "$VMID" --boot c --bootdisk ${INTERFACE}${NEXT_SLOT} &>/dev/null; then
msg_ok "$(translate 'Disk configured as bootable')"
else
msg_error "$(translate 'Could not configure the disk as bootable')"
fi
fi
else
msg_error "$(translate 'Could not configure disk') ${INTERFACE}${NEXT_SLOT} $(translate 'for VM') $VMID"
echo "DEBUG: Tried to configure: --${INTERFACE}${NEXT_SLOT} \"$IMPORTED_DISK${SSD_OPTION}\""
echo "DEBUG: VM config after import:"
qm config "$VMID" | grep -E "(unused|${INTERFACE})"
fi
else
msg_error "$(translate 'Could not find the imported disk')"
echo "DEBUG: VM config after import:"
qm config "$VMID"
fi
else
msg_error "$(translate 'Could not import') $IMAGE"
fi fi
PROCESSED=$((PROCESSED + 1))
else
msg_error "$(translate 'Could not assign disk') ${INTERFACE}${NEXT_SLOT} $(translate 'to VM') $VMID"
FAILED=$((FAILED + 1))
fi
done done
echo ""
if [[ $FAILED -eq 0 ]]; then
msg_ok "$(translate 'All images imported and configured successfully')"
elif [[ $PROCESSED -gt 0 ]]; then
msg_warn "$(translate 'Completed with errors —') $(translate 'imported:') $PROCESSED, $(translate 'failed:') $FAILED"
else
msg_error "$(translate 'All imports failed')"
fi
msg_success "$(translate 'Press Enter to return to menu...')"
msg_ok "$(translate 'All selected images have been processed')"
msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r

View File

@@ -1,353 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Mount independent disk on Proxmox host
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 08/04/2025
# ==========================================================
# Description:
# This script detects unassigned physical disks and allows
# the user to mount one of them on the host Proxmox system.
# - Detects unmounted and unassigned disks.
# - Filters out ZFS, LVM, RAID and system disks.
# - Allows selecting a disk.
# - Prepares partition and filesystem if needed.
# - Mounts the disk in the host at a defined mount point.
# ==========================================================
# Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
# ==========================================================
get_disk_info() {
local disk=$1
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs)
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs)
echo "$MODEL" "$SIZE"
}
msg_info "$(translate "Detecting available disks...")"
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
ZFS_DISKS=""
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
for entry in $ZFS_RAW; do
path=""
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
if [ -e "/dev/disk/by-id/$entry" ]; then
path=$(readlink -f "/dev/disk/by-id/$entry")
fi
elif [[ "$entry" == /dev/* ]]; then
path="$entry"
fi
if [ -n "$path" ]; then
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
if [ -n "$base_disk" ]; then
ZFS_DISKS+="/dev/$base_disk"$'\n'
fi
fi
done
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
is_disk_in_use() {
local disk="$1"
while read -r part fstype; do
case "$fstype" in
zfs_member|linux_raid_member)
return 0 ;;
esac
if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then
return 0
fi
done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2)
if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then
return 0
fi
return 1
}
FREE_DISKS=()
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u)
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
while read -r DISK; do
[[ "$DISK" =~ /dev/zd ]] && continue
INFO=($(get_disk_info "$DISK"))
MODEL="${INFO[@]::${#INFO[@]}-1}"
SIZE="${INFO[-1]}"
LABEL=""
SHOW_DISK=true
IS_MOUNTED=false
IS_RAID=false
IS_ZFS=false
IS_LVM=false
while read -r part fstype; do
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
IS_MOUNTED=true
fi
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
REAL_PATH=$(readlink -f "$DISK")
if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
IS_MOUNTED=true
fi
USED_BY=""
REAL_PATH=$(readlink -f "$DISK")
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
else
for SYMLINK in /dev/disk/by-id/*; do
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
break
fi
fi
done
fi
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
if grep -q "active raid" /proc/mdstat; then
SHOW_DISK=false
fi
fi
if $IS_ZFS; then
SHOW_DISK=false
fi
if $IS_MOUNTED; then
SHOW_DISK=false
fi
if $SHOW_DISK; then
[[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]"
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID"
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
fi
done < <(lsblk -dn -e 7,11 -o PATH)
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
cleanup
whiptail --title "$(translate "Error")" --msgbox "$(translate "No available disks found on the host.")" 8 50
clear
exit 1
fi
msg_ok "$(translate "Available disks detected.")"
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
TOTAL_WIDTH=$((MAX_WIDTH + 20))
TOTAL_WIDTH=$((TOTAL_WIDTH < 50 ? 50 : TOTAL_WIDTH))
SELECTED=$(whiptail --title "$(translate "Select Disk")" --radiolist \
"$(translate "Select the disk you want to mount on the host:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3)
if [ -z "$SELECTED" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disk was selected.")" 10 50
clear
exit 1
fi
msg_ok "$(translate "Disk selected successfully:") $SELECTED"
################################################################
PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}')
SKIP_FORMAT=false
DEFAULT_MOUNT="/mnt/data_shared"
if [ -n "$PARTITION" ]; then
PARTITION="/dev/$PARTITION"
CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs)
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
SKIP_FORMAT=true
msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION."
else
whiptail --title "$(translate "Unsupported Filesystem")" --yesno "$(translate "The partition") $PARTITION $(translate "has an unsupported filesystem ($CURRENT_FS).\\nDo you want to format it?")" 10 70
if [ $? -ne 0 ]; then
exit 0
fi
fi
else
CURRENT_FS=$(lsblk -no FSTYPE "$SELECTED" | xargs)
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
SKIP_FORMAT=true
PARTITION="$SELECTED"
msg_ok "$(translate "Detected filesystem") $CURRENT_FS $(translate "directly on disk") $SELECTED."
else
whiptail --title "$(translate "No Valid Partitions")" --yesno "$(translate "The disk has no partitions and no valid filesystem. Do you want to create a new partition and format it?")" 10 70
if [ $? -ne 0 ]; then
exit 0
fi
echo -e "$(translate "Creating partition table and partition...")"
parted -s "$SELECTED" mklabel gpt
parted -s "$SELECTED" mkpart primary 0% 100%
sleep 2
partprobe "$SELECTED"
sleep 2
PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}')
if [ -n "$PARTITION" ]; then
PARTITION="/dev/$PARTITION"
else
whiptail --title "$(translate "Partition Error")" --msgbox "$(translate "Failed to create partition on disk") $SELECTED." 8 70
exit 1
fi
fi
fi
if [ "$SKIP_FORMAT" != true ]; then
FORMAT_TYPE=$(whiptail --title "$(translate "Select Format Type")" --menu "$(translate "Select the filesystem type for") $PARTITION:" 15 60 5 \
"ext4" "$(translate "Extended Filesystem 4 (recommended)")" \
"xfs" "XFS" \
"btrfs" "Btrfs" 3>&1 1>&2 2>&3)
if [ -z "$FORMAT_TYPE" ]; then
whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60
exit 0
fi
whiptail --title "$(translate "WARNING")" --yesno "$(translate "WARNING: This operation will FORMAT the disk") $PARTITION $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\\n\\n$(translate "Are you sure you want to continue")" 15 70
if [ $? -ne 0 ]; then
exit 0
fi
echo -e "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..."
case "$FORMAT_TYPE" in
"ext4") mkfs.ext4 -F "$PARTITION" ;;
"xfs") mkfs.xfs -f "$PARTITION" ;;
"btrfs") mkfs.btrfs -f "$PARTITION" ;;
esac
if [ $? -ne 0 ]; then
cleanup
whiptail --title "$(translate "Format Failed")" --msgbox "$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE." 12 70
exit 1
else
msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE."
partprobe "$SELECTED"
sleep 2
fi
fi
################################################################
MOUNT_POINT=$(whiptail --title "$(translate "Mount Point")" \
--inputbox "$(translate "Enter the mount point for the disk (e.g., /mnt/data_shared):")" \
10 60 "$DEFAULT_MOUNT" 3>&1 1>&2 2>&3)
if [ -z "$MOUNT_POINT" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No mount point was specified.")" 8 40
exit 1
fi
msg_ok "$(translate "Mount point specified:") $MOUNT_POINT"
mkdir -p "$MOUNT_POINT"
UUID=$(blkid -s UUID -o value "$PARTITION")
# Obtener sistema de archivos real
FS_TYPE=$(lsblk -no FSTYPE "$PARTITION" | xargs)
FSTAB_ENTRY="UUID=$UUID $MOUNT_POINT $FS_TYPE defaults 0 0"
if grep -q "UUID=$UUID" /etc/fstab; then
sed -i "s|^.*UUID=$UUID.*|$FSTAB_ENTRY|" /etc/fstab
msg_ok "$(translate "fstab entry updated for") $UUID"
else
echo "$FSTAB_ENTRY" >> /etc/fstab
msg_ok "$(translate "fstab entry added for") $UUID"
fi
##################################################################
mount "$MOUNT_POINT" 2> >(grep -v "systemd still uses")
##################################################################
if [ $? -eq 0 ]; then
if ! getent group sharedfiles >/dev/null; then
groupadd sharedfiles
msg_ok "$(translate "Group 'sharedfiles' created")"
else
msg_ok "$(translate "Group 'sharedfiles' already exists")"
fi
chown root:sharedfiles "$MOUNT_POINT"
chmod 2775 "$MOUNT_POINT"
whiptail --title "$(translate "Success")" --msgbox "$(translate "The disk has been successfully mounted at") $MOUNT_POINT" 8 60
msg_ok "$(translate "Disk mounted at") $MOUNT_POINT"
msg_success "$(translate "Press Enter to return to menu...")"
read -r
else
whiptail --title "$(translate "Mount Error")" --msgbox "$(translate "Failed to mount the disk at") $MOUNT_POINT" 8 60
msg_success "$(translate "Press Enter to return to menu...")"
read -r
exit 1
fi

View File

@@ -1,146 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Mount point from host into LXC container (CT)
# ==========================================================
# Author : MacRimi
# License : MIT
# Description : Mount a folder from /mnt on the host to a mount point in a CT
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
#######################################################
CT_LIST=($(pct list | awk 'NR>1 {print $1":"$3}'))
if [[ ${#CT_LIST[@]} -eq 0 ]]; then
whiptail --title "$(translate "No CTs")" --msgbox "$(translate "No containers found.")" 8 40
exit 0
fi
CT_OPTIONS=()
for entry in "${CT_LIST[@]}"; do
ID="${entry%%:*}"
NAME="${entry##*:}"
CT_OPTIONS+=("$ID" "$NAME")
done
CTID=$(whiptail --title "$(translate "Select CT")" --menu "$(translate "Select the container:")" 20 60 10 "${CT_OPTIONS[@]}" 3>&1 1>&2 2>&3)
[[ -z "$CTID" ]] && exit 0
CT_STATUS=$(pct status "$CTID" | awk '{print $2}')
if [ "$CT_STATUS" != "running" ]; then
msg_info "$(translate "Starting CT") $CTID..."
pct start "$CTID"
sleep 2
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
msg_error "$(translate "Failed to start the CT.")"
exit 1
fi
msg_ok "$(translate "CT started successfully.")"
fi
#######################################################
select_origin_path() {
METHOD=$(whiptail --title "$(translate "Select Host Folder")" --menu "$(translate "How do you want to select the host folder to mount?")" 15 60 5 \
"auto" "$(translate "Select from /mnt")" \
"manual" "$(translate "Enter path manually")" 3>&1 1>&2 2>&3)
case "$METHOD" in
auto)
HOST_DIRS=(/mnt/*)
OPTIONS=()
for dir in "${HOST_DIRS[@]}"; do
[[ -d "$dir" ]] && OPTIONS+=("$dir" "")
done
ORIGIN=$(whiptail --title "$(translate "Select Host Folder")" --menu "$(translate "Select the folder to mount:")" 20 60 10 "${OPTIONS[@]}" 3>&1 1>&2 2>&3)
[[ -z "$ORIGIN" ]] && return 1
;;
manual)
ORIGIN=$(whiptail --title "$(translate "Enter Path")" --inputbox "$(translate "Enter the full path to the host folder:")" 10 60 "/mnt/" 3>&1 1>&2 2>&3)
[[ -z "$ORIGIN" ]] && return 1
;;
esac
if [[ ! -d "$ORIGIN" ]]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "The selected path is not a valid directory:")\n$ORIGIN" 8 60
return 1
fi
# Preparar permisos en el host para uso compartido
SHARE_GID=999
if ! getent group sharedfiles >/dev/null; then
groupadd -g "$SHARE_GID" sharedfiles
msg_ok "$(translate "Group 'sharedfiles' created in the host with GID $SHARE_GID")"
else
msg_ok "$(translate "Group 'sharedfiles' already exists in the host")"
fi
chown root:sharedfiles "$ORIGIN"
chmod 2775 "$ORIGIN"
setfacl -d -m g:sharedfiles:rwx "$ORIGIN"
setfacl -m g:sharedfiles:rwx "$ORIGIN"
msg_ok "$(translate "Host folder prepared with shared group and permissions.")"
return 0
}
select_origin_path || exit 0
#######################################################
CT_NAME=$(pct config "$CTID" | awk -F: '/hostname/ {print $2}' | xargs)
DEFAULT_MOUNT_POINT="/mnt/host_share"
MOUNT_POINT=$(whiptail --title "$(translate "Mount Point to CT")" \
--inputbox "$(translate "Enter the mount point inside the CT (e.g., /mnt/host_share):")" \
10 70 "$DEFAULT_MOUNT_POINT" 3>&1 1>&2 2>&3)
if [[ -z "$MOUNT_POINT" ]]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No mount point specified.")" 8 60
exit 1
fi
if ! pct exec "$CTID" -- test -d "$MOUNT_POINT"; then
if whiptail --yesno "$(translate "Directory does not exist in the CT.")\n\n$MOUNT_POINT\n\n$(translate "Do you want to create it?")" 12 70 --title "$(translate "Create Directory")"; then
pct exec "$CTID" -- mkdir -p "$MOUNT_POINT"
msg_ok "$(translate "Directory created inside CT:") $MOUNT_POINT"
else
msg_error "$(translate "Directory not created. Operation cancelled.")"
exit 1
fi
fi
INDEX=0
while pct config "$CTID" | grep -q "mp${INDEX}:"; do
((INDEX++))
[[ $INDEX -ge 100 ]] && msg_error "Too many mount points." && exit 1
done
msg_info "$(translate "Mounting folder from host to CT...")"
RESULT=$(pct set "$CTID" -mp${INDEX} "$ORIGIN,mp=$MOUNT_POINT,backup=0,ro=0,acl=1" 2>&1)
if [[ $? -eq 0 ]]; then
msg_ok "$(translate "Successfully mounted:")\n$ORIGIN$CT_NAME:$MOUNT_POINT"
else
msg_error "$(translate "Error mounting folder:")\n$RESULT"
exit 1
fi
msg_success "$(translate "Press Enter to return to menu...")"
read -r
exit 0

View File

@@ -1,446 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Mount independent disk on Proxmox host
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : MIT
# Version : 1.3-dialog
# Last Updated: 13/12/2024
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
mount_disk_host_bk() {
get_disk_info() {
local disk=$1
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs)
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs)
echo "$MODEL" "$SIZE"
}
is_usb_disk() {
local disk=$1
local disk_name=$(basename "$disk")
if readlink -f "/sys/block/$disk_name/device" 2>/dev/null | grep -q "usb"; then
return 0
fi
if udevadm info --query=property --name="$disk" 2>/dev/null | grep -q "ID_BUS=usb"; then
return 0
fi
return 1
}
is_system_disk() {
local disk=$1
local disk_name=$(basename "$disk")
local system_mounts=$(df -h | grep -E '^\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(/|/boot|/usr|/var|/home)$' | awk '{print $1}')
for mount_dev in $system_mounts; do
local mount_disk=""
if [[ "$mount_dev" =~ ^/dev/mapper/ ]]; then
local vg_name=$(lvs --noheadings -o vg_name "$mount_dev" 2>/dev/null | xargs)
if [[ -n "$vg_name" ]]; then
local pvs_list=$(pvs --noheadings -o pv_name -S vg_name="$vg_name" 2>/dev/null | xargs)
for pv in $pvs_list; do
if [[ -n "$pv" && -e "$pv" ]]; then
mount_disk=$(lsblk -no PKNAME "$pv" 2>/dev/null)
if [[ -n "$mount_disk" && "/dev/$mount_disk" == "$disk" ]]; then
return 0
fi
fi
done
fi
elif [[ "$mount_dev" =~ ^/dev/[hsv]d[a-z][0-9]* || "$mount_dev" =~ ^/dev/nvme[0-9]+n[0-9]+p[0-9]+ ]]; then
mount_disk=$(lsblk -no PKNAME "$mount_dev" 2>/dev/null)
if [[ -n "$mount_disk" && "/dev/$mount_disk" == "$disk" ]]; then
return 0
fi
fi
done
local fs_type=$(lsblk -no FSTYPE "$disk" 2>/dev/null | head -1)
if [[ "$fs_type" == "btrfs" ]]; then
local temp_mount=$(mktemp -d)
if mount -o ro "$disk" "$temp_mount" 2>/dev/null; then
if btrfs subvolume list "$temp_mount" 2>/dev/null | grep -qE '(@|@home|@var|@boot|@root|root)'; then
umount "$temp_mount" 2>/dev/null
rmdir "$temp_mount" 2>/dev/null
return 0
fi
umount "$temp_mount" 2>/dev/null
fi
rmdir "$temp_mount" 2>/dev/null
while read -r part; do
if [[ -n "$part" ]]; then
local part_fs=$(lsblk -no FSTYPE "/dev/$part" 2>/dev/null)
if [[ "$part_fs" == "btrfs" ]]; then
local mount_point=$(lsblk -no MOUNTPOINT "/dev/$part" 2>/dev/null)
if [[ "$mount_point" == "/" || "$mount_point" == "/boot" || "$mount_point" == "/home" || "$mount_point" == "/var" ]]; then
return 0
fi
fi
fi
done < <(lsblk -ln -o NAME "$disk" | tail -n +2)
fi
local disk_uuid=$(blkid -s UUID -o value "$disk" 2>/dev/null)
local part_uuids=()
while read -r part; do
if [[ -n "$part" ]]; then
local uuid=$(blkid -s UUID -o value "/dev/$part" 2>/dev/null)
if [[ -n "$uuid" ]]; then
part_uuids+=("$uuid")
fi
fi
done < <(lsblk -ln -o NAME "$disk" | tail -n +2)
for uuid in "${part_uuids[@]}" "$disk_uuid"; do
if [[ -n "$uuid" ]] && grep -q "UUID=$uuid" /etc/fstab; then
local mount_point=$(grep "UUID=$uuid" /etc/fstab | awk '{print $2}')
if [[ "$mount_point" == "/" || "$mount_point" == "/boot" || "$mount_point" == "/home" || "$mount_point" == "/var" ]]; then
return 0
fi
fi
done
if grep -q "$disk" /etc/fstab; then
local mount_point=$(grep "$disk" /etc/fstab | awk '{print $2}')
if [[ "$mount_point" == "/" || "$mount_point" == "/boot" || "$mount_point" == "/home" || "$mount_point" == "/var" ]]; then
return 0
fi
fi
local disk_count=$(lsblk -dn -e 7,11 -o PATH | wc -l)
if [[ "$disk_count" -eq 1 ]]; then
return 0
fi
return 1
}
msg_info "$(translate "Detecting available disks...")"
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
ZFS_DISKS=""
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
for entry in $ZFS_RAW; do
path=""
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
if [ -e "/dev/disk/by-id/$entry" ]; then
path=$(readlink -f "/dev/disk/by-id/$entry")
fi
elif [[ "$entry" == /dev/* ]]; then
path="$entry"
fi
if [ -n "$path" ]; then
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
if [ -n "$base_disk" ]; then
ZFS_DISKS+="/dev/$base_disk"$'\n'
fi
fi
done
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
LVM_DEVICES=$(
pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') |
while read -r dev; do
[[ -n "$dev" && -e "$dev" ]] && readlink -f "$dev"
done | sort -u
)
FREE_DISKS=()
while read -r DISK; do
[[ "$DISK" =~ /dev/zd ]] && continue
INFO=($(get_disk_info "$DISK"))
MODEL="${INFO[@]::${#INFO[@]}-1}"
SIZE="${INFO[-1]}"
LABEL=""
SHOW_DISK=true
IS_MOUNTED=false
IS_RAID=false
IS_ZFS=false
IS_LVM=false
IS_SYSTEM=false
IS_USB=false
if is_system_disk "$DISK"; then
IS_SYSTEM=true
fi
if is_usb_disk "$DISK"; then
IS_USB=true
fi
while read -r part fstype; do
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
IS_MOUNTED=true
fi
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
REAL_PATH=""
if [[ -n "$DISK" && -e "$DISK" ]]; then
REAL_PATH=$(readlink -f "$DISK")
fi
if [[ -n "$REAL_PATH" ]] && echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
IS_MOUNTED=true
fi
USED_BY=""
REAL_PATH=""
if [[ -n "$DISK" && -e "$DISK" ]]; then
REAL_PATH=$(readlink -f "$DISK")
fi
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
else
for SYMLINK in /dev/disk/by-id/*; do
[[ -e "$SYMLINK" ]] || continue
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
break
fi
fi
done
fi
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
if grep -q "active raid" /proc/mdstat; then
SHOW_DISK=false
fi
fi
if $IS_ZFS; then SHOW_DISK=false; fi
if $IS_MOUNTED; then SHOW_DISK=false; fi
if $IS_SYSTEM; then SHOW_DISK=false; fi
if $SHOW_DISK; then
[[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]"
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID"
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
if $IS_USB; then
LABEL+=" USB"
else
LABEL+=" $(translate "Internal")"
fi
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
FREE_DISKS+=("$DISK" "$DESCRIPTION" "off")
fi
done < <(lsblk -dn -e 7,11 -o PATH)
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
dialog --title "$(translate "Error")" --msgbox "$(translate "No available disks found on the host.")" 8 60
clear
exit 1
fi
msg_ok "$(translate "Available disks detected.")"
# Building the array for dialog (format: tag item on/off tag item on/off...)
DLG_LIST=()
for ((i=0; i<${#FREE_DISKS[@]}; i+=3)); do
DLG_LIST+=("${FREE_DISKS[i]}" "${FREE_DISKS[i+1]}" "${FREE_DISKS[i+2]}")
done
SELECTED=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "Select Disk")" \
--radiolist "\n$(translate "Select the disk you want to mount on the host:")" 20 90 10 \
"${DLG_LIST[@]}" 2>&1 >/dev/tty)
if [ -z "$SELECTED" ]; then
dialog --title "$(translate "Error")" --msgbox "$(translate "No disk was selected.")" 8 50
clear
exit 1
fi
msg_ok "$(translate "Disk selected successfully:") $SELECTED"
# ------------------- Partitions and formatting ------------------------
PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}')
SKIP_FORMAT=false
DEFAULT_MOUNT="/mnt/backup"
if [ -n "$PARTITION" ]; then
PARTITION="/dev/$PARTITION"
CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs)
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
SKIP_FORMAT=true
msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION."
else
dialog --title "$(translate "Unsupported Filesystem")" --yesno \
"$(translate "The partition") $PARTITION $(translate "has an unsupported filesystem ($CURRENT_FS).\nDo you want to format it?")" 10 70
if [ $? -ne 0 ]; then exit 0; fi
fi
else
CURRENT_FS=$(lsblk -no FSTYPE "$SELECTED" | xargs)
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
SKIP_FORMAT=true
PARTITION="$SELECTED"
msg_ok "$(translate "Detected filesystem") $CURRENT_FS $(translate "directly on disk") $SELECTED."
else
dialog --title "$(translate "No Valid Partitions")" --yesno \
"$(translate "The disk has no partitions and no valid filesystem. Do you want to create a new partition and format it?")" 10 70
if [ $? -ne 0 ]; then exit 0; fi
echo -e "$(translate "Creating partition table and partition...")"
parted -s "$SELECTED" mklabel gpt
parted -s "$SELECTED" mkpart primary 0% 100%
sleep 2
partprobe "$SELECTED"
sleep 2
PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}')
if [ -n "$PARTITION" ]; then
PARTITION="/dev/$PARTITION"
else
dialog --title "$(translate "Partition Error")" --msgbox \
"$(translate "Failed to create partition on disk") $SELECTED." 8 70
exit 1
fi
fi
fi
if [ "$SKIP_FORMAT" != true ]; then
FORMAT_TYPE=$(dialog --title "$(translate "Select Format Type")" --menu \
"$(translate "Select the filesystem type for") $PARTITION:" 15 60 5 \
"ext4" "$(translate "Extended Filesystem 4 (recommended)")" \
"xfs" "XFS" \
"btrfs" "Btrfs" 2>&1 >/dev/tty)
if [ -z "$FORMAT_TYPE" ]; then
dialog --title "$(translate "Format Cancelled")" --msgbox \
"$(translate "Format operation cancelled. The disk will not be added.")" 8 60
exit 0
fi
dialog --title "$(translate "WARNING")" --yesno \
"$(translate "WARNING: This operation will FORMAT the disk") $PARTITION $(translate "with") $FORMAT_TYPE.\n\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\n\n$(translate "Are you sure you want to continue")" 15 70
if [ $? -ne 0 ]; then exit 0; fi
echo -e "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..."
case "$FORMAT_TYPE" in
"ext4") mkfs.ext4 -F "$PARTITION" ;;
"xfs") mkfs.xfs -f "$PARTITION" ;;
"btrfs") mkfs.btrfs -f "$PARTITION" ;;
esac
if [ $? -ne 0 ]; then
cleanup
dialog --title "$(translate "Format Failed")" --msgbox \
"$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE." 12 70
exit 1
else
msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE."
partprobe "$SELECTED"
sleep 2
fi
fi
# ------------------- Mount point and permissions -------------------
MOUNT_POINT=$(dialog --title "$(translate "Mount Point")" \
--inputbox "$(translate "Enter the mount point for the disk (e.g., /mnt/backup):")" \
10 60 "$DEFAULT_MOUNT" 2>&1 >/dev/tty)
if [ -z "$MOUNT_POINT" ]; then
dialog --title "$(translate "Error")" --msgbox "$(translate "No mount point was specified.")" 8 40
exit 1
fi
msg_ok "$(translate "Mount point specified:") $MOUNT_POINT"
mkdir -p "$MOUNT_POINT"
UUID=$(blkid -s UUID -o value "$PARTITION")
FS_TYPE=$(lsblk -no FSTYPE "$PARTITION" | xargs)
FSTAB_ENTRY="UUID=$UUID $MOUNT_POINT $FS_TYPE defaults 0 0"
if grep -q "UUID=$UUID" /etc/fstab; then
sed -i "s|^.*UUID=$UUID.*|$FSTAB_ENTRY|" /etc/fstab
msg_ok "$(translate "fstab entry updated for") $UUID"
else
echo "$FSTAB_ENTRY" >> /etc/fstab
msg_ok "$(translate "fstab entry added for") $UUID"
fi
mount "$MOUNT_POINT" 2> >(grep -v "systemd still uses")
if [ $? -eq 0 ]; then
if ! getent group sharedfiles >/dev/null; then
groupadd sharedfiles
msg_ok "$(translate "Group 'sharedfiles' created")"
else
msg_ok "$(translate "Group 'sharedfiles' already exists")"
fi
chown root:sharedfiles "$MOUNT_POINT"
chmod 2775 "$MOUNT_POINT"
dialog --title "$(translate "Success")" --msgbox "$(translate "The disk has been successfully mounted at") $MOUNT_POINT" 8 60
echo "$MOUNT_POINT" > /usr/local/share/proxmenux/last_backup_mount.txt
msg_ok "$(translate "Disk mounted at") $MOUNT_POINT"
msg_success "$(translate "Press Enter to return to menu...")"
read -r
else
dialog --title "$(translate "Mount Error")" --msgbox "$(translate "Failed to mount the disk at") $MOUNT_POINT" 8 60
msg_success "$(translate "Press Enter to return to menu...")"
read -r
exit 1
fi
}

View File

@@ -0,0 +1,399 @@
#!/bin/bash
# ==========================================================
# ProxMenux - SMART Disk Health & Test Tool
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 12/04/2026
# ==========================================================
# Description:
# SMART health check and disk testing tool for Proxmox VE.
# Supports SATA/SAS disks (smartmontools) and NVMe drives (nvme-cli).
# Exports results as JSON to /usr/local/share/proxmenux/smart/
# for ProxMenux Monitor integration.
# Long tests run on the drive hardware and persist after terminal close.
# ==========================================================
# Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
BACKTITLE="ProxMenux"
SMART_DIR="$BASE_DIR/smart"
UI_MENU_H=22
UI_MENU_W=84
UI_MENU_LIST_H=12
UI_SHORT_MENU_H=16
UI_SHORT_MENU_W=72
UI_SHORT_MENU_LIST_H=6
UI_MSG_H=10
UI_MSG_W=72
UI_RESULT_H=14
UI_RESULT_W=86
# shellcheck source=/dev/null
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
load_language
initialize_cache
SCRIPT_DIR_SMART="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR_SMART/.." && pwd)"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/utils-install-functions.sh" ]]; then
source "$LOCAL_SCRIPTS_LOCAL/global/utils-install-functions.sh"
elif [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then
source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
fi
# Configuration ============================================
# ==========================================================
# Helpers
# ==========================================================
_smart_is_nvme() {
[[ "$1" == *nvme* ]]
}
_smart_disk_label() {
local disk="$1"
local model size
model=$(lsblk -dn -o MODEL "$disk" 2>/dev/null | xargs)
size=$(lsblk -dn -o SIZE "$disk" 2>/dev/null | xargs)
[[ -z "$model" ]] && model="Unknown"
[[ -z "$size" ]] && size="?"
printf '%-8s — %s' "$size" "$model"
}
_smart_json_path() {
local disk="$1"
echo "${SMART_DIR}/$(basename "$disk").json"
}
_smart_ensure_packages() {
local need_smartctl=0 need_nvme=0
command -v smartctl >/dev/null 2>&1 || need_smartctl=1
command -v nvme >/dev/null 2>&1 || need_nvme=1
if [[ $need_smartctl -eq 1 || $need_nvme -eq 1 ]]; then
show_proxmenux_logo
msg_title "$(translate 'SMART Disk Health & Test')"
ensure_repositories
[[ $need_smartctl -eq 1 ]] && install_single_package "smartmontools" "smartctl" "SMART monitoring tools"
[[ $need_nvme -eq 1 ]] && install_single_package "nvme-cli" "nvme" "NVMe management tools"
fi
}
# ==========================================================
# PHASE 1 — SELECTION
# All dialogs run here. No execution, no show_proxmenux_logo.
# ==========================================================
# ── Install packages if missing ───────────────────────────
_smart_ensure_packages
# ── Step 1: Detect disks ──────────────────────────────────
DISK_OPTIONS=()
while read -r disk; do
[[ -z "$disk" ]] && continue
[[ "$disk" =~ ^/dev/zd ]] && continue
label=$(_smart_disk_label "$disk")
DISK_OPTIONS+=("$disk" "$label")
done < <(lsblk -dn -e 7,11 -o PATH 2>/dev/null | grep -E '^/dev/(sd|nvme|vd|hd)')
stop_spinner
if [[ ${#DISK_OPTIONS[@]} -eq 0 ]]; then
dialog --backtitle "$BACKTITLE" \
--title "$(translate 'No Disks Found')" \
--msgbox "\n$(translate 'No physical disks detected for SMART testing.')" \
$UI_MSG_H $UI_MSG_W
exit 1
fi
# ── Step 2: Select disk ───────────────────────────────────
SELECTED_DISK=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Select Disk')" \
--menu "\n$(translate 'Select the disk to test or inspect:')" \
$UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
"${DISK_OPTIONS[@]}" \
2>&1 >/dev/tty)
[[ -z "$SELECTED_DISK" ]] && exit 0
# ── Steps 3+: Action loop for the selected disk ───────────
DISK_LABEL=$(_smart_disk_label "$SELECTED_DISK")
mkdir -p "$SMART_DIR"
while true; do
# ── Select action ───────────────────────────────────────
ACTION=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate 'SMART Action')$(basename "$SELECTED_DISK") (${DISK_LABEL})" \
--menu "\n$(translate 'Select what to do with this disk:')" \
$UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
"status" "$(translate 'Quick health status — overall SMART result + key attributes')" \
"report" "$(translate 'Full report — complete SMART data (scrollable)')" \
"short" "$(translate 'Short test — ~2 minutes, basic surface check')" \
"long" "$(translate 'Long test — full scan, runs in background if closed')" \
"progress" "$(translate 'Check test progress — show active or last test result')" \
2>&1 >/dev/tty)
[[ -z "$ACTION" ]] && exit 0
# ── Long test confirmation ───────────────────────────────
if [[ "$ACTION" == "long" ]]; then
DISK_SIZE=$(lsblk -dn -o SIZE "$SELECTED_DISK" 2>/dev/null | xargs)
if ! dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Long Test — Background')" \
--yesno "\n$(translate 'The long test runs directly on the disk hardware.')\n\n$(translate 'Disk:') $SELECTED_DISK ($DISK_SIZE)\n\n$(translate 'The test will continue even if you close this terminal.')\n$(translate 'Results will be saved automatically to:')\n$(_smart_json_path "$SELECTED_DISK")\n\n$(translate 'Start long test now?')" \
16 $UI_RESULT_W; then
continue
fi
fi
# ========================================================
# PHASE 2 — EXECUTION
# show_proxmenux_logo appears here exactly once per action.
# No dialogs from this point until "Press Enter".
# ========================================================
show_proxmenux_logo
msg_title "$(translate 'SMART Disk Health & Test')"
msg_ok "$(translate 'Disk:') ${BL}${SELECTED_DISK}${DISK_LABEL}${CL}"
echo ""
case "$ACTION" in
# ── Quick status ────────────────────────────────────────
status)
if _smart_is_nvme "$SELECTED_DISK"; then
msg_info "$(translate 'Reading NVMe SMART data...')"
OUTPUT=$(nvme smart-log "$SELECTED_DISK" 2>/dev/null)
stop_spinner
if [[ -z "$OUTPUT" ]]; then
msg_error "$(translate 'Could not read SMART data from') $SELECTED_DISK"
else
HEALTH=$(echo "$OUTPUT" | grep -i "critical_warning" | awk '{print $NF}')
if [[ "$HEALTH" == "0" ]]; then
msg_ok "$(translate 'NVMe health status: PASSED')"
else
msg_warn "$(translate 'NVMe health status: WARNING (critical_warning =') $HEALTH)"
fi
echo ""
echo "$OUTPUT" | head -20
fi
else
msg_info "$(translate 'Reading SMART data...')"
HEALTH=$(smartctl -H "$SELECTED_DISK" 2>/dev/null | grep -i "overall-health")
ATTRS=$(smartctl -A "$SELECTED_DISK" 2>/dev/null)
stop_spinner
if [[ -z "$HEALTH" ]]; then
msg_error "$(translate 'Could not read SMART data from') $SELECTED_DISK"
else
if echo "$HEALTH" | grep -qi "PASSED"; then
msg_ok "$(translate 'SMART health status: PASSED')"
else
msg_warn "$HEALTH"
fi
echo ""
echo "$ATTRS" | awk 'NR==1 || /Reallocated_Sector|Current_Pending|Uncorrectable|Temperature_Celsius|Power_On_Hours|Wear_Leveling|Media_Wearout/'
fi
fi
;;
# ── Full report (scrollable) ────────────────────────────
report)
msg_info "$(translate 'Reading full SMART report...')"
TMPFILE=$(mktemp)
if _smart_is_nvme "$SELECTED_DISK"; then
nvme smart-log "$SELECTED_DISK" > "$TMPFILE" 2>/dev/null
nvme id-ctrl "$SELECTED_DISK" >> "$TMPFILE" 2>/dev/null
else
smartctl -x "$SELECTED_DISK" > "$TMPFILE" 2>/dev/null
fi
stop_spinner
if [[ -s "$TMPFILE" ]]; then
dialog --backtitle "$BACKTITLE" \
--title "$(translate 'Full SMART Report')$SELECTED_DISK" \
--textbox "$TMPFILE" 40 $UI_RESULT_W
else
msg_error "$(translate 'Could not read SMART data from') $SELECTED_DISK"
fi
rm -f "$TMPFILE"
;;
# ── Short test ──────────────────────────────────────────
short)
if _smart_is_nvme "$SELECTED_DISK"; then
msg_info "$(translate 'Starting NVMe short self-test...')"
if nvme device-self-test "$SELECTED_DISK" --self-test-code=1 >/dev/null 2>&1; then
stop_spinner
msg_ok "$(translate 'Short self-test started on') $SELECTED_DISK"
msg_ok "$(translate 'Test typically completes in ~2 minutes.')"
msg_ok "$(translate 'Use "Check test progress" to see results.')"
else
stop_spinner
msg_error "$(translate 'Failed to start self-test on') $SELECTED_DISK"
fi
else
msg_info "$(translate 'Starting SMART short self-test...')"
OUTPUT=$(smartctl -t short "$SELECTED_DISK" 2>/dev/null)
stop_spinner
if echo "$OUTPUT" | grep -qi "Test will complete"; then
msg_ok "$(translate 'Short self-test started on') $SELECTED_DISK"
ESTIMATE=$(echo "$OUTPUT" | grep -i "complete after" | head -1)
[[ -n "$ESTIMATE" ]] && msg_ok "$ESTIMATE"
msg_ok "$(translate 'Use "Check test progress" to see results.')"
else
msg_error "$(translate 'Failed to start self-test on') $SELECTED_DISK"
echo "$OUTPUT" | tail -5
fi
fi
;;
# ── Long test (background) ──────────────────────────────
long)
JSON_PATH=$(_smart_json_path "$SELECTED_DISK")
DISK_SAFE=$(printf '%q' "$SELECTED_DISK")
JSON_SAFE=$(printf '%q' "$JSON_PATH")
if _smart_is_nvme "$SELECTED_DISK"; then
msg_info "$(translate 'Starting NVMe long self-test...')"
if nvme device-self-test "$SELECTED_DISK" --self-test-code=2 >/dev/null 2>&1; then
stop_spinner
msg_ok "$(translate 'Long self-test started on') $SELECTED_DISK"
DISK_LABEL_SAFE=$(printf '%q' "$DISK_LABEL")
NOTIFY_SCRIPT="/usr/bin/notification_manager.py"
nohup bash -c "
while nvme device-self-test ${DISK_SAFE} --self-test-code=0 2>/dev/null | grep -qi 'in progress'; do
sleep 60
done
nvme smart-log -o json ${DISK_SAFE} > ${JSON_SAFE} 2>/dev/null
# Send notification when test completes
if [[ -f \"${NOTIFY_SCRIPT}\" ]]; then
HOSTNAME=\$(hostname -s)
TEST_RESULT=\$(nvme self-test-log ${DISK_SAFE} 2>/dev/null | head -20)
if echo \"\$TEST_RESULT\" | grep -qi 'completed without error\|success'; then
python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity INFO \
--title \"\${HOSTNAME}: SMART Long Test Completed\" \
--message \"NVMe disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed successfully.\" 2>/dev/null || true
else
python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity WARNING \
--title \"\${HOSTNAME}: SMART Long Test Completed\" \
--message \"NVMe disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed. Check results for details.\" 2>/dev/null || true
fi
fi
" >/dev/null 2>&1 &
disown $!
else
stop_spinner
msg_error "$(translate 'Failed to start long self-test on') $SELECTED_DISK"
fi
else
msg_info "$(translate 'Starting SMART long self-test...')"
OUTPUT=$(smartctl -t long "$SELECTED_DISK" 2>/dev/null)
stop_spinner
if echo "$OUTPUT" | grep -qi "Test will complete"; then
msg_ok "$(translate 'Long self-test started on') $SELECTED_DISK"
ESTIMATE=$(echo "$OUTPUT" | grep -i "complete after" | head -1)
[[ -n "$ESTIMATE" ]] && msg_ok "$ESTIMATE"
echo ""
msg_ok "$(translate 'Test runs on the drive hardware — safe to close this terminal.')"
msg_ok "$(translate 'Results will be saved to:') $JSON_PATH"
DISK_LABEL_SAFE=$(printf '%q' "$DISK_LABEL")
NOTIFY_SCRIPT="/usr/bin/notification_manager.py"
nohup bash -c "
while smartctl -c ${DISK_SAFE} 2>/dev/null | grep -qiE 'Self-test routine in progress|[1-9][0-9]?% of test remaining'; do
sleep 60
done
smartctl --json=c ${DISK_SAFE} > ${JSON_SAFE} 2>/dev/null
# Send notification when test completes
if [[ -f \"${NOTIFY_SCRIPT}\" ]]; then
HOSTNAME=\$(hostname -s)
TEST_RESULT=\$(smartctl -l selftest ${DISK_SAFE} 2>/dev/null | grep -E '^# ?1')
if echo \"\$TEST_RESULT\" | grep -qi 'Completed without error'; then
python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity INFO \
--title \"\${HOSTNAME}: SMART Long Test Completed\" \
--message \"Disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed successfully.\" 2>/dev/null || true
elif echo \"\$TEST_RESULT\" | grep -qi 'error\|fail'; then
python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity CRITICAL \
--title \"\${HOSTNAME}: SMART Long Test FAILED\" \
--message \"Disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed with ERRORS. Check disk health immediately.\" 2>/dev/null || true
else
python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity INFO \
--title \"\${HOSTNAME}: SMART Long Test Completed\" \
--message \"Disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed. Check results for details.\" 2>/dev/null || true
fi
fi
" >/dev/null 2>&1 &
disown $!
else
msg_error "$(translate 'Failed to start long self-test on') $SELECTED_DISK"
echo "$OUTPUT" | tail -5
fi
fi
;;
# ── Check progress ──────────────────────────────────────
progress)
if _smart_is_nvme "$SELECTED_DISK"; then
msg_info "$(translate 'Reading NVMe self-test log...')"
OUTPUT=$(nvme self-test-log "$SELECTED_DISK" 2>/dev/null)
stop_spinner
if [[ -z "$OUTPUT" ]]; then
msg_warn "$(translate 'No self-test log available for') $SELECTED_DISK"
else
echo "$OUTPUT" | head -30
fi
else
msg_info "$(translate 'Reading SMART self-test log...')"
# Active test: only "X% of test remaining" appears when a test is actually running
ACTIVE=$(smartctl -c "$SELECTED_DISK" 2>/dev/null | grep -iE "[1-9][0-9]?% of test remaining|Self-test routine in progress")
# Log: grab only result rows (^# N ...) and the column header (^Num)
LOG_OUT=$(smartctl -l selftest "$SELECTED_DISK" 2>/dev/null)
LOG_HEADER=$(echo "$LOG_OUT" | grep -E "^Num")
LOG_ENTRIES=$(echo "$LOG_OUT" | grep -E "^# ?[0-9]")
stop_spinner
if [[ -n "$ACTIVE" ]]; then
msg_ok "$(translate 'Test in progress:')"
echo "$ACTIVE"
echo ""
else
msg_ok "$(translate 'No test currently running')"
echo ""
fi
if [[ -n "$LOG_ENTRIES" ]]; then
msg_ok "$(translate 'Recent test results:')"
[[ -n "$LOG_HEADER" ]] && echo "$LOG_HEADER"
echo "$LOG_ENTRIES"
else
msg_warn "$(translate 'No self-test history found for') $SELECTED_DISK"
fi
fi
;;
esac
# ── Auto-export JSON (except long — handled by background monitor)
if [[ "$ACTION" != "long" && "$ACTION" != "report" ]]; then
JSON_PATH=$(_smart_json_path "$SELECTED_DISK")
if _smart_is_nvme "$SELECTED_DISK"; then
nvme smart-log -o json "$SELECTED_DISK" > "$JSON_PATH" 2>/dev/null
else
smartctl --json=c "$SELECTED_DISK" > "$JSON_PATH" 2>/dev/null
fi
[[ -s "$JSON_PATH" ]] || rm -f "$JSON_PATH"
fi
# ── "report" uses dialog --textbox, no Press Enter needed
if [[ "$ACTION" != "report" ]]; then
echo ""
msg_success "$(translate 'Press Enter to continue...')"
read -r
fi
done

View File

@@ -1,73 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - A menu-driven script for Proxmox VE management
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 28/01/2025
# Description : Allows unmounting a previously mounted disk
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
MOUNTED_DISKS=($(mount | grep '^/dev/' | grep 'on /mnt/' | awk '{print $3}'))
if [[ ${#MOUNTED_DISKS[@]} -eq 0 ]]; then
whiptail --title "$(translate "No Disks")" --msgbox "$(translate "No mounted disks found under /mnt.")" 8 50
exit 0
fi
MENU_ITEMS=()
for MNT in "${MOUNTED_DISKS[@]}"; do
UUID=$(blkid | grep "$MNT" | awk '{print $2}' | tr -d '"')
DESC="$MNT $UUID"
MENU_ITEMS+=("$MNT" "$DESC")
done
SELECTED=$(whiptail --title "$(translate "Unmount Disk")" --menu "$(translate "Select the disk you want to unmount:")" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3)
[[ -z "$SELECTED" ]] && exit 0
whiptail --title "$(translate "Confirm Unmount")" --yesno "$(translate "Are you sure you want to unmount") $SELECTED?" 10 60 || exit 0
umount "$SELECTED" 2>/dev/null
if [ $? -ne 0 ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "Failed to unmount disk at") $SELECTED" 8 60
exit 1
else
msg_ok "$(translate "Unmounted:") $SELECTED"
fi
whiptail --title "$(translate "Delete Mount Folder")" --yesno "$(translate "Do you want to delete the mount point folder") $SELECTED?" 10 60
if [ $? -eq 0 ]; then
rm -rf "$SELECTED"
msg_ok "$(translate "Deleted folder:") $SELECTED"
fi
DEVICE=$(findmnt -no SOURCE "$SELECTED")
UUID=$(blkid -s UUID -o value "$DEVICE")
if [ -n "$UUID" ]; then
sed -i "/UUID=$UUID/d" /etc/fstab
msg_ok "$(translate "fstab entry removed for") $UUID"
fi
whiptail --title "$(translate "Done")" --msgbox "$(translate "Disk unmounted and cleaned successfully.")" 8 60

View File

@@ -0,0 +1,628 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Export VM to OVA or OVF
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 07/04/2026
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --title "$(translate "Missing dependency")" \
--msgbox "$(translate "Required command not found:") $cmd" 8 60
return 1
fi
return 0
}
human_bytes() {
local bytes="$1"
local units=("B" "KB" "MB" "GB" "TB" "PB")
local idx=0
local value="$bytes"
[[ -z "$value" || ! "$value" =~ ^[0-9]+$ ]] && { echo "N/A"; return; }
while [[ "$value" -ge 1024 && "$idx" -lt 5 ]]; do
value=$((value / 1024))
idx=$((idx + 1))
done
echo "${value}${units[$idx]}"
}
sanitize_name() {
local raw="$1"
local out
out=$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-zA-Z0-9._-]/_/g' | sed 's/__*/_/g' | sed 's/^_\+//;s/_\+$//')
[[ -z "$out" ]] && out="vm"
echo "$out"
}
xml_escape() {
local s="$1"
s=${s//&/&amp;}
s=${s//</&lt;}
s=${s//>/&gt;}
s=${s//\"/&quot;}
s=${s//\'/&apos;}
echo "$s"
}
validate_destination_dir() {
local dir="$1"
if [[ ! -d "$dir" ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Directory error")" \
--msgbox "$(translate "Destination directory does not exist:")\n$dir" 8 74
return 1
fi
if [[ ! -w "$dir" ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Permission error")" \
--msgbox "$(translate "Destination directory is not writable:")\n$dir" 8 70
return 1
fi
return 0
}
select_vm() {
local options=()
local line vmid name status
while read -r line; do
[[ -z "$line" ]] && continue
vmid=$(echo "$line" | awk '{print $1}')
name=$(echo "$line" | awk '{print $2}')
status=$(echo "$line" | awk '{print $3}')
[[ -z "$vmid" || "$vmid" == "VMID" ]] && continue
[[ -z "$name" ]] && name="vm-${vmid}"
options+=("$vmid" "$name [$status]")
done < <(qm list 2>/dev/null)
if [[ ${#options[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No VMs found")" \
--msgbox "$(translate "No virtual machines were found on this host.")" 8 60
return 1
fi
VMID=$(dialog --backtitle "ProxMenux" --title "$(translate "Export VM to OVA or OVF")" \
--menu "$(translate "Select VM to export:")" 20 80 12 \
"${options[@]}" 3>&1 1>&2 2>&3)
[[ -n "$VMID" ]] || return 1
return 0
}
ensure_vm_stopped() {
local status
status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}')
if [[ "$status" == "stopped" ]]; then
return 0
fi
if ! dialog --backtitle "ProxMenux" --title "$(translate "VM is running")" --yesno \
"$(translate "For a consistent export, the VM should be stopped.")\n\n$(translate "Do you want ProxMenux to stop it now?")" 10 70; then
return 1
fi
qm shutdown "$VMID" --timeout 120 >/dev/null 2>&1 || true
local i
for i in $(seq 1 60); do
status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}')
[[ "$status" == "stopped" ]] && return 0
sleep 2
done
if dialog --backtitle "ProxMenux" --title "$(translate "Shutdown timeout")" --yesno \
"$(translate "Graceful shutdown timed out.")\n\n$(translate "Force stop VM now?")" 10 60; then
qm stop "$VMID" >/dev/null 2>&1 || true
sleep 2
status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}')
[[ "$status" == "stopped" ]] && return 0
fi
dialog --backtitle "ProxMenux" --title "$(translate "Cannot continue")" \
--msgbox "$(translate "VM is still running. Export cancelled.")" 8 60
return 1
}
select_export_mode() {
EXPORT_MODE=$(dialog --backtitle "ProxMenux" --title "$(translate "Export Format")" \
--menu "$(translate "Select export format:")" 14 70 4 \
"ova" "$(translate "OVA (single portable file)")" \
"ovf" "$(translate "OVF (descriptor + VMDK files)")" \
3>&1 1>&2 2>&3)
[[ -n "$EXPORT_MODE" ]] || return 1
return 0
}
select_destination_dir() {
local dump_dir="/var/lib/vz/dump"
local iso_dir="/var/lib/vz/template/iso"
local options=(
"1" "$dump_dir [$(translate "recommended")]"
"2" "$iso_dir [$(translate "recommended")]"
"M" "$(translate "Manual path entry")"
)
while true; do
local choice
choice=$(dialog --backtitle "ProxMenux" --title "$(translate "Destination Directory")" \
--menu "$(translate "Select where to export VM files (OVA/OVF + temporary workspace):")" \
16 84 8 "${options[@]}" 3>&1 1>&2 2>&3)
[[ -n "$choice" ]] || return 1
case "$choice" in
M)
DEST_DIR=$(dialog --backtitle "ProxMenux" --title "$(translate "Manual destination path")" \
--inputbox "$(translate "Enter destination directory for exported file(s):")" \
10 90 "/var/lib/vz/dump" 3>&1 1>&2 2>&3)
[[ -n "$DEST_DIR" ]] || continue
if [[ ! -d "$DEST_DIR" ]]; then
if dialog --backtitle "ProxMenux" --title "$(translate "Create directory")" \
--yesno "$(translate "The selected directory does not exist:")\n$DEST_DIR\n\n$(translate "Do you want to create it now?")" \
11 80; then
if ! mkdir -p "$DEST_DIR" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(translate "Directory error")" \
--msgbox "$(translate "Could not create destination directory:")\n$DEST_DIR" 8 74
continue
fi
else
continue
fi
fi
validate_destination_dir "$DEST_DIR" || continue
return 0
;;
1)
DEST_DIR="$dump_dir"
validate_destination_dir "$DEST_DIR" || continue
return 0
;;
2)
DEST_DIR="$iso_dir"
validate_destination_dir "$DEST_DIR" || continue
return 0
;;
*)
continue
;;
esac
done
}
get_vm_metadata() {
VM_CONF=$(qm config "$VMID" 2>/dev/null) || return 1
VM_NAME=$(echo "$VM_CONF" | awk -F': ' '/^name:/{print $2; exit}')
[[ -z "$VM_NAME" ]] && VM_NAME="vm-${VMID}"
VM_MEMORY=$(echo "$VM_CONF" | awk -F': ' '/^memory:/{print $2; exit}')
[[ -z "$VM_MEMORY" ]] && VM_MEMORY=1024
VM_CORES=$(echo "$VM_CONF" | awk -F': ' '/^cores:/{print $2; exit}')
VM_SOCKETS=$(echo "$VM_CONF" | awk -F': ' '/^sockets:/{print $2; exit}')
[[ -z "$VM_CORES" ]] && VM_CORES=1
[[ -z "$VM_SOCKETS" ]] && VM_SOCKETS=1
VM_VCPUS=$((VM_CORES * VM_SOCKETS))
VM_OSTYPE=$(echo "$VM_CONF" | awk -F': ' '/^ostype:/{print $2; exit}')
case "$VM_OSTYPE" in
l26|l24) VM_OS_DESC="Linux" ;;
win11|win10|win8|win7|w2k8|w2k12|w2k16|w2k19|w2k22|wxp|w2k|w2k3)
VM_OS_DESC="Windows"
;;
*) VM_OS_DESC="Other" ;;
esac
NET_COUNT=$(echo "$VM_CONF" | grep -E '^net[0-9]+:' | wc -l)
}
get_virtual_size_bytes() {
local src="$1"
local bytes=""
bytes=$(qemu-img info "$src" 2>/dev/null | sed -n 's/.*virtual size:.*(\([0-9]\+\) bytes).*/\1/p' | head -1)
if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then
echo "$bytes"
return 0
fi
if [[ -b "$src" ]]; then
bytes=$(blockdev --getsize64 "$src" 2>/dev/null || true)
if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then
echo "$bytes"
return 0
fi
fi
bytes=$(stat -c%s "$src" 2>/dev/null || true)
if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then
echo "$bytes"
return 0
fi
echo "0"
return 0
}
collect_vm_disks() {
DISK_COUNT=0
unset DISK_SLOTS DISK_SRCS DISK_VSIZES
declare -ga DISK_SLOTS DISK_SRCS DISK_VSIZES
local line slot value source src
while IFS= read -r line; do
if [[ "$line" =~ ^(scsi|sata|virtio|ide)[0-9]+: ]]; then
slot="${line%%:*}"
value="${line#*: }"
[[ "$value" == *"media=cdrom"* ]] && continue
[[ "$value" == *"cloudinit"* ]] && continue
source="${value%%,*}"
[[ -z "$source" || "$source" == "none" ]] && continue
src=""
if [[ "$source" == /dev/* || "$source" == /* ]]; then
src="$source"
elif [[ "$source" == *:* ]]; then
src=$(pvesm path "$source" 2>/dev/null || true)
fi
if [[ -z "$src" || ! -e "$src" ]]; then
continue
fi
DISK_SLOTS+=("$slot")
DISK_SRCS+=("$src")
DISK_VSIZES+=("$(get_virtual_size_bytes "$src")")
DISK_COUNT=$((DISK_COUNT + 1))
fi
done <<< "$VM_CONF"
[[ "$DISK_COUNT" -gt 0 ]] || return 1
return 0
}
check_destination_space() {
local total=0
local i
for i in "${DISK_VSIZES[@]}"; do
[[ "$i" =~ ^[0-9]+$ ]] && total=$((total + i))
done
local factor=120
[[ "$EXPORT_MODE" == "ova" ]] && factor=220
REQUIRED_BYTES=$((total * factor / 100))
AVAILABLE_BYTES=$(df -PB1 "$DEST_DIR" | awk 'NR==2{print $4}')
[[ "$AVAILABLE_BYTES" =~ ^[0-9]+$ ]] || AVAILABLE_BYTES=0
if [[ "$AVAILABLE_BYTES" -lt "$REQUIRED_BYTES" ]]; then
if ! dialog --backtitle "ProxMenux" --title "$(translate "Low free space warning")" --yesno \
"$(translate "Estimated required free space:") $(human_bytes "$REQUIRED_BYTES") ($REQUIRED_BYTES bytes)\n$(translate "Current free space:") $(human_bytes "$AVAILABLE_BYTES") ($AVAILABLE_BYTES bytes)\n\n$(translate "Do you want to continue anyway?")" 13 90; then
return 1
fi
fi
return 0
}
generate_ovf_descriptor() {
local ovf_path="$1"
local vm_name_xml os_desc_xml
vm_name_xml=$(xml_escape "$VM_NAME")
os_desc_xml=$(xml_escape "$VM_OS_DESC")
{
echo '<?xml version="1.0" encoding="UTF-8"?>'
echo '<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1" xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1" xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData" xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData">'
echo ' <References>'
} > "$ovf_path"
local idx file_id disk_id file_name file_size capacity
for idx in "${!EXPORT_DISK_FILES[@]}"; do
file_id="file$((idx + 1))"
file_name="${EXPORT_DISK_FILES[$idx]}"
file_size=$(stat -c%s "$WORK_DIR/$file_name")
echo " <File ovf:id=\"$file_id\" ovf:href=\"$file_name\" ovf:size=\"$file_size\"/>" >> "$ovf_path"
done
{
echo ' </References>'
echo ' <DiskSection>'
echo ' <Info>Virtual disk information</Info>'
} >> "$ovf_path"
for idx in "${!EXPORT_DISK_FILES[@]}"; do
file_id="file$((idx + 1))"
disk_id="vmdisk$((idx + 1))"
capacity="${DISK_VSIZES[$idx]}"
[[ -z "$capacity" || "$capacity" -le 0 ]] && capacity=$(stat -c%s "$WORK_DIR/${EXPORT_DISK_FILES[$idx]}")
echo " <Disk ovf:diskId=\"$disk_id\" ovf:fileRef=\"$file_id\" ovf:capacity=\"$capacity\" ovf:capacityAllocationUnits=\"byte\" ovf:format=\"http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized\"/>" >> "$ovf_path"
done
{
echo ' </DiskSection>'
echo " <VirtualSystem ovf:id=\"$(sanitize_name "$VM_NAME")\">"
echo ' <Info>A virtual machine</Info>'
echo " <Name>$vm_name_xml</Name>"
echo ' <OperatingSystemSection ovf:id="94">'
echo ' <Info>Guest operating system</Info>'
echo " <Description>$os_desc_xml</Description>"
echo ' </OperatingSystemSection>'
echo ' <VirtualHardwareSection>'
echo ' <Info>Virtual hardware requirements</Info>'
echo ' <System>'
echo ' <vssd:ElementName>Virtual Hardware Family</vssd:ElementName>'
echo ' <vssd:InstanceID>0</vssd:InstanceID>'
echo ' <vssd:VirtualSystemIdentifier>vm</vssd:VirtualSystemIdentifier>'
echo ' <vssd:VirtualSystemType>vmx-14</vssd:VirtualSystemType>'
echo ' </System>'
echo ' <Item>'
echo ' <rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits>'
echo ' <rasd:Description>Number of Virtual CPUs</rasd:Description>'
echo ' <rasd:ElementName>Virtual CPU(s)</rasd:ElementName>'
echo ' <rasd:InstanceID>1</rasd:InstanceID>'
echo " <rasd:VirtualQuantity>$VM_VCPUS</rasd:VirtualQuantity>"
echo ' <rasd:ResourceType>3</rasd:ResourceType>'
echo ' </Item>'
echo ' <Item>'
echo ' <rasd:AllocationUnits>byte * 2^20</rasd:AllocationUnits>'
echo ' <rasd:Description>Memory Size</rasd:Description>'
echo ' <rasd:ElementName>Memory</rasd:ElementName>'
echo ' <rasd:InstanceID>2</rasd:InstanceID>'
echo " <rasd:VirtualQuantity>$VM_MEMORY</rasd:VirtualQuantity>"
echo ' <rasd:ResourceType>4</rasd:ResourceType>'
echo ' </Item>'
echo ' <Item>'
echo ' <rasd:Address>0</rasd:Address>'
echo ' <rasd:Description>SCSI Controller</rasd:Description>'
echo ' <rasd:ElementName>SCSI Controller 0</rasd:ElementName>'
echo ' <rasd:InstanceID>10</rasd:InstanceID>'
echo ' <rasd:ResourceSubType>lsilogic</rasd:ResourceSubType>'
echo ' <rasd:ResourceType>6</rasd:ResourceType>'
echo ' </Item>'
} >> "$ovf_path"
for idx in "${!EXPORT_DISK_FILES[@]}"; do
disk_id="vmdisk$((idx + 1))"
echo ' <Item>' >> "$ovf_path"
echo " <rasd:AddressOnParent>$idx</rasd:AddressOnParent>" >> "$ovf_path"
echo ' <rasd:Description>Hard disk</rasd:Description>' >> "$ovf_path"
echo " <rasd:ElementName>Hard disk $((idx + 1))</rasd:ElementName>" >> "$ovf_path"
echo " <rasd:HostResource>ovf:/disk/$disk_id</rasd:HostResource>" >> "$ovf_path"
echo " <rasd:InstanceID>$((200 + idx + 1))</rasd:InstanceID>" >> "$ovf_path"
echo ' <rasd:Parent>10</rasd:Parent>' >> "$ovf_path"
echo ' <rasd:ResourceType>17</rasd:ResourceType>' >> "$ovf_path"
echo ' </Item>' >> "$ovf_path"
done
if [[ "$NET_COUNT" -gt 0 ]]; then
local n
for n in $(seq 1 "$NET_COUNT"); do
{
echo ' <Item>'
echo ' <rasd:AutomaticAllocation>true</rasd:AutomaticAllocation>'
echo ' <rasd:Connection>VM Network</rasd:Connection>'
echo " <rasd:ElementName>Ethernet adapter $n</rasd:ElementName>"
echo " <rasd:InstanceID>$((300 + n))</rasd:InstanceID>"
echo ' <rasd:ResourceType>10</rasd:ResourceType>'
echo ' </Item>'
} >> "$ovf_path"
done
fi
{
echo ' </VirtualHardwareSection>'
echo ' </VirtualSystem>'
echo '</Envelope>'
} >> "$ovf_path"
}
generate_manifest() {
local mf_path="$1"
shift
local files=("$@")
: > "$mf_path"
local f hash
for f in "${files[@]}"; do
hash=$(sha1sum "$WORK_DIR/$f" | awk '{print $1}')
echo "SHA1($f)= $hash" >> "$mf_path"
done
}
print_export_result() {
local mode="$1"
local path="$2"
echo ""
msg_title "$(translate "Export Summary")"
msg_ok "$(translate "VM:") ${VMID}${VM_NAME}"
msg_ok "$(translate "vCPUs:") ${VM_VCPUS} $(translate "Memory:") ${VM_MEMORY} MB $(translate "Disks exported:") ${DISK_COUNT}"
echo ""
if [[ "$mode" == "ova" ]]; then
local ova_size ova_sha1
ova_size=$(stat -c%s "$path" 2>/dev/null || echo 0)
ova_sha1=$(sha1sum "$path" 2>/dev/null | awk '{print $1}')
msg_ok "$(translate "Format:") OVA — $(translate "single portable archive")"
msg_ok "$(translate "File:") $path"
msg_ok "$(translate "Size:") $(human_bytes "$ova_size") (${ova_size} $(translate "bytes"))"
msg_ok "SHA1: ${ova_sha1}"
else
local fsz total_size=0 f
msg_ok "$(translate "Format:") OVF — $(translate "descriptor + VMDK files")"
msg_ok "$(translate "Directory:") $path"
for f in "${EXPORT_DISK_FILES[@]}"; do
fsz=$(stat -c%s "$path/$f" 2>/dev/null || echo 0)
total_size=$((total_size + fsz))
msg_info2 " ${f} [$(human_bytes "$fsz")]"
done
msg_ok "$(translate "Total size:") $(human_bytes "$total_size")"
fi
echo ""
msg_ok "$(translate "Compatible with:") VMware ESXi 6.7+ (vmx-14) · VMware Workstation / Fusion · VirtualBox · Proxmox VE"
msg_info2 "$(translate "Not portable:") $(translate "PCI passthrough, TPM state, cloud-init configuration, Proxmox hooks")"
echo ""
}
run_export() {
show_proxmenux_logo
msg_title "$(translate "Export VM to OVA or OVF")"
msg_ok "$(translate "VM selected:") $VMID ($VM_NAME)"
msg_ok "$(translate "Export mode:") ${EXPORT_MODE^^}"
msg_ok "$(translate "Destination:") $DEST_DIR"
local ts vm_safe base_name
ts=$(date +%Y%m%d_%H%M%S)
vm_safe=$(sanitize_name "$VM_NAME")
base_name="${vm_safe}-${VMID}-${ts}"
WORK_DIR=$(mktemp -d "$DEST_DIR/.ovaovf-${base_name}-XXXXXX")
if [[ ! -d "$WORK_DIR" ]]; then
msg_error "$(translate "Could not create temporary working directory.")"
return 1
fi
msg_ok "$(translate "Working directory:") $WORK_DIR"
# Clean up temp dir on unexpected exit (Ctrl+C, unhandled error, etc.)
trap 'rm -rf "$WORK_DIR" 2>/dev/null' EXIT
declare -ga EXPORT_DISK_FILES
EXPORT_DISK_FILES=()
local i src dst disk_name
for i in "${!DISK_SRCS[@]}"; do
src="${DISK_SRCS[$i]}"
disk_name="${base_name}-disk$((i + 1)).vmdk"
dst="$WORK_DIR/$disk_name"
echo ""
msg_info "$(translate "Converting disk") $((i + 1))/$DISK_COUNT: ${DISK_SLOTS[$i]}"
msg_info2 "$(translate "Source:") $src"
if ! qemu-img convert -p -O vmdk -o subformat=streamOptimized "$src" "$dst"; then
msg_error "$(translate "Disk conversion failed for") ${DISK_SLOTS[$i]}"
return 1
fi
EXPORT_DISK_FILES+=("$disk_name")
msg_ok "$(translate "Converted:") $disk_name"
done
local ovf_file mf_file
ovf_file="${base_name}.ovf"
mf_file="${base_name}.mf"
msg_info "$(translate "Generating OVF descriptor...")"
generate_ovf_descriptor "$WORK_DIR/$ovf_file"
msg_info "$(translate "Generating manifest...")"
generate_manifest "$WORK_DIR/$mf_file" "$ovf_file" "${EXPORT_DISK_FILES[@]}"
if [[ "$EXPORT_MODE" == "ovf" ]]; then
local final_dir="$DEST_DIR/${base_name}-ovf"
rm -rf "$final_dir"
trap - EXIT
mv "$WORK_DIR" "$final_dir"
print_export_result "ovf" "$final_dir"
return 0
fi
local ova_path="$DEST_DIR/${base_name}.ova"
msg_info "$(translate "Packaging OVA file...")"
if ! tar -C "$WORK_DIR" -cf "$ova_path" "$ovf_file" "$mf_file" "${EXPORT_DISK_FILES[@]}"; then
msg_error "$(translate "Failed to create OVA archive.")"
return 1
fi
trap - EXIT
rm -rf "$WORK_DIR"
print_export_result "ova" "$ova_path"
return 0
}
main() {
require_cmd dialog || exit 1
require_cmd qm || exit 1
require_cmd pvesm || exit 1
require_cmd qemu-img || exit 1
require_cmd tar || exit 1
require_cmd sha1sum || exit 1
if ! command -v pveversion >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
--msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60
exit 1
fi
select_vm || exit 0
ensure_vm_stopped || exit 0
select_export_mode || exit 0
select_destination_dir || exit 0
get_vm_metadata || {
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
--msgbox "$(translate "Could not read VM configuration.")" 8 60
exit 1
}
collect_vm_disks || {
dialog --backtitle "ProxMenux" --title "$(translate "No exportable disks")" \
--msgbox "$(translate "No exportable VM disks were found (CD-ROM/cloud-init are excluded).")" 9 80
exit 1
}
check_destination_space || exit 0
if ! dialog --backtitle "ProxMenux" --title "$(translate "Confirm export")" --yesno \
"$(translate "VM:") $VMID ($VM_NAME)\n$(translate "Disks to export:") $DISK_COUNT\n$(translate "Format:") ${EXPORT_MODE^^}\n$(translate "Destination:") $DEST_DIR\n\n$(translate "Continue?")" 13 80; then
exit 0
fi
if run_export; then
echo ""
msg_success "$(translate "Press Enter to return...")"
read -r
exit 0
else
echo ""
msg_error "$(translate "Export failed.")"
msg_info2 "$(translate "Temporary working directory (if present):") $WORK_DIR"
msg_success "$(translate "Press Enter to return...")"
read -r
exit 1
fi
}
main "$@"

View File

@@ -0,0 +1,612 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Import VM from OVA or OVF
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 10/04/2026
# ==========================================================
# Description:
# Imports a virtual machine from an OVA or OVF package into Proxmox VE.
# Compatible with exports from VMware ESXi, VMware Workstation/Fusion,
# VirtualBox, and Proxmox itself (via export_vm_ova_ovf).
#
# What is imported:
# - Disk images (VMDK converted to the target storage format)
# - CPU and memory settings
# - Number of network interfaces
# - VM name and OS type hint
#
# What requires manual review after import:
# - Network bridge assignment (vmbr0 assigned by default)
# - NIC model (e1000 by default — change to VirtIO if guest supports it)
# - Firmware (BIOS/UEFI — must match what the original VM used)
# - VirtIO/qemu-guest-agent installation inside the guest (especially from ESXi)
# - PCI passthrough, TPM, cloud-init, snapshots — not portable in OVF/OVA
# ==========================================================
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
load_language
initialize_cache
BACKTITLE="ProxMenux"
UI_MENU_H=20
UI_MENU_W=84
UI_MENU_LIST_H=10
# Globals populated during the flow
SOURCE_FILE=""
OVF_FILE=""
OVF_DIR=""
WORK_DIR=""
OVF_VM_NAME=""
OVF_VCPUS=1
OVF_MEMORY_MB=1024
OVF_DISK_FILES=()
OVF_DISK_CAPACITIES=()
OVF_NET_COUNT=0
OVF_OS_TYPE="other"
NEW_VMID=""
NEW_VM_NAME=""
STORAGE=""
BRIDGE="vmbr0"
# -------------------------------------------------------
# HELPERS
# -------------------------------------------------------
human_bytes() {
local bytes="$1"
local units=("B" "KB" "MB" "GB" "TB")
local idx=0 value="$bytes"
[[ -z "$value" || ! "$value" =~ ^[0-9]+$ ]] && { echo "N/A"; return; }
while [[ "$value" -ge 1024 && "$idx" -lt 4 ]]; do
value=$((value / 1024))
idx=$((idx + 1))
done
echo "${value}${units[$idx]}"
}
# -------------------------------------------------------
# SELECT SOURCE FILE
# -------------------------------------------------------
select_source_file() {
local dump_dir="/var/lib/vz/dump"
local iso_dir="/var/lib/vz/template/iso"
local options=(
"1" "$dump_dir"
"2" "$iso_dir"
"M" "$(translate "Manual path entry")"
)
while true; do
local choice
choice=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "Import VM from OVA or OVF")" \
--menu "$(translate "Where is the OVA/OVF file located?")" \
14 82 4 "${options[@]}" 3>&1 1>&2 2>&3)
[[ -n "$choice" ]] || return 1
local search_dir=""
case "$choice" in
1) search_dir="$dump_dir" ;;
2) search_dir="$iso_dir" ;;
M)
search_dir=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "Custom Path")" \
--inputbox "\n$(translate "Enter directory containing OVA/OVF files:")" \
10 82 "/var/lib/vz/dump" 3>&1 1>&2 2>&3)
[[ -n "$search_dir" ]] || continue
;;
esac
if [[ ! -d "$search_dir" ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Not found")" \
--msgbox "$(translate "Directory does not exist:")\n$search_dir" 8 74
continue
fi
local file_opts=()
while IFS= read -r f; do
local fname size_h
fname=$(basename "$f")
size_h=$(du -sh "$f" 2>/dev/null | awk '{print $1}')
file_opts+=("$f" "$fname [$size_h]")
done < <(find "$search_dir" -maxdepth 2 \( -name "*.ova" -o -name "*.ovf" \) 2>/dev/null | sort)
if [[ ${#file_opts[@]} -eq 0 ]]; then
dialog --backtitle "$BACKTITLE" \
--title "$(translate "No files found")" \
--msgbox "$(translate "No .ova or .ovf files found in:")\n\n$search_dir" 10 74
continue
fi
local selected
selected=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "Select OVA/OVF file")" \
--menu "$(translate "Select the file to import:")" \
$UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
"${file_opts[@]}" 3>&1 1>&2 2>&3)
[[ -n "$selected" ]] || continue
SOURCE_FILE="$selected"
return 0
done
}
# -------------------------------------------------------
# EXTRACT OVA / LOCATE OVF
# -------------------------------------------------------
prepare_ovf() {
local src="$SOURCE_FILE"
local ext="${src##*.}"
ext="${ext,,}"
if [[ "$ext" == "ova" ]]; then
WORK_DIR=$(mktemp -d "/tmp/.proxmenux-import-XXXXXX")
trap 'rm -rf "$WORK_DIR" 2>/dev/null' EXIT
msg_info "$(translate "Extracting OVA archive...")"
if ! tar xf "$src" -C "$WORK_DIR" 2>/dev/null; then
msg_error "$(translate "Failed to extract OVA file:") $src"
return 1
fi
msg_ok "$(translate "Archive extracted.")"
OVF_FILE=$(find "$WORK_DIR" -maxdepth 2 -name "*.ovf" | head -1)
if [[ -z "$OVF_FILE" ]]; then
msg_error "$(translate "No .ovf descriptor found inside OVA.")"
return 1
fi
OVF_DIR=$(dirname "$OVF_FILE")
elif [[ "$ext" == "ovf" ]]; then
OVF_FILE="$src"
OVF_DIR=$(dirname "$src")
WORK_DIR=""
else
msg_error "$(translate "Unsupported format. Only .ova and .ovf files are supported.")"
return 1
fi
return 0
}
# -------------------------------------------------------
# PARSE OVF XML
# -------------------------------------------------------
parse_ovf() {
local ovf_file="$1"
local result
result=$(awk '
BEGIN {
in_item=0; rt=""; qty=""
file_count=0; cap_count=0; net_count=0
name=""; vcpu="1"; mem="1024"; os=""
}
/<[Nn]ame>/ {
match($0, /<[Nn]ame>([^<]+)</, a)
if (a[1] != "" && name == "") name = a[1]
}
/[Ll]inux/ && /[Dd]escription|[Oo]perating/ { if (os == "") os="linux" }
/[Ww]indows/ && /[Dd]escription|[Oo]perating/ { if (os == "") os="windows" }
/ovf:href=|href=/ {
n = split($0, parts, /"/)
for (i=1; i<=n; i++) {
if (parts[i] ~ /\.(vmdk|qcow2|img|raw)$/) {
files[file_count++] = parts[i]
}
}
}
/[Cc]apacity=/ {
match($0, /[Cc]apacity="([0-9]+)"/, a)
if (a[1]+0 > 0) caps[cap_count++] = a[1]
}
/<Item>|<Item / { in_item=1; rt=""; qty="" }
/<\/Item>/ {
if (in_item) {
if (rt=="3" && qty ~ /^[0-9]+$/) vcpu=qty
if (rt=="4" && qty ~ /^[0-9]+$/) mem=qty
if (rt=="10") net_count++
}
in_item=0
}
/ResourceType>/ {
match($0, /ResourceType>([0-9]+)</, a); rt=a[1]
}
/VirtualQuantity>/ {
match($0, /VirtualQuantity>([0-9]+)</, a); qty=a[1]
}
END {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", name)
if (name == "") name = "imported-vm"
print "NAME=" name
print "VCPU=" vcpu
print "MEM=" mem
print "NET=" net_count
print "OS=" os
for (i=0; i<file_count; i++) print "FILE=" files[i]
for (i=0; i<cap_count; i++) print "CAP=" caps[i]
}
' "$ovf_file")
OVF_VM_NAME=$(echo "$result" | grep '^NAME=' | cut -d= -f2-)
OVF_VCPUS=$(echo "$result" | grep '^VCPU=' | cut -d= -f2-)
OVF_MEMORY_MB=$(echo "$result" | grep '^MEM=' | cut -d= -f2-)
OVF_NET_COUNT=$(echo "$result" | grep '^NET=' | cut -d= -f2-)
OVF_OS_TYPE=$(echo "$result" | grep '^OS=' | cut -d= -f2-)
OVF_DISK_FILES=()
while IFS= read -r line; do
OVF_DISK_FILES+=("${line#FILE=}")
done < <(echo "$result" | grep '^FILE=')
OVF_DISK_CAPACITIES=()
while IFS= read -r line; do
OVF_DISK_CAPACITIES+=("${line#CAP=}")
done < <(echo "$result" | grep '^CAP=')
[[ -z "$OVF_VM_NAME" ]] && OVF_VM_NAME="imported-vm"
[[ ! "$OVF_VCPUS" =~ ^[0-9]+$ ]] && OVF_VCPUS=1
[[ ! "$OVF_MEMORY_MB" =~ ^[0-9]+$ ]] && OVF_MEMORY_MB=1024
[[ ! "$OVF_NET_COUNT" =~ ^[0-9]+$ ]] && OVF_NET_COUNT=0
case "$OVF_OS_TYPE" in
linux) OVF_OS_TYPE="l26" ;;
windows) OVF_OS_TYPE="win10" ;;
*) OVF_OS_TYPE="other" ;;
esac
[[ ${#OVF_DISK_FILES[@]} -gt 0 ]] || return 1
return 0
}
# -------------------------------------------------------
# SELECT IMPORT OPTIONS (dialogs — no terminal output)
# -------------------------------------------------------
select_import_options() {
# VMID
local suggested_vmid
suggested_vmid=$(pvesh get /cluster/nextid 2>/dev/null || echo "100")
while true; do
NEW_VMID=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "VM ID")" \
--inputbox "\n$(translate "Enter the VMID for the new VM:") ($(translate "suggested:") $suggested_vmid)" \
10 72 "$suggested_vmid" 3>&1 1>&2 2>&3)
[[ -n "$NEW_VMID" ]] || return 1
if ! [[ "$NEW_VMID" =~ ^[0-9]+$ ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Invalid VMID")" \
--msgbox "$(translate "VMID must be a number.")" 8 50
continue
fi
if qm status "$NEW_VMID" &>/dev/null; then
dialog --backtitle "$BACKTITLE" --title "$(translate "VMID in use")" \
--msgbox "$(translate "VMID $NEW_VMID is already in use. Please choose another.")" 8 60
continue
fi
break
done
# VM Name
NEW_VM_NAME=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "VM Name")" \
--inputbox "\n$(translate "Enter name for the imported VM:")" \
10 72 "$OVF_VM_NAME" 3>&1 1>&2 2>&3)
[[ -n "$NEW_VM_NAME" ]] || return 1
# Storage
local storage_list storage_opts=()
storage_list=$(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1}')
if [[ -z "$storage_list" ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "No storage")" \
--msgbox "$(translate "No storage volumes available for VM images.")" 8 60
return 1
fi
while IFS= read -r s; do
storage_opts+=("$s" "")
done <<< "$storage_list"
STORAGE=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "Select Storage")" \
--menu "$(translate "Select storage for imported disk(s):")" \
$UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
"${storage_opts[@]}" 3>&1 1>&2 2>&3)
[[ -n "$STORAGE" ]] || return 1
# Network bridge
local bridge_opts=()
while IFS= read -r br; do
[[ -n "$br" ]] && bridge_opts+=("$br" "")
done < <(ip link show type bridge 2>/dev/null | awk -F': ' '/^[0-9]+:/{print $2}' | sed 's/@.*//')
if [[ ${#bridge_opts[@]} -gt 1 ]]; then
BRIDGE=$(dialog --backtitle "$BACKTITLE" \
--title "$(translate "Network Bridge")" \
--menu "$(translate "Select bridge for network interface(s):")" \
$UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \
"${bridge_opts[@]}" 3>&1 1>&2 2>&3)
[[ -n "$BRIDGE" ]] || return 1
elif [[ ${#bridge_opts[@]} -eq 1 ]]; then
BRIDGE="${bridge_opts[0]}"
fi
return 0
}
# -------------------------------------------------------
# CONFIRM BEFORE IMPORT (dialog)
# -------------------------------------------------------
confirm_import() {
local disk_count="${#OVF_DISK_FILES[@]}"
local disk_info="" i
for i in "${!OVF_DISK_FILES[@]}"; do
local cap="${OVF_DISK_CAPACITIES[$i]:-0}"
disk_info+="\n disk$((i+1)): ${OVF_DISK_FILES[$i]} ($(human_bytes "$cap"))"
done
local msg
msg="$(translate "New VM:") $NEW_VMID ($NEW_VM_NAME)\n"
msg+="$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "OS type:") $OVF_OS_TYPE\n"
msg+="$(translate "NICs:") $OVF_NET_COUNT $(translate "Bridge:") $BRIDGE\n"
msg+="$(translate "Storage:") $STORAGE\n"
msg+="$(translate "Disks to import:") $disk_count${disk_info}\n\n"
msg+="$(translate "Continue?")"
dialog --backtitle "$BACKTITLE" \
--title "$(translate "Confirm Import")" \
--yesno "$msg" 18 84 3>&1 1>&2 2>&3
}
# -------------------------------------------------------
# RUN IMPORT (terminal output only — no dialogs)
# -------------------------------------------------------
run_import() {
show_proxmenux_logo
msg_title "$(translate "Import VM from OVA or OVF")"
msg_ok "$(translate "VM:") $NEW_VMID ($NEW_VM_NAME)"
msg_ok "$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "OS:") $OVF_OS_TYPE"
msg_ok "$(translate "Storage:") $STORAGE $(translate "Bridge:") $BRIDGE $(translate "NICs:") $OVF_NET_COUNT"
echo ""
# 1. Create VM shell
msg_info "$(translate "Creating VM...")"
if ! qm create "$NEW_VMID" \
--name "$NEW_VM_NAME" \
--memory "$OVF_MEMORY_MB" \
--cores "$OVF_VCPUS" \
--ostype "$OVF_OS_TYPE" \
--scsihw lsi \
--net0 "e1000,bridge=$BRIDGE" \
&>/dev/null; then
msg_error "$(translate "Failed to create VM") $NEW_VMID"
return 1
fi
msg_ok "$(translate "VM shell created:") $NEW_VMID"
# Add extra NICs (net0 already created above)
local n
for n in $(seq 1 $((OVF_NET_COUNT - 1))); do
qm set "$NEW_VMID" "--net${n}" "e1000,bridge=$BRIDGE" &>/dev/null || true
done
[[ "$OVF_NET_COUNT" -gt 1 ]] && msg_ok "$(translate "Network interfaces added:") $OVF_NET_COUNT"
# 2. Import disks
local disk_count="${#OVF_DISK_FILES[@]}"
local i disk_file src_path
local TEMP_STATUS_FILE TEMP_DISK_FILE
for i in "${!OVF_DISK_FILES[@]}"; do
disk_file="${OVF_DISK_FILES[$i]}"
src_path="$OVF_DIR/$disk_file"
if [[ ! -f "$src_path" ]]; then
msg_error "$(translate "Disk file not found:") $src_path"
return 1
fi
echo ""
msg_info "$(translate "Importing disk") $((i + 1))/$disk_count: $disk_file"
msg_info2 "$(translate "Source:") $src_path"
TEMP_STATUS_FILE=$(mktemp)
TEMP_DISK_FILE=$(mktemp)
(
qm importdisk "$NEW_VMID" "$src_path" "$STORAGE" 2>&1
echo $? > "$TEMP_STATUS_FILE"
) | while IFS= read -r line; do
if [[ "$line" =~ transferred ]]; then
local pct
pct=$(echo "$line" | grep -oP "\d+\.\d+(?=%)")
[[ -n "$pct" ]] && echo -ne "\r${TAB}${BL}- $(translate "Importing:") $disk_file -${CL} ${pct}%"
elif [[ "$line" =~ successfully\ imported\ disk ]]; then
echo "$line" | grep -oP "(?<=successfully imported disk ').*(?=')" > "$TEMP_DISK_FILE"
fi
done
echo -ne "\n"
local import_status
import_status=$(cat "$TEMP_STATUS_FILE" 2>/dev/null)
rm -f "$TEMP_STATUS_FILE"
[[ -z "$import_status" ]] && import_status=1
if [[ "$import_status" -ne 0 ]]; then
msg_error "$(translate "Import failed for:") $disk_file"
rm -f "$TEMP_DISK_FILE"
return 1
fi
# Locate the unused disk entry in VM config
local unused_id unused_disk
unused_id=$(qm config "$NEW_VMID" | grep -E '^unused[0-9]+:' | tail -1 | cut -d: -f1)
unused_disk=$(qm config "$NEW_VMID" | grep -E '^unused[0-9]+:' | tail -1 | cut -d: -f2- | xargs)
rm -f "$TEMP_DISK_FILE"
if [[ -z "$unused_disk" ]]; then
msg_error "$(translate "Could not locate imported disk in VM config.")"
return 1
fi
# Attach to scsi slot i
if ! qm set "$NEW_VMID" "--scsi${i}" "$unused_disk" &>/dev/null; then
msg_error "$(translate "Failed to attach disk as scsi$i.")"
return 1
fi
# Remove the unused marker
[[ -n "$unused_id" ]] && qm set "$NEW_VMID" --delete "$unused_id" &>/dev/null || true
msg_ok "$(translate "Disk attached as:") scsi${i} (${disk_file})"
done
# 3. Set boot disk
echo ""
msg_info "$(translate "Configuring boot order...")"
if qm set "$NEW_VMID" --boot c --bootdisk "scsi0" &>/dev/null; then
msg_ok "$(translate "Boot disk:") scsi0"
fi
return 0
}
# -------------------------------------------------------
# PRINT FINAL RESULT
# -------------------------------------------------------
print_import_result() {
local disk_count="${#OVF_DISK_FILES[@]}"
echo ""
msg_title "$(translate "Import Summary")"
msg_ok "$(translate "VM imported successfully")"
msg_ok "$(translate "VM ID:") $NEW_VMID $(translate "Name:") $NEW_VM_NAME"
msg_ok "$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "Disks:") $disk_count"
msg_ok "$(translate "Storage:") $STORAGE $(translate "Bridge:") $BRIDGE $(translate "NICs:") $OVF_NET_COUNT"
echo ""
msg_ok "$(translate "To start the VM:") qm start $NEW_VMID"
echo ""
msg_title "$(translate "Manual steps recommended after import")"
msg_info2 "$(translate "Network :") $(translate "Verify bridge assignment and NIC model — change to VirtIO if guest drivers are available")"
msg_info2 "$(translate "Firmware :") $(translate "Check BIOS/UEFI in Hardware > BIOS — must match what the original VM used")"
msg_info2 "$(translate "Drivers :") $(translate "If imported from ESXi: install qemu-guest-agent inside the guest OS")"
msg_info2 "$(translate "Display :") $(translate "Set Display > Graphic card (VGA, SPICE or VirtIO) to match the guest")"
msg_info2 "$(translate "OS type :") $(translate "Verify Options > OS Type — currently set to:") $OVF_OS_TYPE"
echo ""
msg_info2 "$(translate "Not imported:") $(translate "PCI passthrough, TPM state, cloud-init, snapshots, Proxmox-specific hooks")"
echo ""
}
# -------------------------------------------------------
# MAIN
# -------------------------------------------------------
main() {
if ! command -v pveversion >/dev/null 2>&1; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60
exit 1
fi
for cmd in dialog qm pvesm qemu-img tar; do
if ! command -v "$cmd" >/dev/null 2>&1; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Missing dependency")" \
--msgbox "$(translate "Required command not found:") $cmd" 8 60
exit 1
fi
done
# Step 1: pick the OVA/OVF file (dialog)
select_source_file || exit 0
# Step 2: extract + parse (terminal output)
show_proxmenux_logo
msg_title "$(translate "Import VM from OVA or OVF")"
msg_ok "$(translate "Source:") $SOURCE_FILE"
echo ""
prepare_ovf || {
echo ""
msg_success "$(translate "Press Enter to return...")"
read -r
exit 1
}
msg_info "$(translate "Parsing OVF descriptor...")"
if ! parse_ovf "$OVF_FILE"; then
msg_error "$(translate "Could not parse OVF file, or no disk image references found.")"
echo ""
msg_success "$(translate "Press Enter to return...")"
read -r
exit 1
fi
msg_ok "$(translate "OVF parsed:")"
msg_info2 " $(translate "Name:") $OVF_VM_NAME $(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB"
msg_info2 " $(translate "Disks:") ${#OVF_DISK_FILES[@]} $(translate "NICs:") $OVF_NET_COUNT $(translate "OS hint:") $OVF_OS_TYPE"
# Clean screen before returning to dialogs
show_proxmenux_logo
# Step 3: configure the new VM (dialogs)
select_import_options || exit 0
# Step 4: confirm (dialog)
confirm_import || exit 0
# Step 5: do the import (terminal output only)
if run_import; then
print_import_result
msg_success "$(translate "Press Enter to return to menu...")"
read -r
exit 0
else
echo ""
msg_error "$(translate "Import failed. VM $NEW_VMID may be in partial state.")"
msg_info2 "$(translate "To remove partial VM:") qm destroy $NEW_VMID --destroy-unreferenced-disks 1"
echo ""
msg_success "$(translate "Press Enter to return...")"
read -r
exit 1
fi
}
main "$@"

View File

@@ -80,21 +80,21 @@ function select_disk_type() {
while true; do while true; do
local choice local choice
choice=$(whiptail --backtitle "ProxMenux" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \ choice=$(whiptail --backtitle "ProxMenux" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \
"1" "$(translate "Add virtual disk")" \ "a" "$(translate "Add virtual disk")" \
"2" "$(translate "Add import disk")" \ "b" "$(translate "Add import disk")" \
"3" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ "c" "$(translate "Add Controller or NVMe (PCI passthrough)")" \
"r" "$(translate "Reset current storage selection")" \ "r" "$(translate "Reset current storage selection")" \
"d" "$(translate "[ Finish and continue ]")" \ "d" "$(translate "──── [ Finish and continue ] ────")" \
--ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || return 1 --ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || return 1
case "$choice" in case "$choice" in
1) a)
select_virtual_disk select_virtual_disk
;; ;;
2) b)
select_import_disk select_import_disk
;; ;;
3) c)
select_controller_nvme select_controller_nvme
;; ;;
r) r)

View File

@@ -58,7 +58,7 @@ function select_linux_iso() {
--backtitle "ProxMenux" \ --backtitle "ProxMenux" \
--title "Opciones de instalación de Linux" \ --title "Opciones de instalación de Linux" \
--menu "\nSeleccione el tipo de instalación de Linux:\n\n$header" \ --menu "\nSeleccione el tipo de instalación de Linux:\n\n$header" \
18 72 10 \ 20 70 10 \
1 "$(printf '%-35s│ %s' 'Instalar con metodo tradicional' 'Desde ISO oficial')" \ 1 "$(printf '%-35s│ %s' 'Instalar con metodo tradicional' 'Desde ISO oficial')" \
2 "$(printf '%-35s│ %s' 'Instalar con script Cloud-Init' 'Helper Scripts')" \ 2 "$(printf '%-35s│ %s' 'Instalar con script Cloud-Init' 'Helper Scripts')" \
3 "$(printf '%-35s│ %s' 'Instalar con ISO personal' 'Almacenamiento local')" \ 3 "$(printf '%-35s│ %s' 'Instalar con ISO personal' 'Almacenamiento local')" \
@@ -140,7 +140,7 @@ function select_linux_iso_official() {
CHOICE=$(dialog --backtitle "ProxMenux" \ CHOICE=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Official Linux Distributions")" \ --title "$(translate "Official Linux Distributions")" \
--menu "$(translate "Select the Linux distribution to install:")\n\n$HEADER_TEXT" 20 80 12 \ --menu "$(translate "Select the Linux distribution to install:")\n\n$HEADER_TEXT" 20 70 12 \
"${MENU_OPTIONS[@]}" \ "${MENU_OPTIONS[@]}" \
3>&1 1>&2 2>&3) 3>&1 1>&2 2>&3)
@@ -269,7 +269,7 @@ local OTHER_OPTIONS=(
local choice local choice
choice=$(dialog --backtitle "ProxMenux" \ choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Other Prebuilt Linux VMs")" \ --title "$(translate "Other Prebuilt Linux VMs")" \
--menu "\n$(translate "Select one of the ready-to-run Linux VMs:")" 18 70 10 \ --menu "\n$(translate "Select one of the ready-to-run Linux VMs:")" 20 70 10 \
"${OTHER_OPTIONS[@]}" 3>&1 1>&2 2>&3) "${OTHER_OPTIONS[@]}" 3>&1 1>&2 2>&3)
if [[ $? -ne 0 || "$choice" == "4" ]]; then if [[ $? -ne 0 || "$choice" == "4" ]]; then

View File

@@ -51,7 +51,7 @@ function select_windows_iso() {
--backtitle "ProxMenux" \ --backtitle "ProxMenux" \
--title "Opciones de instalación de Windows" \ --title "Opciones de instalación de Windows" \
--menu "\nSeleccione el tipo de instalación de Windows:\n\n$header" \ --menu "\nSeleccione el tipo de instalación de Windows:\n\n$header" \
18 70 10 \ 20 70 10 \
1 "$(printf '%-34s│ %s' 'Instalar con ISO UUP Dump' 'UUP Dump ISO creator')" \ 1 "$(printf '%-34s│ %s' 'Instalar con ISO UUP Dump' 'UUP Dump ISO creator')" \
2 "$(printf '%-34s│ %s' 'Instalar con ISO personal' 'Almacenamiento local')" \ 2 "$(printf '%-34s│ %s' 'Instalar con ISO personal' 'Almacenamiento local')" \
3 "Volver al menú principal" \ 3 "Volver al menú principal" \

View File

@@ -51,6 +51,11 @@ if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then
source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh"
fi fi
if [[ -f "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh" ]]; then
source "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh"
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh" ]]; then
source "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh"
fi
if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then
source "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" source "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh"
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then
@@ -475,24 +480,24 @@ function select_disk_type() {
while true; do while true; do
local choice local choice
choice=$(whiptail --backtitle "ProxMenuX" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \ choice=$(whiptail --backtitle "ProxMenuX" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \
"1" "$(translate "Add virtual disk")" \ "a" "$(translate "Add virtual disk")" \
"2" "$(translate "Add import disk")" \ "b" "$(translate "Add import disk")" \
"3" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ "c" "$(translate "Add Controller or NVMe (PCI passthrough)")" \
"r" "$(translate "Reset current storage selection")" \ "r" "$(translate "Reset current storage selection")" \
"d" "$(translate "[ Finish and continue ]")" \ "d" "$(translate "──── [ Finish and continue ] ────")" \
--ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || { --ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || {
msg_warn "$(translate "Storage plan selection cancelled.")" msg_warn "$(translate "Storage plan selection cancelled.")"
return 1 return 1
} }
case "$choice" in case "$choice" in
1) a)
select_virtual_disk select_virtual_disk
;; ;;
2) b)
select_import_disk select_import_disk
;; ;;
3) c)
select_controller_nvme select_controller_nvme
;; ;;
r) r)
@@ -575,50 +580,6 @@ function select_virtual_disk() {
VIRTUAL_DISKS+=("${STORAGE}:${DISK_SIZE}") VIRTUAL_DISKS+=("${STORAGE}:${DISK_SIZE}")
} }
function select_import_disk() {
msg_info "$(translate "Detecting available disks...")"
_refresh_host_storage_cache
local FREE_DISKS=()
local DISK INFO MODEL SIZE LABEL DESCRIPTION
while read -r DISK; do
[[ "$DISK" =~ /dev/zd ]] && continue
_disk_is_host_system_used "$DISK" && continue
INFO=($(lsblk -dn -o MODEL,SIZE "$DISK"))
MODEL="${INFO[@]::${#INFO[@]}-1}"
SIZE="${INFO[-1]}"
LABEL=""
if _disk_used_in_guest_configs "$DISK"; then
LABEL+=" [⚠ $(translate "In use by VM/LXC config")]"
fi
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
if _array_contains "$DISK" "${IMPORT_DISKS[@]}"; then
FREE_DISKS+=("$DISK" "$DESCRIPTION" "ON")
else
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
fi
done < <(lsblk -dn -e 7,11 -o PATH)
stop_spinner
if [[ ${#FREE_DISKS[@]} -eq 0 ]]; then
whiptail --title "Error" --msgbox "$(translate "No importable disks available. System disks and protected disks are hidden.")" 9 70
return 1
fi
local selected
selected=$(whiptail --title "Select Import Disks" --checklist \
"$(translate "Select the disks you want to import (use spacebar to toggle):")" 20 78 10 \
"${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) || return 1
IMPORT_DISKS=()
local item
for item in $(echo "$selected" | tr -d '"'); do
IMPORT_DISKS+=("$item")
done
export IMPORT_DISKS
}
function select_controller_nvme() { function select_controller_nvme() {
local VM_STORAGE_IOMMU_REBOOT_POLICY="defer" local VM_STORAGE_IOMMU_REBOOT_POLICY="defer"
@@ -747,7 +708,7 @@ function prompt_controller_conflict_policy() {
shift shift
local -a source_vms=("$@") local -a source_vms=("$@")
local msg vmid vm_name st ob local msg vmid vm_name st ob
msg="$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" msg="\n$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n"
for vmid in "${source_vms[@]}"; do for vmid in "${source_vms[@]}"; do
vm_name=$(_vm_name_by_id "$vmid") vm_name=$(_vm_name_by_id "$vmid")
st="stopped"; _vm_status_is_running "$vmid" && st="running" st="stopped"; _vm_status_is_running "$vmid" && st="running"
@@ -757,7 +718,7 @@ function prompt_controller_conflict_policy() {
msg+="\n$(translate "Choose action for this controller/NVMe:")" msg+="\n$(translate "Choose action for this controller/NVMe:")"
local choice local choice
choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 20 80 10 \
"1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \
"2" "$(translate "Move to target VM (remove from source VM config)")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \
"3" "$(translate "Skip this device")" \ "3" "$(translate "Skip this device")" \
@@ -1486,18 +1447,26 @@ if [[ "$GPU_WIZARD_APPLIED" == "yes" ]]; then
echo -e "${TAB}$(translate "Then change the VM display to none (vga: none) when the system is stable.")" echo -e "${TAB}$(translate "Then change the VM display to none (vga: none) when the system is stable.")"
fi fi
local HOST_REBOOT_REQUIRED="no" local HOST_REBOOT_REQUIRED="no"
local REBOOT_REASONS=""
if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
HOST_REBOOT_REQUIRED="yes" HOST_REBOOT_REQUIRED="yes"
msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")" msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")"
REBOOT_REASONS+="$(translate "IOMMU has been enabled on this system.")\n"
fi fi
if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then
HOST_REBOOT_REQUIRED="yes" HOST_REBOOT_REQUIRED="yes"
REBOOT_REASONS+="$(translate "GPU passthrough changes require a host reboot.")\n"
fi fi
if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then
if whiptail --title "$(translate "Reboot Recommended")" --yesno \ echo ""
"$(translate "A host reboot is required to apply passthrough changes before starting the VM.")\n\n$(translate "Do you want to reboot now?")" 11 78; then if whiptail --title "$(translate "Reboot Required")" --yesno \
"\n${REBOOT_REASONS}\n$(translate "A host reboot is required before starting the VM. Reboot now?")" 13 78; then
msg_warn "$(translate "Rebooting the system...")" msg_warn "$(translate "Rebooting the system...")"
reboot reboot
else
echo ""
msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")"
msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")"
fi fi
fi fi
echo -e echo -e

View File

@@ -118,7 +118,7 @@ function prompt_controller_conflict_policy() {
shift shift
local -a source_vms=("$@") local -a source_vms=("$@")
local msg vmid vm_name st ob local msg vmid vm_name st ob
msg="$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" msg="\n$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n"
for vmid in "${source_vms[@]}"; do for vmid in "${source_vms[@]}"; do
vm_name=$(_vm_name_by_id "$vmid") vm_name=$(_vm_name_by_id "$vmid")
st="stopped"; _vm_status_is_running "$vmid" && st="running" st="stopped"; _vm_status_is_running "$vmid" && st="running"
@@ -128,7 +128,7 @@ function prompt_controller_conflict_policy() {
msg+="\n$(translate "Choose action for this controller/NVMe:")" msg+="\n$(translate "Choose action for this controller/NVMe:")"
local choice local choice
choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 20 80 10 \
"1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \
"2" "$(translate "Move to target VM (remove from source VM config)")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \
"3" "$(translate "Skip this device")" \ "3" "$(translate "Skip this device")" \
@@ -554,6 +554,7 @@ fi
if qm set "$VMID" --hostpci${hostpci_idx} "${pci},pcie=1" >/dev/null 2>&1; then if qm set "$VMID" --hostpci${hostpci_idx} "${pci},pcie=1" >/dev/null 2>&1; then
msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx}${pci})" msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx}${pci})"
DISK_INFO+="<p>Controller/NVMe: ${pci}</p>" DISK_INFO+="<p>Controller/NVMe: ${pci}</p>"
BOOT_ORDER="${BOOT_ORDER:+$BOOT_ORDER;}hostpci${hostpci_idx}"
hostpci_idx=$((hostpci_idx + 1)) hostpci_idx=$((hostpci_idx + 1))
else else
msg_error "$(translate "Failed to assign Controller/NVMe") (${pci})" msg_error "$(translate "Failed to assign Controller/NVMe") (${pci})"
@@ -769,18 +770,26 @@ if [[ "${WIZARD_ADD_GPU:-no}" == "yes" ]]; then
echo -e echo -e
fi fi
local HOST_REBOOT_REQUIRED="no" local HOST_REBOOT_REQUIRED="no"
local REBOOT_REASONS=""
if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
HOST_REBOOT_REQUIRED="yes" HOST_REBOOT_REQUIRED="yes"
msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")" msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")"
REBOOT_REASONS+="$(translate "IOMMU has been enabled on this system.")\n"
fi fi
if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then
HOST_REBOOT_REQUIRED="yes" HOST_REBOOT_REQUIRED="yes"
REBOOT_REASONS+="$(translate "GPU passthrough changes require a host reboot.")\n"
fi fi
if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then
if whiptail --title "$(translate "Reboot Recommended")" --yesno \ echo ""
"$(translate "A host reboot is required to apply passthrough changes before starting the VM.")\n\n$(translate "Do you want to reboot now?")" 11 78; then if whiptail --title "$(translate "Reboot Required")" --yesno \
"\n${REBOOT_REASONS}\n$(translate "A host reboot is required before starting the VM. Reboot now?")" 13 78; then
msg_warn "$(translate "Rebooting the system...")" msg_warn "$(translate "Rebooting the system...")"
reboot reboot
else
echo ""
msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")"
msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")"
fi fi
fi fi
msg_success "$(translate "Press Enter to return to the main menu...")" msg_success "$(translate "Press Enter to return to the main menu...")"
@@ -807,10 +816,6 @@ elif [[ "$OS_TYPE" == "3" ]]; then
echo -e echo -e
fi fi
if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")"
fi
msg_success "$(translate "Press Enter to return to the main menu...")" msg_success "$(translate "Press Enter to return to the main menu...")"
read -r read -r
bash "$LOCAL_SCRIPTS/menus/create_vm_menu.sh" bash "$LOCAL_SCRIPTS/menus/create_vm_menu.sh"

View File

@@ -44,6 +44,11 @@ if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then
source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh"
fi fi
if [[ -f "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh" ]]; then
source "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh"
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh" ]]; then
source "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh"
fi
if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then
source "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" source "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh"
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then
@@ -490,24 +495,24 @@ function select_disk_type() {
while true; do while true; do
local choice local choice
choice=$(whiptail --backtitle "ProxMenuX" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \ choice=$(whiptail --backtitle "ProxMenuX" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \
"1" "$(translate "Add virtual disk")" \ "a" "$(translate "Add virtual disk")" \
"2" "$(translate "Add import disk")" \ "b" "$(translate "Add import disk")" \
"3" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ "c" "$(translate "Add Controller or NVMe (PCI passthrough)")" \
"r" "$(translate "Reset current storage selection")" \ "r" "$(translate "Reset current storage selection")" \
"d" "$(translate "[ Finish and continue ]")" \ "d" "$(translate "──── [ Finish and continue ] ────")" \
--ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || { --ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || {
msg_warn "$(translate "Storage plan selection cancelled.")" msg_warn "$(translate "Storage plan selection cancelled.")"
return 1 return 1
} }
case "$choice" in case "$choice" in
1) a)
select_virtual_disk select_virtual_disk
;; ;;
2) b)
select_import_disk select_import_disk
;; ;;
3) c)
select_controller_nvme select_controller_nvme
;; ;;
r) r)
@@ -590,49 +595,6 @@ function select_virtual_disk() {
} }
function select_import_disk() {
msg_info "$(translate "Detecting available disks...")"
_refresh_host_storage_cache
local FREE_DISKS=()
local DISK INFO MODEL SIZE LABEL DESCRIPTION
while read -r DISK; do
[[ "$DISK" =~ /dev/zd ]] && continue
_disk_is_host_system_used "$DISK" && continue
INFO=($(lsblk -dn -o MODEL,SIZE "$DISK"))
MODEL="${INFO[@]::${#INFO[@]}-1}"
SIZE="${INFO[-1]}"
LABEL=""
if _disk_used_in_guest_configs "$DISK"; then
LABEL+=" [⚠ $(translate "In use by VM/LXC config")]"
fi
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
if _array_contains "$DISK" "${IMPORT_DISKS[@]}"; then
FREE_DISKS+=("$DISK" "$DESCRIPTION" "ON")
else
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
fi
done < <(lsblk -dn -e 7,11 -o PATH)
stop_spinner
if [[ ${#FREE_DISKS[@]} -eq 0 ]]; then
whiptail --title "Error" --msgbox "$(translate "No importable disks available. System disks and protected disks are hidden.")" 9 70
return 1
fi
local selected
selected=$(whiptail --title "Select Import Disks" --checklist \
"$(translate "Select the disks you want to import (use spacebar to toggle):")" 20 78 10 \
"${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) || return 1
IMPORT_DISKS=()
local item
for item in $(echo "$selected" | tr -d '"'); do
IMPORT_DISKS+=("$item")
done
export IMPORT_DISKS
}
function select_controller_nvme() { function select_controller_nvme() {
local VM_STORAGE_IOMMU_REBOOT_POLICY="defer" local VM_STORAGE_IOMMU_REBOOT_POLICY="defer"
@@ -761,7 +723,7 @@ function prompt_controller_conflict_policy() {
shift shift
local -a source_vms=("$@") local -a source_vms=("$@")
local msg vmid vm_name st ob local msg vmid vm_name st ob
msg="$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" msg="\n$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n"
for vmid in "${source_vms[@]}"; do for vmid in "${source_vms[@]}"; do
vm_name=$(_vm_name_by_id "$vmid") vm_name=$(_vm_name_by_id "$vmid")
st="stopped"; _vm_status_is_running "$vmid" && st="running" st="stopped"; _vm_status_is_running "$vmid" && st="running"
@@ -771,7 +733,7 @@ function prompt_controller_conflict_policy() {
msg+="\n$(translate "Choose action for this controller/NVMe:")" msg+="\n$(translate "Choose action for this controller/NVMe:")"
local choice local choice
choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 20 80 10 \
"1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \
"2" "$(translate "Move to target VM (remove from source VM config)")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \
"3" "$(translate "Skip this device")" \ "3" "$(translate "Skip this device")" \
@@ -1398,6 +1360,7 @@ function create_vm() {
msg_ok "Configured controller/NVMe as hostpci${HOSTPCI_INDEX}: ${PCI_DEV}" msg_ok "Configured controller/NVMe as hostpci${HOSTPCI_INDEX}: ${PCI_DEV}"
DISK_INFO="${DISK_INFO}<p>Controller/NVMe: ${PCI_DEV}</p>" DISK_INFO="${DISK_INFO}<p>Controller/NVMe: ${PCI_DEV}</p>"
CONSOLE_DISK_INFO="${CONSOLE_DISK_INFO}- Controller/NVMe: ${PCI_DEV} (hostpci${HOSTPCI_INDEX})\n" CONSOLE_DISK_INFO="${CONSOLE_DISK_INFO}- Controller/NVMe: ${PCI_DEV} (hostpci${HOSTPCI_INDEX})\n"
BOOT_ORDER_LIST+=("hostpci${HOSTPCI_INDEX}")
HOSTPCI_INDEX=$((HOSTPCI_INDEX + 1)) HOSTPCI_INDEX=$((HOSTPCI_INDEX + 1))
else else
msg_error "Failed to configure controller/NVMe: ${PCI_DEV}" msg_error "Failed to configure controller/NVMe: ${PCI_DEV}"
@@ -1511,18 +1474,26 @@ else
echo -e "${TAB}$(translate "Then change the VM display to none (vga: none) when the system is stable.")" echo -e "${TAB}$(translate "Then change the VM display to none (vga: none) when the system is stable.")"
fi fi
local HOST_REBOOT_REQUIRED="no" local HOST_REBOOT_REQUIRED="no"
local REBOOT_REASONS=""
if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
HOST_REBOOT_REQUIRED="yes" HOST_REBOOT_REQUIRED="yes"
msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")" msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")"
REBOOT_REASONS+="$(translate "IOMMU has been enabled on this system.")\n"
fi fi
if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then
HOST_REBOOT_REQUIRED="yes" HOST_REBOOT_REQUIRED="yes"
REBOOT_REASONS+="$(translate "GPU passthrough changes require a host reboot.")\n"
fi fi
if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then
if whiptail --title "$(translate "Reboot Recommended")" --yesno \ echo ""
"$(translate "A host reboot is required to apply passthrough changes before starting the VM.")\n\n$(translate "Do you want to reboot now?")" 11 78; then if whiptail --title "$(translate "Reboot Required")" --yesno \
"\n${REBOOT_REASONS}\n$(translate "A host reboot is required before starting the VM. Reboot now?")" 13 78; then
msg_warn "$(translate "Rebooting the system...")" msg_warn "$(translate "Rebooting the system...")"
reboot reboot
else
echo ""
msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")"
msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")"
fi fi
fi fi
echo -e echo -e