blob: 6aaa1aedb5499acf45b4999b6ff22d614bfd902d [file] [log] [blame]
// Copyright 2022 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Export an element: terminal-ssh-dialog
*/
import {lib} from '../../libdot/index.js';
import {hterm} from '../../hterm/index.js';
import {
deleteIdentityFiles, getIdentityFileNames, getIndexeddbFileSystem,
importIdentityFiles,
} from './nassh_fs.js';
import {LitElement, createRef, css, html, live, ref} from './lit.js';
import './terminal_button.js';
import './terminal_dialog.js';
import './terminal_dropdown.js';
import './terminal_label.js';
import {ProfileType, deleteProfile, getProfileIds, getProfileValues,
setProfileIds, setProfileValues} from './terminal_profiles.js';
import './terminal_textfield.js';
const GOOGLE_HOST_REGEXP = new RegExp(
'\\.(' +
'corp\\.google\\.com|' +
'c\\.googlers\\.com|' +
'cloud\\.googlecorp\\.com|' +
'(internal|proxy)\\.gcpnode\\.com' +
')$');
// SSH options (e.g. '-4') that do not accept a value. Note that all SSH options
// only has one letter (following a hyphen), so we can just use a string to
// store all of them.
const SSH_FLAG_OPTIONS = '46AaCfGgKkMNnqsTtVvXxYy@';
/**
* This represents a parsed SSH command. If the SSH command has a destination
* argument, it should be stored in the `destination` field. All other arguments
* should be stored in `argstr`.
*
* @typedef {{
* destination: ?string,
* argstr: string,
* }}
*/
export let ParsedCommand;
/**
* Parse the ssh command string.
*
* @param {string} command
* @return {!ParsedCommand} The parsed command.
*/
export function parseCommand(command) {
// Split the command into tokens. Like nassh, we (only) support simple
// quoting with double quote symbols.
const matches = command.matchAll(/(?:"[^"]*"|\S+)/g);
let skipNext = false;
for (const match of matches) {
if (skipNext) {
skipNext = false;
continue;
}
// Remove the the double quotes if they are there.
const arg = match[0].replace(/(^"|"$)/g, '');
if (!arg.startsWith('-')) {
// Found the destination argument.
return {
destination: arg,
argstr: (command.slice(0, match.index) +
command.slice(match.index + match[0].length)).trim(),
};
}
// `arg` is an option. We needs to handle all these situations:
//
// - a flag option: e.g. '-4'
// - an option that take a value in the next item: e.g. '-p'
// - an option that take a value inplace: e.g. '-p22'
// - a combination of flags, where the last one might take a value either
// inplace or not: e.g. '-4A', `-4Ap22`, or `-4Ap` with the port number
// in the next item.
for (let j = 1; j < arg.length; ++j) {
if (SSH_FLAG_OPTIONS.includes(arg[j])) {
continue;
}
// The option takes a value. If we have nothing left in `arg`, then the
// value is in the next item.
//
// TODO(lxj): be stricter and check it against a list of known options?
skipNext = j === (arg.length - 1);
break;
}
}
return {
destination: null,
argstr: command.trim(),
};
}
/**
* @typedef {{
* username: string,
* hostname: string,
* port: ?number,
* }}
*/
export let ParsedDestination;
/**
* @param {?string} destination Either 'username@hostname' or
* 'ssh://username@hostname[:port]'.
* @return {?ParsedDestination} Return null if it failed.
*/
export function parseSSHDestination(destination) {
if (!destination) {
return null;
}
// Match ssh://username@hostname[:port]. Openssh does not seem to support ipv6
// as the hostname in this format, so we will also not support it.
const sshUrlMatch =
destination.match(/^ssh:\/\/(.+)@([^:@]+)(?::(\d+))?$/i);
if (sshUrlMatch) {
const [username, hostname, port] = sshUrlMatch.slice(1);
return {
username,
hostname,
port: port ? Number.parseInt(port, 10) : null,
};
}
// Match username@hostname
const match = destination.match(/^(.+)@([^@]+)$/);
if (match) {
const username = match[1];
let hostname = match[2];
// This is to remove the possible "[]" surrounding the ipv6 address.
// Openssh does not seem to support this format though.
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1);
}
return {username, hostname, port: null};
}
return null;
}
/**
* The terminal ssh dialog for creating new ssh connections or modify existing
* ones. Use method `show()` to set the nassh profile id and show the dialog.
*/
export class TerminalSSHDialog extends LitElement {
/** @override */
static get properties() {
return {
nasshProfileId_: {state: true},
userTitle_: {state: true},
parsedCommand_: {state: true},
identities_: {state: true},
settingsProfiles_: {state: true},
suppressCommandError_: {state: true},
};
}
/** @override */
static get styles() {
return css`
:host {
--terminal-dialog-min-width: 500px;
}
#identity-container {
display: flex;
margin-bottom: 16px;
}
#identity-dropdown {
flex-grow: 1;
margin-right: 8px;
}
#identity-input, .hide {
display: none;
}
#settings-profile-container {
margin-bottom: 16px;
}
div[slot="buttons"] {
display: flex;
}
div[slot="buttons"] > span {
flex-grow: 1;
}
`;
}
constructor() {
super();
// The title manually set by the user.
this.userTitle_ = '';
/**
* This should be in sync with `this.commandRef_.value.value`.
*
* @private {!ParsedCommand}
*/
this.parsedCommand_ = {argstr: '', destination: null};
// We suppress the command error for new ssh connection at the beginning
// because we don't want to show the error immediately when (or before) the
// user just start typing.
this.suppressCommandError_ = true;
// This is set in Show(). Empty string means we are creating a new SSH
// connection.
this.nasshProfileId_ = '';
this.DEFAULT_IDENTITY = {
label: hterm.messageManager.get('TERMINAL_HOME_DEFAULT_IDENTITY'),
value: '',
};
this.identities_ = [this.DEFAULT_IDENTITY];
/** @private {?IndexeddbFs} */
this.fileSystem_;
this.settingsProfiles_ = [hterm.Terminal.DEFAULT_PROFILE_ID];
this.dialogRef_ = createRef();
this.commandRef_ = createRef();
this.relayArgsRef_ = createRef();
this.mountPathRef_ = createRef();
this.identityDropdownRef_ = createRef();
this.settingsProfileDropdownRef_ = createRef();
this.okRef_ = createRef();
}
/** @return {string} */
getTitle_() {
if (this.userTitle_) {
return this.userTitle_;
}
if (this.parsedCommand_.destination) {
return this.parsedCommand_.destination;
}
return hterm.messageManager.get('TERMINAL_HOME_NEW_SSH_CONNECTION');
}
/** @override */
render() {
const msg = (id) => hterm.messageManager.get(id);
let commandError = '';
if (!parseSSHDestination(this.parsedCommand_.destination)) {
commandError = msg('TERMINAL_HOME_SSH_SPECIFY_DESTINATION');
} else {
this.suppressCommandError_ = false;
}
const identityLabel = msg('IDENTITY_LABEL');
const settingsProfileLabel = msg('TERMINAL_PROFILE_LABEL');
const example = 'username@hostname -p <port> -R 1234:localhost:5678';
return html`
<terminal-dialog ${ref(this.dialogRef_)}
@close="${this.onDialogClose_}">
<div slot="title">
<terminal-textfield blendIn fitContent
ariaLabel="${msg('TERMINAL_HOME_SSH_CONNECTION_NAME')}"
value="${live(this.getTitle_())}"
@keydown="${this.onTextfieldKeydown_}"
@input="${(e) => this.userTitle_ = e.target.value}">
</terminal-textfield>
</div>
<terminal-textfield ${ref(this.commandRef_)}
error="${this.suppressCommandError_ ? '' : commandError}"
label="${msg('TERMINAL_HOME_SSH_COMMAND')}"
@keydown="${this.onTextfieldKeydown_}"
@change="${() => this.suppressCommandError_ = false}"
@input="${this.onCommandUpdated_}"
placeholder="${example}"
title="${example}">
<span slot="inline-prefix">ssh&nbsp</span>
</terminal-textfield>
<div id="settings-profile-container"
class="${this.settingsProfiles_.length <= 1 ? 'hide' : ''}">
<terminal-label>${settingsProfileLabel}</terminal-label>
<terminal-dropdown ${ref(this.settingsProfileDropdownRef_)}
.options="${this.settingsProfiles_.map((value) => ({value}))}"
ariaLabel="${settingsProfileLabel}">
</terminal-dropdown>
</div>
<terminal-label>${identityLabel}</terminal-label>
<div id="identity-container">
<terminal-dropdown
${ref(this.identityDropdownRef_)}
ariaLabel="${identityLabel}"
id="identity-dropdown"
@delete-item=${this.onDeleteIdentity_}
.options="${this.identities_}">
</terminal-dropdown>
<terminal-button @click=${this.onImportButtonClick_}>
${msg('TERMINAL_HOME_IMPORT_IDENTITY')}
</terminal-button>
<input id="identity-input" type="file" multiple
@change=${this.onIdentityInputChange_}>
</div>
<terminal-textfield ${ref(this.relayArgsRef_)} id="relay-args"
label="${msg('FIELD_NASSH_OPTIONS_PLACEHOLDER')}"
@keydown="${this.onTextfieldKeydown_}">
</terminal-textfield>
<terminal-textfield ${ref(this.mountPathRef_)} id="mount-path"
label="${msg('MOUNT_PATH_LABEL')}"
placeholder="${msg('FIELD_MOUNT_PATH_PLACEHOLDER')}"
@keydown="${this.onTextfieldKeydown_}">
</terminal-textfield>
<div slot="buttons">
<span></span>
<terminal-button class="cancel"
@click="${(e) => this.dialogRef_.value.cancel()}">
${msg('CANCEL_BUTTON_LABEL')}
</terminal-button>
<terminal-button ${ref(this.okRef_)} class="action"
?disabled="${commandError}"
@click="${this.onOkClick_}">
${msg('SAVE_LABEL')}
</terminal-button>
</div>
</terminal-dialog>
`;
}
/**
* Show the dialog. All content in the dialog will be refreshed automatically.
*
* @param {string=} nasshProfileId A non-empty value means editing an existing
* connection with the id. An empty value means creating a new connection.
*/
async show(nasshProfileId = '') {
// Since this dialog can be reused, we need to be careful here and make sure
// we update all state (including member variables and also child HTML
// elements that have internal state (e.g. `terminal-textfield`)).
if (!this.fileSystem_) {
this.fileSystem_ = await getIndexeddbFileSystem();
}
this.loadIdentities_();
this.nasshProfileId_ = nasshProfileId;
let command = '';
let relayArgs = '';
let identity = this.DEFAULT_IDENTITY.value;
let settingsProfile = '';
let mountPath = '';
this.userTitle_ = '';
if (this.nasshProfileId_) {
[command,
this.userTitle_,
relayArgs,
identity,
settingsProfile,
mountPath] =
await getProfileValues(ProfileType.NASSH, this.nasshProfileId_, [
'terminalSSHDialogCommand',
'description',
'nassh-options',
'identity',
'terminal-profile',
'mount-path',
], '');
// We might have some old SSH profile without the "command". In this case,
// we construct it from the other profile values.
if (!command) {
console.warn('Construct command string from other profile values.');
const [username, hostname, port, argstr] =
await getProfileValues(ProfileType.NASSH, this.nasshProfileId_, [
'username',
'hostname',
'port',
'argstr',
], '');
command = `${username}@${hostname}`;
if (port) {
command += ` -p ${port}`;
}
if (argstr) {
command += ' ' + argstr;
}
}
}
this.commandRef_.value.value = command;
this.parsedCommand_ = parseCommand(/** @type {string} */(command));
this.relayArgsRef_.value.value = relayArgs;
this.mountPathRef_.value.value = mountPath;
this.identityDropdownRef_.value.value = identity;
this.suppressCommandError_ = !this.nasshProfileId_;
this.settingsProfiles_ = await getProfileIds(ProfileType.HTERM);
this.settingsProfileDropdownRef_.value.value =
settingsProfile || hterm.Terminal.DEFAULT_PROFILE_ID;
this.shadowRoot.querySelector('terminal-dialog').show();
this.shadowRoot.querySelector('terminal-textfield[fitContent]')
.updateFitContentWidth();
this.commandRef_.value.focus();
}
/**
* @param {!Event} event
*/
onOkClick_(event) {
if (!event.target.hasAttribute('disabled')) {
this.dialogRef_.value.accept();
}
}
/** @param {!Event} event */
onCommandUpdated_(event) {
this.parsedCommand_ = parseCommand(event.target.value);
if (this.parsedCommand_.destination) {
const parsedDestination = parseSSHDestination(
this.parsedCommand_.destination);
if (parsedDestination?.hostname.match(GOOGLE_HOST_REGEXP)) {
const relayArgs = this.relayArgsRef_.value;
// Add the google relay arg if it is not there already.
if (!/(^|\s)--config=google\b/.test(relayArgs.value)) {
relayArgs.value = `--config=google ${relayArgs.value}`;
}
}
}
}
/** @param {!Event} event */
onTextfieldKeydown_(event) {
if (event.key === 'Enter') {
event.preventDefault();
this.okRef_.value.click();
}
}
/** @param {!Event} event */
onImportButtonClick_(event) {
this.shadowRoot.querySelector('#identity-input').click();
}
/** @param {!Event} event */
async onIdentityInputChange_(event) {
await importIdentityFiles(lib.notNull(this.fileSystem_),
event.target.files);
this.loadIdentities_();
}
/** @param {!Event} event */
async onDialogClose_(event) {
if (event.detail.accept) {
// Save the connection.
const parsedDestination = parseSSHDestination(
this.parsedCommand_.destination);
if (!parsedDestination) {
// This should not happen since we should have prevented the user from
// clicking the ok button.
throw new Error('Unable to parse destination from {}',
this.parsedCommand_.destination);
}
if (this.nasshProfileId_) {
// Remove the profile first to ensure a clean state.
await deleteProfile(ProfileType.NASSH, this.nasshProfileId_, false);
} else {
const profileIds = await getProfileIds(ProfileType.NASSH);
this.nasshProfileId_ = lib.PreferenceManager.newRandomId(profileIds);
await setProfileIds(ProfileType.NASSH, [
...profileIds,
this.nasshProfileId_,
]);
}
const values = {
'terminalSSHDialogCommand': this.commandRef_.value.value,
'description': this.getTitle_(),
'username': parsedDestination.username,
'hostname': parsedDestination.hostname,
// We only save the port number if it appears in the destination. If the
// user specify it with `-p`, then it will go into 'argstr'.
'port': parsedDestination.port,
'argstr': this.parsedCommand_.argstr,
'nassh-options': this.relayArgsRef_.value.value,
'mount-path': this.mountPathRef_.value.value,
'identity': this.identityDropdownRef_.value.value,
};
values['terminal-profile'] =
this.settingsProfileDropdownRef_.value.value;
setProfileValues(ProfileType.NASSH, this.nasshProfileId_, values);
}
this.dispatchEvent(new CustomEvent('close'));
}
async loadIdentities_() {
this.identities_ = [
this.DEFAULT_IDENTITY,
...(await getIdentityFileNames(lib.notNull(this.fileSystem_)))
.map((value) => ({value, deletable: true})),
];
}
async onDeleteIdentity_(e) {
const identityName = e.detail.option.value;
if (!identityName) {
throw new Error('identity name is empty');
}
if (identityName === this.identityDropdownRef_.value.value) {
// Switch to the default identity.
this.identityDropdownRef_.value.value = '';
}
await deleteIdentityFiles(lib.notNull(this.fileSystem_), identityName);
await this.loadIdentities_();
}
}
customElements.define('terminal-ssh-dialog', TerminalSSHDialog);