LCOV - code coverage report
Current view: top level - pkg/shell - hosts_dialog.jsx Coverage Total Hit
Test: cockpit Lines: 5.1 % 1045 53
Test Date: 2026-06-16 14:09:37

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2021 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          123 : 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          123 : import React from 'react';
      16          123 : 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          123 : const _ = cockpit.gettext;
      40              : 
      41          123 : export const HostModalState = () => {
      42            0 :     function set_props(props, callback) {
      43            0 :         self.modal_properties = props;
      44            0 :         self.modal_callback = callback;
      45            0 :         self.dispatchEvent("changed");
      46            0 :     }
      47              : 
      48            0 :     function close_modal() {
      49            0 :         set_props(null, null);
      50            0 :     }
      51              : 
      52            0 :     function show_modal(properties) {
      53            0 :         return new Promise((resolve, reject) => {
      54            0 :             set_props(properties, result => { resolve(result); return Promise.resolve() });
      55            0 :         });
      56            0 :     }
      57              : 
      58          123 :     const self = {
      59          123 :         state: null,
      60              : 
      61          123 :         show_modal,
      62          123 :         close_modal,
      63          123 :     };
      64              : 
      65          123 :     return cockpit.event_target(self);
      66          123 : };
      67              : 
      68              : /* Activate and jump to a newly added machine, identified by its
      69              :    connection string.
      70              :  */
      71            0 : function jump_to_new_connection_string(shell_state, connection_string) {
      72              :     // Get the address of the machine from its connection string
      73            0 :     const addr = split_connection_string(connection_string).address;
      74              :     // Tell the Loader that now is the time to start monitoring the
      75              :     // manifests.
      76            0 :     shell_state.loader.connect(addr);
      77              :     // Navigate to it.
      78            0 :     shell_state.jump({ host: addr });
      79            0 : }
      80              : 
      81            0 : export async function add_host(state, shell_state) {
      82            0 :     const connection_string = await state.show_modal({ });
      83            0 :     if (connection_string)
      84            0 :         jump_to_new_connection_string(shell_state, connection_string);
      85            0 : }
      86              : 
      87            0 : export async function edit_host(state, shell_state, machine) {
      88            0 :     const { current_machine } = shell_state;
      89            0 :     const connection_string = await state.show_modal({ address: machine.address });
      90            0 :     if (connection_string) {
      91            0 :         const addr = split_connection_string(connection_string).address;
      92            0 :         if (machine == current_machine && addr != machine.address)
      93            0 :             jump_to_new_connection_string(shell_state, connection_string);
      94            0 :     }
      95            0 : }
      96              : 
      97            0 : 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            0 :     if (machine.connection_string == "localhost" ||
     110            0 :         machine.state == "connected" ||
     111            0 :         machine.state == "connecting") {
     112            0 :         shell_state.loader.connect(machine.address);
     113            0 :         return machine.connection_string;
     114            0 :     }
     115              : 
     116            0 :     let connection_string = null;
     117              : 
     118            0 :     if (machine.problem && codes[machine.problem]) {
     119              :         // trouble shooting
     120            0 :         connection_string = await state.show_modal({
     121            0 :             address: machine.address,
     122            0 :             template: codes[machine.problem],
     123            0 :         });
     124            0 :     } else if (!window.sessionStorage.getItem("connection-warning-shown")) {
     125              :         // connect by launching into the "Connection warning" dialog.
     126            0 :         connection_string = await state.show_modal({
     127            0 :             address: machine.address,
     128            0 :             template: "connect"
     129            0 :         });
     130            0 :     } else {
     131              :         // Try to connect without any dialog
     132            0 :         try {
     133            0 :             await try2Connect(shell_state.machines, machine.connection_string);
     134            0 :             connection_string = machine.connection_string;
     135            0 :         } catch (err) {
     136              :             // continue with troubleshooting in the dialog
     137            0 :             connection_string = await state.show_modal({
     138            0 :                 address: machine.address,
     139            0 :                 template: codes[err.problem] || "change-port",
     140            0 :                 error_options: err,
     141            0 :             });
     142            0 :         }
     143            0 :     }
     144              : 
     145            0 :     if (connection_string) {
     146              :         // make the rest of the shell aware that the machine is now connected
     147            0 :         const parts = split_connection_string(connection_string);
     148            0 :         shell_state.loader.connect(parts.address);
     149            0 :         shell_state.update();
     150            0 :     }
     151              : 
     152            0 :     return connection_string;
     153            0 : }
     154              : 
     155          123 : export const codes = {
     156          123 :     danger: "connect",
     157          123 :     "no-cockpit": "not-supported",
     158          123 :     "not-supported": "not-supported",
     159          123 :     "protocol-error": "not-supported",
     160          123 :     "authentication-not-supported": "change-auth",
     161          123 :     "authentication-failed": "change-auth",
     162          123 :     "no-forwarding": "change-auth",
     163          123 :     "unknown-hostkey": "unknown-hostkey",
     164          123 :     "invalid-hostkey": "invalid-hostkey",
     165          123 :     "not-found": "add-machine",
     166          123 :     "unknown-host": "unknown-host"
     167          123 : };
     168              : 
     169            0 : function full_address(machines_ins, address) {
     170            0 :     const machine = machines_ins.lookup(address);
     171            0 :     if (machine && machine.address !== "localhost")
     172            0 :         return machine.connection_string;
     173              : 
     174            0 :     return address;
     175            0 : }
     176              : 
     177            0 : function is_method_supported(methods, method) {
     178            0 :     const result = methods[method];
     179            0 :     return result ? result !== "no-server-support" : false;
     180            0 : }
     181              : 
     182            0 : function prevent_default(callback) {
     183            0 :     return event => {
     184            0 :         callback();
     185            0 :         event.preventDefault();
     186            0 :         return false;
     187            0 :     };
     188            0 : }
     189              : 
     190          123 : 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          123 : }
     213              : 
     214          123 : class Connect extends React.Component {
     215            0 :     constructor(props) {
     216            0 :         super(props);
     217              : 
     218            0 :         this.state = {
     219            0 :             inProgress: false,
     220            0 :         };
     221            0 :     }
     222              : 
     223            0 :     onConnect() {
     224            0 :         window.sessionStorage.setItem("connection-warning-shown", true);
     225            0 :         this.setState({ inProgress: true });
     226            0 :         this.props.run(try2Connect(this.props.machines_ins, this.props.full_address), ex => {
     227            0 :             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            0 :             this.setState({ inProgress: false });
     243            0 :             this.props.setError(ex, keep_message);
     244            0 :         });
     245            0 :     }
     246              : 
     247            0 :     render() {
     248            0 :         return (
     249            0 :             <Modal id="hosts_connect_server_dialog" isOpen
     250            0 :                    position="top" variant="small"
     251            0 :                    onClose={this.props.onClose}
     252              :             >
     253            0 :                 <ModalHeader title={fmt_to_fragments(_("Connect to $0?"), <b className="ct-heading-font-weight">{this.props.host}</b>)}
     254            0 :                     titleIconVariant="warning"
     255            0 :                 />
     256            0 :                 <ModalBody>
     257            0 :                     <Content component={ContentVariants.p}>
     258            0 :                         {_("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            0 :                     </Content>
     260            0 :                     <Content component={ContentVariants.p}>
     261            0 :                         <a href="https://cockpit-project.org/guide/latest/multi-host.html" target="blank" rel="noopener noreferrer">
     262            0 :                             <ExternalLinkAltIcon /> {_("Read more")}
     263            0 :                         </a>
     264            0 :                     </Content>
     265            0 :                 </ModalBody>
     266            0 :                 <ModalFooter>
     267            0 :                     <HelperText>
     268            0 :                         <HelperTextItem>{_("You will be reminded once per session.")}</HelperTextItem>
     269            0 :                     </HelperText>
     270            0 :                     <Button variant="warning" isLoading={this.state.inProgress}
     271            0 :                                     onClick={() => this.onConnect()}>
     272            0 :                         {_("Connect")}
     273            0 :                     </Button>
     274            0 :                     <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
     275            0 :                         { _("Cancel") }
     276            0 :                     </Button>
     277            0 :                 </ModalFooter>
     278            0 :             </Modal>
     279              :         );
     280            0 :     }
     281          123 : }
     282              : 
     283          123 : class AddMachine extends React.Component {
     284            0 :     constructor(props) {
     285            0 :         super(props);
     286              : 
     287            0 :         let address_parts = null;
     288            0 :         if (this.props.full_address)
     289            0 :             address_parts = split_connection_string(this.props.full_address);
     290              : 
     291            0 :         let host_address = "";
     292            0 :         let host_user = "";
     293            0 :         if (address_parts) {
     294            0 :             host_address = address_parts.address;
     295            0 :             if (address_parts.port)
     296            0 :                 host_address += ":" + address_parts.port;
     297            0 :             host_user = address_parts.user;
     298            0 :         }
     299              : 
     300            0 :         let color = props.machines_ins.unused_color();
     301            0 :         let old_machine = null;
     302            0 :         if (props.old_address)
     303            0 :             old_machine = props.machines_ins.lookup(props.old_address);
     304            0 :         if (old_machine)
     305            0 :             color = this.rgb2Hex(old_machine.color);
     306            0 :         if (old_machine && !old_machine.visible)
     307            0 :             old_machine = null;
     308              : 
     309            0 :         this.state = {
     310            0 :             user: host_user || "",
     311            0 :             address: host_address || "",
     312            0 :             color,
     313            0 :             addressError: "",
     314            0 :             inProgress: false,
     315            0 :             old_machine,
     316            0 :             userChanged: false,
     317            0 :         };
     318              : 
     319            0 :         this.onAddressChange = this.onAddressChange.bind(this);
     320            0 :         this.onAddHost = this.onAddHost.bind(this);
     321            0 :     }
     322              : 
     323            0 :     rgb2Hex(c) {
     324            0 :         function toHex(d) {
     325            0 :             return ("0" + (parseInt(d, 10).toString(16)))
     326            0 :                     .slice(-2);
     327            0 :         }
     328              : 
     329            0 :         if (c[0] === "#")
     330            0 :             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            0 :     }
     335              : 
     336            0 :     onAddressChange() {
     337            0 :         let error = "";
     338            0 :         if (this.state.address.search(/\s+/) !== -1)
     339            0 :             error = _("The IP address or hostname cannot contain whitespace.");
     340            0 :         else {
     341            0 :             const machine = this.props.machines_ins.lookup(this.state.address);
     342            0 :             const machine_address = machine ? full_address(this.props.machines_ins, machine.address) : undefined;
     343            0 :             if (machine && machine.on_disk && machine_address != this.props.old_address) {
     344            0 :                 if (machine.visible)
     345            0 :                     error = _("This machine has already been added.");
     346            0 :                 else if (!this.state.userChanged)
     347            0 :                     this.setState({ user: machine.user, color: this.rgb2Hex(machine.color) });
     348            0 :             } else if (this.state.old_machine && !machine && !this.state.userChanged) { // When editing host by changing its address generate new color
     349            0 :                 this.setState((_, prevProps) => ({ color: prevProps.machines_ins.unused_color(), userChanged: true }));
     350            0 :             }
     351            0 :         }
     352              : 
     353            0 :         this.setState({ addressError: error });
     354              : 
     355            0 :         return error;
     356            0 :     }
     357              : 
     358            0 :     onAddHost() {
     359            0 :         const parts = split_connection_string(this.state.address);
     360              :         // user in "User name:" field wins over user in connection string
     361            0 :         const address = generate_connection_string(this.state.user || parts.user, parts.port, parts.address);
     362              : 
     363            0 :         if (this.onAddressChange())
     364            0 :             return;
     365              : 
     366            0 :         this.props.setAddress(address);
     367              : 
     368            0 :         if (this.state.old_machine && address === this.state.old_machine.connection_string) {
     369            0 :             this.props.setError(null);
     370            0 :             this.setState({ inProgress: true });
     371            0 :             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            0 :             return;
     377            0 :         }
     378              : 
     379            0 :         this.props.setError(null);
     380            0 :         this.setState({ inProgress: true });
     381              : 
     382            0 :         this.props.setGoal(() => {
     383            0 :             const parts = split_connection_string(this.state.address);
     384              :             // user in "User name:" field wins over user in connection string
     385            0 :             const address = generate_connection_string(this.state.user || parts.user, parts.port, parts.address);
     386              : 
     387            0 :             return new Promise((resolve, reject) => {
     388            0 :                 this.props.machines_ins.add(address, this.state.color)
     389            0 :                         .then(() => {
     390              :                             // When changing address of machine, hide the old one
     391            0 :                             if (this.state.old_machine && this.state.old_machine != this.props.machines_ins.lookup(address)) {
     392            0 :                                 this.props.machines_ins.change(this.state.old_machine.key, { visible: false })
     393            0 :                                         .then(resolve);
     394            0 :                             } else {
     395            0 :                                 resolve();
     396            0 :                             }
     397            0 :                         })
     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            0 :             });
     404            0 :         });
     405              : 
     406            0 :         if (!window.sessionStorage.getItem("connection-warning-shown")) {
     407            0 :             this.props.setError({ problem: "danger", command: "close" });
     408            0 :         } else {
     409            0 :             this.props.run(try2Connect(this.props.machines_ins, address), ex => {
     410            0 :                 if (ex.problem === "no-host") {
     411            0 :                     let host_id_port = address;
     412            0 :                     let port = "22";
     413            0 :                     const port_index = host_id_port.lastIndexOf(":");
     414            0 :                     if (port_index === -1) {
     415            0 :                         host_id_port = address + ":22";
     416            0 :                     } else {
     417            0 :                         port = host_id_port.substring(port_index + 1);
     418            0 :                     }
     419              : 
     420            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);
     421            0 :                     ex.problem = "not-found";
     422            0 :                 }
     423            0 :                 this.setState({ inProgress: false });
     424            0 :                 this.props.setError(ex);
     425            0 :             });
     426            0 :         }
     427            0 :     }
     428              : 
     429            0 :     render() {
     430            0 :         const invisible = this.props.machines_ins.addresses.filter(addr => {
     431            0 :             const m = this.props.machines_ins.lookup(addr);
     432            0 :             return !m || !m.visible;
     433            0 :         });
     434              : 
     435            0 :         const callback = this.onAddHost;
     436            0 :         const title = this.state.old_machine ? _("Edit host") : _("Add new host");
     437            0 :         const submitText = this.state.old_machine ? _("Set") : _("Add");
     438              : 
     439            0 :         const body = <Form isHorizontal onSubmit={prevent_default(callback)}>
     440            0 :             <FormGroup label={_("Host")}>
     441            0 :                 <TextInput id="add-machine-address" onChange={(_event, address) => this.setState({ address })}
     442            0 :                         validated={this.state.addressError ? "error" : "default"} onBlur={this.onAddressChange}
     443            0 :                         isDisabled={this.props.old_address === "localhost"} list="options" value={this.state.address} />
     444            0 :                 <datalist id="options">
     445            0 :                     {invisible.map(a => <option key={a} value={a} />)}
     446            0 :                 </datalist>
     447            0 :                 <FormHelper helperTextInvalid={this.state.addressError} helperText={_("Can be a hostname, IP address, alias name, or ssh:// URI")} />
     448            0 :             </FormGroup>
     449            0 :             <FormGroup label={_("User name")}>
     450            0 :                 <TextInput id="add-machine-user" onChange={(_event, value) => this.setState({ user: value, userChanged: true })}
     451            0 :                         isDisabled={this.props.old_address === "localhost"} value={this.state.user} />
     452            0 :                 <FormHelper helperText={_("When empty, connect with the current user")} />
     453            0 :             </FormGroup>
     454            0 :             <FormGroup label={_("Color")}>
     455            0 :                 <input type="color" value={this.state.color} onChange={(e) => this.setState({ color: e.target.value }) } />
     456            0 :             </FormGroup>
     457            0 :         </Form>;
     458              : 
     459            0 :         return (
     460            0 :             <Modal id="hosts_setup_server_dialog" isOpen
     461            0 :                    position="top" variant="medium"
     462            0 :                    onClose={this.props.onClose}
     463              :             >
     464            0 :                 <ModalHeader title={title} />
     465            0 :                 <ModalBody>
     466            0 :                     <Stack hasGutter>
     467            0 :                         { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
     468            0 :                         {body}
     469            0 :                     </Stack>
     470            0 :                 </ModalBody>
     471            0 :                 <ModalFooter>
     472            0 :                     <Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
     473            0 :                             isDisabled={this.state.address === "" || this.state.addressError !== "" || this.state.inProgress}>
     474            0 :                         { submitText }
     475            0 :                     </Button>
     476            0 :                     <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
     477            0 :                         { _("Cancel") }
     478            0 :                     </Button>
     479            0 :                 </ModalFooter>
     480            0 :             </Modal>
     481              :         );
     482            0 :     }
     483          123 : }
     484              : 
     485          123 : 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          123 : }
     581              : 
     582          123 : class HostKey extends React.Component {
     583            0 :     constructor(props) {
     584            0 :         super(props);
     585              : 
     586            0 :         this.state = {
     587            0 :             inProgress: false,
     588            0 :             verifyExpanded: false,
     589            0 :             error_options: props.error_options,
     590            0 :         };
     591              : 
     592            0 :         this.onAddKey = this.onAddKey.bind(this);
     593            0 :     }
     594              : 
     595            0 :     componentDidMount() {
     596            0 :         if (!this.props.error_options || !this.props.error_options["host-key"]) {
     597            0 :             const options = {};
     598            0 :             let match_problem = this.props.template;
     599            0 :             if (this.props.template == "unknown-host") {
     600            0 :                 options.session = "private";
     601            0 :                 match_problem = "unknown-hostkey";
     602            0 :             }
     603              : 
     604            0 :             try2Connect(this.props.machines_ins, this.props.full_address, options)
     605            0 :                     .then(this.props.complete)
     606            0 :                     .catch(ex => {
     607            0 :                         if (ex.problem !== match_problem) {
     608            0 :                             this.props.setError(ex);
     609            0 :                         } else {
     610            0 :                             this.setState({ error_options: ex });
     611            0 :                         }
     612            0 :                     });
     613            0 :         }
     614            0 :     }
     615              : 
     616            0 :     onAddKey() {
     617            0 :         this.setState({ inProgress: true });
     618              : 
     619            0 :         const key = this.state.error_options["host-key"];
     620            0 :         const machine = this.props.machines_ins.lookup(this.props.full_address);
     621            0 :         let q;
     622            0 :         if (!machine || machine.on_disk) {
     623            0 :             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            0 :         this.props.run(q.then(() => {
     630            0 :             return try2Connect(this.props.machines_ins, this.props.full_address, {})
     631            0 :                     .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            0 :                         else {
     635            0 :                             this.setState({ inProgress: false });
     636            0 :                             throw ex;
     637            0 :                         }
     638            0 :                     });
     639            0 :         }));
     640            0 :     }
     641              : 
     642            0 :     render() {
     643            0 :         let key_type = "";
     644            0 :         let fp = "";
     645            0 :         if (this.state.error_options && this.state.error_options["host-key"]) {
     646            0 :             key_type = this.state.error_options["host-key"].split(" ")[1];
     647            0 :             fp = this.state.error_options["host-fingerprint"];
     648            0 :         }
     649              : 
     650            0 :         const scan_cmd = `ssh-keyscan -t ${key_type} localhost | ssh-keygen -lf -`;
     651              : 
     652            0 :         const callback = this.onAddKey;
     653            0 :         const title = cockpit.format(this.props.template === "invalid-hostkey" ? _("$0 key changed") : _("New host: $0"),
     654            0 :                                      this.props.host);
     655            0 :         const submitText = _("Trust and add host");
     656            0 :         let unknown = false;
     657            0 :         let body = null;
     658            0 :         if (!key_type) {
     659            0 :             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            0 :             const fingerprint_help = <Popover bodyContent={
     673            0 :                 _("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            0 :                 <OutlinedQuestionCircleIcon />
     675            0 :             </Popover>;
     676            0 :             body = <>
     677            0 :                 <p>{cockpit.format(_("You are connecting to $0 for the first time."), this.props.host)}</p>
     678            0 :                 <ExpandableSection toggleText={ _("Verify fingerprint") }
     679            0 :                                    isExpanded={this.state.verifyExpanded}
     680            0 :                                    onToggle={(_ev, verifyExpanded) => this.setState({ verifyExpanded }) }>
     681            0 :                     <div>{_("Run this command over a trusted network or physically on the remote machine:")}</div>
     682            0 :                     <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help hostkey-verify-help-cmds pf-v6-u-font-family-monospace">{scan_cmd}</ClipboardCopy>
     683            0 :                     <div>{_("The fingerprint should match:")} {fingerprint_help}</div>
     684            0 :                     <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help hostkey-fingerprint pf-v6-u-font-family-monospace">{fp}</ClipboardCopy>
     685            0 :                 </ExpandableSection>
     686            0 :                 <Alert variant='warning' isInline isPlain title={_("Malicious pages on a remote machine may affect other connected hosts")} />
     687            0 :             </>;
     688            0 :         }
     689              : 
     690            0 :         return (
     691            0 :             <Modal id="hosts_setup_server_dialog" isOpen
     692            0 :                    position="top" variant="medium"
     693            0 :                    onClose={this.props.onClose}
     694              :             >
     695            0 :                 <ModalHeader title={title} />
     696            0 :                 <ModalBody>
     697            0 :                     <Stack hasGutter>
     698            0 :                         { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
     699            0 :                         {body}
     700            0 :                     </Stack>
     701            0 :                 </ModalBody>
     702            0 :                 <ModalFooter>
     703            0 :                     { unknown ||
     704            0 :                         <Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
     705            0 :                                 isDisabled={this.state.inProgress}>
     706            0 :                             { submitText }
     707            0 :                         </Button>
     708              :                     }
     709            0 :                     <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
     710            0 :                         { _("Cancel") }
     711            0 :                     </Button>
     712            0 :                 </ModalFooter>
     713            0 :             </Modal>
     714              :         );
     715            0 :     }
     716          123 : }
     717              : 
     718          123 : class ChangeAuth extends React.Component {
     719            0 :     constructor(props) {
     720            0 :         super(props);
     721              : 
     722            0 :         this.state = {
     723            0 :             auth: "password",
     724            0 :             auto_login: false,
     725            0 :             custom_password: "",
     726            0 :             custom_password_error: "",
     727            0 :             locked_identity_password: "",
     728            0 :             locked_identity_password_error: "",
     729            0 :             login_setup_new_key_password: "",
     730            0 :             login_setup_new_key_password_error: "",
     731            0 :             login_setup_new_key_password2: "",
     732            0 :             login_setup_new_key_password2_error: "",
     733            0 :             user: "",
     734            0 :             default_ssh_key: null,
     735            0 :             identity_path: null,
     736            0 :             inProgress: true, // componentDidMount changes to false once loaded
     737            0 :         };
     738              : 
     739            0 :         this.keys = null;
     740            0 :         if (credentials)
     741            0 :             this.keys = credentials.keys_instance();
     742              : 
     743            0 :         this.getSupports = this.getSupports.bind(this);
     744            0 :         this.updateIdentity = this.updateIdentity.bind(this);
     745            0 :         this.login = this.login.bind(this);
     746            0 :         this.maybe_create_key = this.maybe_create_key.bind(this);
     747            0 :         this.authorize_key = this.authorize_key.bind(this);
     748            0 :         this.maybe_unlock_key = this.maybe_unlock_key.bind(this);
     749            0 :     }
     750              : 
     751            0 :     updateIdentity() {
     752            0 :         let identity_path = null;
     753            0 :         if (this.props.error_options && this.props.error_options.error && this.props.error_options.error.startsWith("locked identity"))
     754            0 :             identity_path = this.props.error_options.error.split(": ")[1];
     755              : 
     756            0 :         const default_ssh_key = this.state.default_ssh_key;
     757            0 :         if (default_ssh_key && default_ssh_key.encrypted)
     758            0 :             default_ssh_key.unaligned_passphrase = identity_path && identity_path === default_ssh_key.name;
     759              : 
     760            0 :         this.setState({ identity_path, default_ssh_key });
     761            0 :     }
     762              : 
     763            0 :     componentDidMount() {
     764            0 :         cockpit.user()
     765            0 :                 .then(user =>
     766            0 :                     cockpit.script(ssh_show_default_key_sh, [], { })
     767            0 :                             .then(data => {
     768            0 :                                 let default_ssh_key = null;
     769            0 :                                 const info = data.split("\n");
     770            0 :                                 if (info[0])
     771            0 :                                     default_ssh_key = { name: info[0], exists: true, encrypted: info[1] === "encrypted" };
     772              :                                 else
     773            0 :                                     default_ssh_key = { name: user.home + "/.ssh/id_rsa", type: "rsa", exists: false };
     774              : 
     775            0 :                                 return this.setState({ inProgress: false, default_ssh_key, user }, this.updateIdentity);
     776            0 :                             })
     777            0 :                 )
     778            0 :                 .catch(ex => { this.setState({ inProgress: false }); this.props.setError(ex) });
     779              : 
     780            0 :         if (!this.props.error_options || this.props.error_options["auth-method-results"] === null) {
     781            0 :             try2Connect(this.props.machines_ins, this.props.full_address)
     782            0 :                     .then(this.props.complete)
     783            0 :                     .catch(ex => {
     784            0 :                         this.setState({ inProgress: false });
     785            0 :                         this.props.setError(ex);
     786            0 :                     });
     787            0 :         }
     788            0 :     }
     789              : 
     790            0 :     componentWillUnmount() {
     791            0 :         if (this.keys)
     792            0 :             this.keys.close();
     793            0 :         this.keys = null;
     794            0 :     }
     795              : 
     796            0 :     componentDidUpdate(prevProps) {
     797            0 :         if (prevProps.error_options !== this.props.error_options)
     798            0 :             this.updateIdentity();
     799            0 :     }
     800              : 
     801            0 :     getSupports() {
     802            0 :         let methods = null;
     803            0 :         let available = null;
     804              : 
     805            0 :         let offer_login_password = false;
     806            0 :         let offer_key_password = false;
     807              : 
     808            0 :         if (this.props.error_options) {
     809            0 :             available = {};
     810              : 
     811            0 :             methods = this.props.error_options["auth-method-results"];
     812            0 :             if (methods) {
     813            0 :                 for (const method in methods) {
     814            0 :                     if (is_method_supported(methods, method)) {
     815            0 :                         available[method] = true;
     816            0 :                     }
     817            0 :                 }
     818            0 :             }
     819              : 
     820            0 :             offer_login_password = !!available.password;
     821            0 :             offer_key_password = this.state.identity_path !== null;
     822            0 :         } else {
     823            0 :             offer_login_password = true;
     824            0 :             offer_key_password = false;
     825            0 :         }
     826              : 
     827            0 :         return {
     828            0 :             offer_login_password,
     829            0 :             offer_key_password,
     830            0 :         };
     831            0 :     }
     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            0 :     maybe_unlock_key() {
     846            0 :         const { offer_login_password, offer_key_password } = this.getSupports();
     847            0 :         const both = offer_login_password && offer_key_password;
     848              : 
     849            0 :         if ((both && this.state.auth === "key") || (!both && offer_key_password))
     850            0 :             return this.keys.load(this.state.identity_path, this.state.locked_identity_password);
     851              :         else
     852            0 :             return Promise.resolve();
     853            0 :     }
     854              : 
     855            0 :     login() {
     856            0 :         const options = {};
     857            0 :         const user = split_connection_string(this.props.full_address).user || "";
     858            0 :         const do_key_password_change = this.state.auto_login && this.state.default_ssh_key.unaligned_passphrase;
     859              : 
     860            0 :         let custom_password_error = "";
     861            0 :         let locked_identity_password_error = "";
     862            0 :         let login_setup_new_key_password_error = "";
     863            0 :         let login_setup_new_key_password2_error = "";
     864              : 
     865            0 :         const { offer_login_password, offer_key_password } = this.getSupports();
     866            0 :         const both = offer_login_password && offer_key_password;
     867              : 
     868            0 :         if ((both && this.state.auth === "password") || (!both && offer_login_password)) {
     869            0 :             if (!this.state.custom_password)
     870            0 :                 custom_password_error = _("The password can not be empty");
     871              : 
     872            0 :             options.password = this.state.custom_password;
     873            0 :             options.session = 'shared';
     874            0 :             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            0 :                 if (this.state.user && this.state.user.name)
     880            0 :                     options.user = this.state.user.name;
     881            0 :                 options["temp-session"] = false; /* Compatibility option */
     882            0 :             }
     883            0 :         }
     884              : 
     885            0 :         if ((offer_key_password && !(both && this.state.auth === "password")) && !this.state.locked_identity_password)
     886            0 :             locked_identity_password_error = _("The key password can not be empty");
     887              : 
     888            0 :         if (this.state.auto_login && !do_key_password_change && this.state.login_setup_new_key_password !== this.state.login_setup_new_key_password2)
     889            0 :             login_setup_new_key_password2_error = _("The key passwords do not match");
     890              : 
     891            0 :         if (do_key_password_change && !this.state.login_setup_new_key_password)
     892            0 :             login_setup_new_key_password_error = _("The new key password can not be empty");
     893              : 
     894            0 :         if (do_key_password_change && this.state.login_setup_new_key_password !== this.state.login_setup_new_key_password2)
     895            0 :             login_setup_new_key_password2_error = _("The key passwords do not match");
     896              : 
     897            0 :         if (custom_password_error || locked_identity_password_error || login_setup_new_key_password_error || login_setup_new_key_password2_error) {
     898            0 :             this.setState({
     899            0 :                 custom_password_error,
     900            0 :                 locked_identity_password_error,
     901            0 :                 login_setup_new_key_password_error,
     902            0 :                 login_setup_new_key_password2_error,
     903            0 :             });
     904            0 :             return;
     905            0 :         }
     906              : 
     907            0 :         this.setState({ inProgress: true });
     908            0 :         const machine = this.props.machines_ins.lookup(this.props.full_address);
     909              : 
     910            0 :         this.props.run(this.maybe_unlock_key()
     911            0 :                 .then(() => {
     912            0 :                     return try2Connect(this.props.machines_ins, this.props.full_address, options)
     913            0 :                             .then(() => {
     914            0 :                                 if (machine)
     915            0 :                                     return this.props.machines_ins.change(machine.address, { user });
     916              :                                 else
     917            0 :                                     return Promise.resolve();
     918            0 :                             })
     919            0 :                             .then(() => {
     920            0 :                                 if (do_key_password_change)
     921            0 :                                     return this.keys.change(this.state.default_ssh_key.name, this.state.locked_identity_password, this.state.login_setup_new_key_password);
     922            0 :                                 else if (this.state.auto_login)
     923            0 :                                     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            0 :                                     return Promise.resolve();
     927            0 :                             });
     928            0 :                 })
     929            0 :                 .catch(ex => {
     930            0 :                     this.setState({ inProgress: false });
     931            0 :                     throw ex;
     932            0 :                 }));
     933            0 :     }
     934              : 
     935            0 :     render() {
     936            0 :         const { offer_login_password, offer_key_password } = this.getSupports();
     937            0 :         const both = offer_login_password && offer_key_password;
     938              : 
     939            0 :         let offer_key_setup = true;
     940            0 :         let show_password_advice = true;
     941            0 :         if (!this.state.default_ssh_key)
     942            0 :             offer_key_setup = false;
     943            0 :         else if (this.state.default_ssh_key.unaligned_passphrase)
     944            0 :             offer_key_setup = (both && this.state.auth === "key") || (!both && offer_key_password);
     945            0 :         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            0 :             show_password_advice = false;
     950            0 :             offer_key_setup = false;
     951            0 :         }
     952              : 
     953            0 :         const callback = this.login;
     954            0 :         const title = cockpit.format(_("Log in to $0"), this.props.full_address);
     955            0 :         const submitText = _("Log in");
     956            0 :         let statement = "";
     957              : 
     958            0 :         if (!offer_login_password && !offer_key_password)
     959            0 :             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            0 :         else if (offer_login_password && !offer_key_password)
     961            0 :             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            0 :         else if (offer_key_password && !offer_login_password)
     963            0 :             statement = <>
     964            0 :                 <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            0 :                 {show_password_advice && <span className="password-change-advice">{_("You may want to change the password of the key for automatic login.")}</span>}
     966            0 :             </>;
     967            0 :         else if (both)
     968            0 :             statement = <>
     969            0 :                 <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            0 :                 {show_password_advice && <span className="password-change-advice">{_("You may want to change the password of the key for automatic login.")}</span>}
     971            0 :             </>;
     972              : 
     973            0 :         let auto_text = null;
     974            0 :         let auto_details = null;
     975            0 :         if (this.state.default_ssh_key) {
     976            0 :             const lmach = this.props.machines_ins.lookup(null);
     977            0 :             const key = this.state.default_ssh_key.name;
     978            0 :             const luser = this.state.user.name;
     979            0 :             const lhost = lmach ? lmach.label || lmach.address : "localhost";
     980            0 :             const afile = "~/.ssh/authorized_keys";
     981            0 :             const ruser = split_connection_string(this.props.full_address).user || this.state.user.name;
     982            0 :             const rhost = split_connection_string(this.props.full_address).address;
     983            0 :             if (!this.state.default_ssh_key.exists) {
     984            0 :                 auto_text = _("Create a new SSH key and authorize it");
     985            0 :                 auto_details = <>
     986            0 :                     <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            0 :                     <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            0 :                                 type="password" value={this.state.login_setup_new_key_password} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} />
     990            0 :                         <FormHelper helperTextInvalid={this.state.login_setup_new_key_password_error} />
     991            0 :                     </FormGroup>
     992            0 :                     <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            0 :                                 type="password" value={this.state.login_setup_new_key_password2} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} />
     995            0 :                         <FormHelper helperTextInvalid={this.state.login_setup_new_key_password2_error} />
     996            0 :                     </FormGroup>
     997            0 :                     <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            0 :                 </>;
     999            0 :             } else if (this.state.default_ssh_key.unaligned_passphrase) {
    1000            0 :                 auto_text = cockpit.format(_("Change the password of $0"), key);
    1001            0 :                 auto_details = <>
    1002            0 :                     <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            0 :                     <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            0 :                                 type="password" value={this.state.login_setup_new_key_password} validated={this.state.login_setup_new_key_password_error ? "error" : "default"} />
    1006            0 :                         <FormHelper helperTextInvalid={this.state.login_setup_new_key_password_error} />
    1007            0 :                     </FormGroup>
    1008            0 :                     <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            0 :                                 type="password" value={this.state.login_setup_new_key_password2} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} />
    1011              : 
    1012            0 :                         <FormHelper helperTextInvalid={this.state.login_setup_new_key_password2_error} />
    1013            0 :                     </FormGroup>
    1014            0 :                     <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            0 :                 </>;
    1016            0 :             } else {
    1017            0 :                 auto_text = _("Authorize SSH key");
    1018            0 :                 auto_details = <>
    1019            0 :                     <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            0 :                     <p>{_("This will allow you to log in without password in the future.")}</p>
    1021            0 :                 </>;
    1022            0 :             }
    1023            0 :         }
    1024              : 
    1025            0 :         const body = <>
    1026            0 :             {statement}
    1027            0 :             <br />
    1028            0 :             {(offer_login_password || offer_key_password) &&
    1029            0 :                 <Form isHorizontal onSubmit={prevent_default(callback)}>
    1030            0 :                     {both &&
    1031            0 :                         <FormGroup label={_("Authentication")} isInline hasNoPaddingTop>
    1032            0 :                             <Radio isChecked={this.state.auth === "password"}
    1033            0 :                                    onChange={() => this.setState({ auth: "password" })}
    1034            0 :                                    id="auth-password"
    1035            0 :                                    value="password"
    1036            0 :                                    label={_("Password")} />
    1037            0 :                             <Radio isChecked={this.state.auth === "key"}
    1038            0 :                                    onChange={() => this.setState({ auth: "key" })}
    1039            0 :                                    id="auth-key"
    1040            0 :                                    value="key"
    1041            0 :                                    label={_("SSH key")} />
    1042            0 :                         </FormGroup>
    1043              :                     }
    1044            0 :                     {((both && this.state.auth === "password") || (!both && offer_login_password)) &&
    1045            0 :                         <FormGroup label={_("Password")}>
    1046            0 :                             <TextInput id="login-custom-password" onChange={(_event, value) => this.setState({ custom_password: value })}
    1047            0 :                                        type="password" value={this.state.custom_password} validated={this.state.custom_password_error ? "error" : "default"} />
    1048            0 :                             <FormHelper helperTextInvalid={this.state.custom_password_error} />
    1049            0 :                         </FormGroup>
    1050              :                     }
    1051            0 :                     {((both && this.state.auth === "key") || (!both && offer_key_password)) &&
    1052            0 :                         <FormGroup label={_("Key password")}>
    1053            0 :                             <TextInput id="locked-identity-password" onChange={(_event, value) => this.setState({ locked_identity_password: value })}
    1054            0 :                                     type="password" autoComplete="new-password" value={this.state.locked_identity_password} validated={this.state.locked_identity_password_error ? "error" : "default"} />
    1055            0 :                             <FormHelper
    1056            0 :                                 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            0 :                                 helperTextInvalid={this.state.locked_identity_password_error} />
    1058            0 :                         </FormGroup>
    1059              :                     }
    1060            0 :                     {offer_key_setup &&
    1061            0 :                         <FormGroup label={ _("Automatic login") } hasNoPaddingTop isInline>
    1062            0 :                             <Checkbox onChange={(_event, checked) => this.setState({ auto_login: checked })}
    1063            0 :                                       isChecked={this.state.auto_login} id="login-setup-keys"
    1064            0 :                                       label={auto_text} body={this.state.auto_login ? auto_details : null} />
    1065            0 :                         </FormGroup>
    1066              :                     }
    1067            0 :                 </Form>
    1068              :             }
    1069            0 :         </>;
    1070              : 
    1071            0 :         return (
    1072            0 :             <Modal id="hosts_setup_server_dialog" isOpen
    1073            0 :                    position="top" variant="medium"
    1074            0 :                    onClose={this.props.onClose}
    1075              :             >
    1076            0 :                 <ModalHeader title={title} />
    1077            0 :                 <ModalBody>
    1078            0 :                     <Stack hasGutter>
    1079            0 :                         { this.props.dialogError && <ModalError dialogError={this.props.dialogError} />}
    1080            0 :                         {body}
    1081            0 :                     </Stack>
    1082            0 :                 </ModalBody>
    1083            0 :                 <ModalFooter>
    1084            0 :                     <Button variant="primary" onClick={callback} isLoading={this.state.inProgress}
    1085            0 :                             isDisabled={this.state.inProgress || (!offer_login_password && !offer_key_password) || !this.state.default_ssh_key || !this.props.error_options}>
    1086            0 :                         { submitText }
    1087            0 :                     </Button>
    1088            0 :                     <Button variant="link" className="btn-cancel" onClick={this.props.onClose}>
    1089            0 :                         { _("Cancel") }
    1090            0 :                     </Button>
    1091            0 :                 </ModalFooter>
    1092            0 :             </Modal>
    1093              :         );
    1094            0 :     }
    1095          123 : }
    1096              : 
    1097            0 : function try2Connect(machines_ins, address, options) {
    1098            0 :     return new Promise((resolve, reject) => {
    1099            0 :         const conn_options = { ...options, payload: "echo", host: address };
    1100              : 
    1101            0 :         conn_options["init-superuser"] = get_init_superuser_for_options(conn_options);
    1102              : 
    1103            0 :         const machine = machines_ins.lookup(address);
    1104            0 :         if (machine && machine.host_key && !machine.on_disk) {
    1105            0 :             conn_options['temp-session'] = false; // Compatibility option
    1106            0 :             conn_options.session = 'shared';
    1107            0 :             conn_options['host-key'] = machine.host_key;
    1108            0 :         }
    1109              : 
    1110            0 :         const client = cockpit.channel(conn_options);
    1111            0 :         client.send("x");
    1112            0 :         client.addEventListener("message", () => {
    1113            0 :             resolve();
    1114            0 :             client.close();
    1115            0 :         });
    1116            0 :         client.addEventListener("close", (event, options) => {
    1117            0 :             reject(options);
    1118            0 :         });
    1119            0 :     });
    1120            0 : }
    1121              : 
    1122          123 : class HostModalInner extends React.Component {
    1123            0 :     constructor(props) {
    1124            0 :         super(props);
    1125              : 
    1126            0 :         this.state = {
    1127            0 :             current_template: this.props.template || "add-machine",
    1128            0 :             address: full_address(props.machines_ins, props.address),
    1129            0 :             old_address: full_address(props.machines_ins, props.address),
    1130            0 :             error_options: this.props.error_options,
    1131            0 :             dialogError: "", // Error to be shown in the modal
    1132            0 :         };
    1133              : 
    1134            0 :         this.promise_callback = null;
    1135              : 
    1136            0 :         this.addressOrLabel = this.addressOrLabel.bind(this);
    1137            0 :         this.changeContent = this.changeContent.bind(this);
    1138            0 :         this.setGoal = this.setGoal.bind(this);
    1139            0 :         this.setError = this.setError.bind(this);
    1140            0 :         this.setAddress = this.setAddress.bind(this);
    1141            0 :         this.run = this.run.bind(this);
    1142            0 :         this.complete = this.complete.bind(this);
    1143            0 :     }
    1144              : 
    1145            0 :     addressOrLabel() {
    1146            0 :         const machine = this.props.machines_ins.lookup(this.state.address);
    1147            0 :         let host = split_connection_string(this.state.address).address;
    1148            0 :         if (machine && machine.label)
    1149            0 :             host = machine.label;
    1150            0 :         return host;
    1151            0 :     }
    1152              : 
    1153            0 :     changeContent(template, error_options, with_error_message) {
    1154            0 :         if (this.state.current_template !== template)
    1155            0 :             this.setState({
    1156            0 :                 current_template: template,
    1157            0 :                 error_options,
    1158            0 :                 dialogError: with_error_message ? cockpit.message(error_options) : null,
    1159            0 :             });
    1160            0 :     }
    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            0 :     setGoal(callback) {
    1170            0 :         this.promise_callback = callback;
    1171            0 :     }
    1172              : 
    1173            0 :     setError(error, keep_message_on_change) {
    1174            0 :         if (error === null)
    1175            0 :             return this.setState({ dialogError: null });
    1176              : 
    1177            0 :         let template = null;
    1178            0 :         if (error.problem && error.command === "close")
    1179            0 :             template = codes[error.problem];
    1180              : 
    1181            0 :         if (template && this.state.current_template !== template)
    1182            0 :             this.changeContent(template, error, keep_message_on_change);
    1183              :         else
    1184            0 :             this.setState({ error_options: error, dialogError: cockpit.message(error) });
    1185            0 :     }
    1186              : 
    1187            0 :     setAddress(address) {
    1188            0 :         this.setState({ address });
    1189            0 :     }
    1190              : 
    1191            0 :     run(promise, failure_callback) {
    1192            0 :         return new Promise((resolve, reject) => {
    1193            0 :             const promise_funcs = [];
    1194            0 :             const self = this;
    1195              : 
    1196            0 :             function next(i) {
    1197            0 :                 promise_funcs[i]()
    1198            0 :                         .then(val => {
    1199            0 :                             i = i + 1;
    1200            0 :                             if (i < promise_funcs.length) {
    1201            0 :                                 next(i);
    1202            0 :                             } else {
    1203            0 :                                 resolve();
    1204            0 :                                 self.props.onClose();
    1205            0 :                             }
    1206            0 :                         })
    1207            0 :                         .catch(ex => {
    1208            0 :                             if (failure_callback)
    1209            0 :                                 failure_callback(ex);
    1210              :                             else
    1211            0 :                                 self.setError(ex);
    1212            0 :                         });
    1213            0 :             }
    1214              : 
    1215            0 :             promise_funcs.push(() => { return promise });
    1216              : 
    1217            0 :             if (this.promise_callback)
    1218            0 :                 promise_funcs.push(this.promise_callback);
    1219              : 
    1220            0 :             if (this.props.caller_callback)
    1221            0 :                 promise_funcs.push(() => this.props.caller_callback(this.state.address));
    1222              : 
    1223            0 :             next(0);
    1224            0 :         });
    1225            0 :     }
    1226              : 
    1227            0 :     render() {
    1228            0 :         const template = this.state.current_template;
    1229              : 
    1230            0 :         const props = {
    1231            0 :             template,
    1232            0 :             host: this.addressOrLabel(),
    1233            0 :             full_address: this.state.address,
    1234            0 :             old_address: this.state.old_address,
    1235            0 :             address_data: split_connection_string(this.state.address),
    1236            0 :             error_options: this.state.error_options,
    1237            0 :             dialogError: this.state.dialogError,
    1238            0 :             machines_ins: this.props.machines_ins,
    1239            0 :             onClose: () => {
    1240            0 :                 if (this.props.caller_cancelled)
    1241            0 :                     this.props.caller_cancelled();
    1242            0 :                 this.props.onClose();
    1243            0 :             },
    1244            0 :             run: this.run,
    1245            0 :             setGoal: this.setGoal,
    1246            0 :             setError: this.setError,
    1247            0 :             setAddress: this.setAddress,
    1248            0 :             try2Connect: this.try2Connect,
    1249            0 :             complete: this.complete,
    1250            0 :         };
    1251              : 
    1252            0 :         if (template === "connect")
    1253            0 :             return <Connect {...props} />;
    1254            0 :         else if (template === "add-machine")
    1255            0 :             return <AddMachine {...props} />;
    1256            0 :         else if (template === "unknown-hostkey" || template === "unknown-host" || template === "invalid-hostkey")
    1257            0 :             return <HostKey {...props} />;
    1258            0 :         else if (template === "change-auth")
    1259            0 :             return <ChangeAuth {...props} />;
    1260            0 :         else if (template === "change-port")
    1261            0 :             return <MachinePort {...props} />;
    1262            0 :         else if (template === "not-supported")
    1263            0 :             return <NotSupported {...props} />;
    1264              : 
    1265            0 :         console.error("Unknown template:", template);
    1266            0 :         return null;
    1267            0 :     }
    1268          123 : }
    1269              : 
    1270          123 : HostModalInner.propTypes = {
    1271          123 :     machines_ins: PropTypes.object.isRequired,
    1272          123 :     onClose: PropTypes.func.isRequired,
    1273          123 :     caller_callback: PropTypes.func,
    1274          123 :     address: PropTypes.string,
    1275          123 :     template: PropTypes.string,
    1276          123 : };
    1277              : 
    1278          120 : export const HostModal = ({ state, machines }) => {
    1279          120 :     if (!state.modal_properties)
    1280          120 :         return null;
    1281              : 
    1282           19 :     return <HostModalInner machines_ins={machines}
    1283            0 :                            onClose={() => state.close_modal()}
    1284           19 :                            {...state.modal_properties}
    1285           19 :                            caller_callback={state.modal_callback}
    1286            0 :                            caller_cancelled={() => state.modal_callback(null)} />;
    1287          120 : };
        

Generated by: LCOV version 2.0-1