diff --git a/ui/webui/src/apis/storage.js b/ui/webui/src/apis/storage.js index f8b881d12cf3..a3868e8e8a43 100644 --- a/ui/webui/src/apis/storage.js +++ b/ui/webui/src/apis/storage.js @@ -77,6 +77,20 @@ export const getAllDiskSelection = () => { ); }; +/** + * @param {string} deviceName A device name + * @param {string} password A password + * + * @returns {Promise} Resolves true if success otherwise false + */ +export const unlockDevice = ({ deviceName, passphrase }) => { + return new StorageClient().client.call( + "/org/fedoraproject/Anaconda/Modules/Storage/DeviceTree", + "org.fedoraproject.Anaconda.Modules.Storage.DeviceTree.Handler", + "UnlockDevice", [deviceName, passphrase] + ); +}; + /** * @param {string} disk A device name * diff --git a/ui/webui/src/components/app.jsx b/ui/webui/src/components/app.jsx index bbc05180226e..6ccdb397aba8 100644 --- a/ui/webui/src/components/app.jsx +++ b/ui/webui/src/components/app.jsx @@ -35,6 +35,8 @@ import { LocalizationClient, startEventMonitorLocalization } from "../apis/local import { StorageClient, initDataStorage, startEventMonitorStorage } from "../apis/storage.js"; import { PayloadsClient } from "../apis/payloads"; +import { WithDialogs } from "dialogs.jsx"; + import { readBuildstamp, getIsFinal } from "../helpers/betanag.js"; import { readConf } from "../helpers/conf.js"; import { useReducerWithThunk, reducer, initialState } from "../reducer.js"; @@ -141,16 +143,18 @@ export const Application = () => { })} } - setIsHelpExpanded(false)} - title={title} - storageData={state.storage} - localizationData={state.localization} - dispatch={dispatch} - conf={conf} - /> + + setIsHelpExpanded(false)} + title={title} + storageData={state.storage} + localizationData={state.localization} + dispatch={dispatch} + conf={conf} + /> + ); diff --git a/ui/webui/src/components/review/ReviewConfiguration.jsx b/ui/webui/src/components/review/ReviewConfiguration.jsx index 3157aa7a008f..80a655248e1f 100644 --- a/ui/webui/src/components/review/ReviewConfiguration.jsx +++ b/ui/webui/src/components/review/ReviewConfiguration.jsx @@ -70,10 +70,24 @@ export const ReviewDescriptionList = ({ children }) => { ); }; -const DeviceRow = ({ data, requests }) => { - const name = data.name.v; +const checkDeviceInSubTree = (device, rootDevice, deviceData) => { + const parents = device.parents.v; + + if (parents.length && parents[0] === rootDevice) { + return true; + } else if (parents.length && parents[0] !== rootDevice) { + return checkDeviceInSubTree(deviceData[parents[0]], rootDevice, deviceData); + } else { + return false; + } +}; + +const DeviceRow = ({ deviceData, disk, requests }) => { const [isExpanded, setIsExpanded] = useState(false); + const data = deviceData[disk]; + const name = data.name.v; + const renderRow = row => { const iconColumn = row.reformat.v ? : null; return { @@ -87,7 +101,12 @@ const DeviceRow = ({ data, requests }) => { }; }; - const partitionRows = requests?.filter(req => req["device-spec"].includes(name)).map(renderRow) || []; + const partitionRows = requests?.filter(req => { + const partitionName = Object.keys(deviceData).find(device => deviceData[device].path.v === req["device-spec"]); + const device = deviceData[partitionName]; + + return checkDeviceInSubTree(device, name, deviceData); + }).map(renderRow) || []; return ( @@ -205,7 +224,7 @@ export const ReviewConfiguration = ({ deviceData, diskSelection, language, reque {_("Storage devices and configurations")} {diskSelection.selectedDisks.map(disk => { - return ; + return ; })} diff --git a/ui/webui/src/components/storage/CustomMountPoint.jsx b/ui/webui/src/components/storage/CustomMountPoint.jsx index 62ffa6fe9dc7..bd745bca2266 100644 --- a/ui/webui/src/components/storage/CustomMountPoint.jsx +++ b/ui/webui/src/components/storage/CustomMountPoint.jsx @@ -16,25 +16,31 @@ */ import cockpit from "cockpit"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Alert, + Button, Checkbox, Flex, + FlexItem, HelperText, HelperTextItem, Popover, Select, SelectOption, SelectVariant, + Text, TextContent, + TextVariants, } from "@patternfly/react-core"; import { HelpIcon } from "@patternfly/react-icons"; import { ListingTable } from "cockpit-components-table.jsx"; import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; + import { AnacondaPage } from "../AnacondaPage.jsx"; +import { UnlockDialog } from "./UnlockDialog.jsx"; import { createPartitioning, @@ -119,6 +125,20 @@ const MountpointCheckbox = ({ reformat, isRootMountPoint, handleCheckReFormat, p export const CustomMountPoint = ({ deviceData, diskSelection, partitioningData, dispatch, idPrefix, setIsFormValid, onAddErrorNotification, toggleContextHelp, stepNotification }) => { const [creatingPartitioning, setCreatingPartitioning] = useState(true); + const [showUnlockDialog, setShowUnlockDialog] = useState(false); + + const lockedLUKSPartitions = useMemo(() => { + const devs = partitioningData?.requests?.map(r => r["device-spec"]) || []; + + return Object.keys(deviceData).filter(d => { + return ( + devs.includes(deviceData[d].path.v) && + deviceData[d].formatData.type.v === "luks" && + deviceData[d].formatData.attrs.v.has_key !== "True" + ); + }); + }, [deviceData, partitioningData?.requests]); + useEffect(() => { const validateMountPoints = requests => { if (requests) { @@ -136,6 +156,10 @@ export const CustomMountPoint = ({ deviceData, diskSelection, partitioningData, const partitioningDevicesPaths = partitioningData?.requests.map(r => r["device-spec"]) || []; const canReusePartitioning = selectedDevicesPaths.length === partitioningDevicesPaths.length && selectedDevicesPaths.every(d => partitioningDevicesPaths.includes(d)); + useEffect(() => { + setShowUnlockDialog(lockedLUKSPartitions.length > 0); + }, [lockedLUKSPartitions]); + useEffect(() => { if (canReusePartitioning) { setCreatingPartitioning(false); @@ -204,16 +228,28 @@ export const CustomMountPoint = ({ deviceData, diskSelection, partitioningData, const isNotMountPoint = ["biosboot"].includes(row["format-type"]); // TODO: Anaconda does not support formatting btrfs yet const isBtrfs = row["format-type"] === "btrfs"; + const isLockedLUKS = lockedLUKSPartitions.some(p => row["device-spec"].includes(p)); + return { props: { key: row["device-spec"] }, columns: [ { title: row["device-spec"] }, - { title: row["format-type"] }, + { + title: ( + + {row["format-type"]} + {isLockedLUKS && + } + + ) + }, { title: ( ) }, @@ -245,15 +281,19 @@ export const CustomMountPoint = ({ deviceData, diskSelection, partitioningData, title={stepNotification.message} variant="danger" />} - - {_("We discovered your partitioned and formatted filesystems, so now you can select your own custom mount point for each filesystem.")} - - + <> + + {_("We discovered your partitioned and formatted filesystems, so now you can select your own custom mount point for each filesystem.")} + + + + {showUnlockDialog && + setShowUnlockDialog(false)} partition={lockedLUKSPartitions[0]} />} ); }; diff --git a/ui/webui/src/components/storage/InstallationDestination.jsx b/ui/webui/src/components/storage/InstallationDestination.jsx index 33d8e22949a0..703ff3503003 100644 --- a/ui/webui/src/components/storage/InstallationDestination.jsx +++ b/ui/webui/src/components/storage/InstallationDestination.jsx @@ -43,7 +43,7 @@ import { Tooltip, } from "@patternfly/react-core"; -import { HelpIcon, LockIcon } from "@patternfly/react-icons"; +import { HelpIcon, LockIcon, LockOpenIcon } from "@patternfly/react-icons"; import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; import { ListingTable } from "cockpit-components-table.jsx"; @@ -293,6 +293,16 @@ const LocalStandardDisks = ({ deviceData, diskSelection, dispatch, idPrefix, set }, ]; + const lockIcon = partition => { + const isLocked = partition.formatData.attrs.v.has_key?.toLowerCase() !== "true"; + + if (isLocked) { + return ; + } else { + return ; + } + }; + const expandedContent = (disk) => ( {partition.path.v} - {partition.formatData.type.v === "luks" && } + {partition.formatData.type.v === "luks" && {lockIcon(partition)}} ) }; diff --git a/ui/webui/src/components/storage/UnlockDialog.jsx b/ui/webui/src/components/storage/UnlockDialog.jsx new file mode 100644 index 000000000000..0c77b2984d43 --- /dev/null +++ b/ui/webui/src/components/storage/UnlockDialog.jsx @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ + +import cockpit from "cockpit"; +import React, { useState } from "react"; + +import { + Button, + InputGroup, + Form, + FormGroup, + Modal, + Text, + TextContent, + TextVariants, + TextInput, +} from "@patternfly/react-core"; +import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons"; + +import { ModalError } from "cockpit-components-inline-notification.jsx"; + +import { getDevicesAction } from "../../actions/storage-actions.js"; + +import { + createPartitioning, + unlockDevice, +} from "../../apis/storage.js"; + +const _ = cockpit.gettext; + +export const UnlockDialog = ({ partition, onClose, dispatch }) => { + const [password, setPassword] = useState(""); + const [passwordHidden, setPasswordHidden] = useState(true); + const [dialogError, dialogErrorSet] = useState(); + const [inProgress, setInProgress] = useState(false); + + const onSubmit = () => { + setInProgress(true); + return unlockDevice({ deviceName: partition, passphrase: password }) + .then( + res => { + if (res[0]) { + // Blivet does not send a signal when a device is unlocked, + // so we need to refresh the device data manually. + dispatch(getDevicesAction()); + // Also refresh the partitioning data which will now show the children + // of the unlocked device. + createPartitioning({ method: "MANUAL" }); + onClose(); + } else { + dialogErrorSet(_("Incorrect passphrase")); + setInProgress(false); + } + }, + exc => { + dialogErrorSet(exc.message); + setInProgress(false); + }); + }; + + return ( + onClose()} + title={cockpit.format(_("Unlock encrypted partition $0"), partition)} + description={ + + {_("You need to unlock encrypted partitions before you can continue.")} + + } + footer={ + <> + + + + }> +
{ + e.preventDefault(); + onSubmit(); + }}> + {dialogError && } + + + + + + + +
+ ); +}; diff --git a/ui/webui/test/check-storage b/ui/webui/test/check-storage index ab504049a98f..1276a86e57ac 100755 --- a/ui/webui/test/check-storage +++ b/ui/webui/test/check-storage @@ -320,6 +320,9 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase): def select_mountpoint(self, i ,s, disks, expected_partitions): i.open() i.next() + + s.check_disk_visible("vda") + s.rescan_disks() for disk in disks: dev = disk.split('/')[-1] @@ -352,6 +355,15 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase): def check_partition(self, row, partition): self.browser.wait_in_text(f"#custom-mountpoint-table tbody tr:nth-child({row}) td[data-label='Partition']", partition) + def unlock_device(self, passphrase, xfail=None): + self.browser.wait_visible("#unlock-device-dialog") + self.browser.set_input_text("#unlock-device-dialog-luks-password", passphrase) + self.browser.click("#unlock-device-dialog-submit-btn") + if xfail: + self.browser.wait_in_text("#unlock-device-dialog .pf-c-alert", xfail) + self.browser.click("#unlock-device-dialog-cancel-btn") + self.browser.wait_not_present("#unlock-device-dialog") + def select_reformat(self, row): self.browser.set_checked(f"#custom-mountpoint-table tbody tr:nth-child({row}) td[data-label='Reformat'] input", True) @@ -589,5 +601,60 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase): r.check_disk_row(disk, row, "Format type", "xfs") r.check_disk_row(disk, row, "Mount point", "/home") + def testEncryptedUnlock(self): + b = self.browser + m = self.machine + i = Installer(b, m) + s = Storage(b, m) + r = Review(b) + + m.add_disk(10) + + # BIOS boot partition, /boot partition, / + disk1 = "/dev/vda" + m.execute(f""" + sgdisk --zap-all {disk1} + sgdisk --new=0:0:+1MiB -t 0:ef02 {disk1} + sgdisk --new=0:0:+1GiB {disk1} + sgdisk --new=0:0:0 {disk1} + mkfs.xfs {disk1}2 + mkfs.xfs {disk1}3 + """) + + + disk2 = "vdb" + m.execute(f""" + sgdisk --zap-all /dev/{disk2} + sgdisk --new=0:0 /dev/{disk2} + echo einszweidrei | cryptsetup luksFormat /dev/{disk2}1 + echo einszweidrei | cryptsetup luksOpen /dev/{disk2}1 encrypted-vol + mkfs.xfs /dev/mapper/encrypted-vol + cryptsetup luksClose encrypted-vol + """) + self.udevadm_settle() + + partitions = {} + partitions[disk1] = [f"{disk1}2", f"{disk1}3"] + partitions[disk2] = [f"{disk2}1"] + self.select_mountpoint(i, s, [disk1, disk2], partitions) + + self.unlock_device("1234", "Failed to unlock LUKS partition") + + b.click("#custom-mountpoint-table tbody tr:nth-child(4) td[data-label='Format type'] #unlock-luks-btn") + self.unlock_device("einszweidrei") + b.wait_not_present("#custom-mountpoint-table tbody tr:nth-child(4) td[data-label='Format type'] #unlock-luks-btn") + + self.check_partition(4, "/dev/mapper/luks") + self.check_format_type(4, "xfs") + + self.select_from_dropdown(2, "boot", "/boot") + self.select_from_dropdown(3, "root", "/") + self.select_from_dropdown(4, "home", "/home") + + i.next() + + r.expand_disk_table(disk2) + r.check_disk_row(disk2, 1, "Partition", "/dev/mapper/luks") + if __name__ == '__main__': test_main()