Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 341 : import cockpit from "cockpit";
7 :
8 : import {
9 : get_init_superuser_for_options, split_connection_string, generate_connection_string
10 : } from "./machines/machines";
11 : import * as credentials from "credentials";
12 : import ssh_show_default_key_sh from "../lib/ssh-show-default-key.sh";
13 : import ssh_add_key_sh from "../lib/ssh-add-key.sh";
14 :
15 341 : import React from 'react';
16 341 : import PropTypes from 'prop-types';
17 :
18 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
19 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
20 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
21 : import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js";
22 : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
23 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
24 : import {
25 : Modal, ModalBody, ModalFooter, ModalHeader
26 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
27 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
28 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
29 : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
30 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
31 : import { OutlinedQuestionCircleIcon, ExternalLinkAltIcon } from "@patternfly/react-icons";
32 : import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
33 : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content";
34 :
35 : import { FormHelper } from "cockpit-components-form-helper";
36 : import { ModalError } from "cockpit-components-inline-notification.jsx";
37 : import { fmt_to_fragments } from "utils.js";
38 :
39 341 : const _ = cockpit.gettext;
40 :
41 341 : export const HostModalState = () => {
42 13 : function set_props(props, callback) {
43 13 : self.modal_properties = props;
44 13 : self.modal_callback = callback;
45 13 : self.dispatchEvent("changed");
46 13 : }
47 :
48 12 : function close_modal() {
49 12 : set_props(null, null);
50 12 : }
51 :
52 13 : function show_modal(properties) {
53 13 : return new Promise((resolve, reject) => {
54 12 : set_props(properties, result => { resolve(result); return Promise.resolve() });
55 13 : });
56 13 : }
57 :
58 341 : const self = {
59 341 : state: null,
60 :
61 341 : show_modal,
62 341 : close_modal,
63 341 : };
64 :
65 341 : return cockpit.event_target(self);
66 341 : };
67 :
68 : /* Activate and jump to a newly added machine, identified by its
69 : connection string.
70 : */
71 5 : function jump_to_new_connection_string(shell_state, connection_string) {
72 : // Get the address of the machine from its connection string
73 5 : const addr = split_connection_string(connection_string).address;
74 : // Tell the Loader that now is the time to start monitoring the
75 : // manifests.
76 5 : shell_state.loader.connect(addr);
77 : // Navigate to it.
78 5 : shell_state.jump({ host: addr });
79 5 : }
80 :
81 5 : export async function add_host(state, shell_state) {
82 5 : const connection_string = await state.show_modal({ });
83 5 : if (connection_string)
84 5 : jump_to_new_connection_string(shell_state, connection_string);
85 5 : }
86 :
87 2 : export async function edit_host(state, shell_state, machine) {
88 2 : const { current_machine } = shell_state;
89 2 : const connection_string = await state.show_modal({ address: machine.address });
90 2 : if (connection_string) {
91 2 : const addr = split_connection_string(connection_string).address;
92 2 : if (machine == current_machine && addr != machine.address)
93 1 : jump_to_new_connection_string(shell_state, connection_string);
94 2 : }
95 2 : }
96 :
97 11 : export async function connect_host(state, shell_state, machine) {
98 : // We need to trigger the loader for machines that already
99 : // have state "connected". The state of a machine object
100 : // survives a full shell reload, but the loader of course has
101 : // no channel open for it yet. The bridge likely has the SSH
102 : // connection still open, so the loader can do its job right
103 : // away, like triggering the packages reload.
104 : //
105 : // "localhost" is a special case: we can always connect the
106 : // loader without any extra credentials and we never want to
107 : // show any dialogs for it.
108 : //
109 11 : if (machine.connection_string == "localhost" ||
110 10 : machine.state == "connected" ||
111 6 : machine.state == "connecting") {
112 8 : shell_state.loader.connect(machine.address);
113 8 : return machine.connection_string;
114 8 : }
115 :
116 9 : let connection_string = null;
117 :
118 8 : if (machine.problem && codes[machine.problem]) {
119 : // trouble shooting
120 8 : connection_string = await state.show_modal({
121 8 : address: machine.address,
122 8 : template: codes[machine.problem],
123 7 : });
124 3 : } else if (!window.sessionStorage.getItem("connection-warning-shown")) {
125 : // connect by launching into the "Connection warning" dialog.
126 4 : connection_string = await state.show_modal({
127 4 : address: machine.address,
128 4 : template: "connect"
129 4 : });
130 3 : } else {
131 : // Try to connect without any dialog
132 4 : try {
133 4 : await try2Connect(shell_state.machines, machine.connection_string);
134 4 : connection_string = machine.connection_string;
135 3 : } catch (err) {
136 : // continue with troubleshooting in the dialog
137 3 : connection_string = await state.show_modal({
138 3 : address: machine.address,
139 3 : template: codes[err.problem] || "change-port",
140 3 : error_options: err,
141 3 : });
142 3 : }
143 4 : }
144 :
145 8 : if (connection_string) {
146 : // make the rest of the shell aware that the machine is now connected
147 8 : const parts = split_connection_string(connection_string);
148 8 : shell_state.loader.connect(parts.address);
149 8 : shell_state.update();
150 8 : }
151 :
152 8 : return connection_string;
153 11 : }
154 :
155 341 : export const codes = {
156 341 : danger: "connect",
157 341 : "no-cockpit": "not-supported",
158 341 : "not-supported": "not-supported",
159 341 : "protocol-error": "not-supported",
160 341 : "authentication-not-supported": "change-auth",
161 341 : "authentication-failed": "change-auth",
162 341 : "no-forwarding": "change-auth",
163 341 : "unknown-hostkey": "unknown-hostkey",
164 341 : "invalid-hostkey": "invalid-hostkey",
165 341 : "not-found": "add-machine",
166 341 : "unknown-host": "unknown-host"
167 341 : };
168 :
169 13 : function full_address(machines_ins, address) {
170 13 : const machine = machines_ins.lookup(address);
171 8 : if (machine && machine.address !== "localhost")
172 6 : return machine.connection_string;
173 :
174 12 : return address;
175 13 : }
176 :
177 7 : function is_method_supported(methods, method) {
178 7 : const result = methods[method];
179 2 : return result ? result !== "no-server-support" : false;
180 7 : }
181 :
182 12 : function prevent_default(callback) {
183 0 : return event => {
184 0 : callback();
185 0 : event.preventDefault();
186 0 : return false;
187 0 : };
188 12 : }
189 :
190 341 : class NotSupported extends React.Component {
191 0 : render() {
192 0 : return (
193 0 : <Modal id="hosts_setup_server_dialog" isOpen
194 0 : position="top" variant="medium"
195 0 : onClose={this.props.onClose}
196 : >
197 0 : <ModalHeader title={_("Cockpit is not installed")} />
198 0 : <ModalBody>
199 0 : <Stack hasGutter>
200 0 : { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
201 0 : <p>{cockpit.format(_("A compatible version of Cockpit is not installed on $0."), this.props.full_address)}</p>
202 0 : </Stack>
203 0 : </ModalBody>
204 0 : <ModalFooter>
205 0 : <Button variant="secondary" className="btn-cancel" onClick={this.props.onClose}>
206 0 : { _("Close") }
207 0 : </Button>
208 0 : </ModalFooter>
209 0 : </Modal>
210 : );
211 0 : }
212 341 : }
213 :
214 341 : class Connect extends React.Component {
215 11 : constructor(props) {
216 11 : super(props);
217 :
218 11 : this.state = {
219 11 : inProgress: false,
220 11 : };
221 11 : }
222 :
223 11 : onConnect() {
224 11 : window.sessionStorage.setItem("connection-warning-shown", true);
225 11 : this.setState({ inProgress: true });
226 9 : this.props.run(try2Connect(this.props.machines_ins, this.props.full_address), ex => {
227 9 : let keep_message = false;
228 0 : if (ex.problem === "no-host") {
229 0 : let host_id_port = this.props.full_address;
230 0 : let port = "22";
231 0 : const port_index = host_id_port.lastIndexOf(":");
232 0 : if (port_index === -1) {
233 0 : host_id_port = this.props.full_address + ":22";
234 0 : } else {
235 0 : port = host_id_port.substring(port_index + 1);
236 0 : }
237 :
238 0 : 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);
239 0 : ex.problem = "not-found";
240 0 : keep_message = true;
241 0 : }
242 9 : this.setState({ inProgress: false });
243 9 : this.props.setError(ex, keep_message);
244 9 : });
245 11 : }
246 :
247 11 : render() {
248 11 : return (
249 11 : <Modal id="hosts_connect_server_dialog" isOpen
250 11 : position="top" variant="small"
251 11 : onClose={this.props.onClose}
252 : >
253 11 : <ModalHeader title={fmt_to_fragments(_("Connect to $0?"), <b className="ct-heading-font-weight">{this.props.host}</b>)}
254 11 : titleIconVariant="warning"
255 11 : />
256 11 : <ModalBody>
257 11 : <Content component={ContentVariants.p}>
258 11 : {_("Connected hosts can fully control each other. This includes running programs that could harm your system or steal data. Only connect to trusted machines.")}
259 11 : </Content>
260 11 : <Content component={ContentVariants.p}>
261 11 : <a href="https://cockpit-project.org/guide/latest/multi-host.html" target="blank" rel="noopener noreferrer">
262 11 : <ExternalLinkAltIcon /> {_("Read more")}
263 11 : </a>
264 11 : </Content>
265 11 : </ModalBody>
266 11 : <ModalFooter>
267 11 : <HelperText>
268 11 : <HelperTextItem>{_("You will be reminded once per session.")}</HelperTextItem>
269 11 : </HelperText>
270 11 : <Button variant="warning" isLoading={this.state.inProgress}
271 11 : onClick={() => this.onConnect()}>
272 11 : {_("Connect")}
273 11 : </Button>
274 11 : <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
275 11 : { _("Cancel") }
276 11 : </Button>
277 11 : </ModalFooter>
278 11 : </Modal>
279 : );
280 11 : }
281 341 : }
282 :
283 341 : class AddMachine extends React.Component {
284 12 : constructor(props) {
285 12 : super(props);
286 :
287 12 : let address_parts = null;
288 12 : if (this.props.full_address)
289 9 : address_parts = split_connection_string(this.props.full_address);
290 :
291 12 : let host_address = "";
292 12 : let host_user = "";
293 9 : if (address_parts) {
294 9 : host_address = address_parts.address;
295 9 : if (address_parts.port)
296 3 : host_address += ":" + address_parts.port;
297 9 : host_user = address_parts.user;
298 9 : }
299 :
300 12 : let color = props.machines_ins.unused_color();
301 12 : let old_machine = null;
302 12 : if (props.old_address)
303 9 : old_machine = props.machines_ins.lookup(props.old_address);
304 12 : if (old_machine)
305 4 : color = this.rgb2Hex(old_machine.color);
306 4 : if (old_machine && !old_machine.visible)
307 2 : old_machine = null;
308 :
309 12 : this.state = {
310 12 : user: host_user || "",
311 6 : address: host_address || "",
312 12 : color,
313 12 : addressError: "",
314 12 : inProgress: false,
315 12 : old_machine,
316 12 : userChanged: false,
317 12 : };
318 :
319 12 : this.onAddressChange = this.onAddressChange.bind(this);
320 12 : this.onAddHost = this.onAddHost.bind(this);
321 12 : }
322 :
323 3 : rgb2Hex(c) {
324 0 : function toHex(d) {
325 0 : return ("0" + (parseInt(d, 10).toString(16)))
326 0 : .slice(-2);
327 0 : }
328 :
329 3 : if (c[0] === "#")
330 3 : return c;
331 :
332 0 : const colors = /rgb\((\d*), (\d*), (\d*)\)/.exec(c);
333 0 : return "#" + toHex(colors[1]) + toHex(colors[2]) + toHex(colors[3]);
334 3 : }
335 :
336 11 : onAddressChange() {
337 11 : let error = "";
338 11 : if (this.state.address.search(/\s+/) !== -1)
339 1 : error = _("The IP address or hostname cannot contain whitespace.");
340 11 : else {
341 11 : const machine = this.props.machines_ins.lookup(this.state.address);
342 3 : const machine_address = machine ? full_address(this.props.machines_ins, machine.address) : undefined;
343 2 : if (machine && machine.on_disk && machine_address != this.props.old_address) {
344 2 : if (machine.visible)
345 1 : error = _("This machine has already been added.");
346 2 : else if (!this.state.userChanged)
347 2 : this.setState({ user: machine.user, color: this.rgb2Hex(machine.color) });
348 1 : } else if (this.state.old_machine && !machine && !this.state.userChanged) { // When editing host by changing its address generate new color
349 1 : this.setState((_, prevProps) => ({ color: prevProps.machines_ins.unused_color(), userChanged: true }));
350 2 : }
351 11 : }
352 :
353 11 : this.setState({ addressError: error });
354 :
355 11 : return error;
356 11 : }
357 :
358 11 : onAddHost() {
359 11 : const parts = split_connection_string(this.state.address);
360 : // user in "User name:" field wins over user in connection string
361 10 : const address = generate_connection_string(this.state.user || parts.user, parts.port, parts.address);
362 :
363 11 : if (this.onAddressChange())
364 11 : return;
365 :
366 11 : this.props.setAddress(address);
367 :
368 1 : if (this.state.old_machine && address === this.state.old_machine.connection_string) {
369 1 : this.props.setError(null);
370 1 : this.setState({ inProgress: true });
371 1 : this.props.run(this.props.machines_ins.change(this.state.old_machine.key, { color: this.state.color }))
372 0 : .catch(ex => {
373 0 : this.setState({ inProgress: false });
374 0 : throw ex;
375 0 : });
376 1 : return;
377 1 : }
378 :
379 11 : this.props.setError(null);
380 11 : this.setState({ inProgress: true });
381 :
382 10 : this.props.setGoal(() => {
383 10 : const parts = split_connection_string(this.state.address);
384 : // user in "User name:" field wins over user in connection string
385 9 : const address = generate_connection_string(this.state.user || parts.user, parts.port, parts.address);
386 :
387 10 : return new Promise((resolve, reject) => {
388 10 : this.props.machines_ins.add(address, this.state.color)
389 10 : .then(() => {
390 : // When changing address of machine, hide the old one
391 2 : if (this.state.old_machine && this.state.old_machine != this.props.machines_ins.lookup(address)) {
392 2 : this.props.machines_ins.change(this.state.old_machine.key, { visible: false })
393 2 : .then(resolve);
394 2 : } else {
395 10 : resolve();
396 10 : }
397 10 : })
398 0 : .catch(ex => {
399 0 : ex.message = cockpit.format(_("Failed to add machine: $0"), cockpit.message(ex));
400 0 : this.setState({ dialogError: cockpit.message(ex), inProgress: false });
401 0 : reject(ex);
402 0 : });
403 10 : });
404 10 : });
405 :
406 10 : if (!window.sessionStorage.getItem("connection-warning-shown")) {
407 10 : this.props.setError({ problem: "danger", command: "close" });
408 5 : } else {
409 5 : this.props.run(try2Connect(this.props.machines_ins, address), ex => {
410 1 : if (ex.problem === "no-host") {
411 1 : let host_id_port = address;
412 1 : let port = "22";
413 1 : const port_index = host_id_port.lastIndexOf(":");
414 1 : if (port_index === -1) {
415 1 : host_id_port = address + ":22";
416 1 : } else {
417 1 : port = host_id_port.substring(port_index + 1);
418 1 : }
419 :
420 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);
421 1 : ex.problem = "not-found";
422 1 : }
423 5 : this.setState({ inProgress: false });
424 5 : this.props.setError(ex);
425 5 : });
426 6 : }
427 11 : }
428 :
429 12 : render() {
430 12 : const invisible = this.props.machines_ins.addresses.filter(addr => {
431 12 : const m = this.props.machines_ins.lookup(addr);
432 12 : return !m || !m.visible;
433 12 : });
434 :
435 12 : const callback = this.onAddHost;
436 3 : const title = this.state.old_machine ? _("Edit host") : _("Add new host");
437 3 : const submitText = this.state.old_machine ? _("Set") : _("Add");
438 :
439 12 : const body = <Form isHorizontal onSubmit={prevent_default(callback)}>
440 12 : <FormGroup label={_("Host")}>
441 5 : <TextInput id="add-machine-address" onChange={(_event, address) => this.setState({ address })}
442 2 : validated={this.state.addressError ? "error" : "default"} onBlur={this.onAddressChange}
443 12 : isDisabled={this.props.old_address === "localhost"} list="options" value={this.state.address} />
444 12 : <datalist id="options">
445 2 : {invisible.map(a => <option key={a} value={a} />)}
446 12 : </datalist>
447 12 : <FormHelper helperTextInvalid={this.state.addressError} helperText={_("Can be a hostname, IP address, alias name, or ssh:// URI")} />
448 12 : </FormGroup>
449 12 : <FormGroup label={_("User name")}>
450 3 : <TextInput id="add-machine-user" onChange={(_event, value) => this.setState({ user: value, userChanged: true })}
451 12 : isDisabled={this.props.old_address === "localhost"} value={this.state.user} />
452 12 : <FormHelper helperText={_("When empty, connect with the current user")} />
453 12 : </FormGroup>
454 12 : <FormGroup label={_("Color")}>
455 1 : <input type="color" value={this.state.color} onChange={(e) => this.setState({ color: e.target.value }) } />
456 12 : </FormGroup>
457 12 : </Form>;
458 :
459 12 : return (
460 12 : <Modal id="hosts_setup_server_dialog" isOpen
461 12 : position="top" variant="medium"
462 12 : onClose={this.props.onClose}
463 : >
464 12 : <ModalHeader title={title} />
465 12 : <ModalBody>
466 12 : <Stack hasGutter>
467 2 : { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
468 12 : {body}
469 12 : </Stack>
470 12 : </ModalBody>
471 12 : <ModalFooter>
472 12 : <Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
473 12 : isDisabled={this.state.address === "" || this.state.addressError !== "" || this.state.inProgress}>
474 12 : { submitText }
475 12 : </Button>
476 12 : <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
477 12 : { _("Cancel") }
478 12 : </Button>
479 12 : </ModalFooter>
480 12 : </Modal>
481 : );
482 12 : }
483 341 : }
484 :
485 341 : class MachinePort extends React.Component {
486 0 : constructor(props) {
487 0 : super(props);
488 :
489 0 : const machine = props.machines_ins.lookup(props.full_address);
490 0 : if (!machine) {
491 0 : props.onClose();
492 0 : return;
493 0 : }
494 :
495 0 : this.state = {
496 0 : port: machine.port,
497 0 : };
498 :
499 0 : this.onChangePort = this.onChangePort.bind(this);
500 0 : }
501 :
502 0 : onChangePort() {
503 0 : const promise = new Promise((resolve, reject) => {
504 0 : const parts = split_connection_string(this.props.full_address);
505 0 : parts.port = this.state.port;
506 0 : const address = generate_connection_string(parts.user, parts.port, parts.address);
507 0 : const self = this;
508 :
509 0 : function update_host(ex) {
510 0 : self.props.setAddress(address);
511 0 : self.props.machines_ins.change(parts.address, { port: parts.port })
512 0 : .then(() => {
513 : // We failed before so try to connect again now that the machine is saved
514 0 : if (ex) {
515 0 : try2Connect(self.props.machines_ins, address)
516 0 : .then(self.props.complete)
517 0 : .catch(reject);
518 0 : } else {
519 0 : resolve();
520 0 : }
521 0 : })
522 0 : .catch(ex => reject(cockpit.format(_("Failed to edit machine: $0"), cockpit.message(ex))));
523 0 : }
524 :
525 0 : try2Connect(this.props.machines_ins, address)
526 0 : .then(update_host)
527 0 : .catch(ex => {
528 : // any other error means progress, so save
529 0 : if (ex.problem !== 'no-host')
530 0 : update_host(ex);
531 : else
532 0 : reject(ex);
533 0 : });
534 0 : });
535 :
536 0 : this.props.run(promise);
537 0 : }
538 :
539 0 : render() {
540 0 : const callback = this.onChangePort;
541 0 : const title = cockpit.format(_("Could not contact $0"), this.props.full_address);
542 0 : const submitText = _("Update");
543 :
544 0 : const body = <>
545 0 : <p>
546 0 : <span>{cockpit.format(_("Unable to contact $0."), this.props.full_address)}</span>
547 0 : <span>{_("Is sshd running on a different port?")}</span>
548 0 : </p>
549 0 : <Form isHorizontal onSubmit={prevent_default(callback)}>
550 0 : <FormGroup label={_("Port")}>
551 0 : <TextInput id="edit-machine-port" onChange={(_event, value) => this.setState({ port: value })} />
552 0 : </FormGroup>
553 0 : </Form>
554 0 : </>;
555 :
556 0 : return (
557 0 : <Modal id="hosts_setup_server_dialog" isOpen
558 0 : position="top" variant="medium"
559 0 : onClose={this.props.onClose}
560 : >
561 0 : <ModalHeader title={title} />
562 0 : <ModalBody>
563 0 : <Stack hasGutter>
564 0 : { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
565 0 : {body}
566 0 : </Stack>
567 0 : </ModalBody>
568 0 : <ModalFooter>
569 0 : <Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
570 0 : isDisabled={this.state.inProgress}>
571 0 : { submitText }
572 0 : </Button>
573 0 : <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
574 0 : { _("Cancel") }
575 0 : </Button>
576 0 : </ModalFooter>
577 0 : </Modal>
578 : );
579 0 : }
580 341 : }
581 :
582 341 : class HostKey extends React.Component {
583 9 : constructor(props) {
584 9 : super(props);
585 :
586 9 : this.state = {
587 9 : inProgress: false,
588 9 : verifyExpanded: false,
589 9 : error_options: props.error_options,
590 9 : };
591 :
592 9 : this.onAddKey = this.onAddKey.bind(this);
593 9 : }
594 :
595 9 : componentDidMount() {
596 9 : if (!this.props.error_options || !this.props.error_options["host-key"]) {
597 9 : const options = {};
598 9 : let match_problem = this.props.template;
599 9 : if (this.props.template == "unknown-host") {
600 9 : options.session = "private";
601 9 : match_problem = "unknown-hostkey";
602 9 : }
603 :
604 9 : try2Connect(this.props.machines_ins, this.props.full_address, options)
605 9 : .then(this.props.complete)
606 9 : .catch(ex => {
607 0 : if (ex.problem !== match_problem) {
608 0 : this.props.setError(ex);
609 0 : } else {
610 9 : this.setState({ error_options: ex });
611 9 : }
612 9 : });
613 9 : }
614 9 : }
615 :
616 9 : onAddKey() {
617 9 : this.setState({ inProgress: true });
618 :
619 9 : const key = this.state.error_options["host-key"];
620 9 : const machine = this.props.machines_ins.lookup(this.props.full_address);
621 9 : let q;
622 0 : if (!machine || machine.on_disk) {
623 9 : q = this.props.machines_ins.add_key(key);
624 0 : } else {
625 : // When machine isn't saved to disk don't save the key either
626 0 : q = this.props.machines_ins.change(this.props.full_address, { host_key: key });
627 0 : }
628 :
629 9 : this.props.run(q.then(() => {
630 9 : return try2Connect(this.props.machines_ins, this.props.full_address, {})
631 5 : .catch(ex => {
632 0 : if ((ex.problem == "invalid-hostkey" || ex.problem == "unknown-hostkey") && machine && !machine.on_disk)
633 0 : this.props.machines_ins.change(this.props.full_address, { host_key: null });
634 5 : else {
635 5 : this.setState({ inProgress: false });
636 5 : throw ex;
637 5 : }
638 5 : });
639 9 : }));
640 9 : }
641 :
642 9 : render() {
643 9 : let key_type = "";
644 9 : let fp = "";
645 9 : if (this.state.error_options && this.state.error_options["host-key"]) {
646 9 : key_type = this.state.error_options["host-key"].split(" ")[1];
647 9 : fp = this.state.error_options["host-fingerprint"];
648 9 : }
649 :
650 9 : const scan_cmd = `ssh-keyscan -t ${key_type} localhost | ssh-keygen -lf -`;
651 :
652 9 : const callback = this.onAddKey;
653 0 : const title = cockpit.format(this.props.template === "invalid-hostkey" ? _("$0 key changed") : _("New host: $0"),
654 9 : this.props.host);
655 9 : const submitText = _("Trust and add host");
656 9 : let unknown = false;
657 9 : let body = null;
658 9 : if (!key_type) {
659 9 : unknown = true;
660 0 : } else if (this.props.template === "invalid-hostkey") {
661 0 : body = <>
662 0 : <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.")} />
663 0 : <p>{_("To ensure that your connection is not intercepted by a malicious third-party, please verify the host key fingerprint:")}</p>
664 0 : <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-fingerprint pf-v6-u-font-family-monospace">{fp}</ClipboardCopy>
665 0 : <p className="hostkey-type">({key_type})</p>
666 0 : <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>
667 0 : <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help-cmds pf-v6-u-font-family-monospace">{scan_cmd}</ClipboardCopy>
668 0 : <p>{_("The resulting fingerprint is fine to share via public methods, including email.")}</p>
669 0 : <p>{_("If the fingerprint matches, click 'Trust and add host'. Otherwise, do not connect and contact your administrator.")}</p>
670 0 : </>;
671 0 : } else {
672 9 : const fingerprint_help = <Popover bodyContent={
673 9 : _("The resulting fingerprint is fine to share via public methods, including email. If you are asking someone else to do the verification for you, they can send the results using any method.")}>
674 9 : <OutlinedQuestionCircleIcon />
675 9 : </Popover>;
676 9 : body = <>
677 9 : <p>{cockpit.format(_("You are connecting to $0 for the first time."), this.props.host)}</p>
678 9 : <ExpandableSection toggleText={ _("Verify fingerprint") }
679 9 : isExpanded={this.state.verifyExpanded}
680 0 : onToggle={(_ev, verifyExpanded) => this.setState({ verifyExpanded }) }>
681 9 : <div>{_("Run this command over a trusted network or physically on the remote machine:")}</div>
682 9 : <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help hostkey-verify-help-cmds pf-v6-u-font-family-monospace">{scan_cmd}</ClipboardCopy>
683 9 : <div>{_("The fingerprint should match:")} {fingerprint_help}</div>
684 9 : <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help hostkey-fingerprint pf-v6-u-font-family-monospace">{fp}</ClipboardCopy>
685 9 : </ExpandableSection>
686 9 : <Alert variant='warning' isInline isPlain title={_("Malicious pages on a remote machine may affect other connected hosts")} />
687 9 : </>;
688 9 : }
689 :
690 9 : return (
691 9 : <Modal id="hosts_setup_server_dialog" isOpen
692 9 : position="top" variant="medium"
693 9 : onClose={this.props.onClose}
694 : >
695 9 : <ModalHeader title={title} />
696 9 : <ModalBody>
697 9 : <Stack hasGutter>
698 0 : { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
699 9 : {body}
700 9 : </Stack>
701 9 : </ModalBody>
702 9 : <ModalFooter>
703 9 : { unknown ||
704 9 : <Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
705 9 : isDisabled={this.state.inProgress}>
706 9 : { submitText }
707 9 : </Button>
708 : }
709 9 : <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
710 9 : { _("Cancel") }
711 9 : </Button>
712 9 : </ModalFooter>
713 9 : </Modal>
714 : );
715 9 : }
716 341 : }
717 :
718 341 : class ChangeAuth extends React.Component {
719 7 : constructor(props) {
720 7 : super(props);
721 :
722 7 : this.state = {
723 7 : auth: "password",
724 7 : auto_login: false,
725 7 : custom_password: "",
726 7 : custom_password_error: "",
727 7 : locked_identity_password: "",
728 7 : locked_identity_password_error: "",
729 7 : login_setup_new_key_password: "",
730 7 : login_setup_new_key_password_error: "",
731 7 : login_setup_new_key_password2: "",
732 7 : login_setup_new_key_password2_error: "",
733 7 : user: "",
734 7 : default_ssh_key: null,
735 7 : identity_path: null,
736 7 : inProgress: true, // componentDidMount changes to false once loaded
737 7 : };
738 :
739 7 : this.keys = null;
740 7 : if (credentials)
741 7 : this.keys = credentials.keys_instance();
742 :
743 7 : this.getSupports = this.getSupports.bind(this);
744 7 : this.updateIdentity = this.updateIdentity.bind(this);
745 7 : this.login = this.login.bind(this);
746 7 : this.maybe_create_key = this.maybe_create_key.bind(this);
747 7 : this.authorize_key = this.authorize_key.bind(this);
748 7 : this.maybe_unlock_key = this.maybe_unlock_key.bind(this);
749 7 : }
750 :
751 7 : updateIdentity() {
752 7 : let identity_path = null;
753 7 : if (this.props.error_options && this.props.error_options.error && this.props.error_options.error.startsWith("locked identity"))
754 5 : identity_path = this.props.error_options.error.split(": ")[1];
755 :
756 7 : const default_ssh_key = this.state.default_ssh_key;
757 7 : if (default_ssh_key && default_ssh_key.encrypted)
758 4 : default_ssh_key.unaligned_passphrase = identity_path && identity_path === default_ssh_key.name;
759 :
760 7 : this.setState({ identity_path, default_ssh_key });
761 7 : }
762 :
763 7 : componentDidMount() {
764 7 : cockpit.user()
765 7 : .then(user =>
766 7 : cockpit.script(ssh_show_default_key_sh, [], { })
767 7 : .then(data => {
768 7 : let default_ssh_key = null;
769 7 : const info = data.split("\n");
770 7 : if (info[0])
771 2 : default_ssh_key = { name: info[0], exists: true, encrypted: info[1] === "encrypted" };
772 : else
773 5 : default_ssh_key = { name: user.home + "/.ssh/id_rsa", type: "rsa", exists: false };
774 :
775 7 : return this.setState({ inProgress: false, default_ssh_key, user }, this.updateIdentity);
776 7 : })
777 7 : )
778 0 : .catch(ex => { this.setState({ inProgress: false }); this.props.setError(ex) });
779 :
780 2 : if (!this.props.error_options || this.props.error_options["auth-method-results"] === null) {
781 2 : try2Connect(this.props.machines_ins, this.props.full_address)
782 2 : .then(this.props.complete)
783 0 : .catch(ex => {
784 0 : this.setState({ inProgress: false });
785 0 : this.props.setError(ex);
786 0 : });
787 2 : }
788 7 : }
789 :
790 6 : componentWillUnmount() {
791 6 : if (this.keys)
792 6 : this.keys.close();
793 6 : this.keys = null;
794 6 : }
795 :
796 7 : componentDidUpdate(prevProps) {
797 7 : if (prevProps.error_options !== this.props.error_options)
798 2 : this.updateIdentity();
799 7 : }
800 :
801 7 : getSupports() {
802 7 : let methods = null;
803 7 : let available = null;
804 :
805 7 : let offer_login_password = false;
806 7 : let offer_key_password = false;
807 :
808 7 : if (this.props.error_options) {
809 7 : available = {};
810 :
811 7 : methods = this.props.error_options["auth-method-results"];
812 7 : if (methods) {
813 7 : for (const method in methods) {
814 7 : if (is_method_supported(methods, method)) {
815 7 : available[method] = true;
816 7 : }
817 7 : }
818 7 : }
819 :
820 7 : offer_login_password = !!available.password;
821 7 : offer_key_password = this.state.identity_path !== null;
822 2 : } else {
823 2 : offer_login_password = true;
824 2 : offer_key_password = false;
825 2 : }
826 :
827 7 : return {
828 7 : offer_login_password,
829 7 : offer_key_password,
830 7 : };
831 7 : }
832 :
833 0 : maybe_create_key(passphrase) {
834 0 : if (!this.state.default_ssh_key.exists)
835 0 : return this.keys.create(this.state.default_ssh_key.name, this.state.default_ssh_key.type, passphrase);
836 : else
837 0 : return Promise.resolve();
838 0 : }
839 :
840 0 : authorize_key(host) {
841 0 : return this.keys.get_pubkey(this.state.default_ssh_key.name)
842 0 : .then(data => cockpit.script(ssh_add_key_sh, [data.trim()], { host, err: "message" }));
843 0 : }
844 :
845 6 : maybe_unlock_key() {
846 6 : const { offer_login_password, offer_key_password } = this.getSupports();
847 4 : const both = offer_login_password && offer_key_password;
848 :
849 2 : if ((both && this.state.auth === "key") || (!both && offer_key_password))
850 2 : return this.keys.load(this.state.identity_path, this.state.locked_identity_password);
851 : else
852 4 : return Promise.resolve();
853 6 : }
854 :
855 6 : login() {
856 6 : const options = {};
857 6 : const user = split_connection_string(this.props.full_address).user || "";
858 2 : const do_key_password_change = this.state.auto_login && this.state.default_ssh_key.unaligned_passphrase;
859 :
860 6 : let custom_password_error = "";
861 6 : let locked_identity_password_error = "";
862 6 : let login_setup_new_key_password_error = "";
863 6 : let login_setup_new_key_password2_error = "";
864 :
865 6 : const { offer_login_password, offer_key_password } = this.getSupports();
866 4 : const both = offer_login_password && offer_key_password;
867 :
868 2 : if ((both && this.state.auth === "password") || (!both && offer_login_password)) {
869 4 : if (!this.state.custom_password)
870 2 : custom_password_error = _("The password can not be empty");
871 :
872 4 : options.password = this.state.custom_password;
873 4 : options.session = 'shared';
874 4 : if (!user) {
875 : /* we don't want to save the default user for everyone
876 : * so we pass current user as an option, but make sure the
877 : * session isn't private
878 : */
879 4 : if (this.state.user && this.state.user.name)
880 4 : options.user = this.state.user.name;
881 4 : options["temp-session"] = false; /* Compatibility option */
882 4 : }
883 4 : }
884 :
885 2 : if ((offer_key_password && !(both && this.state.auth === "password")) && !this.state.locked_identity_password)
886 2 : locked_identity_password_error = _("The key password can not be empty");
887 :
888 2 : if (this.state.auto_login && !do_key_password_change && this.state.login_setup_new_key_password !== this.state.login_setup_new_key_password2)
889 2 : login_setup_new_key_password2_error = _("The key passwords do not match");
890 :
891 2 : if (do_key_password_change && !this.state.login_setup_new_key_password)
892 2 : login_setup_new_key_password_error = _("The new key password can not be empty");
893 :
894 2 : if (do_key_password_change && this.state.login_setup_new_key_password !== this.state.login_setup_new_key_password2)
895 2 : login_setup_new_key_password2_error = _("The key passwords do not match");
896 :
897 2 : if (custom_password_error || locked_identity_password_error || login_setup_new_key_password_error || login_setup_new_key_password2_error) {
898 2 : this.setState({
899 2 : custom_password_error,
900 2 : locked_identity_password_error,
901 2 : login_setup_new_key_password_error,
902 2 : login_setup_new_key_password2_error,
903 2 : });
904 2 : return;
905 2 : }
906 :
907 6 : this.setState({ inProgress: true });
908 6 : const machine = this.props.machines_ins.lookup(this.props.full_address);
909 :
910 6 : this.props.run(this.maybe_unlock_key()
911 6 : .then(() => {
912 6 : return try2Connect(this.props.machines_ins, this.props.full_address, options)
913 6 : .then(() => {
914 6 : if (machine)
915 2 : return this.props.machines_ins.change(machine.address, { user });
916 : else
917 6 : return Promise.resolve();
918 6 : })
919 6 : .then(() => {
920 6 : if (do_key_password_change)
921 2 : return this.keys.change(this.state.default_ssh_key.name, this.state.locked_identity_password, this.state.login_setup_new_key_password);
922 6 : else if (this.state.auto_login)
923 2 : return this.maybe_create_key(this.state.login_setup_new_key_password)
924 0 : .then(() => this.authorize_key(this.props.full_address));
925 : else
926 6 : return Promise.resolve();
927 6 : });
928 6 : })
929 0 : .catch(ex => {
930 0 : this.setState({ inProgress: false });
931 0 : throw ex;
932 0 : }));
933 6 : }
934 :
935 7 : render() {
936 7 : const { offer_login_password, offer_key_password } = this.getSupports();
937 5 : const both = offer_login_password && offer_key_password;
938 :
939 7 : let offer_key_setup = true;
940 7 : let show_password_advice = true;
941 7 : if (!this.state.default_ssh_key)
942 7 : offer_key_setup = false;
943 7 : else if (this.state.default_ssh_key.unaligned_passphrase)
944 2 : offer_key_setup = (both && this.state.auth === "key") || (!both && offer_key_password);
945 3 : else if (this.state.identity_path) {
946 : // This is a locked, non-default identity that will never
947 : // be loaded into the agent, so there is no point in
948 : // offering to change the passphrase.
949 3 : show_password_advice = false;
950 3 : offer_key_setup = false;
951 3 : }
952 :
953 7 : const callback = this.login;
954 7 : const title = cockpit.format(_("Log in to $0"), this.props.full_address);
955 7 : const submitText = _("Log in");
956 7 : let statement = "";
957 :
958 4 : if (!offer_login_password && !offer_key_password)
959 4 : 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>;
960 5 : else if (offer_login_password && !offer_key_password)
961 3 : 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>;
962 5 : else if (offer_key_password && !offer_login_password)
963 4 : statement = <>
964 4 : <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>
965 4 : {show_password_advice && <span className="password-change-advice">{_("You may want to change the password of the key for automatic login.")}</span>}
966 2 : </>;
967 3 : else if (both)
968 3 : statement = <>
969 3 : <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>
970 2 : {show_password_advice && <span className="password-change-advice">{_("You may want to change the password of the key for automatic login.")}</span>}
971 3 : </>;
972 :
973 7 : let auto_text = null;
974 7 : let auto_details = null;
975 7 : if (this.state.default_ssh_key) {
976 7 : const lmach = this.props.machines_ins.lookup(null);
977 7 : const key = this.state.default_ssh_key.name;
978 7 : const luser = this.state.user.name;
979 2 : const lhost = lmach ? lmach.label || lmach.address : "localhost";
980 7 : const afile = "~/.ssh/authorized_keys";
981 7 : const ruser = split_connection_string(this.props.full_address).user || this.state.user.name;
982 7 : const rhost = split_connection_string(this.props.full_address).address;
983 5 : if (!this.state.default_ssh_key.exists) {
984 5 : auto_text = _("Create a new SSH key and authorize it");
985 5 : auto_details = <>
986 5 : <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>
987 5 : <FormGroup label={_("Key password")}>
988 0 : <TextInput id="login-setup-new-key-password" onChange={(_event, value) => this.setState({ login_setup_new_key_password: value })}
989 2 : type="password" value={this.state.login_setup_new_key_password} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} />
990 5 : <FormHelper helperTextInvalid={this.state.login_setup_new_key_password_error} />
991 5 : </FormGroup>
992 5 : <FormGroup label={_("Confirm key password")}>
993 0 : <TextInput id="login-setup-new-key-password2" onChange={(_event, value) => this.setState({ login_setup_new_key_password2: value })}
994 2 : type="password" value={this.state.login_setup_new_key_password2} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} />
995 5 : <FormHelper helperTextInvalid={this.state.login_setup_new_key_password2_error} />
996 5 : </FormGroup>
997 5 : <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>
998 5 : </>;
999 2 : } else if (this.state.default_ssh_key.unaligned_passphrase) {
1000 4 : auto_text = cockpit.format(_("Change the password of $0"), key);
1001 4 : auto_details = <>
1002 4 : <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>
1003 4 : <FormGroup label={_("New key password")}>
1004 0 : <TextInput id="login-setup-new-key-password" onChange={(_event, value) => this.setState({ login_setup_new_key_password: value })}
1005 2 : type="password" value={this.state.login_setup_new_key_password} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} />
1006 4 : <FormHelper helperTextInvalid={this.state.login_setup_new_key_password_error} />
1007 4 : </FormGroup>
1008 2 : <FormGroup label={_("Confirm new key password")} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"}>
1009 0 : <TextInput id="login-setup-new-key-password2" onChange={(_event, value) => this.setState({ login_setup_new_key_password2: value })}
1010 2 : type="password" value={this.state.login_setup_new_key_password2} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} />
1011 :
1012 4 : <FormHelper helperTextInvalid={this.state.login_setup_new_key_password2_error} />
1013 4 : </FormGroup>
1014 4 : <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>
1015 4 : </>;
1016 4 : } else {
1017 4 : auto_text = _("Authorize SSH key");
1018 4 : auto_details = <>
1019 4 : <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>
1020 4 : <p>{_("This will allow you to log in without password in the future.")}</p>
1021 4 : </>;
1022 4 : }
1023 7 : }
1024 :
1025 7 : const body = <>
1026 7 : {statement}
1027 7 : <br />
1028 4 : {(offer_login_password || offer_key_password) &&
1029 7 : <Form isHorizontal onSubmit={prevent_default(callback)}>
1030 7 : {both &&
1031 3 : <FormGroup label={_("Authentication")} isInline hasNoPaddingTop>
1032 3 : <Radio isChecked={this.state.auth === "password"}
1033 0 : onChange={() => this.setState({ auth: "password" })}
1034 3 : id="auth-password"
1035 3 : value="password"
1036 3 : label={_("Password")} />
1037 3 : <Radio isChecked={this.state.auth === "key"}
1038 0 : onChange={() => this.setState({ auth: "key" })}
1039 3 : id="auth-key"
1040 3 : value="key"
1041 3 : label={_("SSH key")} />
1042 3 : </FormGroup>
1043 : }
1044 3 : {((both && this.state.auth === "password") || (!both && offer_login_password)) &&
1045 5 : <FormGroup label={_("Password")}>
1046 4 : <TextInput id="login-custom-password" onChange={(_event, value) => this.setState({ custom_password: value })}
1047 2 : type="password" value={this.state.custom_password} validated={this.state.custom_password_error ? "error" : "default"} />
1048 5 : <FormHelper helperTextInvalid={this.state.custom_password_error} />
1049 5 : </FormGroup>
1050 : }
1051 3 : {((both && this.state.auth === "key") || (!both && offer_key_password)) &&
1052 4 : <FormGroup label={_("Key password")}>
1053 2 : <TextInput id="locked-identity-password" onChange={(_event, value) => this.setState({ locked_identity_password: value })}
1054 2 : type="password" autoComplete="new-password" value={this.state.locked_identity_password} validated={this.state.locked_identity_password_error ? "error" : "default"} />
1055 4 : <FormHelper
1056 4 : 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)}
1057 4 : helperTextInvalid={this.state.locked_identity_password_error} />
1058 4 : </FormGroup>
1059 : }
1060 7 : {offer_key_setup &&
1061 7 : <FormGroup label={ _("Automatic login") } hasNoPaddingTop isInline>
1062 0 : <Checkbox onChange={(_event, checked) => this.setState({ auto_login: checked })}
1063 7 : isChecked={this.state.auto_login} id="login-setup-keys"
1064 2 : label={auto_text} body={this.state.auto_login ? auto_details : null} />
1065 7 : </FormGroup>
1066 : }
1067 7 : </Form>
1068 : }
1069 7 : </>;
1070 :
1071 7 : return (
1072 7 : <Modal id="hosts_setup_server_dialog" isOpen
1073 7 : position="top" variant="medium"
1074 7 : onClose={this.props.onClose}
1075 : >
1076 7 : <ModalHeader title={title} />
1077 7 : <ModalBody>
1078 7 : <Stack hasGutter>
1079 2 : { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
1080 7 : {body}
1081 7 : </Stack>
1082 7 : </ModalBody>
1083 7 : <ModalFooter>
1084 7 : <Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
1085 4 : isDisabled={this.state.inProgress || (!offer_login_password && !offer_key_password) || !this.state.default_ssh_key || !this.props.error_options}>
1086 7 : { submitText }
1087 7 : </Button>
1088 7 : <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
1089 7 : { _("Cancel") }
1090 7 : </Button>
1091 7 : </ModalFooter>
1092 7 : </Modal>
1093 : );
1094 7 : }
1095 341 : }
1096 :
1097 13 : function try2Connect(machines_ins, address, options) {
1098 13 : return new Promise((resolve, reject) => {
1099 13 : const conn_options = { ...options, payload: "echo", host: address };
1100 :
1101 13 : conn_options["init-superuser"] = get_init_superuser_for_options(conn_options);
1102 :
1103 13 : const machine = machines_ins.lookup(address);
1104 3 : if (machine && machine.host_key && !machine.on_disk) {
1105 3 : conn_options['temp-session'] = false; // Compatibility option
1106 3 : conn_options.session = 'shared';
1107 3 : conn_options['host-key'] = machine.host_key;
1108 3 : }
1109 :
1110 13 : const client = cockpit.channel(conn_options);
1111 13 : client.send("x");
1112 12 : client.addEventListener("message", () => {
1113 12 : resolve();
1114 12 : client.close();
1115 12 : });
1116 13 : client.addEventListener("close", (event, options) => {
1117 13 : reject(options);
1118 13 : });
1119 13 : });
1120 13 : }
1121 :
1122 341 : class HostModalInner extends React.Component {
1123 13 : constructor(props) {
1124 13 : super(props);
1125 :
1126 13 : this.state = {
1127 8 : current_template: this.props.template || "add-machine",
1128 13 : address: full_address(props.machines_ins, props.address),
1129 13 : old_address: full_address(props.machines_ins, props.address),
1130 13 : error_options: this.props.error_options,
1131 13 : dialogError: "", // Error to be shown in the modal
1132 13 : };
1133 :
1134 13 : this.promise_callback = null;
1135 :
1136 13 : this.addressOrLabel = this.addressOrLabel.bind(this);
1137 13 : this.changeContent = this.changeContent.bind(this);
1138 13 : this.setGoal = this.setGoal.bind(this);
1139 13 : this.setError = this.setError.bind(this);
1140 13 : this.setAddress = this.setAddress.bind(this);
1141 13 : this.run = this.run.bind(this);
1142 13 : this.complete = this.complete.bind(this);
1143 13 : }
1144 :
1145 13 : addressOrLabel() {
1146 13 : const machine = this.props.machines_ins.lookup(this.state.address);
1147 13 : let host = split_connection_string(this.state.address).address;
1148 12 : if (machine && machine.label)
1149 12 : host = machine.label;
1150 13 : return host;
1151 13 : }
1152 :
1153 10 : changeContent(template, error_options, with_error_message) {
1154 10 : if (this.state.current_template !== template)
1155 10 : this.setState({
1156 10 : current_template: template,
1157 10 : error_options,
1158 1 : dialogError: with_error_message ? cockpit.message(error_options) : null,
1159 10 : });
1160 10 : }
1161 :
1162 0 : complete() {
1163 0 : if (this.promise_callback)
1164 0 : this.promise_callback().then(this.props.onClose);
1165 : else
1166 0 : this.props.onClose();
1167 0 : }
1168 :
1169 11 : setGoal(callback) {
1170 11 : this.promise_callback = callback;
1171 11 : }
1172 :
1173 11 : setError(error, keep_message_on_change) {
1174 11 : if (error === null)
1175 11 : return this.setState({ dialogError: null });
1176 :
1177 10 : let template = null;
1178 10 : if (error.problem && error.command === "close")
1179 10 : template = codes[error.problem];
1180 :
1181 10 : if (template && this.state.current_template !== template)
1182 1 : this.changeContent(template, error, keep_message_on_change);
1183 : else
1184 1 : this.setState({ error_options: error, dialogError: cockpit.message(error) });
1185 11 : }
1186 :
1187 11 : setAddress(address) {
1188 11 : this.setState({ address });
1189 11 : }
1190 :
1191 13 : run(promise, failure_callback) {
1192 13 : return new Promise((resolve, reject) => {
1193 13 : const promise_funcs = [];
1194 13 : const self = this;
1195 :
1196 13 : function next(i) {
1197 13 : promise_funcs[i]()
1198 12 : .then(val => {
1199 12 : i = i + 1;
1200 12 : if (i < promise_funcs.length) {
1201 12 : next(i);
1202 12 : } else {
1203 12 : resolve();
1204 12 : self.props.onClose();
1205 12 : }
1206 12 : })
1207 10 : .catch(ex => {
1208 10 : if (failure_callback)
1209 6 : failure_callback(ex);
1210 : else
1211 6 : self.setError(ex);
1212 10 : });
1213 13 : }
1214 :
1215 13 : promise_funcs.push(() => { return promise });
1216 :
1217 13 : if (this.promise_callback)
1218 13 : promise_funcs.push(this.promise_callback);
1219 :
1220 13 : if (this.props.caller_callback)
1221 12 : promise_funcs.push(() => this.props.caller_callback(this.state.address));
1222 :
1223 13 : next(0);
1224 13 : });
1225 13 : }
1226 :
1227 13 : render() {
1228 13 : const template = this.state.current_template;
1229 :
1230 13 : const props = {
1231 13 : template,
1232 13 : host: this.addressOrLabel(),
1233 13 : full_address: this.state.address,
1234 13 : old_address: this.state.old_address,
1235 13 : address_data: split_connection_string(this.state.address),
1236 13 : error_options: this.state.error_options,
1237 13 : dialogError: this.state.dialogError,
1238 13 : machines_ins: this.props.machines_ins,
1239 1 : onClose: () => {
1240 1 : if (this.props.caller_cancelled)
1241 1 : this.props.caller_cancelled();
1242 1 : this.props.onClose();
1243 1 : },
1244 13 : run: this.run,
1245 13 : setGoal: this.setGoal,
1246 13 : setError: this.setError,
1247 13 : setAddress: this.setAddress,
1248 13 : try2Connect: this.try2Connect,
1249 13 : complete: this.complete,
1250 13 : };
1251 :
1252 13 : if (template === "connect")
1253 13 : return <Connect {...props} />;
1254 13 : else if (template === "add-machine")
1255 12 : return <AddMachine {...props} />;
1256 8 : else if (template === "unknown-hostkey" || template === "unknown-host" || template === "invalid-hostkey")
1257 8 : return <HostKey {...props} />;
1258 8 : else if (template === "change-auth")
1259 3 : return <ChangeAuth {...props} />;
1260 3 : else if (template === "change-port")
1261 3 : return <MachinePort {...props} />;
1262 3 : else if (template === "not-supported")
1263 3 : return <NotSupported {...props} />;
1264 :
1265 3 : console.error("Unknown template:", template);
1266 3 : return null;
1267 13 : }
1268 341 : }
1269 :
1270 341 : HostModalInner.propTypes = {
1271 341 : machines_ins: PropTypes.object.isRequired,
1272 341 : onClose: PropTypes.func.isRequired,
1273 341 : caller_callback: PropTypes.func,
1274 341 : address: PropTypes.string,
1275 341 : template: PropTypes.string,
1276 341 : };
1277 :
1278 338 : export const HostModal = ({ state, machines }) => {
1279 338 : if (!state.modal_properties)
1280 338 : return null;
1281 :
1282 73 : return <HostModalInner machines_ins={machines}
1283 12 : onClose={() => state.close_modal()}
1284 73 : {...state.modal_properties}
1285 73 : caller_callback={state.modal_callback}
1286 1 : caller_cancelled={() => state.modal_callback(null)} />;
1287 338 : };
|