/* * Copyright (C) 2006-2016 DLR, Germany * * All rights reserved * * http://www.rcenvironment.de/ */ package de.rcenvironment.core.start.headless.textui; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map.Entry; import java.util.SortedMap; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.text.WordUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import com.googlecode.lanterna.TerminalFacade; import com.googlecode.lanterna.gui.Action; import com.googlecode.lanterna.gui.DefaultBackgroundRenderer; import com.googlecode.lanterna.gui.GUIScreen; import com.googlecode.lanterna.gui.Window; import com.googlecode.lanterna.gui.component.Button; import com.googlecode.lanterna.gui.component.Label; import com.googlecode.lanterna.gui.component.Panel; import com.googlecode.lanterna.gui.component.PasswordBox; import com.googlecode.lanterna.gui.component.RadioCheckBoxList; import com.googlecode.lanterna.gui.component.TextBox; import com.googlecode.lanterna.gui.dialog.DialogButtons; import com.googlecode.lanterna.gui.dialog.DialogResult; import com.googlecode.lanterna.gui.dialog.ListSelectDialog; import com.googlecode.lanterna.gui.dialog.MessageBox; import com.googlecode.lanterna.gui.dialog.TextInputDialog; import com.googlecode.lanterna.gui.listener.WindowAdapter; import com.googlecode.lanterna.input.Key; import com.googlecode.lanterna.input.Key.Kind; import de.rcenvironment.core.configuration.ConfigurationException; import de.rcenvironment.core.configuration.ConfigurationService; import de.rcenvironment.core.configuration.ConfigurationService.ConfigurablePathId; import de.rcenvironment.core.embedded.ssh.api.SshAccount; import de.rcenvironment.core.embedded.ssh.api.SshAccountConfigurationService; import de.rcenvironment.core.mail.SMTPServerConfiguration; import de.rcenvironment.core.mail.SMTPServerConfigurationService; import de.rcenvironment.core.utils.common.StringUtils; /** * An interactive configuration shell. Currently used to configure SSH accounts with secure password handling. * * @author Robert Mischke */ public class ConfigurationTextUI { private static final int WORD_WRAPPING_MAX_LINE_LENGTH = 60; private static final String OPTION_CHANGE_PASSWORD = "Change password"; private static final String OPTION_CONVERT_TO_RA_ACCOUNT = "Convert to a Remote Access account (change \"role\")"; private static final String DIALOG_TITLE_SUCCESS = "Success"; private static final String DIALOG_TITLE_ERROR = "Error"; private static final int DEFAULT_TEXT_FIELD_WIDTH = 40; private static final String OPTION_ADD_SSH_ACCOUNT = "Remote Access: Add a new SSH account"; private static final String OPTION_EDIT_SSH_ACCOUNTS = "Remote Access: Edit existing SSH accounts"; private static final String OPTION_CONFIGURE_SMTP_SERVER = "Mail: Configure SMTP mail server"; private static final String OPTION_ENABLE_ACCOUNT = "Enable account"; private static final String OPTION_DISABLE_ACCOUNT = "Disable account"; private static final String OPTION_DELETE_ACCOUNT = "Permanently delete account"; private static final String REMOTE_ACCESS_ROLE_ID = "remote_access_user"; //This was the name of the remote access role in earlier versions, keep for backwards compatibility private static final String REMOTE_ACCESS_ROLE_ID_ALIAS = "remote access"; // private static final String OPTION_EXIT = "Exit"; private final ConfigurationService configurationService; private final SshAccountConfigurationService sshAccountOperations; private final SMTPServerConfigurationService smtpServerConfigurationOperations; private GUIScreen guiScreen; private final Log log = LogFactory.getLog(getClass()); /** * UI representation of an SSH account. * * @author Robert Mischke */ private class SshAccountUIEntry { private String displayText; private SshAccount account; SshAccountUIEntry(String displayText, SshAccount account) { this.displayText = displayText; this.account = account; } public SshAccount getAccount() { return account; } @Override public String toString() { return displayText; } } /** * Overrides the default behavior on "enter", which is to move to the next UI element. Instead, a custom {@link Action} can be executed. * * @author Robert Mischke */ private class CustomTextBox extends TextBox { private Action enterAction; CustomTextBox(String initialContent, int width, Action enterAction) { super(initialContent, width); this.enterAction = enterAction; } @Override public void setText(String text) { if (text != null) { super.setText(text); } } @Override public Result keyboardInteraction(Key key) { if (key.getKind() == Kind.Enter) { if (enterAction != null) { enterAction.doAction(); } return Result.EVENT_HANDLED; } return super.keyboardInteraction(key); } } /** * Overrides the default behavior on "enter", which is to move to the next UI element. Instead, a custom {@link Action} can be executed. * * @author Robert Mischke */ private class CustomPasswordBox extends PasswordBox { private Action enterAction; CustomPasswordBox(String initialContent, int width, Action enterAction) { super(initialContent, width); this.enterAction = enterAction; } @Override public void setText(String text) { if (text != null) { super.setText(text); } } @Override public Result keyboardInteraction(Key key) { if (key.getKind() == Kind.Enter) { if (enterAction != null) { enterAction.doAction(); } return Result.EVENT_HANDLED; } return super.keyboardInteraction(key); } } /** * This check box allows a selection based on an item. * * @author Tobias Rodehutskors */ private class SetCheckedItemRadioCheckBoxList extends RadioCheckBoxList { private static final int CLEAR_SELECTION_INDEX = -1; /** * @param item If the item is not known to the check box, no item is selected. Otherwise, the given item is selected. */ public void setCheckedItem(Object item) { this.setCheckedItemIndex(CLEAR_SELECTION_INDEX); // clear the current selection for (int i = 0; i < this.getNrOfItems(); i++) { if (this.getItemAt(i).equals(item)) { this.setCheckedItemIndex(i); break; } } } } /** * Dialog for entering parameters for a new SSH account. * * @author Robert Mischke */ private class AddAccountWindow extends Window { private TextBox textBoxName; private PasswordBox textBoxPassword; AddAccountWindow() { super("Add a new Remote Access account"); final Action okAction = new Action() { @Override public void doAction() { try { final String loginName = textBoxName.getText(); final String password = textBoxPassword.getText(); sshAccountOperations.createAccount(loginName, password); // success -> close dialog AddAccountWindow.this.close(); showSuccessMessageBox("The account \"" + loginName + "\" was successfully added."); } catch (ConfigurationException e) { showErrorMessageBox("Failed to create the account: " + e.getMessage()); } } }; final Action cancelAction = new Action() { @Override public void doAction() { AddAccountWindow.this.close(); } }; textBoxName = new CustomTextBox("", DEFAULT_TEXT_FIELD_WIDTH, okAction); textBoxPassword = new CustomPasswordBox("", DEFAULT_TEXT_FIELD_WIDTH, okAction); addComponent(new Label("Login name:")); addComponent(textBoxName); addComponent(new Label("Password:")); addComponent(textBoxPassword); addComponent(createOkCancelButtonPanel(okAction, cancelAction)); addWindowListener(new WindowAdapter() { @Override public void onUnhandledKeyboardInteraction(Window arg0, Key key) { if (key.getKind() == Key.Kind.Escape) { cancelAction.doAction(); return; } if (key.getKind() == Key.Kind.Enter) { okAction.doAction(); return; } log.debug("Unhandled key in text-mode UI: " + key); } }); } } /** * Dialog for entering the SMTP mail server configuration. * * @author Tobias Rodehutskors */ private class ConfigureSMTPServerWindow extends Window { private TextBox textBoxHost; private TextBox textBoxPort; private SetCheckedItemRadioCheckBoxList radioCheckBoxEncryption; private TextBox textBoxUsername; private PasswordBox passwordBoxPassword; private TextBox textBoxSender; ConfigureSMTPServerWindow() { super("SMTP mail server configuration"); final Action okAction = new Action() { @Override public void doAction() { try { final String host = textBoxHost.getText(); final int port; try { port = Integer.parseInt(textBoxPort.getText()); } catch (NumberFormatException e) { throw new ConfigurationException("Invalid port number."); } final String encryption = (String) radioCheckBoxEncryption.getCheckedItem(); final String username = textBoxUsername.getText(); final String password = passwordBoxPassword.getText(); final String sender = textBoxSender.getText(); smtpServerConfigurationOperations.configureSMTPServer(host, port, encryption, username, password, sender); // success -> close dialog ConfigureSMTPServerWindow.this.close(); showSuccessMessageBox("Successfully stored the SMTP server configuration."); } catch (ConfigurationException e) { showErrorMessageBox("Unable to store the configuration: " + e.getMessage()); } } }; final Action cancelAction = new Action() { @Override public void doAction() { ConfigureSMTPServerWindow.this.close(); } }; addComponent(new Label("Host:")); textBoxHost = new CustomTextBox("", DEFAULT_TEXT_FIELD_WIDTH, okAction); addComponent(textBoxHost); addComponent(new Label("Port:")); textBoxPort = new CustomTextBox("", DEFAULT_TEXT_FIELD_WIDTH, okAction); addComponent(textBoxPort); addComponent(new Label("Encryption:")); radioCheckBoxEncryption = new SetCheckedItemRadioCheckBoxList(); radioCheckBoxEncryption.addItem(SMTPServerConfiguration.EXPLICIT_ENCRYPTION); radioCheckBoxEncryption.addItem(SMTPServerConfiguration.IMPLICIT_ENCRYPTION); addComponent(radioCheckBoxEncryption); addComponent(new Label("Username:")); textBoxUsername = new CustomTextBox("", DEFAULT_TEXT_FIELD_WIDTH, okAction); addComponent(textBoxUsername); addComponent(new Label("Password:")); passwordBoxPassword = new CustomPasswordBox("", DEFAULT_TEXT_FIELD_WIDTH, okAction); addComponent(passwordBoxPassword); addComponent(new Label("Sender:")); textBoxSender = new CustomTextBox("", DEFAULT_TEXT_FIELD_WIDTH, okAction); addComponent(textBoxSender); SMTPServerConfiguration smtpServerConfiguration = smtpServerConfigurationOperations.getSMTPServerConfiguration(); if (smtpServerConfiguration != null) { textBoxHost.setText(smtpServerConfiguration.getHost()); textBoxPort.setText(Integer.toString(smtpServerConfiguration.getPort())); radioCheckBoxEncryption.setCheckedItem(smtpServerConfiguration.getEncryption()); textBoxUsername.setText(smtpServerConfiguration.getUsername()); passwordBoxPassword.setText(smtpServerConfiguration.getPassword()); textBoxSender.setText(smtpServerConfiguration.getSenderAsString()); } addComponent(createOkCancelButtonPanel(okAction, cancelAction)); addWindowListener(new WindowAdapter() { @Override public void onUnhandledKeyboardInteraction(Window arg0, Key key) { if (key.getKind() == Key.Kind.Escape) { cancelAction.doAction(); return; } if (key.getKind() == Key.Kind.Enter) { okAction.doAction(); return; } log.debug("Unhandled key in text-mode UI: " + key); } }); } } public ConfigurationTextUI(ConfigurationService configurationService, SshAccountConfigurationService sshConfigurationService, SMTPServerConfigurationService smtpServerConfigurationOperations) { this.configurationService = configurationService; this.sshAccountOperations = sshConfigurationService; this.smtpServerConfigurationOperations = smtpServerConfigurationOperations; } /** * Main method of the interactive configuration shell. */ public void run() { guiScreen = TerminalFacade.createGUIScreen(); if (guiScreen == null) { log.error("Failed to initialize text-mode UI; terminating"); return; } guiScreen.setBackgroundRenderer(new DefaultBackgroundRenderer("RCE Configuration Shell")); guiScreen.getScreen().startScreen(); String verifyError = sshAccountOperations.verifyExpectedStateForConfigurationEditing(); if (verifyError == null) { try { runMainLoop(); } catch (RuntimeException e) { showErrorMessageBox("There was an internal error running the configuration menu. " + "Please check the log file for details"); log.error("Uncaught RuntimeException in text UI", e); } } else { showErrorMessageBox(verifyError); } guiScreen.getScreen().stopScreen(); } private void runMainLoop() { while (true) { String action = showMainMenu(); if (action == null) { return; // exit } switch (action) { case OPTION_ADD_SSH_ACCOUNT: showAddAccountDialog(); break; case OPTION_EDIT_SSH_ACCOUNTS: showSelectExistingAccountDialog(); break; case OPTION_CONFIGURE_SMTP_SERVER: guiScreen.showWindow(new ConfigureSMTPServerWindow(), GUIScreen.Position.CENTER); break; default: log.error("Invalid action: " + action); } } } private String showMainMenu() { List<String> options = new ArrayList<>(); options.add(OPTION_ADD_SSH_ACCOUNT); options.add(OPTION_EDIT_SSH_ACCOUNTS); options.add(OPTION_CONFIGURE_SMTP_SERVER); String result = (String) ListSelectDialog.showDialog(guiScreen, "Select Action", null, options.toArray()); return result; } @Deprecated // left in for now; can be deleted when UI is finished private void showGeneratePasswordHashDialog() { final String name = TextInputDialog.showTextInputBox(guiScreen, "Password hash generation (temporary)", "Enter an id (this will determine the output file's name)", ""); if (name == null) { return; } final String pw = TextInputDialog.showPasswordInputBox(guiScreen, "Password hash generation (temporary)", "Enter the new password", ""); if (pw == null) { return; } final String hash = sshAccountOperations.generatePasswordHash(pw); final File outputDir = configurationService.getConfigurablePath(ConfigurablePathId.PROFILE_OUTPUT); final File outFile = new File(outputDir, "pwhash_" + name + ".txt"); try { FileUtils.write(outFile, hash); log.info("Password hash written to " + outFile.getAbsolutePath()); } catch (IOException e) { log.error(e.getStackTrace()); } } private void showAddAccountDialog() { guiScreen.showWindow(new AddAccountWindow(), GUIScreen.Position.CENTER); } private void showSelectExistingAccountDialog() { List<SshAccountUIEntry> options = new ArrayList<>(); SortedMap<String, SshAccount> accountMap; try { accountMap = sshAccountOperations.getAllAccountsByLoginName(); } catch (ConfigurationException e) { log.error("Error getting account data", e); showErrorMessageBox(e.getMessage()); return; } for (Entry<String, SshAccount> accountEntry : accountMap.entrySet()) { final String id = accountEntry.getKey(); final SshAccount account = accountEntry.getValue(); String loginName = account.getLoginName(); // should be the same if (!id.equals(loginName)) { log.error(StringUtils.format( "Internal consistency error: account returned with map id '%s', but the account user name is '%s'", id, loginName)); } String displayText; if (isRemoteAccessAccount(account)) { displayText = loginName; } else { displayText = loginName + " [custom configuration]"; } if (!account.isEnabled()) { displayText += " [disabled]"; } options.add(new SshAccountUIEntry(displayText, account)); } if (options.isEmpty()) { showErrorMessageBox("There are no SSH accounts (yet)!"); return; } SshAccountUIEntry accountEntry = (SshAccountUIEntry) ListSelectDialog.showDialog(guiScreen, "Select an account to edit", null, options.toArray()); if (accountEntry == null) { return; // exit } showEditSelectedAccountDialog(accountEntry.getAccount()); } private void showEditSelectedAccountDialog(SshAccount account) { final String loginName = account.getLoginName(); final List<String> options = new ArrayList<>(); // assemble options options.add(OPTION_CHANGE_PASSWORD); if (!isRemoteAccessAccount(account)) { options.add(OPTION_CONVERT_TO_RA_ACCOUNT); } if (account.isEnabled()) { options.add(OPTION_DISABLE_ACCOUNT); } else { options.add(OPTION_ENABLE_ACCOUNT); } options.add(OPTION_DELETE_ACCOUNT); String selection = (String) ListSelectDialog.showDialog(guiScreen, "Configure Account \"" + loginName + "\"", null, options.toArray()); if (selection == null) { return; } try { switch (selection) { case OPTION_CHANGE_PASSWORD: String newPW = TextInputDialog.showPasswordInputBox(guiScreen, OPTION_CHANGE_PASSWORD, "Enter the new passwword for account \"" + loginName + "\":", ""); if (StringUtils.isNullorEmpty(newPW)) { showErrorMessageBox("Password change aborted"); return; } sshAccountOperations.updatePasswordHash(loginName, newPW); showSuccessMessageBox("Account password updated"); break; case OPTION_CONVERT_TO_RA_ACCOUNT: sshAccountOperations.updateRole(loginName, REMOTE_ACCESS_ROLE_ID); showSuccessMessageBox("Converted to \"Remote Access\" account"); break; case OPTION_ENABLE_ACCOUNT: sshAccountOperations.setAccountEnabled(loginName, true); showSuccessMessageBox("Account enabled"); return; case OPTION_DISABLE_ACCOUNT: sshAccountOperations.setAccountEnabled(loginName, false); showSuccessMessageBox("Account disabled"); return; case OPTION_DELETE_ACCOUNT: DialogResult confirmation = MessageBox.showMessageBox(guiScreen, "Confirm account deletion", "Really delete account \"" + loginName + "\"?", DialogButtons.YES_NO); if (confirmation != DialogResult.YES) { return; } sshAccountOperations.deleteAccount(loginName); showSuccessMessageBox("Account \"" + loginName + "\" deleted"); return; default: showErrorMessageBox("Internal error: no such option"); break; } } catch (ConfigurationException e) { showErrorMessageBox("Operation failed: " + e.getMessage()); } } private Panel createOkCancelButtonPanel(final Action okAction, final Action cancelAction) { Button buttonOk = new Button("Ok", okAction); Button buttonCancel = new Button("Cancel", cancelAction); Panel buttonPanel = new Panel(Panel.Orientation.HORISONTAL); // apparently, this is the standard way to do this; see the ActionListDialog() constructor // TODO improve by calculating indentation width? buttonPanel.addComponent(new Label(" ")); buttonPanel.addComponent(buttonOk); buttonPanel.addComponent(buttonCancel); return buttonPanel; } private void showSuccessMessageBox(final String message) { MessageBox.showMessageBox(guiScreen, DIALOG_TITLE_SUCCESS, applyWordWrapping(message)); } private void showErrorMessageBox(String message) { MessageBox.showMessageBox(guiScreen, DIALOG_TITLE_ERROR, applyWordWrapping(message)); } private String applyWordWrapping(String input) { // note: assuming screen width of 80 characters; can it be different? return WordUtils.wrap(input, WORD_WRAPPING_MAX_LINE_LENGTH, "\n", true); // true = break long words } private boolean isRemoteAccessAccount(SshAccount account) { return REMOTE_ACCESS_ROLE_ID.equals(account.getRole()) || REMOTE_ACCESS_ROLE_ID_ALIAS.equals(account.getRole()); // tolerate null // values } }