LCOV - code coverage report
Current view: top level - pkg/shell - hosts_dialog.jsx Coverage Total Hit
Test: cockpit Lines: 82.2 % 1045 859
Test Date: 2026-07-03 14:25:32

            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 : };
        

Generated by: LCOV version 2.0-1