/*
 * Copyright (c) 2006-2011 Christian Plattner. All rights reserved.
 * Please refer to the LICENSE.txt for licensing details.
 */
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;

import ch.ethz.ssh2.Connection;
import ch.ethz.ssh2.InteractiveCallback;
import ch.ethz.ssh2.KnownHosts;
import ch.ethz.ssh2.ServerHostKeyVerifier;
import ch.ethz.ssh2.Session;

/**
 *
 * This is a very primitive SSH-2 dumb terminal (Swing based).
 * 
 * The purpose of this class is to demonstrate:
 * 
 * - Verifying server hostkeys with an existing known_hosts file
 * - Displaying fingerprints of server hostkeys
 * - Adding a server hostkey to a known_hosts file (+hashing the hostname for security) 
 * - Authentication with DSA, RSA, password and keyboard-interactive methods
 *
 */
public class SwingShell
{

	/* 
	 * NOTE: to get this feature to work, replace the "tilde" with your home directory,
	 * at least my JVM does not understand it. Need to check the specs.
	 */

	static final String knownHostPath = "~/.ssh/known_hosts";
	static final String idDSAPath = "~/.ssh/id_dsa";
	static final String idRSAPath = "~/.ssh/id_rsa";

	JFrame loginFrame = null;
	JLabel hostLabel;
	JLabel userLabel;
	JTextField hostField;
	JTextField userField;
	JButton loginButton;

	KnownHosts database = new KnownHosts();

	public SwingShell()
	{
		File knownHostFile = new File(knownHostPath);
		if (knownHostFile.exists())
		{
			try
			{
				database.addHostkeys(knownHostFile);
			}
			catch (IOException e)
			{
			}
		}
	}

	/**
	 * This dialog displays a number of text lines and a text field.
	 * The text field can either be plain text or a password field.
	 */
	class EnterSomethingDialog extends JDialog
	{
		private static final long serialVersionUID = 1L;

		JTextField answerField;
		JPasswordField passwordField;

		final boolean isPassword;

		String answer;

		public EnterSomethingDialog(JFrame parent, String title, String content, boolean isPassword)
		{
			this(parent, title, new String[] { content }, isPassword);
		}

		public EnterSomethingDialog(JFrame parent, String title, String[] content, boolean isPassword)
		{
			super(parent, title, true);

			this.isPassword = isPassword;

			JPanel pan = new JPanel();
			pan.setLayout(new BoxLayout(pan, BoxLayout.Y_AXIS));

			for (int i = 0; i < content.length; i++)
			{
				if ((content[i] == null) || (content[i] == ""))
					continue;
				JLabel contentLabel = new JLabel(content[i]);
				pan.add(contentLabel);

			}

			answerField = new JTextField(20);
			passwordField = new JPasswordField(20);

			if (isPassword)
				pan.add(passwordField);
			else
				pan.add(answerField);

			KeyAdapter kl = new KeyAdapter()
			{
				public void keyTyped(KeyEvent e)
				{
					if (e.getKeyChar() == '\n')
						finish();
				}
			};

			answerField.addKeyListener(kl);
			passwordField.addKeyListener(kl);

			getContentPane().add(BorderLayout.CENTER, pan);

			setResizable(false);
			pack();
			setLocationRelativeTo(null);
		}

		private void finish()
		{
			if (isPassword)
				answer = new String(passwordField.getPassword());
			else
				answer = answerField.getText();

			dispose();
		}
	}

	/**
	 * TerminalDialog is probably the worst terminal emulator ever written - implementing
	 * a real vt100 is left as an exercise to the reader, i.e., to you =)
	 *
	 */
	class TerminalDialog extends JDialog
	{
		private static final long serialVersionUID = 1L;

		JPanel botPanel;
		JButton logoffButton;
		JTextArea terminalArea;

		Session sess;
		InputStream in;
		OutputStream out;

		int x, y;

		/**
		 * This thread consumes output from the remote server and displays it in
		 * the terminal window.
		 *
		 */
		class RemoteConsumer extends Thread
		{
			char[][] lines = new char[y][];
			int posy = 0;
			int posx = 0;

