cockpit/pkg/shell/hosts_dialog.jsx

1041 lines
46 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2021 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 <http://www.gnu.org/licenses/>.
*/
import cockpit from "cockpit";
import { get_init_superuser_for_options } from "./machines/machines";
import * as credentials from "credentials";
import ssh_show_default_key_sh from "raw-loader!./machines/ssh-show-default-key.sh";
import ssh_add_key_sh from "raw-loader!./machines/ssh-add-key.sh";
import React from 'react';
import PropTypes from 'prop-types';
import {
Alert,
Button,
Checkbox,
Form, FormGroup,
Modal,
Radio,
TextInput,
} from '@patternfly/react-core';
import { ModalError } from "cockpit-components-inline-notification.jsx";
const _ = cockpit.gettext;
export const codes = {
"no-cockpit": "not-supported",
"not-supported": "not-supported",
"protocol-error": "not-supported",
"authentication-not-supported": "change-auth",
"authentication-failed": "change-auth",
"no-forwarding": "change-auth",
"unknown-hostkey": "unknown-hostkey",
"invalid-hostkey": "invalid-hostkey",
"not-found": "add-machine",
"unknown-host": "unknown-host"
};
function full_address(machines_ins, address) {
const machine = machines_ins.lookup(address);
if (machine && machine.address !== "localhost")
return machine.connection_string;
return address;
}
function is_method_supported(methods, method) {
const result = methods[method];
return result ? result !== "no-server-support" : false;
}
class NotSupported extends React.Component {
render() {
return (
<Modal id="hosts_setup_server_dialog" isOpen
position="top" variant="medium"
onClose={this.props.onClose}
title={_("Cockpit is not installed")}
footer={<>
{ this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
<Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
{ _("Close") }
</Button>
</>}
>
<p>{cockpit.format(_("A compatible version of Cockpit is not installed on $0."), this.props.full_address)}</p>
</Modal>
);
}
}
class AddMachine extends React.Component {
constructor(props) {
super(props);
let address_parts = null;
if (this.props.full_address)
address_parts = this.props.machines_ins.split_connection_string(this.props.full_address);
let host_address = "";
let host_user = "";
if (address_parts) {
host_address = address_parts.address;
if (address_parts.port)
host_address += ":" + address_parts.port;
host_user = address_parts.user;
}
let color = props.machines_ins.unused_color();
let old_machine = null;
if (props.old_address)
old_machine = props.machines_ins.lookup(props.old_address);
if (old_machine)
color = this.rgb2Hex(old_machine.color);
this.state = {
user: host_user || "",
address: host_address || "",
color: color,
addressError: "",
inProgress: false,
old_machine: old_machine,
userChanged: false,
};
this.onAddressChange = this.onAddressChange.bind(this);
this.onAddHost = this.onAddHost.bind(this);
}
rgb2Hex(c) {
function toHex(d) {
return ("0" + (parseInt(d, 10).toString(16)))
.slice(-2);
}
if (c[0] === "#")
return c;
const colors = /rgb\((\d*), (\d*), (\d*)\)/.exec(c);
return "#" + toHex(colors[1]) + toHex(colors[2]) + toHex(colors[3]);
}
onAddressChange() {
let error = "";
if (this.state.address.search(/\s+/) !== -1)
error = _("The IP address or hostname cannot contain whitespace.");
else {
const machine = this.props.machines_ins.lookup(this.state.address);
if (machine && machine.on_disk && machine.address != this.props.old_address) {
if (machine.visible)
error = _("This machine has already been added.");
else if (!this.state.userChanged)
this.setState({ user: machine.user, color: this.rgb2Hex(machine.color) });
} else if (this.state.old_machine && !machine && !this.state.userChanged) { // When editing host by changing its address generate new color
this.setState({ color: this.props.machines_ins.unused_color(), userChanged: true });
}
}
this.setState({ addressError: error });
return error;
}
onAddHost() {
this.props.setAddress(this.state.address);
if (this.onAddressChange())
return;
let address = this.state.address;
if (this.state.user) {
const parts = this.props.machines_ins.split_connection_string(this.state.address);
address = this.props.machines_ins.generate_connection_string(this.state.user, parts.port, parts.address);
this.props.setAddress(address);
}
if (this.state.old_machine && address === this.state.old_machine.connection_string) {
this.props.setError(null);
this.setState({ inProgress: true });
this.props.run(this.props.machines_ins.change(this.state.old_machine.key, { color: this.state.color }))
.catch(ex => {
this.setState({ inProgress: false });
throw ex;
});
return;
}
this.props.setError(null);
this.setState({ inProgress: true });
this.props.setGoal(() => {
let address = this.state.address;
if (this.state.user) {
const parts = this.props.machines_ins.split_connection_string(this.state.address);
address = this.props.machines_ins.generate_connection_string(this.state.user, parts.port, parts.address);
}
return new Promise((resolve, reject) => {
this.props.machines_ins.add(address, this.state.color)
.then(() => {
// When changing address of machine, hide the old one
if (this.state.old_machine && this.state.old_machine != this.props.machines_ins.lookup(address)) {
this.props.machines_ins.change(this.state.old_machine.key, { visible: false })
.then(resolve);
} else {
resolve();
}
})
.catch(ex => {
ex.message = cockpit.format(_("Failed to add machine: $0"), cockpit.message(ex));
this.setState({ dialogError: cockpit.message(ex), inProgress: false });
reject(ex);
});
});
});
this.props.run(this.props.try2Connect(address), ex => {
if (ex.problem === "no-host") {
let host_id_port = address;
let port = "22";
const port_index = host_id_port.lastIndexOf(":");
if (port_index === -1)
host_id_port = address + ":22";
else
port = host_id_port.substr(port_index + 1);
ex.message = cockpit.format(_("Unable to contact the given host $0. Make sure it has ssh running on port $1, or specify another port in the address."), host_id_port, port);
ex.problem = "not-found";
}
this.setState({ inProgress: false });
this.props.setError(ex);
});
}
render() {
const invisible = this.props.machines_ins.addresses.filter(addr => {
const m = this.props.machines_ins.lookup(addr);
return !m || !m.visible;
});
const callback = this.onAddHost;
const title = this.state.old_machine && !this.state.old_machine.visible ? _("Edit host") : _("Add new host");
const submitText = this.state.old_machine && !this.state.old_machine.visible ? _("Set") : _("Add");
const body = <Form isHorizontal>
<FormGroup label={_("Host")} helperText={_("Can be a hostname, IP address, alias name, or ssh:// URI")}
validated={this.state.addressError ? "error" : "default"} helperTextInvalid={this.state.addressError}>
<TextInput id="add-machine-address" onChange={address => this.setState({ address: address })}
validated={this.state.addressError ? "error" : "default"} onBlur={this.onAddressChange}
isDisabled={this.props.old_address === "localhost"} list="options" value={this.state.address} />
<datalist id="options">
{invisible.map(a => <option key={a} value={a} />)}
</datalist>
</FormGroup>
<FormGroup label={_("User name")} helperText={_("When empty, connect with the current user")}>
<TextInput id="add-machine-user" onChange={value => this.setState({ user: value, userChanged: true })}
isDisabled={this.props.old_address === "localhost"} value={this.state.user} />
</FormGroup>
<FormGroup label={_("Color")}>
<input type="color" value={this.state.color} onChange={e => this.setState({ color: e.target.value }) } />
</FormGroup>
</Form>;
return (
<Modal id="hosts_setup_server_dialog" isOpen
position="top" variant="medium"
onClose={this.props.onClose}
title={title}
footer={<>
{ this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
<Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
isDisabled={this.state.address === "" || this.state.addressError !== "" || this.state.inProgress}>
{ submitText }
</Button>
<Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
{ _("Cancel") }
</Button>
</>}
>
{body}
</Modal>
);
}
}
class MachinePort extends React.Component {
constructor(props) {
super(props);
const machine = props.machines_ins.lookup(props.full_address);
if (!machine) {
props.onClose();
return;
}
this.state = {
port: machine.port,
};
this.onChangePort = this.onChangePort.bind(this);
}
onChangePort() {
const promise = new Promise((resolve, reject) => {
const parts = this.props.machines_ins.split_connection_string(this.props.full_address);
parts.port = this.state.port;
const address = this.props.machines_ins.generate_connection_string(parts.user,
parts.port,
parts.address);
const self = this;
function update_host(ex) {
self.props.setAddress(address);
self.props.machines_ins.change(parts.address, { port: parts.port })
.then(() => {
// We failed before so try to connect again now that the machine is saved
if (ex) {
self.props.try2Connect(address)
.then(self.props.complete)
.catch(reject);
} else {
resolve();
}
})
.catch(ex => reject(cockpit.format(_("Failed to edit machine: $0"), cockpit.message(ex))));
}
this.props.try2Connect(address)
.then(update_host)
.catch(ex => {
// any other error means progress, so save
if (ex.problem !== 'no-host')
update_host(ex);
else
reject(ex);
});
});
this.props.run(promise);
}
render() {
const callback = this.onChangePort;
const title = cockpit.format(_("Could not contact $0"), this.props.full_address);
const submitText = _("Update");
const body = <>
<p>
<span>{cockpit.format(_("Unable to contact $0."), this.props.full_address)}</span>
<span>{_("Is sshd running on a different port?")}</span>
</p>
<Form isHorizontal>
<FormGroup label={_("Port")}>
<TextInput id="edit-machine-port" onChange={value => this.setState({ port: value })} />
</FormGroup>
</Form>
</>;
return (
<Modal id="hosts_setup_server_dialog" isOpen
position="top" variant="medium"
onClose={this.props.onClose}
title={title}
footer={<>
{ this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
<Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
isDisabled={this.state.inProgress}>
{ submitText }
</Button>
<Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
{ _("Cancel") }
</Button>
</>}
>
{body}
</Modal>
);
}
}
class HostKey extends React.Component {
constructor(props) {
super(props);
this.state = {
inProgress: false,
error_options: props.error_options,
};
this.onAddKey = this.onAddKey.bind(this);
}
componentDidMount() {
if (!this.props.error_options || !this.props.error_options["host-key"]) {
const options = {};
let match_problem = this.props.template;
if (this.props.template == "unknown-host") {
options.session = "private";
match_problem = "unknown-hostkey";
}
this.props.try2Connect(this.props.full_address, options)
.then(this.props.complete)
.catch(ex => {
if (ex.problem !== match_problem) {
this.props.setError(ex);
} else {
this.setState({ error_options: ex });
}
});
}
}
onAddKey() {
this.setState({ inProgress: true });
const key = this.state.error_options["host-key"];
const machine = this.props.machines_ins.lookup(this.props.full_address);
let q;
if (!machine || machine.on_disk) {
q = this.props.machines_ins.add_key(key);
} else {
// When machine isn't saved to disk don't save the key either
q = this.props.machines_ins.change(this.props.full_address, { host_key: key });
}
this.props.run(q.then(() => {
return this.props.try2Connect(this.props.full_address, {})
.catch(ex => {
if ((ex.problem == "invalid-hostkey" || ex.problem == "unknown-hostkey") && machine && !machine.on_disk)
this.props.machines_ins.change(this.props.full_address, { host_key: null });
else {
this.setState({ inProgress: false });
throw ex;
}
});
}));
}
render() {
let key_type = "";
let fp = "";
if (this.state.error_options && this.state.error_options["host-key"]) {
key_type = this.state.error_options["host-key"].split(" ")[1];
fp = this.state.error_options["host-fingerprint"];
}
const callback = this.onAddKey;
const title = this.props.template === "invalid-hostkey" ? cockpit.format(_("$0 key changed"), this.props.host) : _("New host");
const submitText = _("Accept key and connect");
let unknown = false;
let body = null;
if (!key_type) {
unknown = true;
} else if (this.props.template === "invalid-hostkey") {
body = <>
<Alert variant='danger' isInline title={_("Changed keys are often the result of an operating system reinstallation. However, an unexpected change may indicate a third-party attempt to intercept your connection.")} />
<p>{_("To ensure that your connection is not intercepted by a malicious third-party, please verify the host key fingerprint:")}</p>
<pre className="hostkey-fingerprint">{fp}</pre>
<p className="hostkey-type">({key_type})</p>
<p>{cockpit.format(_("To verify a fingerprint, run the following on $0 while physically sitting at the machine or through a trusted network:"), this.props.host)}</p>
<pre className="hostkey-verify-help-cmds">ssh-keyscan -t {key_type} localhost | ssh-keygen -lf -</pre>
<p>{_("The resulting fingerprint is fine to share via public methods, including email.")}</p>
<p>{_("If the fingerprint matches, click 'Accept key and connect'. Otherwise, do not connect and contact your administrator.")}</p>
</>;
} else {
body = <>
<p>{cockpit.format(_("You are connecting to $0 for the first time."), this.props.host)}</p>
<p>{_("To ensure that your connection is not intercepted by a malicious third-party, please verify the host key fingerprint:")}</p>
<pre className="hostkey-fingerprint">{fp}</pre>
<p className="hostkey-type">({key_type})</p>
<p>{cockpit.format(_("To verify a fingerprint, run the following on $0 while physically sitting at the machine or through a trusted network:"), this.props.host)}</p>
<pre className="hostkey-verify-help-cmds">ssh-keyscan -t {key_type} localhost | ssh-keygen -lf -</pre>
<p>{_("The resulting fingerprint is fine to share via public methods, including email.")}</p>
<p>{_("If the fingerprint matches, click 'Accept key and connect'. Otherwise, do not connect and contact your administrator.")}</p>
</>;
}
return (
<Modal id="hosts_setup_server_dialog" isOpen
position="top" variant="medium"
onClose={this.props.onClose}
title={title}
footer={<>
{ this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
{ unknown ||
<Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
isDisabled={this.state.inProgress}>
{ submitText }
</Button>
}
<Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
{ _("Cancel") }
</Button>
</>}
>
{body}
</Modal>
);
}
}
class ChangeAuth extends React.Component {
constructor(props) {
super(props);
this.state = {
auth: "password",
auto_login: false,
custom_password: "",
custom_password_error: "",
locked_identity_password: "",
locked_identity_password_error: "",
login_setup_new_key_password: "",
login_setup_new_key_password_error: "",
login_setup_new_key_password2: "",
login_setup_new_key_password2_error: "",
user: "",
default_ssh_key: null,
identity_path: null,
inProgress: true, // componentDidMount changes to false once loaded
};
this.keys = null;
if (credentials)
this.keys = credentials.keys_instance();
this.getSupports = this.getSupports.bind(this);
this.updateIdentity = this.updateIdentity.bind(this);
this.login = this.login.bind(this);
this.maybe_create_key = this.maybe_create_key.bind(this);
this.authorize_key = this.authorize_key.bind(this);
this.maybe_unlock_key = this.maybe_unlock_key.bind(this);
}
updateIdentity() {
let identity_path = null;
if (this.props.error_options && this.props.error_options.error && this.props.error_options.error.startsWith("locked identity"))
identity_path = this.props.error_options.error.split(": ")[1];
const default_ssh_key = this.state.default_ssh_key;
if (default_ssh_key && default_ssh_key.encrypted)
default_ssh_key.unaligned_passphrase = identity_path && identity_path === default_ssh_key.name;
this.setState({ identity_path: identity_path, default_ssh_key: default_ssh_key });
}
componentDidMount() {
cockpit.user()
.then(user =>
cockpit.script(ssh_show_default_key_sh, [], { })
.then(data => {
let default_ssh_key = null;
const info = data.split("\n");
if (info[0])
default_ssh_key = { name: info[0], exists: true, encrypted: info[1] === "encrypted" };
else
default_ssh_key = { name: user.home + "/.ssh/id_rsa", type: "rsa", exists: false };
return this.setState({ inProgress: false, default_ssh_key: default_ssh_key, user: user }, this.updateIdentity);
})
)
.catch(ex => { this.setState({ inProgress: false }); this.props.setError(ex) });
if (!this.props.error_options || this.props.error_options["auth-method-results"] === null) {
this.props.try2Connect(this.props.full_address)
.then(this.props.complete)
.catch(ex => {
this.setState({ inProgress: false });
this.props.setError(ex);
});
}
}
componentWillUnmount() {
if (this.keys)
this.keys.close();
this.keys = null;
}
componentDidUpdate(prevProps) {
if (prevProps.error_options !== this.props.error_options)
this.updateIdentity();
}
getSupports() {
let methods = null;
let available = null;
let offer_login_password = false;
let offer_key_password = false;
if (this.props.error_options) {
available = {};
methods = this.props.error_options["auth-method-results"];
if (methods) {
for (const method in methods) {
if (is_method_supported(methods, method)) {
available[method] = true;
}
}
}
offer_login_password = !!available.password;
offer_key_password = this.state.identity_path !== null;
} else {
offer_login_password = true;
offer_key_password = false;
}
return {
offer_login_password: offer_login_password,
offer_key_password: offer_key_password,
};
}
maybe_create_key(passphrase) {
if (!this.state.default_ssh_key.exists)
return this.keys.create(this.state.default_ssh_key.name, this.state.default_ssh_key.type, passphrase, passphrase);
else
return Promise.resolve();
}
authorize_key(host) {
return this.keys.get_pubkey(this.state.default_ssh_key.name)
.then(data => cockpit.script(ssh_add_key_sh, [data.trim()], { host: host, err: "message" }));
}
maybe_unlock_key() {
const { offer_login_password, offer_key_password } = this.getSupports();
const both = offer_login_password && offer_key_password;
if ((both && this.state.auth === "key") || (!both && offer_key_password))
return this.keys.load(this.state.identity_path, this.state.locked_identity_password);
else
return Promise.resolve();
}
login() {
const options = {};
const user = this.props.machines_ins.split_connection_string(this.props.full_address).user || "";
const do_key_password_change = this.state.auto_login && this.state.default_ssh_key.unaligned_passphrase;
let custom_password_error = "";
let locked_identity_password_error = "";
let login_setup_new_key_password_error = "";
let login_setup_new_key_password2_error = "";
const { offer_login_password, offer_key_password } = this.getSupports();
const both = offer_login_password && offer_key_password;
if ((both && this.state.auth === "password") || (!both && offer_login_password)) {
if (!this.state.custom_password)
custom_password_error = _("The password can not be empty");
options.password = this.state.custom_password;
options.session = 'shared';
if (!user) {
/* we don't want to save the default user for everyone
* so we pass current user as an option, but make sure the
* session isn't private
*/
if (this.state.user && this.state.user.name)
options.user = this.state.user.name;
options["temp-session"] = false; /* Compatibility option */
}
}
if ((offer_key_password && !(both && this.state.auth === "password")) && !this.state.locked_identity_password)
locked_identity_password_error = _("The key password can not be empty");
if (this.state.auto_login && !do_key_password_change && this.state.login_setup_new_key_password !== this.state.login_setup_new_key_password2)
login_setup_new_key_password2_error = _("The key passwords do not match");
if (do_key_password_change && !this.state.login_setup_new_key_password)
login_setup_new_key_password_error = _("The new key password can not be empty");
if (do_key_password_change && this.state.login_setup_new_key_password !== this.state.login_setup_new_key_password2)
login_setup_new_key_password2_error = _("The key passwords do not match");
if (custom_password_error || locked_identity_password_error || login_setup_new_key_password_error || login_setup_new_key_password2_error) {
this.setState({
custom_password_error: custom_password_error,
locked_identity_password_error: locked_identity_password_error,
login_setup_new_key_password_error: login_setup_new_key_password_error,
login_setup_new_key_password2_error: login_setup_new_key_password2_error,
});
return;
}
this.setState({ inProgress: true });
const machine = this.props.machines_ins.lookup(this.props.full_address);
this.props.run(this.maybe_unlock_key()
.then(() => {
return this.props.try2Connect(this.props.full_address, options)
.then(() => {
if (machine)
return this.props.machines_ins.change(machine.address, { user: user });
else
return Promise.resolve();
})
.then(() => {
if (do_key_password_change)
return this.keys.change(this.state.default_ssh_key.name, this.state.locked_identity_password, this.state.login_setup_new_key_password, this.state.login_setup_new_key_password);
else if (this.state.auto_login)
return this.maybe_create_key(this.state.login_setup_new_key_password)
.then(() => this.authorize_key(this.props.full_address));
else
return Promise.resolve();
});
})
.catch(ex => {
this.setState({ inProgress: false });
throw ex;
}));
}
render() {
const { offer_login_password, offer_key_password } = this.getSupports();
const both = offer_login_password && offer_key_password;
let offer_key_setup = true;
let show_password_advice = true;
if (!this.state.default_ssh_key)
offer_key_setup = false;
else if (this.state.default_ssh_key.unaligned_passphrase)
offer_key_setup = (both && this.state.auth === "key") || (!both && offer_key_password);
else if (this.state.identity_path) {
// This is a locked, non-default identity that will never
// be loaded into the agent, so there is no point in
// offering to change the passphrase.
show_password_advice = false;
offer_key_setup = false;
}
const callback = this.login;
const title = cockpit.format(_("Log in to $0"), this.props.full_address);
const submitText = _("Log in");
let statement = "";
if (!offer_login_password && !offer_key_password)
statement = <p>{cockpit.format(_("Unable to log in to $0. The host does not accept password login or any of your SSH keys."), this.props.full_address)}</p>;
else if (offer_login_password && !offer_key_password)
statement = <p>{cockpit.format(_("Unable to log in to $0 using SSH key authentication. Please provide the password. You may want to set up your SSH keys for automatic login."), this.props.full_address)}</p>;
else if (offer_key_password && !offer_login_password)
statement = <>
<p>{cockpit.format(_("The SSH key for logging in to $0 is protected by a password, and the host does not allow logging in with a password. Please provide the password of the key at $1."), this.props.full_address, this.state.identity_path)}</p>
{show_password_advice && <span className="password-change-advice">{_("You may want to change the password of the key for automatic login.")}</span>}
</>;
else if (both)
statement = <>
<p>{cockpit.format(_("The SSH key for logging in to $0 is protected. You can log in with either your login password or by providing the password of the key at $1."), this.props.full_address, this.state.identity_path)}</p>
{show_password_advice && <span className="password-change-advice">{_("You may want to change the password of the key for automatic login.")}</span>}
</>;
let auto_text = null;
let auto_details = null;
if (this.state.default_ssh_key) {
const lmach = this.props.machines_ins.lookup(null);
const key = this.state.default_ssh_key.name;
const luser = this.state.user.name;
const lhost = lmach ? lmach.label || lmach.address : "localhost";
const afile = "~/.ssh/authorized_keys";
const ruser = this.props.machines_ins.split_connection_string(this.props.full_address).user || this.state.user.name;
const rhost = this.props.machines_ins.split_connection_string(this.props.full_address).address;
if (!this.state.default_ssh_key.exists) {
auto_text = _("Create a new SSH key and authorize it");
auto_details = <>
<p>{cockpit.format(_("A new SSH key at $0 will be created for $1 on $2 and it will be added to the $3 file of $4 on $5."), key, luser, lhost, afile, ruser, rhost)}</p>
<FormGroup label={_("Key password")} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} helperTextInvalid={this.state.login_setup_new_key_password_error}>
<TextInput id="login-setup-new-key-password" onChange={value => this.setState({ login_setup_new_key_password: value })}
type="password" value={this.state.login_setup_new_key_password} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} />
</FormGroup>
<FormGroup label={_("Confirm key password")} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} helperTextInvalid={this.state.login_setup_new_key_password2_error}>
<TextInput id="login-setup-new-key-password2" onChange={value => this.setState({ login_setup_new_key_password2: value })}
type="password" value={this.state.login_setup_new_key_password2} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} />
</FormGroup>
<p>{cockpit.format(_("In order to allow log in to $0 as $1 without password in the future, use the login password of $2 on $3 as the key password, or leave the key password blank."), rhost, ruser, luser, lhost)}</p>
</>;
} else if (this.state.default_ssh_key.unaligned_passphrase) {
auto_text = cockpit.format(_("Change the password of $0"), key);
auto_details = <>
<p>{cockpit.format(_("By changing the password of the SSH key $0 to the login password of $1 on $2, the key will be automatically made available and you can log in to $3 without password in the future."), key, luser, lhost, afile, rhost)}</p>
<FormGroup label={_("New key password")} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} helperTextInvalid={this.state.login_setup_new_key_password_error}>
<TextInput id="login-setup-new-key-password" onChange={value => this.setState({ login_setup_new_key_password: value })}
type="password" value={this.state.login_setup_new_key_password} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} />
</FormGroup>
<FormGroup label={_("Confirm new key password")} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} helperTextInvalid={this.state.login_setup_new_key_password2_error}>
<TextInput id="login-setup-new-key-password2" onChange={value => this.setState({ login_setup_new_key_password2: value })}
type="password" value={this.state.login_setup_new_key_password2} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} />
</FormGroup>
<p>{cockpit.format(_("In order to allow log in to $0 as $1 without password in the future, use the login password of $2 on $3 as the key password, or leave the key password blank."), rhost, ruser, luser, lhost)}</p>
</>;
} else {
auto_text = _("Authorize SSH key");
auto_details = <>
<p>{cockpit.format(_("The SSH key $0 of $1 on $2 will be added to the $3 file of $4 on $5."), key, luser, lhost, afile, ruser, rhost)}</p>
<p>{_("This will allow you to log in without password in the future.")}</p>
</>;
}
}
const body = <>
{statement}
<br />
{(offer_login_password || offer_key_password) &&
<Form isHorizontal>
{both &&
<FormGroup label={_("Authentication")} isInline hasNoPaddingTop>
<Radio isChecked={this.state.auth === "password"}
onChange={() => this.setState({ auth: "password" })}
id="auth-password"
value="password"
label={_("Password")} />
<Radio isChecked={this.state.auth === "key"}
onChange={() => this.setState({ auth: "key" })}
id="auth-key"
value="key"
label={_("SSH key")} />
</FormGroup>
}
{((both && this.state.auth === "password") || (!both && offer_login_password)) &&
<FormGroup label={_("Password")} validated={this.state.custom_password_error ? "error" : "default"} helperTextInvalid={this.state.custom_password_error}>
<TextInput id="login-custom-password" onChange={value => this.setState({ custom_password: value })}
type="password" value={this.state.custom_password} validated={this.state.custom_password_error ? "error" : "default"} />
</FormGroup>
}
{((both && this.state.auth === "key") || (!both && offer_key_password)) &&
<FormGroup label={_("Key password")} helperText={cockpit.format(_("The SSH key $0 will be made available for the remainder of the session and will be available for login to other hosts as well."), this.state.identity_path)} validated={this.state.locked_identity_password_error ? "error" : "default"} helperTextInvalid={this.state.locked_identity_password_error}>
<TextInput id="locked-identity-password" onChange={value => this.setState({ locked_identity_password: value })}
type="password" autoComplete="new-password" value={this.state.locked_identity_password} validated={this.state.locked_identity_password_error ? "error" : "default"} />
</FormGroup>
}
{offer_key_setup &&
<FormGroup label={ _("Automatic login") } hasNoPaddingTop isInline>
<Checkbox onChange={checked => this.setState({ auto_login: checked })}
isChecked={this.state.auto_login} id="login-setup-keys"
label={auto_text} body={this.state.auto_login ? auto_details : null} />
</FormGroup>
}
</Form>
}
</>;
return (
<Modal id="hosts_setup_server_dialog" isOpen
position="top" variant="medium"
onClose={this.props.onClose}
title={title}
footer={<>
{ this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
<Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
isDisabled={this.state.inProgress || (!offer_login_password && !offer_key_password) || !this.state.default_ssh_key || !this.props.error_options}>
{ submitText }
</Button>
<Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
{ _("Cancel") }
</Button>
</>}
>
{body}
</Modal>
);
}
}
export class HostModal extends React.Component {
constructor(props) {
super(props);
this.state = {
current_template: this.props.template || "add-machine",
address: full_address(props.machines_ins, props.address),
old_address: full_address(props.machines_ins, props.address),
error_options: null,
dialogError: "", // Error to be shown in the modal
};
this.promise_callback = null;
this.addressOrLabel = this.addressOrLabel.bind(this);
this.changeContent = this.changeContent.bind(this);
this.try2Connect = this.try2Connect.bind(this);
this.setGoal = this.setGoal.bind(this);
this.setError = this.setError.bind(this);
this.setAddress = this.setAddress.bind(this);
this.run = this.run.bind(this);
this.complete = this.complete.bind(this);
}
addressOrLabel() {
const machine = this.props.machines_ins.lookup(this.state.address);
let host = this.props.machines_ins.split_connection_string(this.state.address).address;
if (machine && machine.label)
host = machine.label;
return host;
}
changeContent(template, error_options) {
if (this.state.current_template !== template)
this.setState({ current_template: template, error_options: error_options });
}
try2Connect(address, options) {
return new Promise((resolve, reject) => {
const conn_options = { ...options, payload: "echo", host: address };
conn_options["init-superuser"] = get_init_superuser_for_options(conn_options);
const machine = this.props.machines_ins.lookup(address);
if (machine && machine.host_key && !machine.on_disk) {
conn_options['temp-session'] = false; // Compatibility option
conn_options.session = 'shared';
conn_options['host-key'] = machine.host_key;
}
const client = cockpit.channel(conn_options);
client.send("x");
client.addEventListener("message", () => {
resolve();
client.close();
});
client.addEventListener("close", (event, options) => {
reject(options);
});
});
}
complete() {
if (this.promise_callback)
this.promise_callback().then(this.props.onClose);
else
this.props.onClose();
}
setGoal(callback) {
this.promise_callback = callback;
}
setError(error) {
if (error === null)
return this.setState({ dialogError: null });
let template = null;
if (error.problem && error.command === "close")
template = codes[error.problem];
if (template && this.state.current_template !== template)
this.changeContent(template, error);
else
this.setState({ error_options: error, dialogError: cockpit.message(error) });
}
setAddress(address) {
this.setState({ address: address });
}
run(promise, failure_callback) {
return new Promise((resolve, reject) => {
const promise_funcs = [];
const self = this;
function next(i) {
promise_funcs[i]()
.then(val => {
i = i + 1;
if (i < promise_funcs.length) {
next(i);
} else {
resolve();
self.props.onClose();
}
})
.catch(ex => {
if (failure_callback)
failure_callback(ex);
else
self.setError(ex);
});
}
promise_funcs.push(() => { return promise });
if (this.promise_callback)
promise_funcs.push(this.promise_callback);
if (this.props.caller_callback)
promise_funcs.push(() => this.props.caller_callback(this.state.address));
next(0);
});
}
render() {
const template = this.state.current_template;
const props = {
template: template,
host: this.addressOrLabel(),
full_address: this.state.address,
old_address: this.state.old_address,
address_data: this.props.machines_ins.split_connection_string(this.state.address),
error_options: this.state.error_options,
dialogError: this.state.dialogError,
machines_ins: this.props.machines_ins,
onClose: this.props.onClose,
run: this.run,
setGoal: this.setGoal,
setError: this.setError,
setAddress: this.setAddress,
try2Connect: this.try2Connect,
complete: this.complete,
};
if (template === "add-machine")
return <AddMachine {...props} />;
else if (template === "unknown-hostkey" || template === "unknown-host" || template === "invalid-hostkey")
return <HostKey {...props} />;
else if (template === "change-auth")
return <ChangeAuth {...props} />;
else if (template === "change-port")
return <MachinePort {...props} />;
else if (template === "not-supported")
return <NotSupported {...props} />;
console.error("Unknown template:", template);
return null;
}
}
HostModal.propTypes = {
machines_ins: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
caller_callback: PropTypes.func,
address: PropTypes.string,
template: PropTypes.string,
};