LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-connect-ssh.tsx Coverage Total Hit
Test: cockpit Lines: 97.1 % 418 406
Test Date: 2026-07-17 12:03:54

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2024 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6              : /* Dialogs for setting up SSH connection to a remote host. The central and only exported function here is
       7              :  * connect_host() at the very bottom of this file.
       8              :  */
       9              : 
      10            3 : import React, { useState } from 'react';
      11              : 
      12              : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
      13              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      14              : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
      15              : import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js";
      16              : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
      17              : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
      18              : import {
      19              :     Modal, ModalBody, ModalFooter, ModalHeader
      20              : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
      21              : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
      22              : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
      23              : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      24              : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
      25              : import { OutlinedQuestionCircleIcon } from "@patternfly/react-icons";
      26              : 
      27            3 : import cockpit from "cockpit";
      28              : import * as credentials from "credentials";
      29              : import { Dialogs, DialogResult } from "dialogs";
      30              : import { FormHelper } from "cockpit-components-form-helper";
      31              : import { ModalError } from "cockpit-components-inline-notification.jsx";
      32              : 
      33              : // @ts-expect-error: magic verbatim string import, not a JS module
      34              : import ssh_show_default_key_sh from "./ssh-show-default-key.sh";
      35              : // @ts-expect-error: magic verbatim string import, not a JS module
      36              : import ssh_add_key_sh from "./ssh-add-key.sh";
      37              : 
      38            3 : const _ = cockpit.gettext;
      39              : 
      40            3 : function debug(...args: unknown[]) {
      41            1 :     if (window.debugging === "all" || window.debugging?.includes("connect-ssh"))
      42            1 :         console.debug("cockpit-connect-ssh:", ...args);
      43            3 : }
      44              : 
      45              : type Address = {
      46              :     address: string,
      47              :     port?: number,
      48              :     user?: string
      49              : };
      50              : 
      51            2 : function split_connection_string(conn_to: string): Address {
      52            2 :     const addr: Address = { address: "" };
      53            2 :     let user_spot = -1;
      54            2 :     let port_spot = -1;
      55              : 
      56            2 :     if (conn_to) {
      57            2 :         user_spot = conn_to.lastIndexOf('@');
      58            2 :         port_spot = conn_to.lastIndexOf(':');
      59            2 :     }
      60              : 
      61            1 :     if (user_spot > 0) {
      62            1 :         addr.user = conn_to.substring(0, user_spot);
      63            1 :         conn_to = conn_to.substring(user_spot + 1);
      64            1 :         port_spot = conn_to.lastIndexOf(':');
      65            1 :     }
      66              : 
      67            1 :     if (port_spot > -1) {
      68            1 :         const port = parseInt(conn_to.substring(port_spot + 1), 10);
      69            1 :         if (!isNaN(port)) {
      70            1 :             addr.port = port;
      71            1 :             conn_to = conn_to.substring(0, port_spot);
      72            1 :         }
      73            1 :     }
      74              : 
      75            2 :     addr.address = conn_to;
      76            2 :     return addr;
      77            2 : }
      78              : 
      79            3 : function try_connect(options: cockpit.ChannelOptions): Promise<void> {
      80            3 :     return new Promise((resolve, reject) => {
      81              :         // `binary: false` is the default, but https://github.com/microsoft/TypeScript/issues/58977
      82            3 :         const client = cockpit.channel({ ...options, payload: "echo", binary: false });
      83            3 :         client.send("x");
      84            3 :         client.addEventListener("message", () => {
      85            3 :             resolve();
      86            3 :             client.close();
      87            3 :         });
      88            3 :         client.addEventListener("close", (_ev, options) => reject(options));
      89            3 :     });
      90            3 : }
      91              : 
      92            2 : const UnknownHostDialog = ({ host, error, dialogResult }: {
      93              :     host: string,
      94              :     error: cockpit.JsonObject,
      95              :     dialogResult: DialogResult<void>,
      96            2 : }) => {
      97            2 :     const [inProgress, setInProgress] = useState(false);
      98            2 :     const [verifyExpanded, setVerifyExpanded] = useState(false);
      99            2 :     const [dialogError, setDialogError] = useState("");
     100              : 
     101            2 :     cockpit.assert(error["host-key"] && error["host-fingerprint"],
     102            2 :                    "UnknownHostDialog needs a host-key and host-fingerprint in error");
     103            2 :     const host_key: string = (error["host-key"] as string).trim();
     104            2 :     const host_fp = error["host-fingerprint"] as string;
     105              : 
     106            2 :     const key_type = host_key.split(" ")[1];
     107            2 :     cockpit.assert(key_type, "host-key did not include a key type");
     108              : 
     109            2 :     const scan_cmd = `ssh-keyscan -t ${key_type} localhost | ssh-keygen -lf -`;
     110              : 
     111            2 :     const address = split_connection_string(host);
     112              : 
     113            1 :     const title = cockpit.format(error.problem === "invalid-hostkey" ? _("$0 key changed") : _("Unknown host: $0"),
     114            2 :                                  address.address);
     115            2 :     const submitText = _("Trust and add host");
     116            2 :     let body = null;
     117            1 :     if (error.problem === "invalid-hostkey") {
     118            1 :         body = <>
     119            1 :             <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.")} />
     120            1 :             <p>{_("To ensure that your connection is not intercepted by a malicious third-party, please verify the host key fingerprint:")}</p>
     121            1 :             <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-fingerprint pf-v6-u-font-family-monospace">{host_fp}</ClipboardCopy>
     122            1 :             <p className="hostkey-type">({key_type})</p>
     123            1 :             <p>{cockpit.format(_("To verify a fingerprint, run the following on $0 while physically sitting at the machine or through a trusted network:"), address.address)}</p>
     124            1 :             <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help-cmds pf-v6-u-font-family-monospace">{scan_cmd}</ClipboardCopy>
     125            1 :             <p>{_("The resulting fingerprint is fine to share via public methods, including email.")}</p>
     126            1 :             <p>{_("If the fingerprint matches, click 'Trust and add host'. Otherwise, do not connect and contact your administrator.")}</p>
     127            1 :         </>;
     128            1 :     } else {
     129            2 :         const fingerprint_help = <Popover bodyContent={
     130            2 :             _("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.")}>
     131            2 :             <OutlinedQuestionCircleIcon />
     132            2 :         </Popover>;
     133            2 :         body = <>
     134            2 :             <p>{cockpit.format(_("You are connecting to $0 for the first time."), address.address)}</p>
     135            2 :             <ExpandableSection toggleText={ _("Verify fingerprint") }
     136            2 :                                 isExpanded={verifyExpanded}
     137            1 :                                 onToggle={(_ev, value) => setVerifyExpanded(value) }>
     138            2 :                 <div>{_("Run this command over a trusted network or physically on the remote machine:")}</div>
     139            2 :                 <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help hostkey-verify-help-cmds pf-v6-u-font-family-monospace">{scan_cmd}</ClipboardCopy>
     140            2 :                 <div>{_("The fingerprint should match:")} {fingerprint_help}</div>
     141            2 :                 <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} className="hostkey-verify-help hostkey-fingerprint pf-v6-u-font-family-monospace">{host_fp}</ClipboardCopy>
     142            2 :             </ExpandableSection>
     143            2 :         </>;
     144            2 :     }
     145              : 
     146            2 :     const onAddKey = async () => {
     147            2 :         setInProgress(true);
     148            2 :         debug("onAddKey", error);
     149            2 :         try {
     150            2 :             await cockpit.script(ssh_add_key_sh, [host_key, "known_hosts"], { err: "message" });
     151            2 :             dialogResult.resolve();
     152            0 :         } catch (ex) { // not-covered: OS error
     153            0 :             setDialogError((ex as cockpit.BasicError).toString()); // not-covered: OS error
     154            0 :             setInProgress(false); // not-covered: OS error
     155            0 :         }
     156            2 :     };
     157              : 
     158            2 :     return (
     159            2 :         <Modal id="ssh-unknown-host-dialog" isOpen
     160            2 :                 position="top" variant="medium"
     161            1 :                 onClose={() => dialogResult.reject("cancel")}
     162              :         >
     163            2 :             <ModalHeader title={title} />
     164            2 :             <ModalBody>
     165            2 :                 <Stack hasGutter>
     166            0 :                     { dialogError && <ModalError dialogError={dialogError} />}
     167            2 :                     {body}
     168            2 :                 </Stack>
     169            2 :             </ModalBody>
     170            2 :             <ModalFooter>
     171            2 :                 <Button variant="primary" onClick={onAddKey} isLoading={inProgress} isDisabled={inProgress}>
     172            2 :                     { submitText }
     173            2 :                 </Button>
     174            1 :                 <Button variant="link" className="btn-cancel" onClick={() => dialogResult.reject("cancel")}>
     175            2 :                     { _("Cancel") }
     176            2 :                 </Button>
     177            2 :             </ModalFooter>
     178            2 :         </Modal>
     179              :     );
     180            2 : };
     181              : 
     182              : interface ChangeAuthProps {
     183              :     host: string;
     184              :     user?: string;
     185              :     error: cockpit.JsonObject,
     186              :     dialogResult: DialogResult<void>;
     187              : }
     188              : 
     189              : interface ChangeAuthState {
     190              :     auth: string;
     191              :     setup_ssh: boolean,
     192              :     custom_password: string;
     193              :     custom_password_error: string;
     194              :     locked_identity_password: string;
     195              :     locked_identity_password_error: string;
     196              :     login_setup_new_key_password: string;
     197              :     login_setup_new_key_password2: string;
     198              :     login_setup_new_key_password2_error: string;
     199              :     user: cockpit.UserInfo | null;
     200              :     default_ssh_key: { name: string, type?: string, exists: boolean, encrypted: boolean } | null;
     201              :     identity_path: string | null;
     202              :     in_progress: boolean; // componentDidMount changes to false once loaded
     203              :     dialogError: string;
     204              : }
     205              : 
     206            3 : class ChangeAuthDialog extends React.Component<ChangeAuthProps, ChangeAuthState> {
     207            2 :     constructor(props: ChangeAuthProps) {
     208            2 :         super(props);
     209              : 
     210            2 :         this.state = {
     211            2 :             auth: "password",
     212            2 :             setup_ssh: false,
     213            2 :             custom_password: "",
     214            2 :             custom_password_error: "",
     215            2 :             locked_identity_password: "",
     216            2 :             locked_identity_password_error: "",
     217            2 :             login_setup_new_key_password: "",
     218            2 :             login_setup_new_key_password2: "",
     219            2 :             login_setup_new_key_password2_error: "",
     220            2 :             user: null,
     221            2 :             default_ssh_key: null,
     222            2 :             identity_path: null,
     223            2 :             in_progress: true, // componentDidMount changes to false once loaded
     224            2 :             dialogError: "",
     225            2 :         };
     226              : 
     227            2 :         this.login = this.login.bind(this);
     228            2 :     }
     229              : 
     230              :     // wrapper to silence typescript's "property does not exist"
     231            1 :     keys() {
     232              :         // @ts-expect-error: "property does not exist", yes TS, that's why we add it here..
     233            1 :         if (!this.__keys)
     234              :             // @ts-expect-error: ditto
     235            1 :             this.__keys = credentials.keys_instance();
     236              :         // @ts-expect-error: ditto
     237            1 :         return this.__keys;
     238            1 :     }
     239              : 
     240            2 :     updateIdentity() {
     241            2 :         const e = this.props.error.error as string;
     242            1 :         const identity_path = e?.startsWith("locked identity") ? e.split(": ")[1] : null;
     243              : 
     244            2 :         this.setState({ identity_path });
     245            2 :     }
     246              : 
     247            2 :     async componentDidMount() {
     248            2 :         try {
     249            2 :             const user = await cockpit.user();
     250            2 :             const output = await cockpit.script(ssh_show_default_key_sh, [], { });
     251            2 :             const info = output.split("\n");
     252            2 :             let default_ssh_key = null;
     253            2 :             if (info[0])
     254            1 :                 default_ssh_key = {
     255            1 :                     name: info[0],
     256            1 :                     exists: true,
     257            1 :                     encrypted: info[1] === "encrypted",
     258            1 :                 };
     259              :             else
     260            2 :                 default_ssh_key = {
     261            2 :                     name: user.home + "/.ssh/id_rsa",
     262            2 :                     type: "rsa",
     263            2 :                     exists: false,
     264            2 :                     encrypted: false,
     265            2 :                 };
     266              : 
     267            2 :             return this.setState({ in_progress: false, default_ssh_key, user }, this.updateIdentity);
     268            0 :         } catch (ex) { // not-covered: OS error
     269            0 :             const dialogError = (ex as cockpit.BasicError).toString(); // not-covered: ditto
     270            0 :             this.setState({ in_progress: false, dialogError }); // not-covered: ditto
     271            0 :         }
     272            2 :     }
     273              : 
     274            2 :     componentWillUnmount() {
     275              :         // @ts-expect-error: see keys()
     276            1 :         this.__keys?.close();
     277              :         // @ts-expect-error: see keys()
     278            2 :         this.__keys = null;
     279            2 :     }
     280              : 
     281            2 :     getSupports() {
     282            2 :         const methods = this.props.error["auth-method-results"] as cockpit.JsonObject;
     283            2 :         return {
     284            2 :             offer_login_password: methods.password && methods.password !== "no-server-support",
     285            2 :             offer_key_password: this.state.identity_path !== null,
     286            2 :         };
     287            2 :     }
     288              : 
     289            1 :     async maybe_create_key(passphrase: string) {
     290            1 :         const key = this.state.default_ssh_key!;
     291            1 :         if (!key.exists)
     292            1 :             await this.keys().create(key.name, key.type, passphrase);
     293            1 :     }
     294              : 
     295            1 :     async authorize_key() {
     296            1 :         const key = this.state.default_ssh_key!;
     297            1 :         const pubkey = await this.keys().get_pubkey(key.name);
     298            1 :         await cockpit.script(
     299            1 :             ssh_add_key_sh, [pubkey.trim()],
     300            1 :             { host: this.props.host, ...this.props.user && { user: this.props.user }, err: "message" }
     301            1 :         );
     302            1 :     }
     303              : 
     304            2 :     async maybe_unlock_key() {
     305            2 :         const { offer_login_password, offer_key_password } = this.getSupports();
     306            2 :         const both = offer_login_password && offer_key_password;
     307              : 
     308            1 :         if ((both && this.state.auth === "key") || (!both && offer_key_password))
     309            1 :             await this.keys().load(this.state.identity_path, this.state.locked_identity_password);
     310            2 :     }
     311              : 
     312            2 :     async login() {
     313            2 :         const options: cockpit.ChannelOptions = { host: this.props.host };
     314            2 :         if (this.props.user)
     315            2 :             options.user = this.props.user;
     316              : 
     317            2 :         let custom_password_error = "";
     318            2 :         let locked_identity_password_error = "";
     319            2 :         let login_setup_new_key_password2_error = "";
     320              : 
     321            2 :         const { offer_login_password, offer_key_password } = this.getSupports();
     322            2 :         const both = offer_login_password && offer_key_password;
     323              : 
     324            1 :         if ((both && this.state.auth === "password") || (!both && offer_login_password)) {
     325            2 :             if (!this.state.custom_password)
     326            1 :                 custom_password_error = _("The password can not be empty");
     327              : 
     328            2 :             options.password = this.state.custom_password;
     329            2 :         }
     330              : 
     331            1 :         if ((offer_key_password && !(both && this.state.auth === "password")) && !this.state.locked_identity_password)
     332            1 :             locked_identity_password_error = _("The key password can not be empty");
     333            1 :         if (this.state.setup_ssh && this.state.login_setup_new_key_password !== this.state.login_setup_new_key_password2)
     334            1 :             login_setup_new_key_password2_error = _("The key passwords do not match");
     335              : 
     336            2 :         this.setState({
     337            2 :             custom_password_error,
     338            2 :             locked_identity_password_error,
     339            2 :             login_setup_new_key_password2_error,
     340            2 :         });
     341              : 
     342            2 :         if (custom_password_error || locked_identity_password_error || login_setup_new_key_password2_error)
     343            2 :             return;
     344              : 
     345            2 :         this.setState({ in_progress: true });
     346              : 
     347            2 :         try {
     348            2 :             await this.maybe_unlock_key();
     349            2 :             await try_connect(options);
     350            1 :             if (this.state.setup_ssh) {
     351            1 :                 await this.maybe_create_key(this.state.login_setup_new_key_password);
     352            1 :                 await this.authorize_key();
     353            1 :             }
     354            2 :             this.props.dialogResult.resolve();
     355            2 :         } catch (ex) {
     356            2 :             const err = ex as cockpit.JsonObject;
     357            2 :             if (err.problem === "no-cockpit")
     358              :                 // this is handled in a separate dialog, and the SSH connection succeeded at this point
     359            1 :                 this.props.dialogResult.reject(err);
     360            2 :             this.setState({ in_progress: false, dialogError: cockpit.message(err) });
     361            2 :         }
     362            2 :     }
     363              : 
     364            2 :     render() {
     365            2 :         const { offer_login_password, offer_key_password } = this.getSupports();
     366            2 :         const both = offer_login_password && offer_key_password;
     367              : 
     368            2 :         let offer_key_setup = true;
     369            2 :         if (!this.state.default_ssh_key)
     370            2 :             offer_key_setup = false;
     371            1 :         else if (this.state.identity_path) {
     372              :             // This is a locked, non-default identity that will never
     373              :             // be loaded into the agent, so there is no point in
     374              :             // offering to change the passphrase.
     375            1 :             offer_key_setup = false;
     376            1 :         }
     377              : 
     378            2 :         const address = split_connection_string(this.props.host);
     379            2 :         const title = cockpit.format(_("Log in to $0"), address.address);
     380            2 :         const submitText = _("Log in");
     381            2 :         let statement: React.ReactNode = null;
     382              : 
     383            2 :         if (!offer_login_password && !offer_key_password)
     384            2 :             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.host)}</p>;
     385            2 :         else if (offer_login_password && !offer_key_password)
     386            1 :             statement = <p>{cockpit.format(_("Unable to log in to $0 using SSH key authentication. Please provide the password."), this.props.host)}</p>;
     387            1 :         else if (offer_key_password && !offer_login_password)
     388            1 :             statement = <p>{cockpit.format(_("The SSH key for logging in to $0 is protected by a password, and the host does not allow logging in with a password. Please provide the password of the key at $1."), this.props.host, this.state.identity_path)}</p>;
     389            1 :         else if (both)
     390            1 :             statement = <p>{cockpit.format(_("The SSH key for logging in to $0 is protected. You can log in with either your login password or by providing the password of the key at $1."), this.props.host, this.state.identity_path)}</p>;
     391              : 
     392            2 :         let ssh_key_text = null;
     393            2 :         let ssh_key_details = null;
     394            2 :         if (this.state.default_ssh_key) {
     395            2 :             const key = this.state.default_ssh_key.name;
     396            2 :             const luser = this.state.user!.name;
     397            2 :             const lhost = "localhost";
     398            2 :             const afile = "~/.ssh/authorized_keys";
     399            2 :             const ruser = this.props.user || address.user || this.state.user!.name;
     400            2 :             if (!this.state.default_ssh_key.exists) {
     401            2 :                 ssh_key_text = _("Create a new SSH key and authorize it");
     402            2 :                 ssh_key_details = <>
     403            2 :                     <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, address.address)}</p>
     404            2 :                     <FormGroup label={_("Key password")}>
     405            1 :                         <TextInput id="login-setup-new-key-password" onChange={(_event, value) => this.setState({ login_setup_new_key_password: value })}
     406            2 :                                 type="password" value={this.state.login_setup_new_key_password} />
     407            2 :                     </FormGroup>
     408            2 :                     <FormGroup label={_("Confirm key password")}>
     409            1 :                         <TextInput id="login-setup-new-key-password2" onChange={(_event, value) => this.setState({ login_setup_new_key_password2: value })}
     410            1 :                                 type="password" value={this.state.login_setup_new_key_password2} validated={this.state.login_setup_new_key_password2_error ? "error" : "default"} />
     411            2 :                         <FormHelper helperTextInvalid={this.state.login_setup_new_key_password2_error} />
     412            2 :                     </FormGroup>
     413            2 :                 </>;
     414            1 :             } else {
     415            1 :                 ssh_key_text = _("Authorize SSH key");
     416            1 :                 ssh_key_details = <p>{cockpit.format(_("The SSH key $0 of $1 on $2 will be added to the $3 file of $4 on $5."), key, luser, lhost, afile, ruser, address.address)}</p>;
     417            1 :             }
     418            2 :         }
     419              : 
     420            2 :         const body = <>
     421            2 :             {statement}
     422            2 :             <br />
     423            2 :             {(offer_login_password || offer_key_password) &&
     424            1 :                 <Form isHorizontal onSubmit={ev => { ev.preventDefault(); this.login() }}>
     425            2 :                     {both &&
     426            1 :                         <FormGroup label={_("Authentication")} isInline hasNoPaddingTop>
     427            1 :                             <Radio name="auth-method"
     428            1 :                                    isChecked={this.state.auth === "password"}
     429            0 :                                    onChange={() => this.setState({ auth: "password" })}
     430            1 :                                    id="auth-password"
     431            1 :                                    value="password"
     432            1 :                                    label={_("Password")} />
     433            1 :                             <Radio name="auth-method"
     434            1 :                                    isChecked={this.state.auth === "key"}
     435            1 :                                    onChange={() => this.setState({ auth: "key" })}
     436            1 :                                    id="auth-key"
     437            1 :                                    value="key"
     438            1 :                                    label={_("SSH key")} />
     439            1 :                         </FormGroup>
     440              :                     }
     441            1 :                     {((both && this.state.auth === "password") || (!both && offer_login_password)) &&
     442            2 :                         <FormGroup label={_("Password")}>
     443            2 :                             <TextInput id="login-custom-password" onChange={(_event, value) => this.setState({ custom_password: value })}
     444            1 :                                        type="password" value={this.state.custom_password} validated={this.state.custom_password_error ? "error" : "default"} />
     445            2 :                             <FormHelper helperTextInvalid={this.state.custom_password_error} />
     446            2 :                         </FormGroup>
     447              :                     }
     448            1 :                     {((both && this.state.auth === "key") || (!both && offer_key_password)) &&
     449            1 :                         <FormGroup label={_("Key password")}>
     450            1 :                             <TextInput id="locked-identity-password" onChange={(_event, value) => this.setState({ locked_identity_password: value })}
     451            1 :                                     type="password" autoComplete="new-password" value={this.state.locked_identity_password} validated={this.state.locked_identity_password_error ? "error" : "default"} />
     452            1 :                             <FormHelper
     453            1 :                                 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)}
     454            1 :                                 helperTextInvalid={this.state.locked_identity_password_error} />
     455            1 :                         </FormGroup>
     456              :                     }
     457            2 :                     {offer_key_setup &&
     458            2 :                         <FormGroup label={ _("SSH key login") } hasNoPaddingTop isInline>
     459            1 :                             <Checkbox onChange={(_event, checked) => this.setState({ setup_ssh: checked })}
     460            2 :                                       isChecked={this.state.setup_ssh} id="login-setup-keys"
     461            1 :                                       label={ssh_key_text} body={this.state.setup_ssh ? ssh_key_details : null} />
     462            2 :                         </FormGroup>
     463              :                     }
     464            2 :                 </Form>
     465              :             }
     466            2 :         </>;
     467              : 
     468            1 :         const onCancel = () => this.props.dialogResult.reject("cancel");
     469              : 
     470            2 :         return (
     471            2 :             <Modal id="ssh-change-auth-dialog" isOpen
     472            2 :                    position="top" variant="medium"
     473            2 :                    onClose={onCancel}
     474              :             >
     475            2 :                 <ModalHeader title={title} />
     476            2 :                 <ModalBody>
     477            2 :                     <Stack hasGutter>
     478            2 :                         { this.state.dialogError && <ModalError dialogError={this.state.dialogError} /> }
     479            2 :                         {body}
     480            2 :                     </Stack>
     481            2 :                 </ModalBody>
     482            2 :                 <ModalFooter>
     483            2 :                     <Button variant="primary" onClick={this.login} isLoading={this.state.in_progress}
     484            2 :                             isDisabled={this.state.in_progress || (!offer_login_password && !offer_key_password) || !this.state.default_ssh_key || !this.props.error}>
     485            2 :                         { submitText }
     486            2 :                     </Button>
     487            2 :                     <Button variant="link" className="btn-cancel" onClick={onCancel}>
     488            2 :                         { _("Cancel") }
     489            2 :                     </Button>
     490            2 :                 </ModalFooter>
     491            2 :             </Modal>
     492              :         );
     493            2 :     }
     494            3 : }
     495              : 
     496            1 : const NotSupportedDialog = ({ host, error, dialogResult }: {
     497              :     host: string,
     498              :     error: cockpit.JsonObject,
     499              :     dialogResult: DialogResult<void>,
     500              : }) => (
     501            1 :     <Modal id="ssh-not-supported-dialog" isOpen
     502            1 :             position="top" variant="medium"
     503            1 :             onClose={() => dialogResult.reject(error)}
     504              :     >
     505            1 :         <ModalHeader title={_("Cockpit is not installed")} />
     506            1 :         <ModalBody>
     507            1 :             <Stack hasGutter>
     508            1 :                 <p>{cockpit.format(_("A compatible version of Cockpit is not installed on $0."), host)}</p>
     509            1 :             </Stack>
     510            1 :         </ModalBody>
     511            1 :         <ModalFooter>
     512            1 :             <Button variant="secondary" className="btn-cancel" onClick={() => dialogResult.reject(error)}>
     513            1 :                 { _("Close") }
     514            1 :             </Button>
     515            1 :         </ModalFooter>
     516            1 :     </Modal>
     517              : );
     518              : 
     519            3 : const error_dialogs = {
     520            3 :     "unknown-hostkey": UnknownHostDialog,
     521            3 :     "invalid-hostkey": UnknownHostDialog,
     522            3 :     "authentication-failed": ChangeAuthDialog,
     523            3 :     "no-cockpit": NotSupportedDialog,
     524            3 : };
     525              : 
     526              : /**
     527              :  * Set up SSH connection to a remote host
     528              :  *
     529              :  * Cockpit channels support running on a remote machine through SSH via the
     530              :  * `host` channel option. This only works (without additional authentication
     531              :  * options) if the SSH connection was already established (e.g. through the
     532              :  * deprecated shell's "Add Host" feature), or can be established
     533              :  * noninteractively (e.g. if you have a passwordless SSH key, or a special
     534              :  * noninteractive configuration block in your ~/.ssh/config for the target host).
     535              :  *
     536              :  * For all other cases, call this function first. It shows various dialogs where
     537              :  * the user can specify a login password or unlock their SSH key. If the user
     538              :  * does not already have an SSH key, the dialog also offers to create one. It
     539              :  * also offers to authorize the user's SSH key to the remote machine/user.
     540              :  *
     541              :  * Arguments:
     542              :  * @dialog_context: The page's `DialogsContext`, see ./dialogs.tsx
     543              :  * @host: Same `[user@]host[:port]` format as the channel option; must be
     544              :  *        *exactly* the same as for opening the channel afterwards
     545              :  * @user: Same as the channel option; overrides `user@` portion of @host
     546              :  * Returns: Nothing on success. Afterwards the SSH connection is established and
     547              :  *          you can use the `host` option in channels. Throws a "cancel"
     548              :  *          exception if the user cancelled the dialog. Most SSH errors are
     549              :  *          handled in the dialogs, but you still have to expect and check for
     550              :  *          other Cockpit errors with the usual `{ problem: "...", ... }` structure.
     551              :  *
     552              :  * See pkg/playground/remote.tsx for an example how to use this function.
     553              :  *
     554              :  * [1] https://github.com/cockpit-project/cockpit/blob/main/doc/protocol.md#command-init
     555              :  */
     556            3 : export async function connect_host(dialog_context: Dialogs, host: string, user?: string) {
     557            2 :     const options: cockpit.ChannelOptions = { host, ...user && { user } };
     558            3 :     while (true) {
     559            3 :         try {
     560            3 :             await try_connect(options);
     561            3 :             debug(host, "succeeded");
     562            3 :             break;
     563            2 :         } catch (_ex) {
     564            2 :             const ex = _ex as cockpit.JsonObject;
     565              :             // unknown host or changed host key → re-try with private session to get its host key
     566            2 :             if ((ex.problem === "unknown-host" || ex.problem === "invalid-hostkey") && !ex["host-key"]) {
     567            2 :                 debug(host, "failed with unknown-host, retrying with private session");
     568            2 :                 options.session = "private";
     569            2 :                 continue;
     570            2 :             } else {
     571              :                 // reset
     572            2 :                 delete options.session;
     573            2 :             }
     574              : 
     575              :             // @ts-expect-error: ex is untyped, and this is too much useless hassle
     576            2 :             const dialog = error_dialogs[ex.problem];
     577            2 :             if (dialog) {
     578            2 :                 debug(host, "failed with:", ex, "mapping to", dialog);
     579            2 :                 try {
     580            2 :                     const result = await dialog_context.run(dialog, { host, user, error: ex });
     581            2 :                     debug(host, "dialog result:", result);
     582            1 :                 } catch (_ex) {
     583            1 :                     const dialog_error = _ex as cockpit.JsonObject;
     584            1 :                     debug(host, "dialog", dialog, "failed with:", dialog_error);
     585            1 :                     if (dialog_error.problem === "no-cockpit") {
     586              :                         // avoid another SSH connection
     587            0 :                         await dialog_context.run(NotSupportedDialog, { host, user, error: dialog_error });
     588            0 :                     } else {
     589            1 :                         throw dialog_error;
     590            1 :                     }
     591            1 :                 }
     592            1 :             } else {
     593            1 :                 debug(host, "terminally failed with:", ex);
     594            1 :                 throw ex;
     595            1 :             }
     596            2 :         }
     597            3 :     }
     598            3 : }
        

Generated by: LCOV version 2.0-1