			private void addText(byte[] data, int len)
			{
				for (int i = 0; i < len; i++)
				{
					char c = (char) (data[i] & 0xff);

					if (c == 8) // Backspace, VERASE
					{
						if (posx < 0)
							continue;
						posx--;
						continue;
					}

					if (c == '\r')
					{
						posx = 0;
						continue;
					}

					if (c == '\n')
					{
						posy++;
						if (posy >= y)
						{
							for (int k = 1; k < y; k++)
								lines[k - 1] = lines[k];
							posy--;
							lines[y - 1] = new char[x];
							for (int k = 0; k < x; k++)
								lines[y - 1][k] = ' ';
						}
						continue;
					}

					if (c < 32)
					{
						continue;
					}

					if (posx >= x)
					{
						posx = 0;
						posy++;
						if (posy >= y)
						{
							posy--;
							for (int k = 1; k < y; k++)
								lines[k - 1] = lines[k];
							lines[y - 1] = new char[x];
							for (int k = 0; k < x; k++)
								lines[y - 1][k] = ' ';
						}
					}

					if (lines[posy] == null)
					{
						lines[posy] = new char[x];
						for (int k = 0; k < x; k++)
							lines[posy][k] = ' ';
					}

					lines[posy][posx] = c;
					posx++;
				}

				StringBuffer sb = new StringBuffer(x * y);

				for (int i = 0; i < lines.length; i++)
				{
					if (i != 0)
						sb.append('\n');

					if (lines[i] != null)
					{
						sb.append(lines[i]);
					}

				}
				setContent(sb.toString());
			}

			public void run()
			{
				byte[] buff = new byte[8192];

				try
				{
					while (true)
					{
						int len = in.read(buff);
						if (len == -1)
							return;
						addText(buff, len);
					}
				}
				catch (Exception e)
				{
				}
			}
		}

		public TerminalDialog(JFrame parent, String title, Session sess, int x, int y) throws IOException
		{
			super(parent, title, true);

			this.sess = sess;

			in = sess.getStdout();
			out = sess.getStdin();

			this.x = x;
			this.y = y;

			botPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));

			logoffButton = new JButton("Logout");
			botPanel.add(logoffButton);

			logoffButton.addActionListener(new ActionListener()
			{
				public void actionPerformed(ActionEvent e)
				{
					/* Dispose the dialog, "setVisible(true)" method will return */
					dispose();
				}
			});

			Font f = new Font("Monospaced", Font.PLAIN, 16);

			terminalArea = new JTextArea(y, x);
			terminalArea.setFont(f);
			terminalArea.setBackground(Color.BLACK);
			terminalArea.setForeground(Color.ORANGE);
			/* This is a hack. We cannot disable the caret,
			 * since setting editable to false also changes
			 * the meaning of the TAB key - and I want to use it in bash.
			 * Again - this is a simple DEMO terminal =)
			 */
			terminalArea.setCaretColor(Color.BLACK);

			KeyAdapter kl = new KeyAdapter()
			{
				public void keyTyped(KeyEvent e)
				{
					int c = e.getKeyChar();

					try
					{
						out.write(c);
					}
					catch (IOException e1)
					{
					}
					e.consume();
				}
			};

			terminalArea.addKeyListener(kl);

			getContentPane().add(terminalArea, BorderLayout.CENTER);
			getContentPane().add(botPanel, BorderLayout.PAGE_END);

			setResizable(false);
			pack();
			setLocationRelativeTo(parent);

