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