* Copyright (C) 2023 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import client from "../client.js";
import React from "react";
import { FormHelperText } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import { HelperText, HelperTextItem, } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
import { ExclamationTriangleIcon, InfoCircleIcon } from "@patternfly/react-icons";
import {
encode_filename,
parse_options, unparse_options, extract_option, reload_systemd,
set_crypto_options, is_mounted_synch,
get_active_usage, teardown_active_usage,
} from "../utils.js";
import {
dialog_open,
TextInput, PassInput, CheckBoxes, SelectOne,
TeardownMessage,
init_teardown_usage
} from "../dialog.jsx";
import { init_existing_passphrase, unlock_with_type } from "../crypto/keyslots.jsx";
import { initial_tab_options } from "../block/format-dialog.jsx";
import {
is_mounted, get_fstab_config,
is_valid_mount_point
} from "./utils.jsx";
const _ = cockpit.gettext;
export const mount_options = (opt_ro, extra_options, is_visible) => {
return CheckBoxes("mount_options", _("Mount options"),
{
visible: vals => !client.in_anaconda_mode() && (!is_visible || is_visible(vals)),
value: {
ro: opt_ro,
extra: extra_options || false
},
fields: [
{
title: _("Mount read only"),
tag: "ro",
},
{ title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" },
]
});
};
export const mount_explanation = {
local:
<FormHelperText>
<HelperText>
<HelperTextItem>
{_("Mounts before services start")}
</HelperTextItem>
<HelperTextItem>
{_("Appropriate for critical mounts, such as /var")}
</HelperTextItem>
<HelperTextItem icon={<ExclamationTriangleIcon className="ct-icon-exclamation-triangle" />}>
{_("Boot fails if filesystem does not mount, preventing remote access")}
</HelperTextItem>
</HelperText>
</FormHelperText>,
nofail:
<FormHelperText>
<HelperText>
<HelperTextItem>
{_("Mounts in parallel with services")}
</HelperTextItem>
<HelperTextItem icon={<InfoCircleIcon className="ct-icon-info-circle" />}>
{_("Boot still succeeds when filesystem does not mount")}
</HelperTextItem>
</HelperText>
</FormHelperText>,
netdev:
<FormHelperText>
<HelperText>
<HelperTextItem>
{_("Mounts in parallel with services, but after network is available")}
</HelperTextItem>
<HelperTextItem icon={<InfoCircleIcon className="ct-icon-info-circle" />}>
{_("Boot still succeeds when filesystem does not mount")}
</HelperTextItem>
</HelperText>
</FormHelperText>,
never:
<FormHelperText>
<HelperText>
<HelperTextItem>
{_("Does not mount during boot")}
</HelperTextItem>
<HelperTextItem>
{_("Useful for mounts that are optional or need interaction (such as passphrases)")}
</HelperTextItem>
</HelperText>
</FormHelperText>,
};
export const at_boot_input = (at_boot, is_visible) => {
const init = at_boot || (client.in_anaconda_mode() ? "local" : "nofail");
return SelectOne("at_boot", _("At boot"),
{
visible: vals => !client.in_anaconda_mode() && (!is_visible || is_visible(vals)),
value: init,
explanation: mount_explanation[init],
choices: [
{
value: "local",
title: _("Mount before services start"),
},
{
value: "nofail",
title: _("Mount without waiting, ignore failure"),
},
{
value: "netdev",
title: _("Mount after network becomes available, ignore failure"),
},
{
value: "never",
title: _("Do not mount"),
},
]
});
};
export function update_at_boot_input(dlg, vals, trigger) {
if (trigger === "at_boot")
dlg.set_options("at_boot", { explanation: mount_explanation[vals.at_boot] });
}
export function mounting_dialog(client, block, mode, forced_options, subvol) {
const block_fsys = client.blocks_fsys[block.path];
const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true, subvol);
const options = old_config ? old_opts : initial_tab_options(client, block, true);
const old_dir_for_display = client.strip_mount_point_prefix(old_dir);
if (old_dir_for_display === false)
return Promise.reject(_("This device can not be used for the installation target."));
const split_options = parse_options(options);
extract_option(split_options, "noauto");
const opt_never_auto = extract_option(split_options, "x-cockpit-never-auto");
const opt_ro = extract_option(split_options, "ro");
const opt_nofail = extract_option(split_options, "nofail");
const opt_netdev = extract_option(split_options, "_netdev");
if (forced_options)
for (const opt of forced_options)
extract_option(split_options, opt);
const extra_options = unparse_options(split_options);
const is_filesystem_mounted = is_mounted(client, block, subvol);
function maybe_update_config(new_dir, new_opts, passphrase, passphrase_type, crypto_unlock_readonly) {
let new_config = null;
let all_new_opts;
if (new_opts && old_parents)
all_new_opts = new_opts + "," + old_parents;
else if (new_opts)
all_new_opts = new_opts;
else
all_new_opts = old_parents;
if (new_dir !== "") {
if (new_dir[0] !== "/")
new_dir = "/" + new_dir;
new_config = [
"fstab", {
fsname: old_config ? old_config[1].fsname : undefined,
dir: { t: 'ay', v: encode_filename(new_dir) },
type: { t: 'ay', v: encode_filename("auto") },
opts: { t: 'ay', v: encode_filename(all_new_opts || "defaults") },
freq: { t: 'i', v: 0 },
passno: { t: 'i', v: 0 },
"track-parents": { t: 'b', v: !old_config }
}];
}
function undo() {
if (!old_config && new_config)
return block.RemoveConfigurationItem(new_config, {});
else if (old_config && !new_config)
return block.AddConfigurationItem(old_config, {});
else if (old_config && new_config && (new_dir !== old_dir || new_opts !== old_opts)) {
return block.UpdateConfigurationItem(new_config, old_config, {});
}
}
function get_block_fsys() {
if (block_fsys)
return Promise.resolve(block_fsys);
else
return client.wait_for(() => (client.blocks_cleartext[block.path] &&
client.blocks_fsys[client.blocks_cleartext[block.path].path]));
}
function maybe_mount() {
if (mode === "mount" || (mode === "update" && is_filesystem_mounted)) {
return (get_block_fsys()
.then(block_fsys => {
const block = client.blocks[block_fsys.path];
return (client.mount_at(block, new_dir)
.catch(error => {
return (is_mounted_synch(block)
.then(mounted_at => {
if (mounted_at === new_dir)
return;
return (undo()
.then(() => {
if (is_filesystem_mounted)
return client.mount_at(block, old_dir);
})
.catch(ignored_error => {
console.warn("Error during undo:", ignored_error);
})
.then(() => 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) {
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() {
if (mode === "unmount" && !subvol && !client.in_anaconda_mode()) {
const crypto_backing = client.blocks[block.CryptoBackingDevice];
const crypto_backing_crypto = crypto_backing && client.blocks_crypto[crypto_backing.path];
if (crypto_backing_crypto) {
return crypto_backing_crypto.Lock({});
} else
return Promise.resolve();
}
}
return (reload_systemd()
.then(() => teardown_active_usage(client, usage))
.then(maybe_unlock)
.then(content_block => {
if (!old_config && new_config)
return (content_block.AddConfigurationItem(new_config, {})
.then(maybe_mount));
else if (old_config && !new_config)
return content_block.RemoveConfigurationItem(old_config, {});
else if (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();
})
.then(maybe_lock)
.then(reload_systemd));
}
let at_boot;
if (opt_never_auto)
at_boot = "never";
else if (opt_netdev)
at_boot = "netdev";
else if (opt_nofail)
at_boot = "nofail";
else
at_boot = "local";
let fields = null;
if (mode === "mount" || mode === "update") {
fields = [
TextInput("mount_point", _("Mount point"),
{
value: old_dir_for_display,
validate: val => is_valid_mount_point(client,
block,
client.add_mount_point_prefix(val),
mode === "update" && !is_filesystem_mounted,
mode === "update",
subvol)
}),
mount_options(opt_ro, extra_options, null),
at_boot_input(at_boot),
];
fields = fields.concat([
PassInput("passphrase", _("Passphrase"),
{
visible: vals => vals.needs_explicit_passphrase,
validate: val => !val.length && _("Passphrase cannot be empty"),
})
]);
}
const mode_title = {
mount: _("Mount filesystem"),
unmount: _("Unmount filesystem $0"),
update: _("Mount configuration")
};
const mode_action = {
mount: _("Mount"),
unmount: _("Unmount"),
update: _("Save")
};
function do_unmount() {
let opts = [];
opts.push("noauto");
if (opt_ro)
opts.push("ro");
if (opt_never_auto)
opts.push("x-cockpit-never-auto");
if (opt_nofail)
opts.push("nofail");
if (opt_netdev)
opts.push("_netdev");
if (forced_options)
opts = opts.concat(forced_options);
if (extra_options)
opts = opts.concat(extra_options);
return (maybe_set_crypto_options(null, false, null, null)
.then(() => maybe_update_config(old_dir, unparse_options(opts))));
}
let passphrase_type;
function maybe_set_crypto_options(readonly, auto, nofail, netdev) {
if (client.blocks_crypto[block.path]) {
return set_crypto_options(block, readonly, auto, nofail, netdev);
} else if (client.blocks_crypto[block.CryptoBackingDevice]) {
return set_crypto_options(client.blocks[block.CryptoBackingDevice], readonly, auto, nofail, netdev);
} else
return Promise.resolve();
}
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) {
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 || true),
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,
action: function (vals) {
if (mode === "unmount") {
return do_unmount();
} else if (mode === "mount" || mode === "update") {
let opts = [];
if ((mode === "update" && !is_filesystem_mounted) || vals.at_boot === "never")
opts.push("noauto");
if (vals.mount_options?.ro)
opts.push("ro");
if (vals.at_boot === "never")
opts.push("x-cockpit-never-auto");
if (vals.at_boot === "nofail")
opts.push("nofail");
if (vals.at_boot === "netdev")
opts.push("_netdev");
if (forced_options)
opts = opts.concat(forced_options);
if (vals.mount_options?.extra)
opts = opts.concat(parse_options(vals.mount_options.extra));
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,
crypto_unlock_readonly)
.then(() => maybe_set_crypto_options(vals.mount_options?.ro,
opts.indexOf("noauto") === -1,
vals.at_boot === "nofail",
vals.at_boot === "netdev")));
}
}
},
Inits: [
init_teardown_usage(client, usage, old_dir || true),
init_existing_passphrase(block, true, type => {
passphrase_type = type;
update_explicit_passphrase(dlg.get_value("mount_options")?.ro ?? opt_ro);
}),
]
});
}