			new RemoteConsumer().start();
		}

		public void setContent(String lines)
		{
			// setText is thread safe, it does not have to be called from
			// the Swing GUI thread.
			terminalArea.setText(lines);
		}
	}

	/**
	 * This ServerHostKeyVerifier asks the user on how to proceed if a key cannot be found
	 * in the in-memory database.
	 *
	 */
	class AdvancedVerifier implements ServerHostKeyVerifier
	{
		public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm,
				byte[] serverHostKey) throws Exception
		{
			final String host = hostname;
			final String algo = serverHostKeyAlgorithm;

			String message;

			/* Check database */

			int result = database.verifyHostkey(hostname, serverHostKeyAlgorithm, serverHostKey);

			switch (result)
			{
			case KnownHosts.HOSTKEY_IS_OK:
				return true;

			case KnownHosts.HOSTKEY_IS_NEW:
				message = "Do you want to accept the hostkey (type " + algo + ") from " + host + " ?\n";
				break;

			case KnownHosts.HOSTKEY_HAS_CHANGED:
				message = "WARNING! Hostkey for " + host + " has changed!\nAccept anyway?\n";
				break;

			default:
				throw new IllegalStateException();
			}

			/* Include the fingerprints in the message */

			String hexFingerprint = KnownHosts.createHexFingerprint(serverHostKeyAlgorithm, serverHostKey);
			String bubblebabbleFingerprint = KnownHosts.createBubblebabbleFingerprint(serverHostKeyAlgorithm,
					serverHostKey);

			message += "Hex Fingerprint: " + hexFingerprint + "\nBubblebabble Fingerprint: " + bubblebabbleFingerprint;

			/* Now ask the user */

			int choice = JOptionPane.showConfirmDialog(loginFrame, message);

			if (choice == JOptionPane.YES_OPTION)
			{
				/* Be really paranoid. We use a hashed hostname entry */

				String hashedHostname = KnownHosts.createHashedHostname(hostname);

				/* Add the hostkey to the in-memory database */

				database.addHostkey(new String[] { hashedHostname }, serverHostKeyAlgorithm, serverHostKey);

				/* Also try to add the key to a known_host file */

				try
				{
					KnownHosts.addHostkeyToFile(new File(knownHostPath), new String[] { hashedHostname },
							serverHostKeyAlgorithm, serverHostKey);
				}
				catch (IOException ignore)
				{
				}

				return true;
			}

			if (choice == JOptionPane.CANCEL_OPTION)
			{
				throw new Exception("The user aborted the server hostkey verification.");
			}

			return false;
		}
	}

	/**
	 * The logic that one has to implement if "keyboard-interactive" autentication shall be
	 * supported.
	 *
	 */
	class InteractiveLogic implements InteractiveCallback
	{
		int promptCount = 0;
		String lastError;

		public InteractiveLogic(String lastError)
		{
			this.lastError = lastError;
		}

		/* the callback may be invoked several times, depending on how many questions-sets the server sends */

		public String[] replyToChallenge(String name, String instruction, int numPrompts, String[] prompt,
				boolean[] echo) throws IOException
		{
			String[] result = new String[numPrompts];

			for (int i = 0; i < numPrompts; i++)
			{
				/* Often, servers just send empty strings for "name" and "instruction" */

				String[] content = new String[] { lastError, name, instruction, prompt[i] };

				if (lastError != null)
				{
					/* show lastError only once */
					lastError = null;
				}

				EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame, "Keyboard Interactive Authentication",
						content, !echo[i]);

				esd.setVisible(true);

				if (esd.answer == null)
					throw new IOException("Login aborted by user");

				result[i] = esd.answer;
				promptCount++;
			}

			return result;
		}

		/* We maintain a prompt counter - this enables the detection of situations where the ssh
		 * server is signaling "authentication failed" even though it did not send a single prompt.
		 */

		public int getPromptCount()
		{
			return promptCount;
		}
	}

	/**
	 * The SSH-2 connection is established in this thread.
	 * If we would not use a separate thread (e.g., put this code in
	 * the event handler of the "Login" button) then the GUI would not
	 * be responsive (missing window repaints if you move the window etc.)
	 */
	class ConnectionThread extends Thread
	{
		String hostname;
		String username;

		public ConnectionThread(String hostname, String username)
		{
			this.hostname = hostname;
			this.username = username;
		}

		public void run()
		{
			Connection conn = new Connection(hostname);

			try
			{
				/*
				 * 
				 * CONNECT AND VERIFY SERVER HOST KEY (with callback)
				 * 
				 */

				String[] hostkeyAlgos = database.getPreferredServerHostkeyAlgorithmOrder(hostname);

				if (hostkeyAlgos != null)
					conn.setServerHostKeyAlgorithms(hostkeyAlgos);

				conn.connect(new AdvancedVerifier());

				/*
				 * 
				 * AUTHENTICATION PHASE
				 * 
				 */

				boolean enableKeyboardInteractive = true;
				boolean enableDSA = true;
				boolean enableRSA = true;

				String lastError = null;

				while (true)
				{
					if ((enableDSA || enableRSA) && conn.isAuthMethodAvailable(username, "publickey"))
					{
						if (enableDSA)
						{
							File key = new File(idDSAPath);

							if (key.exists())
							{
								EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame, "DSA Authentication",
										new String[] { lastError, "Enter DSA private key password:" }, true);
								esd.setVisible(true);

								boolean res = conn.authenticateWithPublicKey(username, key, esd.answer);

								if (res == true)
									break;

								lastError = "DSA authentication failed.";
							}
							enableDSA = false; // do not try again
						}

						if (enableRSA)
						{
							File key = new File(idRSAPath);

							if (key.exists())
							{
								EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame, "RSA Authentication",
										new String[] { lastError, "Enter RSA private key password:" }, true);
								esd.setVisible(true);

								boolean res = conn.authenticateWithPublicKey(username, key, esd.answer);

								if (res == true)
									break;

								lastError = "RSA authentication failed.";
							}
							enableRSA = false; // do not try again
						}

						continue;
					}

					if (enableKeyboardInteractive && conn.isAuthMethodAvailable(username, "keyboard-interactive"))
					{
						InteractiveLogic il = new InteractiveLogic(lastError);

						boolean res = conn.authenticateWithKeyboardInteractive(username, il);

						if (res == true)
							break;

						if (il.getPromptCount() == 0)
						{
							// aha. the server announced that it supports "keyboard-interactive", but when
							// we asked for it, it just denied the request without sending us any prompt.
							// That happens with some server versions/configurations.
							// We just disable the "keyboard-interactive" method and notify the user.

							lastError = "Keyboard-interactive does not work.";

							enableKeyboardInteractive = false; // do not try this again
						}
						else
						{
							lastError = "Keyboard-interactive auth failed."; // try again, if possible
						}

						continue;
					}

					if (conn.isAuthMethodAvailable(username, "password"))
					{
						final EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame,
								"Password Authentication",
								new String[] { lastError, "Enter password for " + username }, true);

						esd.setVisible(true);

						if (esd.answer == null)
							throw new IOException("Login aborted by user");

						boolean res = conn.authenticateWithPassword(username, esd.answer);

						if (res == true)
							break;

						lastError = "Password authentication failed."; // try again, if possible

						continue;
					}

					throw new IOException("No supported authentication methods available.");
				}

				/*
				 * 
				 * AUTHENTICATION OK. DO SOMETHING.
				 * 
				 */

				Session sess = conn.openSession();

				int x_width = 90;
				int y_width = 30;

				sess.requestPTY("dumb", x_width, y_width, 0, 0, null);
				sess.startShell();

				TerminalDialog td = new TerminalDialog(loginFrame, username + "@" + hostname, sess, x_width, y_width);

				/* The following call blocks until the dialog has been closed */

				td.setVisible(true);

			}
			catch (IOException e)
			{
				//e.printStackTrace();
				JOptionPane.showMessageDialog(loginFrame, "Exception: " + e.getMessage());
			}

			/*
			 * 
			 * CLOSE THE CONNECTION.
			 * 
			 */

			conn.close();

			/*
			 * 
			 * CLOSE THE LOGIN FRAME - APPLICATION WILL BE EXITED (no more frames)
			 * 
			 */

			Runnable r = new Runnable()
			{
				public void run()
				{
					loginFrame.dispose();
				}
			};

			SwingUtilities.invokeLater(r);
		}
	}

	void loginPressed()
	{
		String hostname = hostField.getText().trim();
		String username = userField.getText().trim();

		if ((hostname.length() == 0) || (username.length() == 0))
		{
			JOptionPane.showMessageDialog(loginFrame, "Please fill out both fields!");
			return;
		}

		loginButton.setEnabled(false);
		hostField.setEnabled(false);
		userField.setEnabled(false);

		ConnectionThread ct = new ConnectionThread(hostname, username);

		ct.start();
	}

	void showGUI()
	{
		loginFrame = new JFrame("Ganymed SSH2 SwingShell");

		hostLabel = new JLabel("Hostname:");
		userLabel = new JLabel("Username:");

		hostField = new JTextField("", 20);
		userField = new JTextField("", 10);

		loginButton = new JButton("Login");

		loginButton.addActionListener(new ActionListener()
		{
			public void actionPerformed(java.awt.event.ActionEvent e)
			{
				loginPressed();
			}
		});

		JPanel loginPanel = new JPanel();

		loginPanel.add(hostLabel);
		loginPanel.add(hostField);
		loginPanel.add(userLabel);
		loginPanel.add(userField);
		loginPanel.add(loginButton);

		loginFrame.getRootPane().setDefaultButton(loginButton);

		loginFrame.getContentPane().add(loginPanel, BorderLayout.PAGE_START);
		//loginFrame.getContentPane().add(textArea, BorderLayout.CENTER);

		loginFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

		loginFrame.pack();
		loginFrame.setResizable(false);
		loginFrame.setLocationRelativeTo(null);
		loginFrame.setVisible(true);
	}

	void startGUI()
	{
		Runnable r = new Runnable()
		{
			public void run()
			{
				showGUI();
			}
		};

		SwingUtilities.invokeLater(r);

	}

	public static void main(String[] args)
	{
		SwingShell client = new SwingShell();
		client.startGUI();
	}
}
