1/*
2 * Copyright (c) 2006-2011 Christian Plattner. All rights reserved.
3 * Please refer to the LICENSE.txt for licensing details.
4 */
5import java.awt.BorderLayout;
6import java.awt.Color;
7import java.awt.FlowLayout;
8import java.awt.Font;
9import java.awt.event.ActionEvent;
10import java.awt.event.ActionListener;
11import java.awt.event.KeyAdapter;
12import java.awt.event.KeyEvent;
13import java.io.File;
14import java.io.IOException;
15import java.io.InputStream;
16import java.io.OutputStream;
17
18import javax.swing.BoxLayout;
19import javax.swing.JButton;
20import javax.swing.JDialog;
21import javax.swing.JFrame;
22import javax.swing.JLabel;
23import javax.swing.JOptionPane;
24import javax.swing.JPanel;
25import javax.swing.JPasswordField;
26import javax.swing.JTextArea;
27import javax.swing.JTextField;
28import javax.swing.SwingUtilities;
29
30import ch.ethz.ssh2.Connection;
31import ch.ethz.ssh2.InteractiveCallback;
32import ch.ethz.ssh2.KnownHosts;
33import ch.ethz.ssh2.ServerHostKeyVerifier;
34import ch.ethz.ssh2.Session;
35
36/**
37 *
38 * This is a very primitive SSH-2 dumb terminal (Swing based).
39 *
40 * The purpose of this class is to demonstrate:
41 *
42 * - Verifying server hostkeys with an existing known_hosts file
43 * - Displaying fingerprints of server hostkeys
44 * - Adding a server hostkey to a known_hosts file (+hashing the hostname for security)
45 * - Authentication with DSA, RSA, password and keyboard-interactive methods
46 *
47 */
48public class SwingShell
49{
50
51	/*
52	 * NOTE: to get this feature to work, replace the "tilde" with your home directory,
53	 * at least my JVM does not understand it. Need to check the specs.
54	 */
55
56	static final String knownHostPath = "~/.ssh/known_hosts";
57	static final String idDSAPath = "~/.ssh/id_dsa";
58	static final String idRSAPath = "~/.ssh/id_rsa";
59
60	JFrame loginFrame = null;
61	JLabel hostLabel;
62	JLabel userLabel;
63	JTextField hostField;
64	JTextField userField;
65	JButton loginButton;
66
67	KnownHosts database = new KnownHosts();
68
69	public SwingShell()
70	{
71		File knownHostFile = new File(knownHostPath);
72		if (knownHostFile.exists())
73		{
74			try
75			{
76				database.addHostkeys(knownHostFile);
77			}
78			catch (IOException e)
79			{
80			}
81		}
82	}
83
84	/**
85	 * This dialog displays a number of text lines and a text field.
86	 * The text field can either be plain text or a password field.
87	 */
88	class EnterSomethingDialog extends JDialog
89	{
90		private static final long serialVersionUID = 1L;
91
92		JTextField answerField;
93		JPasswordField passwordField;
94
95		final boolean isPassword;
96
97		String answer;
98
99		public EnterSomethingDialog(JFrame parent, String title, String content, boolean isPassword)
100		{
101			this(parent, title, new String[] { content }, isPassword);
102		}
103
104		public EnterSomethingDialog(JFrame parent, String title, String[] content, boolean isPassword)
105		{
106			super(parent, title, true);
107
108			this.isPassword = isPassword;
109
110			JPanel pan = new JPanel();
111			pan.setLayout(new BoxLayout(pan, BoxLayout.Y_AXIS));
112
113			for (int i = 0; i < content.length; i++)
114			{
115				if ((content[i] == null) || (content[i] == ""))
116					continue;
117				JLabel contentLabel = new JLabel(content[i]);
118				pan.add(contentLabel);
119
120			}
121
122			answerField = new JTextField(20);
123			passwordField = new JPasswordField(20);
124
125			if (isPassword)
126				pan.add(passwordField);
127			else
128				pan.add(answerField);
129
130			KeyAdapter kl = new KeyAdapter()
131			{
132				public void keyTyped(KeyEvent e)
133				{
134					if (e.getKeyChar() == '\n')
135						finish();
136				}
137			};
138
139			answerField.addKeyListener(kl);
140			passwordField.addKeyListener(kl);
141
142			getContentPane().add(BorderLayout.CENTER, pan);
143
144			setResizable(false);
145			pack();
146			setLocationRelativeTo(null);
147		}
148
149		private void finish()
150		{
151			if (isPassword)
152				answer = new String(passwordField.getPassword());
153			else
154				answer = answerField.getText();
155
156			dispose();
157		}
158	}
159
160	/**
161	 * TerminalDialog is probably the worst terminal emulator ever written - implementing
162	 * a real vt100 is left as an exercise to the reader, i.e., to you =)
163	 *
164	 */
165	class TerminalDialog extends JDialog
166	{
167		private static final long serialVersionUID = 1L;
168
169		JPanel botPanel;
170		JButton logoffButton;
171		JTextArea terminalArea;
172
173		Session sess;
174		InputStream in;
175		OutputStream out;
176
177		int x, y;
178
179		/**
180		 * This thread consumes output from the remote server and displays it in
181		 * the terminal window.
182		 *
183		 */
184		class RemoteConsumer extends Thread
185		{
186			char[][] lines = new char[y][];
187			int posy = 0;
188			int posx = 0;
189
190			private void addText(byte[] data, int len)
191			{
192				for (int i = 0; i < len; i++)
193				{
194					char c = (char) (data[i] & 0xff);
195
196					if (c == 8) // Backspace, VERASE
197					{
198						if (posx < 0)
199							continue;
200						posx--;
201						continue;
202					}
203
204					if (c == '\r')
205					{
206						posx = 0;
207						continue;
208					}
209
210					if (c == '\n')
211					{
212						posy++;
213						if (posy >= y)
214						{
215							for (int k = 1; k < y; k++)
216								lines[k - 1] = lines[k];
217							posy--;
218							lines[y - 1] = new char[x];
219							for (int k = 0; k < x; k++)
220								lines[y - 1][k] = ' ';
221						}
222						continue;
223					}
224
225					if (c < 32)
226					{
227						continue;
228					}
229
230					if (posx >= x)
231					{
232						posx = 0;
233						posy++;
234						if (posy >= y)
235						{
236							posy--;
237							for (int k = 1; k < y; k++)
238								lines[k - 1] = lines[k];
239							lines[y - 1] = new char[x];
240							for (int k = 0; k < x; k++)
241								lines[y - 1][k] = ' ';
242						}
243					}
244
245					if (lines[posy] == null)
246					{
247						lines[posy] = new char[x];
248						for (int k = 0; k < x; k++)
249							lines[posy][k] = ' ';
250					}
251
252					lines[posy][posx] = c;
253					posx++;
254				}
255
256				StringBuffer sb = new StringBuffer(x * y);
257
258				for (int i = 0; i < lines.length; i++)
259				{
260					if (i != 0)
261						sb.append('\n');
262
263					if (lines[i] != null)
264					{
265						sb.append(lines[i]);
266					}
267
268				}
269				setContent(sb.toString());
270			}
271
272			public void run()
273			{
274				byte[] buff = new byte[8192];
275
276				try
277				{
278					while (true)
279					{
280						int len = in.read(buff);
281						if (len == -1)
282							return;
283						addText(buff, len);
284					}
285				}
286				catch (Exception e)
287				{
288				}
289			}
290		}
291
292		public TerminalDialog(JFrame parent, String title, Session sess, int x, int y) throws IOException
293		{
294			super(parent, title, true);
295
296			this.sess = sess;
297
298			in = sess.getStdout();
299			out = sess.getStdin();
300
301			this.x = x;
302			this.y = y;
303
304			botPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
305
306			logoffButton = new JButton("Logout");
307			botPanel.add(logoffButton);
308
309			logoffButton.addActionListener(new ActionListener()
310			{
311				public void actionPerformed(ActionEvent e)
312				{
313					/* Dispose the dialog, "setVisible(true)" method will return */
314					dispose();
315				}
316			});
317
318			Font f = new Font("Monospaced", Font.PLAIN, 16);
319
320			terminalArea = new JTextArea(y, x);
321			terminalArea.setFont(f);
322			terminalArea.setBackground(Color.BLACK);
323			terminalArea.setForeground(Color.ORANGE);
324			/* This is a hack. We cannot disable the caret,
325			 * since setting editable to false also changes
326			 * the meaning of the TAB key - and I want to use it in bash.
327			 * Again - this is a simple DEMO terminal =)
328			 */
329			terminalArea.setCaretColor(Color.BLACK);
330
331			KeyAdapter kl = new KeyAdapter()
332			{
333				public void keyTyped(KeyEvent e)
334				{
335					int c = e.getKeyChar();
336
337					try
338					{
339						out.write(c);
340					}
341					catch (IOException e1)
342					{
343					}
344					e.consume();
345				}
346			};
347
348			terminalArea.addKeyListener(kl);
349
350			getContentPane().add(terminalArea, BorderLayout.CENTER);
351			getContentPane().add(botPanel, BorderLayout.PAGE_END);
352
353			setResizable(false);
354			pack();
355			setLocationRelativeTo(parent);
356
357			new RemoteConsumer().start();
358		}
359
360		public void setContent(String lines)
361		{
362			// setText is thread safe, it does not have to be called from
363			// the Swing GUI thread.
364			terminalArea.setText(lines);
365		}
366	}
367
368	/**
369	 * This ServerHostKeyVerifier asks the user on how to proceed if a key cannot be found
370	 * in the in-memory database.
371	 *
372	 */
373	class AdvancedVerifier implements ServerHostKeyVerifier
374	{
375		public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm,
376				byte[] serverHostKey) throws Exception
377		{
378			final String host = hostname;
379			final String algo = serverHostKeyAlgorithm;
380
381			String message;
382
383			/* Check database */
384
385			int result = database.verifyHostkey(hostname, serverHostKeyAlgorithm, serverHostKey);
386
387			switch (result)
388			{
389			case KnownHosts.HOSTKEY_IS_OK:
390				return true;
391
392			case KnownHosts.HOSTKEY_IS_NEW:
393				message = "Do you want to accept the hostkey (type " + algo + ") from " + host + " ?\n";
394				break;
395
396			case KnownHosts.HOSTKEY_HAS_CHANGED:
397				message = "WARNING! Hostkey for " + host + " has changed!\nAccept anyway?\n";
398				break;
399
400			default:
401				throw new IllegalStateException();
402			}
403
404			/* Include the fingerprints in the message */
405
406			String hexFingerprint = KnownHosts.createHexFingerprint(serverHostKeyAlgorithm, serverHostKey);
407			String bubblebabbleFingerprint = KnownHosts.createBubblebabbleFingerprint(serverHostKeyAlgorithm,
408					serverHostKey);
409
410			message += "Hex Fingerprint: " + hexFingerprint + "\nBubblebabble Fingerprint: " + bubblebabbleFingerprint;
411
412			/* Now ask the user */
413
414			int choice = JOptionPane.showConfirmDialog(loginFrame, message);
415
416			if (choice == JOptionPane.YES_OPTION)
417			{
418				/* Be really paranoid. We use a hashed hostname entry */
419
420				String hashedHostname = KnownHosts.createHashedHostname(hostname);
421
422				/* Add the hostkey to the in-memory database */
423
424				database.addHostkey(new String[] { hashedHostname }, serverHostKeyAlgorithm, serverHostKey);
425
426				/* Also try to add the key to a known_host file */
427
428				try
429				{
430					KnownHosts.addHostkeyToFile(new File(knownHostPath), new String[] { hashedHostname },
431							serverHostKeyAlgorithm, serverHostKey);
432				}
433				catch (IOException ignore)
434				{
435				}
436
437				return true;
438			}
439
440			if (choice == JOptionPane.CANCEL_OPTION)
441			{
442				throw new Exception("The user aborted the server hostkey verification.");
443			}
444
445			return false;
446		}
447	}
448
449	/**
450	 * The logic that one has to implement if "keyboard-interactive" autentication shall be
451	 * supported.
452	 *
453	 */
454	class InteractiveLogic implements InteractiveCallback
455	{
456		int promptCount = 0;
457		String lastError;
458
459		public InteractiveLogic(String lastError)
460		{
461			this.lastError = lastError;
462		}
463
464		/* the callback may be invoked several times, depending on how many questions-sets the server sends */
465
466		public String[] replyToChallenge(String name, String instruction, int numPrompts, String[] prompt,
467				boolean[] echo) throws IOException
468		{
469			String[] result = new String[numPrompts];
470
471			for (int i = 0; i < numPrompts; i++)
472			{
473				/* Often, servers just send empty strings for "name" and "instruction" */
474
475				String[] content = new String[] { lastError, name, instruction, prompt[i] };
476
477				if (lastError != null)
478				{
479					/* show lastError only once */
480					lastError = null;
481				}
482
483				EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame, "Keyboard Interactive Authentication",
484						content, !echo[i]);
485
486				esd.setVisible(true);
487
488				if (esd.answer == null)
489					throw new IOException("Login aborted by user");
490
491				result[i] = esd.answer;
492				promptCount++;
493			}
494
495			return result;
496		}
497
498		/* We maintain a prompt counter - this enables the detection of situations where the ssh
499		 * server is signaling "authentication failed" even though it did not send a single prompt.
500		 */
501
502		public int getPromptCount()
503		{
504			return promptCount;
505		}
506	}
507
508	/**
509	 * The SSH-2 connection is established in this thread.
510	 * If we would not use a separate thread (e.g., put this code in
511	 * the event handler of the "Login" button) then the GUI would not
512	 * be responsive (missing window repaints if you move the window etc.)
513	 */
514	class ConnectionThread extends Thread
515	{
516		String hostname;
517		String username;
518
519		public ConnectionThread(String hostname, String username)
520		{
521			this.hostname = hostname;
522			this.username = username;
523		}
524
525		public void run()
526		{
527			Connection conn = new Connection(hostname);
528
529			try
530			{
531				/*
532				 *
533				 * CONNECT AND VERIFY SERVER HOST KEY (with callback)
534				 *
535				 */
536
537				String[] hostkeyAlgos = database.getPreferredServerHostkeyAlgorithmOrder(hostname);
538
539				if (hostkeyAlgos != null)
540					conn.setServerHostKeyAlgorithms(hostkeyAlgos);
541
542				conn.connect(new AdvancedVerifier());
543
544				/*
545				 *
546				 * AUTHENTICATION PHASE
547				 *
548				 */
549
550				boolean enableKeyboardInteractive = true;
551				boolean enableDSA = true;
552				boolean enableRSA = true;
553
554				String lastError = null;
555
556				while (true)
557				{
558					if ((enableDSA || enableRSA) && conn.isAuthMethodAvailable(username, "publickey"))
559					{
560						if (enableDSA)
561						{
562							File key = new File(idDSAPath);
563
564							if (key.exists())
565							{
566								EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame, "DSA Authentication",
567										new String[] { lastError, "Enter DSA private key password:" }, true);
568								esd.setVisible(true);
569
570								boolean res = conn.authenticateWithPublicKey(username, key, esd.answer);
571
572								if (res == true)
573									break;
574
575								lastError = "DSA authentication failed.";
576							}
577							enableDSA = false; // do not try again
578						}
579
580						if (enableRSA)
581						{
582							File key = new File(idRSAPath);
583
584							if (key.exists())
585							{
586								EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame, "RSA Authentication",
587										new String[] { lastError, "Enter RSA private key password:" }, true);
588								esd.setVisible(true);
589
590								boolean res = conn.authenticateWithPublicKey(username, key, esd.answer);
591
592								if (res == true)
593									break;
594
595								lastError = "RSA authentication failed.";
596							}
597							enableRSA = false; // do not try again
598						}
599
600						continue;
601					}
602
603					if (enableKeyboardInteractive && conn.isAuthMethodAvailable(username, "keyboard-interactive"))
604					{
605						InteractiveLogic il = new InteractiveLogic(lastError);
606
607						boolean res = conn.authenticateWithKeyboardInteractive(username, il);
608
609						if (res == true)
610							break;
611
612						if (il.getPromptCount() == 0)
613						{
614							// aha. the server announced that it supports "keyboard-interactive", but when
615							// we asked for it, it just denied the request without sending us any prompt.
616							// That happens with some server versions/configurations.
617							// We just disable the "keyboard-interactive" method and notify the user.
618
619							lastError = "Keyboard-interactive does not work.";
620
621							enableKeyboardInteractive = false; // do not try this again
622						}
623						else
624						{
625							lastError = "Keyboard-interactive auth failed."; // try again, if possible
626						}
627
628						continue;
629					}
630
631					if (conn.isAuthMethodAvailable(username, "password"))
632					{
633						final EnterSomethingDialog esd = new EnterSomethingDialog(loginFrame,
634								"Password Authentication",
635								new String[] { lastError, "Enter password for " + username }, true);
636
637						esd.setVisible(true);
638
639						if (esd.answer == null)
640							throw new IOException("Login aborted by user");
641
642						boolean res = conn.authenticateWithPassword(username, esd.answer);
643
644						if (res == true)
645							break;
646
647						lastError = "Password authentication failed."; // try again, if possible
648
649						continue;
650					}
651
652					throw new IOException("No supported authentication methods available.");
653				}
654
655				/*
656				 *
657				 * AUTHENTICATION OK. DO SOMETHING.
658				 *
659				 */
660
661				Session sess = conn.openSession();
662
663				int x_width = 90;
664				int y_width = 30;
665
666				sess.requestPTY("dumb", x_width, y_width, 0, 0, null);
667				sess.startShell();
668
669				TerminalDialog td = new TerminalDialog(loginFrame, username + "@" + hostname, sess, x_width, y_width);
670
671				/* The following call blocks until the dialog has been closed */
672
673				td.setVisible(true);
674
675			}
676			catch (IOException e)
677			{
678				//e.printStackTrace();
679				JOptionPane.showMessageDialog(loginFrame, "Exception: " + e.getMessage());
680			}
681
682			/*
683			 *
684			 * CLOSE THE CONNECTION.
685			 *
686			 */
687
688			conn.close();
689
690			/*
691			 *
692			 * CLOSE THE LOGIN FRAME - APPLICATION WILL BE EXITED (no more frames)
693			 *
694			 */
695
696			Runnable r = new Runnable()
697			{
698				public void run()
699				{
700					loginFrame.dispose();
701				}
702			};
703
704			SwingUtilities.invokeLater(r);
705		}
706	}
707
708	void loginPressed()
709	{
710		String hostname = hostField.getText().trim();
711		String username = userField.getText().trim();
712
713		if ((hostname.length() == 0) || (username.length() == 0))
714		{
715			JOptionPane.showMessageDialog(loginFrame, "Please fill out both fields!");
716			return;
717		}
718
719		loginButton.setEnabled(false);
720		hostField.setEnabled(false);
721		userField.setEnabled(false);
722
723		ConnectionThread ct = new ConnectionThread(hostname, username);
724
725		ct.start();
726	}
727
728	void showGUI()
729	{
730		loginFrame = new JFrame("Ganymed SSH2 SwingShell");
731
732		hostLabel = new JLabel("Hostname:");
733		userLabel = new JLabel("Username:");
734
735		hostField = new JTextField("", 20);
736		userField = new JTextField("", 10);
737
738		loginButton = new JButton("Login");
739
740		loginButton.addActionListener(new ActionListener()
741		{
742			public void actionPerformed(java.awt.event.ActionEvent e)
743			{
744				loginPressed();
745			}
746		});
747
748		JPanel loginPanel = new JPanel();
749
750		loginPanel.add(hostLabel);
751		loginPanel.add(hostField);
752		loginPanel.add(userLabel);
753		loginPanel.add(userField);
754		loginPanel.add(loginButton);
755
756		loginFrame.getRootPane().setDefaultButton(loginButton);
757
758		loginFrame.getContentPane().add(loginPanel, BorderLayout.PAGE_START);
759		//loginFrame.getContentPane().add(textArea, BorderLayout.CENTER);
760
761		loginFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
762
763		loginFrame.pack();
764		loginFrame.setResizable(false);
765		loginFrame.setLocationRelativeTo(null);
766		loginFrame.setVisible(true);
767	}
768
769	void startGUI()
770	{
771		Runnable r = new Runnable()
772		{
773			public void run()
774			{
775				showGUI();
776			}
777		};
778
779		SwingUtilities.invokeLater(r);
780
781	}
782
783	public static void main(String[] args)
784	{
785		SwingShell client = new SwingShell();
786		client.startGUI();
787	}
788}
789