1 // $Id$
2 //
3 // Smuxi - Smart MUltipleXed Irc
4 //
5 // Copyright (c) 2009 Mirco Bauer <meebey@meebey.net>
6 //
7 // Full GPL License: <http://www.gnu.org/licenses/gpl.txt>
8 //
9 // This program is free software; you can redistribute it and/or modify
10 // it under the terms of the GNU General Public License as published by
11 // the Free Software Foundation; either version 2 of the License, or
12 // (at your option) any later version.
13 //
14 // This program is distributed in the hope that it will be useful,
15 // but WITHOUT ANY WARRANTY; without even the implied warranty of
16 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 // GNU General Public License for more details.
18 //
19 // You should have received a copy of the GNU General Public License
20 // along with this program; if not, write to the Free Software
21 // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307 USA
22 
23 using System;
24 using System.IO;
25 using System.Net;
26 using System.Net.Sockets;
27 using System.Text.RegularExpressions;
28 using System.Reflection;
29 using SysDiag = System.Diagnostics;
30 using Smuxi.Common;
31 
32 namespace Smuxi.Frontend
33 {
34     public class SshTunnelManager : IDisposable
35     {
36 #if LOG4NET
37         private static readonly log4net.ILog f_Logger = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
38 #endif
39         private static readonly string   f_LibraryTextDomain = "smuxi-frontend";
40         private SysDiag.Process          f_Process;
41         private SysDiag.ProcessStartInfo f_ProcessStartInfo;
42         private string                   f_Program;
43         private string                   f_Parameters;
44         private string                   f_Username;
45         private string                   f_Password;
46         private string                   f_Keyfile;
47         private string                   f_Hostname;
48         private int                      f_Port = -1;
49         private string                   f_ForwardBindAddress;
50         private int                      f_ForwardBindPort;
51         private string                   f_ForwardHostName;
52         private int                      f_ForwardHostPort;
53         private string                   f_BackwardBindAddress;
54         private int                      f_BackwardBindPort;
55         private string                   f_BackwardHostName;
56         private int                      f_BackwardHostPort;
57 
SshTunnelManager(string program, string parameters, string username, string password, string keyfile, string hostname, int port, string forwardBindAddress, int forwardBindPort, string forwardHostName, int forwardHostPort, string backwardBindAddress, int backwardBindPort, string backwardHostName, int backwardHostPort)58         public SshTunnelManager(string program, string parameters,
59                                 string username, string password, string keyfile,
60                                 string hostname, int port,
61                                 string forwardBindAddress, int forwardBindPort,
62                                 string forwardHostName, int forwardHostPort,
63                                 string backwardBindAddress, int backwardBindPort,
64                                 string backwardHostName, int backwardHostPort)
65         {
66             Trace.Call(program, parameters, username, "XXX", keyfile, hostname,
67                        port, forwardBindAddress, forwardBindPort,
68                        forwardHostName, forwardHostPort,
69                        backwardBindAddress, backwardBindPort,
70                        backwardHostName, backwardHostPort);
71 
72             if (hostname == null) {
73                 throw new ArgumentNullException("hostname");
74             }
75             if (forwardBindAddress == null) {
76                 throw new ArgumentNullException("forwardBindAddress");
77             }
78             if (forwardHostName == null) {
79                 throw new ArgumentNullException("forwardHostName");
80             }
81             if (backwardBindAddress == null) {
82                 throw new ArgumentNullException("backwardBindAddress");
83             }
84             if (backwardHostName == null) {
85                 throw new ArgumentNullException("backwardHostName");
86             }
87 
88             f_Program = program;
89             f_Parameters = parameters;
90             f_Username = username;
91             f_Password = password;
92             f_Keyfile = keyfile;
93             f_Hostname = hostname;
94             f_Port = port;
95 
96             f_ForwardBindAddress = forwardBindAddress;
97             f_ForwardBindPort    = forwardBindPort;
98             f_ForwardHostName    = forwardHostName;
99             f_ForwardHostPort    = forwardHostPort;
100 
101             f_BackwardBindAddress = backwardBindAddress;
102             f_BackwardBindPort    = backwardBindPort;
103             f_BackwardHostName    = backwardHostName;
104             f_BackwardHostPort    = backwardHostPort;
105         }
106 
~SshTunnelManager()107         ~SshTunnelManager()
108         {
109             Trace.Call();
110 
111             Dispose(false);
112         }
113 
Dispose()114         public void Dispose()
115         {
116             Trace.Call();
117 
118             Dispose(true);
119             GC.SuppressFinalize(this);
120         }
121 
Dispose(bool disposing)122         protected virtual void Dispose(bool disposing)
123         {
124             Trace.Call(disposing);
125 
126             if (f_Process != null) {
127                 f_Process.Dispose();
128             }
129         }
130 
Setup()131         public void Setup()
132         {
133             Trace.Call();
134 
135             if (String.IsNullOrEmpty(f_Program)) {
136                 // use plink by default if it's there
137                 var location = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
138                 var plinkPath = Path.Combine(location, "plink.exe");
139                 if (File.Exists(plinkPath)) {
140                     f_Program = plinkPath;
141                 } else {
142                     // TODO: find ssh
143                     f_Program = "/usr/bin/ssh";
144                 }
145             }
146             if (!File.Exists(f_Program)) {
147                 throw new ApplicationException(_("SSH client application was not found: " + f_Program));
148             }
149             if (f_Program.ToLower().EndsWith("putty.exe")) {
150                 throw new ApplicationException(_("SSH client must be either OpenSSH (ssh) or Plink (plink.exe, not putty.exe)"));
151             }
152 
153             bool isPutty = false;
154             if (f_Program.ToLower().EndsWith("plink.exe")) {
155                 isPutty = true;
156             }
157 
158             if (isPutty) {
159                 f_ProcessStartInfo = CreatePlinkProcessStartInfo();
160             } else {
161                 f_ProcessStartInfo = CreateOpenSshProcessStartInfo();
162             }
163 
164             // make sure the tunnel is killed when smuxi is quitting
165             // BUG: this will not kill the tunnel if Smuxi was killed using a
166             // process signal like SIGTERM! Not sure how to handle that case...
167             System.AppDomain.CurrentDomain.ProcessExit += delegate {
168 #if LOG4NET
169                 f_Logger.Debug("Setup(): our process is exiting, let's dispose!");
170 #endif
171                 Dispose();
172             };
173         }
174 
Connect()175         public void Connect()
176         {
177             Trace.Call();
178 
179 #if LOG4NET
180             f_Logger.Debug("Connect(): checking if local forward port is free...");
181 #endif
182             using (TcpClient tcpClient = new TcpClient()) {
183                 try {
184                     tcpClient.Connect(f_ForwardBindAddress, f_ForwardBindPort);
185                     // the connect worked, panic!
186                     var msg = String.Format(
187                         _("The local SSH forwarding port {0} is already in " +
188                           "use. Is there an old SSH tunnel still active?"),
189                         f_ForwardBindPort
190                     );
191                     throw new ApplicationException(msg);
192                 } catch (SocketException) {
193                 }
194             }
195 
196 #if LOG4NET
197             f_Logger.Debug("Connect(): setting up ssh tunnel using command: " +
198                            f_ProcessStartInfo.FileName + " " +
199                            f_ProcessStartInfo.Arguments);
200 #endif
201             f_Process = SysDiag.Process.Start(f_ProcessStartInfo);
202 
203             // lets assume the tunnel didn't fail yet as long as the process is
204             // still running and keep checking if the port is ready during that
205             bool forwardPortReady = false, backwardPortReady  = false;
206             while (!forwardPortReady || !backwardPortReady) {
207                 if (f_Process.HasExited) {
208                     string output = f_Process.StandardOutput.ReadToEnd();
209                     string error = f_Process.StandardError.ReadToEnd();
210                     string msg = String.Format(
211                         _("SSH tunnel setup failed (exit code: {0})\n\n" +
212                           "SSH program: {1}\n" +
213                           "SSH parameters: {2}\n\n" +
214                           "Program Error:\n" +
215                           "{3}\n" +
216                           "Program Output:\n" +
217                           "{4}\n"),
218                         f_Process.ExitCode,
219                         f_ProcessStartInfo.FileName,
220                         f_ProcessStartInfo.Arguments,
221                         error,
222                         output
223                     );
224 #if LOG4NET
225                     f_Logger.Error("Connect(): " + msg);
226 #endif
227                     throw new ApplicationException(msg);
228                 }
229 
230                 // check forward port
231                 using (TcpClient tcpClient = new TcpClient()) {
232                     try {
233                         tcpClient.Connect(f_ForwardBindAddress, f_ForwardBindPort);
234 #if LOG4NET
235                         f_Logger.Debug("Connect(): ssh tunnel's forward port is ready");
236 #endif
237                         forwardPortReady = true;
238                     } catch (SocketException ex) {
239 #if LOG4NET
240                         f_Logger.Debug("Connect(): ssh tunnel's forward port is not reading yet, retrying...", ex);
241 #endif
242                     }
243                 }
244 
245                 backwardPortReady = true;
246                 // we can't test the back-port as the .NET remoting channel
247                 // would need to be ready at this point, which isn't
248                 /*
249                 // check backward port
250                 using (TcpClient tcpClient = new TcpClient()) {
251                     try {
252                         tcpClient.Connect(f_BackwardBindAddress, f_BackwardBindPort);
253 #if LOG4NET
254                         f_Logger.Debug("Connect(): ssh tunnel's backward port is ready");
255 #endif
256                         backwardPortReady = true;
257                     } catch (SocketException ex) {
258 #if LOG4NET
259                         f_Logger.Debug("Connect(): ssh tunnel's backward port is not reading yet, retrying...", ex);
260 #endif
261                     }
262                 }
263                 */
264 #if LOG4NET
265                 f_Logger.Info("Connect(): ssh tunnel is not ready yet, retrying...");
266 #endif
267                 System.Threading.Thread.Sleep(1000);
268             }
269 #if LOG4NET
270             f_Logger.Info("Connect(): ssh tunnel is ready");
271 #endif
272         }
273 
Disconnect()274         public void Disconnect()
275         {
276             Trace.Call();
277 
278             if (f_Process != null && !f_Process.HasExited) {
279 #if LOG4NET
280                 f_Logger.Debug("Disconnect(): killing ssh tunnel...");
281 #endif
282                 f_Process.Kill();
283                 f_Process.WaitForExit();
284 #if LOG4NET
285                 f_Logger.Debug("Disconnect(): ssh tunnel exited");
286 #endif
287             }
288         }
289 
CreateOpenSshProcessStartInfo()290         private SysDiag.ProcessStartInfo CreateOpenSshProcessStartInfo()
291         {
292             string sshArguments = String.Empty;
293 
294             Version sshVersion = GetOpenSshVersion();
295             // starting with OpenSSH version 4.4p1 we can use the
296             // ExitOnForwardFailure option for detecting tunnel issues better
297             // as the process will quit nicely, for more details see:
298             // http://projects.qnetp.net/issues/show/145
299             // NOTE: the patch level is mapped to the micro component
300             if (sshVersion >= new Version("4.4.1")) {
301                 // exit if the tunnel setup didn't work somehow
302                 sshArguments += " -o ExitOnForwardFailure=yes";
303             }
304 
305             // with OpenSSH 3.8 we can use the keep-alive feature of SSH that
306             // will check the remote peer in defined intervals and kills the
307             // tunnel if it reached the max value
308             if (sshVersion >= new Version("3.8")) {
309                 // exit if the peer can't be reached for more than 90 seconds
310                 sshArguments += " -o ServerAliveInterval=30 -o ServerAliveCountMax=3";
311             }
312 
313             // run in the background (detach)
314             // plink doesn't support this and we can't control the process this way!
315             //sshArguments += " -f";
316 
317             // don't execute a remote command
318             sshArguments += " -N";
319 
320             // HACK: force SSH to always flush the send buffer, as needed by
321             // .NET Remoting just like the X11 protocol
322             sshArguments += " -X";
323 
324             if (!String.IsNullOrEmpty(f_Username)) {
325                 sshArguments += String.Format(" -l {0}", f_Username);
326             }
327             if (!String.IsNullOrEmpty(f_Password)) {
328                 // TODO: pass password,  but how?
329             }
330             if (!String.IsNullOrEmpty(f_Keyfile)) {
331                 if (!File.Exists(f_Keyfile)) {
332                     throw new ApplicationException(_("SSH keyfile not found."));
333                 }
334                 try {
335                     using (File.OpenRead(f_Keyfile)) {}
336                 } catch (Exception ex) {
337                     throw new ApplicationException(
338                         _("SSH keyfile could not be read."), ex
339                     );
340                 }
341                 sshArguments += String.Format(" -i \"{0}\"", f_Keyfile);
342             }
343             if (f_Port != -1) {
344                 sshArguments += String.Format(" -p {0}", f_Port);
345             }
346 
347             // ssh tunnel
348             sshArguments += String.Format(
349                 " -L {0}:{1}:{2}:{3}",
350                 f_ForwardBindAddress,
351                 f_ForwardBindPort,
352                 f_ForwardHostName,
353                 f_ForwardHostPort
354             );
355 
356             // ssh back tunnel
357             sshArguments += String.Format(
358                 " -R {0}:{1}:{2}:{3}",
359                 f_BackwardBindAddress,
360                 f_BackwardBindPort,
361                 f_BackwardHostName,
362                 f_BackwardHostPort
363             );
364 
365             // custom ssh parameters
366             sshArguments += String.Format(" {0}", f_Parameters);
367 
368             // ssh host
369             sshArguments += String.Format(" {0}", f_Hostname);
370 
371             SysDiag.ProcessStartInfo psi = new SysDiag.ProcessStartInfo();
372             psi.FileName = f_Program;
373             psi.Arguments = sshArguments;
374             psi.UseShellExecute = false;
375             psi.RedirectStandardOutput = true;
376             psi.RedirectStandardError = true;
377             return psi;
378         }
379 
GetOpenSshVersion()380         private Version GetOpenSshVersion()
381         {
382             SysDiag.ProcessStartInfo psi = new SysDiag.ProcessStartInfo();
383             psi.FileName = f_Program;
384             psi.Arguments = "-V";
385             psi.UseShellExecute = false;
386             psi.RedirectStandardOutput = true;
387             psi.RedirectStandardError = true;
388 
389             string error;
390             string output;
391             int exitCode;
392             using (var process = SysDiag.Process.Start(psi)) {
393                 error = process.StandardError.ReadToEnd();
394                 output = process.StandardOutput.ReadToEnd();
395                 process.WaitForExit();
396                 exitCode = process.ExitCode;
397             }
398 
399             string haystack;
400             // we expect the version output on stderr
401             if (error.Length > 0) {
402                 haystack = error;
403             } else {
404                 haystack = output;
405             }
406             // OpenSSH_6.2p2 Debian-6, OpenSSL 1.0.1e 11 Feb 2013
407             // OpenSSH_6.2, OpenSSL 1.0.1c 10 May 2012
408             Match match = Regex.Match(haystack, @"OpenSSH[_\w](\d+).(\d+)(?:.(\d+))?");
409             if (match.Success) {
410                 string major, minor, micro;
411                 string version = null;
412                 if (match.Groups.Count >= 3) {
413                     major = match.Groups[1].Value;
414                     minor = match.Groups[2].Value;
415                     version = String.Format("{0}.{1}", major, minor);
416                 }
417                 if (match.Groups.Count >= 4) {
418                     micro = match.Groups[3].Value;
419                     version = String.Format("{0}.{1}", version, micro);
420                 }
421                 version = version.TrimEnd('.');
422 #if LOG4NET
423                 f_Logger.Debug("GetOpenSshVersion(): found version: " + version);
424 #endif
425                 return new Version(version);
426             }
427 
428             string msg = String.Format(
429                 _("OpenSSH version number not found (exit code: {0})\n\n" +
430                   "SSH program: {1}\n\n" +
431                   "Program Error:\n" +
432                   "{2}\n" +
433                   "Program Output:\n" +
434                   "{3}\n"),
435                 exitCode,
436                 f_Program,
437                 error,
438                 output
439             );
440 #if LOG4NET
441             f_Logger.Error("GetOpenSshVersion(): " + msg);
442 #endif
443             throw new ApplicationException(msg);
444         }
445 
CreatePlinkProcessStartInfo()446         private SysDiag.ProcessStartInfo CreatePlinkProcessStartInfo()
447         {
448             string sshArguments = String.Empty;
449 
450             var sshVersion = GetPlinkVersionString();
451             // Smuxi by default ships Plink of Quest PuTTY which allows to
452             // accept any fingerprint but does _not_ work with pagent thus we
453             // need to also support the regular plink if the user wants
454             // ssh key authentication instead
455             if (sshVersion.EndsWith("_q1.129")) {
456                 // HACK: don't ask for SSH key fingerprints
457                 // this is nasty but plink.exe can't ask for fingerprint
458                 // confirmation and thus the connect would always fail
459                 sshArguments += " -auto_store_key_in_cache";
460             }
461 
462             // no interactive mode please
463             sshArguments += " -batch";
464             // don't execute a remote command
465             sshArguments += " -N";
466 
467             // HACK: force SSH to always flush the send buffer, as needed by
468             // .NET remoting just like the X11 protocol
469             sshArguments += " -X";
470 
471             if (String.IsNullOrEmpty(f_Username)) {
472                 throw new ApplicationException(_("PuTTY / Plink requires a username to be set."));
473             }
474             sshArguments += String.Format(" -l {0}", f_Username);
475 
476             if (!String.IsNullOrEmpty(f_Password)) {
477                 sshArguments += String.Format(" -pw {0}", f_Password);
478             }
479             if (!String.IsNullOrEmpty(f_Keyfile)) {
480                 if (!File.Exists(f_Keyfile)) {
481                     throw new ApplicationException(_("SSH keyfile not found."));
482                 }
483                 try {
484                     using (File.OpenRead(f_Keyfile)) {}
485                 } catch (Exception ex) {
486                     throw new ApplicationException(
487                         _("SSH keyfile could not be read."), ex
488                     );
489                 }
490                 sshArguments += String.Format(" -i \"{0}\"", f_Keyfile);
491             }
492             if (f_Port != -1) {
493                 sshArguments += String.Format(" -P {0}", f_Port);
494             }
495 
496             // ssh tunnel
497             sshArguments += String.Format(
498                 " -L {0}:{1}:{2}:{3}",
499                 f_ForwardBindAddress,
500                 f_ForwardBindPort,
501                 f_ForwardHostName,
502                 f_ForwardHostPort
503             );
504 
505             // ssh back tunnel
506             sshArguments += String.Format(
507                 " -R {0}:{1}:{2}:{3}",
508                 f_BackwardBindAddress,
509                 f_BackwardBindPort,
510                 f_BackwardHostName,
511                 f_BackwardHostPort
512             );
513 
514             // custom ssh parameters
515             sshArguments += String.Format(" {0}", f_Parameters);
516 
517             // ssh host
518             sshArguments += String.Format(" {0}", f_Hostname);
519 
520             SysDiag.ProcessStartInfo psi = new SysDiag.ProcessStartInfo();
521             psi.FileName = f_Program;
522             psi.Arguments = sshArguments;
523             psi.CreateNoWindow = true;
524             psi.UseShellExecute = false;
525             psi.RedirectStandardOutput = true;
526             psi.RedirectStandardError = true;
527             return psi;
528         }
529 
GetPlinkVersionString()530         private string GetPlinkVersionString()
531         {
532             var startInfo = new SysDiag.ProcessStartInfo() {
533                 FileName = f_Program,
534                 Arguments = "-V",
535                 UseShellExecute = false,
536                 RedirectStandardOutput = true,
537                 RedirectStandardError = true,
538                 CreateNoWindow = true
539             };
540             string error;
541             string output;
542             int exitCode;
543             using (var process = SysDiag.Process.Start(startInfo)) {
544                 error = process.StandardError.ReadToEnd();
545                 output = process.StandardOutput.ReadToEnd();
546                 process.WaitForExit();
547                 exitCode = process.ExitCode;
548             }
549 
550             Match match = Regex.Match(output, @"[0-9]+\.[0-9a-zA-Z_\.]+");
551             if (match.Success) {
552                 var version = match.Value;
553 #if LOG4NET
554                 f_Logger.Debug("GetPlinkVersionString(): found version: " + version);
555 #endif
556                 return version;
557             }
558 
559             string msg = String.Format(
560                 _("Plink version number not found (exit code: {0})\n\n" +
561                   "SSH program: {1}\n\n" +
562                   "Program Error:\n" +
563                   "{2}\n" +
564                   "Program Output:\n" +
565                   "{3}\n"),
566                 exitCode,
567                 f_Program,
568                 error,
569                 output
570             );
571 #if LOG4NET
572             f_Logger.Error("GetPlinkVersionString(): " + msg);
573 #endif
574             throw new ApplicationException(msg);
575         }
576 
_(string msg)577         private static string _(string msg)
578         {
579             return LibraryCatalog.GetString(msg, f_LibraryTextDomain);
580         }
581     }
582 }
583