diff --git a/pkg/storaged/block/actions.jsx b/pkg/storaged/block/actions.jsx new file mode 100644 index 000000000000..df46ac272240 --- /dev/null +++ b/pkg/storaged/block/actions.jsx @@ -0,0 +1,36 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2024 Red Hat, Inc. + * + * Cockpit 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. + * + * Cockpit 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 Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import client from "../client"; + +import { format_dialog } from "./format-dialog.jsx"; + +const _ = cockpit.gettext; + +export function std_format_action(backing_block, content_block) { + const excuse = backing_block.ReadOnly ? _("Device is read-only") : null; + + return { + title: _("Format"), + action: () => format_dialog(client, backing_block.path), + excuse, + danger: true + }; +} diff --git a/pkg/storaged/block/format-dialog.jsx b/pkg/storaged/block/format-dialog.jsx index 68d79a11e4ca..6d72f69a1c55 100644 --- a/pkg/storaged/block/format-dialog.jsx +++ b/pkg/storaged/block/format-dialog.jsx @@ -121,7 +121,7 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, const content_block = block.IdUsage == "crypto" ? client.blocks_cleartext[path] : block; const offer_keep_keys = block.IdUsage == "crypto"; - const unlock_before_format = offer_keep_keys && !content_block; + const unlock_before_format = offer_keep_keys && (!content_block || content_block.ReadOnly); const create_partition = (start !== undefined); @@ -230,6 +230,8 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, extract_option(crypto_split_options, "noauto"); extract_option(crypto_split_options, "nofail"); extract_option(crypto_split_options, "_netdev"); + extract_option(crypto_split_options, "readonly"); + extract_option(crypto_split_options, "read-only"); const crypto_extra_options = unparse_options(crypto_split_options); let [, old_dir, old_opts] = get_fstab_config(block, true, @@ -438,6 +440,8 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, if (is_encrypted(vals)) { let opts = []; if (is_filesystem(vals)) { + if (vals.mount_options?.ro) + opts.push("readonly"); if (!mount_now || vals.at_boot == "never") opts.push("noauto"); if (vals.at_boot == "nofail") @@ -516,17 +520,24 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, if (config_items.length > 0) options["config-items"] = { t: 'a(sa{sv})', v: config_items }; - function maybe_unlock() { + async function maybe_unlock() { const content_block = client.blocks_cleartext[path]; - if (content_block) + if (content_block) { + if (content_block.ReadOnly) { + const block_crypto = client.blocks_crypto[path]; + await block_crypto.Lock({}); + await unlock_with_type(client, block, vals.old_passphrase, existing_passphrase_type, false); + } return content_block; + } - return (unlock_with_type(client, block, vals.old_passphrase, existing_passphrase_type) - .catch(error => { - dlg.set_values({ needs_explicit_passphrase: true }); - return Promise.reject(error); - }) - .then(() => client.blocks_cleartext[path])); + try { + await unlock_with_type(client, block, vals.old_passphrase, existing_passphrase_type, false); + return client.blocks_cleartext[path]; + } catch (error) { + dlg.set_values({ needs_explicit_passphrase: true }); + throw error; + } } function format() { @@ -588,6 +599,15 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, if (is_encrypted(vals)) remember_passphrase(new_block, vals.passphrase); + if (is_encrypted(vals) && is_filesystem(vals) && vals.mount_options?.ro) { + const block_crypto = await client.wait_for(() => block_crypto_for_block(path)); + await block_crypto.Lock({}); + if (vals.passphrase) + await block_crypto.Unlock(vals.passphrase, { "read-only": { t: "b", v: true } }); + else + await unlock_with_type(client, block, vals.old_passphrase, existing_passphrase_type, true); + } + if (is_filesystem(vals) && mount_now) { const block_fsys = await client.wait_for(() => block_fsys_for_block(path)); await client.mount_at(client.blocks[block_fsys.path], mount_point); diff --git a/pkg/storaged/block/unformatted-data.jsx b/pkg/storaged/block/unformatted-data.jsx index 7c4e5ed12c01..7d9179bed700 100644 --- a/pkg/storaged/block/unformatted-data.jsx +++ b/pkg/storaged/block/unformatted-data.jsx @@ -18,10 +18,9 @@ */ import cockpit from "cockpit"; -import client from "../client"; import { StorageCard, new_card } from "../pages.jsx"; -import { format_dialog } from "./format-dialog.jsx"; +import { std_format_action } from "./actions.jsx"; import { std_lock_action } from "../crypto/actions.jsx"; const _ = cockpit.gettext; @@ -33,7 +32,7 @@ export function make_unformatted_data_card(next, backing_block, content_block) { component: StorageCard, actions: [ std_lock_action(backing_block, content_block), - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ] }); } diff --git a/pkg/storaged/block/unrecognized-data.jsx b/pkg/storaged/block/unrecognized-data.jsx index f5056d6524f9..e94fb9a29043 100644 --- a/pkg/storaged/block/unrecognized-data.jsx +++ b/pkg/storaged/block/unrecognized-data.jsx @@ -19,13 +19,12 @@ import cockpit from "cockpit"; import React from "react"; -import client from "../client"; import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; import { StorageCard, StorageDescription, new_card } from "../pages.jsx"; -import { format_dialog } from "./format-dialog.jsx"; +import { std_format_action } from "./actions.jsx"; import { std_lock_action } from "../crypto/actions.jsx"; const _ = cockpit.gettext; @@ -38,7 +37,7 @@ export function make_unrecognized_data_card(next, backing_block, content_block) props: { backing_block, content_block }, actions: [ std_lock_action(backing_block, content_block), - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ] }); } diff --git a/pkg/storaged/btrfs/device.jsx b/pkg/storaged/btrfs/device.jsx index 3f317240fe05..0c844885af94 100644 --- a/pkg/storaged/btrfs/device.jsx +++ b/pkg/storaged/btrfs/device.jsx @@ -28,7 +28,7 @@ import { DescriptionList } from "@patternfly/react-core/dist/esm/components/Desc import { StorageCard, StorageDescription, new_card, register_crossref } from "../pages.jsx"; import { StorageUsageBar } from "../storage-controls.jsx"; import { std_lock_action } from "../crypto/actions.jsx"; -import { format_dialog } from "../block/format-dialog.jsx"; +import { std_format_action } from "../block/actions.jsx"; import { btrfs_device_usage } from "./utils.jsx"; const _ = cockpit.gettext; @@ -90,7 +90,7 @@ export function btrfs_device_actions(backing_block, content_block) { if (backing_block && content_block) return [ std_lock_action(backing_block, content_block), - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ]; else return []; diff --git a/pkg/storaged/crypto/actions.jsx b/pkg/storaged/crypto/actions.jsx index d6139c8283f9..ad7a8ec19ef5 100644 --- a/pkg/storaged/crypto/actions.jsx +++ b/pkg/storaged/crypto/actions.jsx @@ -23,7 +23,6 @@ import client from "../client"; import { get_existing_passphrase, unlock_with_type } from "./keyslots.jsx"; import { set_crypto_auto_option } from "../utils.js"; import { dialog_open, PassInput } from "../dialog.jsx"; -import { remember_passphrase } from "../anaconda.jsx"; const _ = cockpit.gettext; @@ -45,8 +44,7 @@ export function unlock(block) { Action: { Title: _("Unlock"), action: async function (vals) { - await crypto.Unlock(vals.passphrase, {}); - remember_passphrase(block, vals.passphrase); + await unlock_with_type(client, block, vals.passphrase, null); await set_crypto_auto_option(block, true); } } diff --git a/pkg/storaged/crypto/keyslots.jsx b/pkg/storaged/crypto/keyslots.jsx index 9a6962d6fbe9..6ee9afd2c4b8 100644 --- a/pkg/storaged/crypto/keyslots.jsx +++ b/pkg/storaged/crypto/keyslots.jsx @@ -19,6 +19,7 @@ import cockpit from "cockpit"; import React from "react"; +import client from "../client.js"; import { CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js"; @@ -38,7 +39,10 @@ import { dialog_open, SelectOneRadio, TextInput, PassInput, Skip } from "../dialog.jsx"; -import { decode_filename, encode_filename, get_block_mntopts, block_name, for_each_async, get_children, parse_options, unparse_options, edit_crypto_config } from "../utils.js"; +import { + decode_filename, encode_filename, get_block_mntopts, block_name, for_each_async, get_children, + parse_options, extract_option, unparse_options, edit_crypto_config +} from "../utils.js"; import { StorageButton } from "../storage-controls.jsx"; import clevis_luks_passphrase_sh from "./clevis-luks-passphrase.sh"; @@ -72,22 +76,51 @@ export function clevis_recover_passphrase(block, just_type) { .then(output => output.trim()); } -function clevis_unlock(block) { +async function clevis_unlock(client, block, luksname, readonly) { const dev = decode_filename(block.Device); - const clear_dev = "luks-" + block.IdUUID; - return cockpit.spawn(["clevis", "luks", "unlock", "-d", dev, "-n", clear_dev], - { superuser: true }); + const clear_dev = luksname || "luks-" + block.IdUUID; + + if (readonly) { + // HACK - clevis-luks-unlock can not unlock things readonly. + // But see https://github.com/latchset/clevis/pull/317 (merged + // Feb 2023, unreleased as of Feb 2024). + const passphrase = await clevis_recover_passphrase(block, false); + const crypto = client.blocks_crypto[block.path]; + const unlock_options = { "read-only": { t: "b", v: readonly } }; + await crypto.Unlock(passphrase, unlock_options); + return; + } + + await cockpit.spawn(["clevis", "luks", "unlock", "-d", dev, "-n", clear_dev], + { superuser: true }); } -export async function unlock_with_type(client, block, passphrase, passphrase_type) { +export async function unlock_with_type(client, block, passphrase, passphrase_type, override_readonly) { const crypto = client.blocks_crypto[block.path]; + let readonly = false; + let luksname = null; + + for (const c of block.Configuration) { + if (c[0] == "crypttab") { + const options = parse_options(decode_filename(c[1].options.v)); + readonly = extract_option(options, "readonly") || extract_option(options, "read-only"); + luksname = decode_filename(c[1].name.v); + break; + } + } + + if (override_readonly !== null && override_readonly !== undefined) + readonly = override_readonly; + + const unlock_options = { "read-only": { t: "b", v: readonly } }; + if (passphrase) { - await crypto.Unlock(passphrase, {}); + await crypto.Unlock(passphrase, unlock_options); remember_passphrase(block, passphrase); } else if (passphrase_type == "stored") { - await crypto.Unlock("", {}); + await crypto.Unlock("", unlock_options); } else if (passphrase_type == "clevis") { - await clevis_unlock(block); + await clevis_unlock(client, block, luksname, readonly); } else { // This should always be caught and should never show up in the UI throw new Error("No passphrase"); @@ -187,7 +220,8 @@ export function init_existing_passphrase(block, just_type, callback) { return { title: _("Unlocking disk"), func: dlg => { - return get_existing_passphrase(block, just_type).then(passphrase => { + const backing = client.blocks[block.CryptoBackingDevice]; + return get_existing_passphrase(backing || block, just_type).then(passphrase => { if (!passphrase) dlg.set_values({ needs_explicit_passphrase: true }); if (callback) diff --git a/pkg/storaged/crypto/locked-encrypted-data.jsx b/pkg/storaged/crypto/locked-encrypted-data.jsx index 155aade4e7d0..8936a48d3b7d 100644 --- a/pkg/storaged/crypto/locked-encrypted-data.jsx +++ b/pkg/storaged/crypto/locked-encrypted-data.jsx @@ -18,10 +18,9 @@ */ import cockpit from "cockpit"; -import client from "../client"; import { StorageCard, new_card } from "../pages.jsx"; -import { format_dialog } from "../block/format-dialog.jsx"; +import { std_format_action } from "../block/actions.jsx"; import { unlock } from "./actions.jsx"; const _ = cockpit.gettext; @@ -35,7 +34,7 @@ export function make_locked_encrypted_data_card(next, block) { props: { block }, actions: [ { title: _("Unlock"), action: () => unlock(block) }, - { title: _("Format"), action: () => format_dialog(client, block.path), danger: true }, + std_format_action(block, null), ] }); } diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx index c76a1b067817..fe5f64f1390c 100644 --- a/pkg/storaged/dialog.jsx +++ b/pkg/storaged/dialog.jsx @@ -863,11 +863,12 @@ export const SelectSpace = (tag, title, options) => { }; }; -const CheckBoxComponent = ({ tag, val, title, tooltip, update_function }) => { +const CheckBoxComponent = ({ tag, val, title, tooltip, disabled, update_function }) => { return ( {title} @@ -905,6 +906,7 @@ export const CheckBoxes = (tag, title, options) => { tag={ftag} val={fval} title={field.title} + disabled={field.disabled} tooltip={field.tooltip} options={options} update_function={fchange} />; diff --git a/pkg/storaged/filesystem/filesystem.jsx b/pkg/storaged/filesystem/filesystem.jsx index ff8b70f375db..37a4965638cd 100644 --- a/pkg/storaged/filesystem/filesystem.jsx +++ b/pkg/storaged/filesystem/filesystem.jsx @@ -34,7 +34,7 @@ import { import { StorageLink, StorageUsageBar, StorageSize } from "../storage-controls.jsx"; import { StorageCard, StorageDescription, new_card, useIsNarrow } from "../pages.jsx"; -import { format_dialog } from "../block/format-dialog.jsx"; +import { std_format_action } from "../block/actions.jsx"; import { is_mounted, MountPoint, mount_point_text, edit_mount_point } from "./utils.jsx"; import { mounting_dialog } from "./mounting-dialog.jsx"; import { check_mismounted_fsys, MismountAlert } from "./mismounting.jsx"; @@ -101,7 +101,7 @@ export function make_filesystem_card(next, backing_block, content_block, fstab_c content_block && mounted ? { title: _("Unmount"), action: () => mounting_dialog(client, content_block, "unmount") } : { title: _("Mount"), action: () => mounting_dialog(client, content_block || backing_block, "mount") }, - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ] }); } diff --git a/pkg/storaged/filesystem/mounting-dialog.jsx b/pkg/storaged/filesystem/mounting-dialog.jsx index ae58ebad3226..bb21343a43d9 100644 --- a/pkg/storaged/filesystem/mounting-dialog.jsx +++ b/pkg/storaged/filesystem/mounting-dialog.jsx @@ -57,7 +57,10 @@ export const mount_options = (opt_ro, extra_options, is_visible) => { extra: extra_options || false }, fields: [ - { title: _("Mount read only"), tag: "ro" }, + { + title: _("Mount read only"), + tag: "ro", + }, { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" }, ] }); @@ -168,7 +171,7 @@ export function mounting_dialog(client, block, mode, forced_options, subvol) { const is_filesystem_mounted = is_mounted(client, block, subvol); - function maybe_update_config(new_dir, new_opts, passphrase, passphrase_type) { + function maybe_update_config(new_dir, new_opts, passphrase, passphrase_type, crypto_unlock_readonly) { let new_config = null; let all_new_opts; @@ -243,16 +246,32 @@ export function mounting_dialog(client, block, mode, forced_options, subvol) { return Promise.resolve(); } - function maybe_unlock() { - const crypto = client.blocks_crypto[block.path]; - if (mode == "mount" && crypto) { - return (unlock_with_type(client, block, passphrase, passphrase_type) - .catch(error => { - dlg.set_values({ needs_explicit_passphrase: true }); - return Promise.reject(error); - })); - } else - return Promise.resolve(); + async function maybe_unlock() { + if (mode == "mount" || (mode == "update" && is_filesystem_mounted)) { + let crypto = client.blocks_crypto[block.path]; + const backing = client.blocks[block.CryptoBackingDevice]; + + if (backing && block.ReadOnly != crypto_unlock_readonly) { + // We are working on a open crypto device, but it + // has the wrong readonly-ness. Close it so that we can reopen it below. + crypto = client.blocks_crypto[backing.path]; + await crypto.Lock({}); + } + + if (crypto) { + try { + await unlock_with_type(client, client.blocks[crypto.path], + passphrase, passphrase_type, crypto_unlock_readonly); + return await client.wait_for(() => client.blocks_cleartext[crypto.path]); + } catch (error) { + passphrase_type = null; + dlg.set_values({ needs_explicit_passphrase: true }); + throw error; + } + } + } + + return block; } function maybe_lock() { @@ -275,14 +294,14 @@ export function mounting_dialog(client, block, mode, forced_options, subvol) { return (reload_systemd() .then(() => teardown_active_usage(client, usage)) .then(maybe_unlock) - .then(() => { + .then(content_block => { if (!old_config && new_config) - return (block.AddConfigurationItem(new_config, {}) + return (content_block.AddConfigurationItem(new_config, {}) .then(maybe_mount)); else if (old_config && !new_config) - return block.RemoveConfigurationItem(old_config, {}); + return content_block.RemoveConfigurationItem(old_config, {}); else if (old_config && new_config) - return (block.UpdateConfigurationItem(old_config, new_config, {}) + return (content_block.UpdateConfigurationItem(old_config, new_config, {}) .then(maybe_mount)); else if (new_config && !is_mounted(client, block)) return maybe_mount(); @@ -314,18 +333,17 @@ export function mounting_dialog(client, block, mode, forced_options, subvol) { mode == "update", subvol) }), - mount_options(opt_ro, extra_options), + mount_options(opt_ro, extra_options, null), at_boot_input(at_boot), ]; - if (block.IdUsage == "crypto" && mode == "mount") - fields = fields.concat([ - PassInput("passphrase", _("Passphrase"), - { - visible: vals => vals.needs_explicit_passphrase, - validate: val => !val.length && _("Passphrase cannot be empty"), - }) - ]); + fields = fields.concat([ + PassInput("passphrase", _("Passphrase"), + { + visible: vals => vals.needs_explicit_passphrase, + validate: val => !val.length && _("Passphrase cannot be empty"), + }) + ]); } const mode_title = { @@ -372,11 +390,26 @@ export function mounting_dialog(client, block, mode, forced_options, subvol) { const usage = get_active_usage(client, block.path, null, null, false, subvol); + function update_explicit_passphrase(vals_ro) { + const backing = client.blocks[block.CryptoBackingDevice]; + let need_passphrase = (block.IdUsage == "crypto" && mode == "mount"); + if (backing) { + // XXX - take subvols into account. + if (block.ReadOnly != vals_ro) + need_passphrase = true; + } + dlg.set_values({ needs_explicit_passphrase: need_passphrase && !passphrase_type }); + } + const dlg = dialog_open({ Title: cockpit.format(mode_title[mode], old_dir_for_display), Fields: fields, Teardown: TeardownMessage(usage, old_dir), - update: update_at_boot_input, + update: function (dlg, vals, trigger) { + update_at_boot_input(dlg, vals, trigger); + if (trigger == "mount_options") + update_explicit_passphrase(vals.mount_options.ro); + }, Action: { Title: mode_action[mode], disable_on_error: usage.Teardown, @@ -399,10 +432,13 @@ export function mounting_dialog(client, block, mode, forced_options, subvol) { opts = opts.concat(forced_options); if (vals.mount_options?.extra) opts = opts.concat(parse_options(vals.mount_options.extra)); + // XXX - take subvols into account. + const crypto_unlock_readonly = vals.mount_options?.ro ?? opt_ro; return (maybe_update_config(client.add_mount_point_prefix(vals.mount_point), unparse_options(opts), vals.passphrase, - passphrase_type) + passphrase_type, + crypto_unlock_readonly) .then(() => maybe_set_crypto_options(vals.mount_options?.ro, opts.indexOf("noauto") == -1, vals.at_boot == "nofail", @@ -412,9 +448,10 @@ export function mounting_dialog(client, block, mode, forced_options, subvol) { }, Inits: [ init_active_usage_processes(client, usage, old_dir), - (block.IdUsage == "crypto" && mode == "mount") - ? init_existing_passphrase(block, true, type => { passphrase_type = type }) - : null + init_existing_passphrase(block, true, type => { + passphrase_type = type; + update_explicit_passphrase(dlg.get_value("mount_options")?.ro ?? opt_ro); + }), ] }); } diff --git a/pkg/storaged/lvm2/physical-volume.jsx b/pkg/storaged/lvm2/physical-volume.jsx index d3f2c1482779..2f6716ad1ad9 100644 --- a/pkg/storaged/lvm2/physical-volume.jsx +++ b/pkg/storaged/lvm2/physical-volume.jsx @@ -26,7 +26,7 @@ import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index. import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; import { StorageCard, StorageDescription, new_card, register_crossref } from "../pages.jsx"; -import { format_dialog } from "../block/format-dialog.jsx"; +import { std_format_action } from "../block/actions.jsx"; import { std_lock_action } from "../crypto/actions.jsx"; import { StorageUsageBar } from "../storage-controls.jsx"; @@ -47,7 +47,7 @@ export function make_lvm2_physical_volume_card(next, backing_block, content_bloc props: { backing_block, content_block }, actions: [ std_lock_action(backing_block, content_block), - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ] }); diff --git a/pkg/storaged/mdraid/mdraid-disk.jsx b/pkg/storaged/mdraid/mdraid-disk.jsx index 21d0e4dd57b3..4f061284e3b7 100644 --- a/pkg/storaged/mdraid/mdraid-disk.jsx +++ b/pkg/storaged/mdraid/mdraid-disk.jsx @@ -26,7 +26,7 @@ import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index. import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; import { StorageCard, StorageDescription, new_card, register_crossref } from "../pages.jsx"; -import { format_dialog } from "../block/format-dialog.jsx"; +import { std_format_action } from "../block/actions.jsx"; import { block_short_name, fmt_size, mdraid_name } from "../utils.js"; import { std_lock_action } from "../crypto/actions.jsx"; @@ -44,7 +44,7 @@ export function make_mdraid_disk_card(next, backing_block, content_block) { props: { backing_block, content_block, mdraid }, actions: [ std_lock_action(backing_block, content_block), - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ] }); diff --git a/pkg/storaged/stratis/blockdev.jsx b/pkg/storaged/stratis/blockdev.jsx index 117f268a5614..c001d83e7aec 100644 --- a/pkg/storaged/stratis/blockdev.jsx +++ b/pkg/storaged/stratis/blockdev.jsx @@ -26,7 +26,7 @@ import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index. import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; import { StorageCard, StorageDescription, new_card, register_crossref } from "../pages.jsx"; -import { format_dialog } from "../block/format-dialog.jsx"; +import { std_format_action } from "../block/actions.jsx"; import { fmt_size } from "../utils.js"; import { std_lock_action } from "../crypto/actions.jsx"; @@ -45,7 +45,7 @@ export function make_stratis_blockdev_card(next, backing_block, content_block) { props: { backing_block, content_block, pool, stopped_pool }, actions: [ std_lock_action(backing_block, content_block), - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ] }); diff --git a/pkg/storaged/swap/swap.jsx b/pkg/storaged/swap/swap.jsx index de588dfc2667..2a4bc3706596 100644 --- a/pkg/storaged/swap/swap.jsx +++ b/pkg/storaged/swap/swap.jsx @@ -26,7 +26,7 @@ import { DescriptionList } from "@patternfly/react-core/dist/esm/components/Desc import { useEvent } from "hooks"; import { StorageCard, StorageDescription, new_card } from "../pages.jsx"; -import { format_dialog } from "../block/format-dialog.jsx"; +import { std_format_action } from "../block/actions.jsx"; import { fmt_size, decode_filename, encode_filename, parse_options, unparse_options, extract_option, @@ -97,7 +97,7 @@ export function make_swap_card(next, backing_block, content_block) { (block_swap && !block_swap.Active ? { title: _("Start"), action: start } : null), - { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + std_format_action(backing_block, content_block), ] }); } diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js index 23e050e1cb8a..f09a444f257d 100644 --- a/pkg/storaged/utils.js +++ b/pkg/storaged/utils.js @@ -102,6 +102,7 @@ export function set_crypto_options(block, readonly, auto, nofail, netdev) { const opts = config.options ? parse_options(decode_filename(config.options.v)) : []; if (readonly !== null) { extract_option(opts, "readonly"); + extract_option(opts, "read-only"); if (readonly) opts.push("readonly"); } diff --git a/test/verify/check-storage-luks b/test/verify/check-storage-luks index 664a15cacd38..e7fbaf3b1d87 100755 --- a/test/verify/check-storage-luks +++ b/test/verify/check-storage-luks @@ -166,6 +166,20 @@ class TestStorageLuks(storagelib.StorageCase): self.assert_in_configuration(dev, "crypttab", "options", "readonly") self.assert_in_configuration(cleartext_dev, "fstab", "opts", "ro") + # Check that the clear text device is actually readonly + self.assertEqual(m.execute(f"lsblk -no RO {cleartext_dev}").strip(), "1") + + # Change "read only" mount option. This should reopen LUKS as + # necessary and ask for a passphrase. + b.click(self.card_desc("ext4 filesystem", "Mount point") + " button") + self.dialog_wait_open() + self.dialog_set_val("mount_options.ro", val=False) + self.dialog_set_val("passphrase", "vainu-reku-toma-rolle-kaja") + self.dialog_apply() + self.dialog_wait_close() + + self.assertEqual(m.execute(f"lsblk -no RO {cleartext_dev}").strip(), "0") + # Delete the partition. self.click_card_dropdown("Partition", "Delete") self.confirm() @@ -186,6 +200,54 @@ class TestStorageLuks(storagelib.StorageCase): # FIXME: race condition after unmounting; hard to investigate, re-check after storage redesign self.allow_browser_errors("validateDOMNesting.*cannot appear as a child.* ul") + def testFormatReadOnly(self): + m = self.machine + b = self.browser + + self.login_and_go("/storage") + + disk = self.add_ram_disk(400) + self.click_card_row("Storage", name=disk) + + # Create a encrypted filesystem and mount it readonly. This + # needs to reopen the LUKS layer after formatting. + + self.click_card_dropdown("Unformatted data", "Format") + self.dialog({"type": "ext4", + "mount_point": "/run/foo", + "mount_options.ro": True, + "crypto": "luks1", + "passphrase": "vainu-reku-toma-rolle-kaja", + "passphrase2": "vainu-reku-toma-rolle-kaja"}) + b.wait_visible(self.card("ext4 filesystem")) + + uuid = m.execute(f"cryptsetup luksUUID {disk}").strip() + cleartext_dev = "/dev/mapper/luks-" + uuid + + self.assert_in_configuration(disk, "crypttab", "options", "readonly") + self.assert_in_configuration(cleartext_dev, "fstab", "opts", "ro") + self.assertEqual(m.execute(f"lsblk -no RO {cleartext_dev}").strip(), "1") + + # Reformat it, while keeping the encryption layer and keeping + # it read-only. This needs to reopen LUKS twice, once to make + # the cleartext device writable for formatting, and once to + # make it read-only again, as requested. + + self.click_card_dropdown("ext4 filesystem", "Format") + self.dialog_wait_open() + self.dialog_wait_val("mount_point", "/run/foo") + self.dialog_wait_val("type", "ext4") + self.dialog_wait_val("crypto", " keep") + self.dialog_wait_val("crypto_options", "") + b.wait_visible(self.dialog_field("mount_options.ro") + ":checked") + self.dialog_set_val("type", "xfs") + self.dialog_set_val("old_passphrase", "vainu-reku-toma-rolle-kaja") + self.dialog_apply() + self.dialog_wait_close() + b.wait_visible(self.card("xfs filesystem")) + + self.assertEqual(m.execute(f"lsblk -no RO {cleartext_dev}").strip(), "1") + def testNoFsys(self): m = self.machine b = self.browser @@ -223,12 +285,10 @@ class TestStorageLuks(storagelib.StorageCase): b.click(self.card_button("Locked data", "Unlock")) self.dialog({"passphrase": "vainu-reku-toma-rolle-kaja"}) - # Try to format it, just for kicks - self.click_card_dropdown("Unformatted data", "Format") - self.dialog({"type": "ext4", - "mount_point": "/run/foo"}) - b.wait_visible(self.card("ext4 filesystem")) - self.wait_mounted("ext4 filesystem") + # Should be unlocked readonly + uuid = m.execute(f"cryptsetup luksUUID {disk}").strip() + cleartext_dev = "/dev/mapper/luks-" + uuid + self.assertEqual(m.execute(f"lsblk -no RO {cleartext_dev}").strip(), "1") # Now create a empty, encrypted partition self.click_card_dropdown("Solid State Drive", "Create partition table") @@ -548,6 +608,24 @@ class TestStorageNBDE(storagelib.StorageCase, packagelib.PackageCase): self.dialog_wait_close() self.wait_mounted("ext4 filesystem") + # LUKS should be read/write + uuid = m.execute(f"cryptsetup luksUUID {dev}").strip() + cleartext_dev = "/dev/mapper/luks-" + uuid + self.assertEqual(m.execute(f"lsblk -no RO {cleartext_dev}").strip(), "0") + + # Remount "readonly", LUKS should be readonly + b.click(self.card_button("ext4 filesystem", "Unmount")) + self.confirm() + b.click(self.card_button("Filesystem", "Mount")) + self.dialog_wait_open() + self.dialog_set_val("mount_options.ro", val=True) + self.dialog_apply() + self.dialog_wait_close() + self.wait_mounted("ext4 filesystem") + + # Now LUKS should be readonly + self.assertEqual(m.execute(f"lsblk -no RO {cleartext_dev}").strip(), "1") + # Edit the key, without providing an existing passphrase # b.click(panel + "ul li:nth-child(2) [aria-label='Edit']")