├── .gitignore ├── .scalafmt.conf ├── .travis.yml ├── CONTRIBUTORS.md ├── LICENSE ├── PERFORMANCES-STATUS.md ├── README.md ├── RELEASE-NOTES.md ├── TODO-LIST.md ├── build.sbt ├── cleanup.sh ├── project ├── build.properties └── plugins.sbt ├── publish.sbt ├── scripts ├── dummy ├── futuretest ├── helloworld ├── powershelltest ├── remote-vmstat ├── rexec ├── shortest └── simplefuturetest ├── src ├── main │ └── scala │ │ ├── fr │ │ └── janalyse │ │ │ └── ssh │ │ │ ├── AllOperations.scala │ │ │ ├── CommonOperations.scala │ │ │ ├── ExecResult.scala │ │ │ ├── Expect.scala │ │ │ ├── OS.scala │ │ │ ├── PowerShellOperations.scala │ │ │ ├── Process.scala │ │ │ ├── SSH.scala │ │ │ ├── SSHBatch.scala │ │ │ ├── SSHCommand.scala │ │ │ ├── SSHConnectionManager.scala │ │ │ ├── SSHExec.scala │ │ │ ├── SSHFtp.scala │ │ │ ├── SSHLazyLogging.scala │ │ │ ├── SSHOptions.scala │ │ │ ├── SSHPassword.scala │ │ │ ├── SSHPowerShell.scala │ │ │ ├── SSHReact.scala │ │ │ ├── SSHRemoteFile.scala │ │ │ ├── SSHScp.scala │ │ │ ├── SSHShell.scala │ │ │ ├── SSHTimeoutException.scala │ │ │ ├── SSHTools.scala │ │ │ ├── SSHUserInfo.scala │ │ │ ├── ShellOperations.scala │ │ │ ├── TransfertOperations.scala │ │ │ └── package.scala │ │ └── jassh │ │ └── package.scala └── test │ ├── resources │ ├── logback-test.xml │ ├── setup_travis.sh │ └── sshconfig │ └── scala │ └── fr │ └── janalyse │ └── ssh │ ├── BecomeTest.scala │ ├── CatTest.scala │ ├── CompressedTransfertTest.scala │ ├── ExpectTest.scala │ ├── ExternalSSHAPITest.scala │ ├── SSHAPITest.scala │ ├── SSHConnectionManagerTest.scala │ ├── SSHReactTest.scala │ ├── ShellHistoryTest.scala │ ├── ShellOperationsTest.scala │ ├── SmallTest.scala │ ├── SomeHelp.scala │ ├── StabilityTest.scala │ └── TimeoutTest.scala └── version.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | *.jar 2 | *.class 3 | dist/* 4 | target/ 5 | *.pscala 6 | *~ 7 | .classpath 8 | .project 9 | .cache 10 | .settings 11 | images/ 12 | *.png 13 | nohup.out 14 | .target 15 | bin/ 16 | .cache-* 17 | .ensime 18 | .ensime_cache 19 | .DS_Store/ 20 | .idea/ 21 | .bsp/ 22 | *.iml 23 | 24 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.8.3 2 | runner.dialect = scala3 3 | align.preset = most 4 | maxColumn = 200 5 | assumeStandardLibraryStripMargin = true 6 | align.stripMargin = true 7 | indent.defnSite = 2 8 | 9 | //align.tokens.add = [ 10 | // {code = "=", owner = "Term.Arg.Named"} 11 | //] 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | sudo: true 3 | 4 | scala: 5 | - 2.13.6 6 | 7 | jdk: 8 | - openjdk11 9 | 10 | os: 11 | - linux 12 | 13 | before_script: 14 | - sh src/test/resources/setup_travis.sh 15 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | - Depend on scalalogging instead of scalalogging-slf4j : mgregson (https://github.com/mgregson) 4 | - Added cd, rm, and rmdir to sftp : mgregson (https://github.com/mgregson) 5 | - powershell support : gangstead (https://github.com/gangstead) 6 | - travis CI support : zaneli (https://github.com/zaneli) 7 | - PreferredAuthentications : herbinator (https://github.com/herbinator) 8 | - Add additional SSH options : chEbba (https://github.com/chEbba) 9 | - Add method to create a directory on remote SFTP : davekim (https://github.com/davekim) 10 | - Add getStream method to SSHFtp : Dmitry Melnichenko (https://github.com/slothspot) 11 | - Fix integer overflow issue while sending big files : mlahia (https://github.com/mlahia) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /PERFORMANCES-STATUS.md: -------------------------------------------------------------------------------- 1 | # PERFORMANCES STATUS AND TREND 2 | 3 | ## NEW LANFEUST 4 | 5 | ``` 6 | 2024-10-05 - 332.6 c/s - 267.9 Mb/s (NixOS 24.05 - openjdk 21.0.3 2024-04-16 - OpenSSH_9.8p1, OpenSSL 3.0.14 4 Jun 2024) 7 | 2023-11-17 - 339.3 c/s - 303.0 Mb/s (NixOS 23.05 - openjdk 21 2023-09-19 - OpenSSH_9.3p2, OpenSSL 3.0.12 24 Oct 2023) 8 | 2019-07-14 - 1782.5 c/s - 150.8 Mb/s (Mint 4.15 - java hotspot 1.8.0_172 64b - OpenSSH 1:7.6p1-4ubuntu0.3, OpenSSL 1.1.1-1ubuntu2.1~18.04.3) 9 | ``` 10 | 11 | ## LANFEUST 12 | 13 | ``` 14 | 2016-10-03 - 634.6 c/s - 39.4 Mb/s (gentoo 4.4.6 - java hotspot 1.8.0_102 64b - OpenSSH_7.3p1-hpn14v11, OpenSSL 1.0.2h 3 May 2016) 15 | 2016-01-25 - 607.9 c/s - 38.8 Mb/s (gentoo 4.0.5 - java hotspot 1.8.0_72 64b - OpenSSH_7.1p2-hpn14v10, OpenSSL 1.0.2e 3 Dec 2015) 16 | 2015-08-25 - 626.8 c/s - 42.3 Mb/s (gentoo 4.0.5 - java hotspot 1.7.0_80 64b - OpenSSH_6.9p1-hpn14v5, OpenSSL 1.0.1p 9 Jul 2015 17 | 2015-07-16 - 626.5 c/s - 39.4 Mb/s (gentoo 3.18.12 - java hotspot 1.8.0_45 64b - OpenSSH_6.7p1-hpn14v5, OpenSSL 1.0.1p 9 Jul 2015 18 | - 618.0 c/s - 42.7 Mb/s (gentoo 3.18.12 - java hotspot 1.7.0_80 64b - OpenSSH_6.7p1-hpn14v5, OpenSSL 1.0.1p 9 Jul 2015 19 | 2015-03-20 - 632.2 c/s - 41.6 Mb/s (gentoo 3.16.5 - java hotspot 1.7.0_76 64b - OpenSSH_6.7p1-hpn14v5, OpenSSL 1.0.1k 8 Jan 2015 20 | 2014-05-12 - 630.5 c/s - 48.6 Mb/s (gentoo 3.12.13 - java hotspot 1.7.0_55 64b - OpenSSH_5.9_p1-r4 21 | 2013-10-18 - 611.6 c/s - 48.0 Mb/s (gentoo 3.10.7 - java hotspot 1.7.0_40 64b - OpenSSH_5.9p1-hpn13v11lpk, OpenSSL 1.0.1e 11 Feb 2013) 22 | 2013-07-02 - 582.2 c/s - 50.3 Mb/s (gentoo 3.8.13 - java hotspot 1.7.0_25 64b - OpenSSH_5.9p1-hpn13v11lpk, OpenSSL 1.0.1c 10 May 2012) 23 | - 574.0 c/s - 43.0 Mb/s (gentoo 3.8.13 - java hotspot 1.7.0_25 32b - OpenSSH_5.9p1-hpn13v11lpk, OpenSSL 1.0.1c 10 May 2012) 24 | - 562.7 c/s - 49.4 Mb/s (gentoo 3.8.13 - java hotspot 1.7.0_11 64b - OpenSSH_5.9p1-hpn13v11lpk, OpenSSL 1.0.1c 10 May 2012) 25 | - 566.8 c/s - 44.3 Mb/s (gentoo 3.8.13 - java hotspot 1.6.0_45 64b - OpenSSH_5.9p1-hpn13v11lpk, OpenSSL 1.0.1c 10 May 2012) 26 | 2013-06-23 - 552.8 c/s - 47.2 Mb/s (gentoo 3.8.13 - java hotspot 1.6.0_45 64b - OpenSSH_5.9p1-hpn13v11lpk, OpenSSL 1.0.1c 10 May 2012) 27 | 2013-02-22 - 566.6 c/s - 45.4 Mb/s (gentoo - java hotspot 1.6.0 64b) 28 | ``` 29 | 30 | ## ZORGLUB 31 | 32 | ``` 33 | 2016-10-03 - 439.3 c/s - 71.9 Mb/s (macosx 10.11.6 - java hotspot 1.8.0.72 64b - OpenSSH_6.9p1, LibreSSL 2.1.8) 34 | 2016-02-03 - 446.9 c/s - 59.3 Mb/s (macosx 10.11.3 - java hotspot 1.8.0_72 64b - OpenSSH_6.9p1, LibreSSL 2.1.8) 35 | 2015-08-25 - 498.2 c/s - 81.5 Mb/s (macosx 10.10.5 - java hotspot 1.8.0_45 64b - OpenSSH_6.2p2, OSSLShim 0.9.8r 8 Dec 2011) 36 | 2015-07-16 - 501.2 c/s - 87.0 Mb/s (macosx 10.10.4 - java hotspot 1.7.0_80 64b - OpenSSH_6.2p2, OSSLShim 0.9.8r 8 Dec 2011) 37 | 2015-03-20 - 496.1 c/s - 88.0 Mb/s (macosx 10.10.2 - java hotspot 1.7.0_51 64b - OpenSSH_6.2p2, OSSLShim 0.9.8r) 38 | 2015-03-19 - 490.1 c/s - 88.2 Mb/s (macosx 10.10.2 - java hotspot 1.7.0_51 64b - OpenSSH_6.2p2, OSSLShim 0.9.8r) 39 | 2014-05-12 - 539.8 c/s - 87.0 Mb/s (macosx 10.9.2 - java hotspot 1.7.0_51 64b - OpenSSH_6.2p2, OSSLShim 0.9.8r) 40 | 2014-01-20 - 507.2 c/s - 87.2 Mb/s (macosx 10.9.1 - java hotspot 1.7.0_40 64b - OpenSSH_6.2p2, OSSLShim 0.9.8r) 41 | 2013-10-18 - 485.5 c/s - 85.7 Mb/s (macosx 10.8.5 - java hotspot 1.7.0_40 64b - OpenSSH_5.9p1, OpenSSL 0.9.8y 5 Feb 2013) 42 | 2013-07-02 - 486.8 c/s - 55.3 Mb/s (macosx 10.8.4 - java hotspot 1.6.0_51 64b - OpenSSH_5.9p1, OpenSSL 0.9.8x 10 May 2012) 43 | 2013-06-23 - 484.3 c/s - 59.0 Mb/s (macosx 10.8.4 - java hotspot 1.6.0_45 64b - OpenSSH_5.9p1, OpenSSL 0.9.8x 10 May 2012) 44 | ``` 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JASSH - the SCALA SSH API [![Build Status][travisImg]][travisLink] [![License][licenseImg]][licenseLink] [![Maven][mavenImg]][mavenLink] [![Scaladex][scaladexImg]][scaladexLink] 2 | 3 | High level scala SSH API for easy and fast operations on remote servers. 4 | 5 | This API is [JSCH](https://github.com/mwiede/jsch) based. Interfaces are stable. Many helper functions are provided to simplify unix operations [ps, ls, cat, kill, find, ...](https://javadoc.io/doc/fr.janalyse/janalyse-ssh_3/latest/api/fr/janalyse/ssh/AllOperations.html), an other goal of this API is to create an unix abstraction layer (Linux, Aix, Solaris, Darwin, ...). 6 | 7 | One of the main difference of this API with others is that it can work with **persisted shell sessions**. Many commands can then be sent 8 | to an already running and **initialized** shell session ! Thanks to this feature you can greatly speed up your SSH shell performances, 9 | from 70 cmd/s to more than 500 cmd/s ! There is no differences in API between persisted and not persisted shell sessions, that's the 10 | reason why the API looks very simple from scala point of view; when you execute a command in a shell persisted session you get directly 11 | the output of the command but not the return code. The return code will be accessible only indirectly using for example a "echo $?" command. 12 | 13 | The current release doesn't provide full shell interaction with executed commands, you only send a command and get the result, but 14 | I'm currently working to provide full interactivity, to allow interaction with commands such as to provide data after the command is 15 | started (send a password once the prompt is visible, ...). This work is currently visible through SSHReact class and SSHReactTest 16 | test class. 17 | 18 | In your build.sbt, add this (available in maven central) : 19 | ``` 20 | libraryDependencies += "fr.janalyse" %% "janalyse-ssh" % version 21 | ``` 22 | Latest `version`: [![Maven][mavenImg]][mavenLink] [![Scaladex][scaladexImg]][scaladexLink] 23 | 24 | 25 | [**Scala docs**](https://javadoc.io/doc/fr.janalyse/janalyse-ssh_3) 26 | 27 | 28 | [mavenImg]: https://img.shields.io/maven-central/v/fr.janalyse/janalyse-ssh_3.svg 29 | [mavenImg2]: https://maven-badges.herokuapp.com/maven-central/fr.janalyse/janalyse-ssh_3/badge.svg 30 | [mavenLink]: https://search.maven.org/#search%7Cga%7C1%7Cfr.janalyse.janalyse-ssh 31 | 32 | [scaladexImg]: https://index.scala-lang.org/dacr/jassh/janalyse-ssh/latest.svg 33 | [scaladexLink]: https://index.scala-lang.org/dacr/jassh 34 | 35 | [licenseImg]: https://img.shields.io/github/license/dacr/jassh.svg 36 | [licenseImg2]: https://img.shields.io/:license-apache2-blue.svg 37 | [licenseLink]: LICENSE 38 | 39 | [codacyImg]: https://img.shields.io/codacy/a335d839f49646389d88d02c01e0d6f6.svg 40 | [codacyImg2]: https://api.codacy.com/project/badge/grade/a335d839f49646389d88d02c01e0d6f6 41 | [codacyLink]: https://www.codacy.com/app/dacr/jassh/dashboard 42 | 43 | [codecovImg]: https://img.shields.io/codecov/c/github/dacr/jassh/master.svg 44 | [codecovImg2]: https://codecov.io/github/dacr/jassh/coverage.svg?branch=master 45 | [codecovLink]: http://codecov.io/github/dacr/jassh?branch=master 46 | 47 | [travisImg]: https://img.shields.io/travis/dacr/jassh.svg 48 | [travisImg2]: https://travis-ci.org/dacr/jassh.png?branch=master 49 | [travisLink]:https://travis-ci.org/dacr/jassh 50 | 51 | 52 | ---- 53 | 54 | ## hello world script 55 | 56 | It requires a local user named "test" with password "testtest", 57 | remember that you can remove the password, if your public key has 58 | been added in authorized_keys file of the test user. 59 | 60 | ```scala 61 | // --------------------- 62 | //> using scala "3.3.1" 63 | //> using dep "fr.janalyse::janalyse-ssh:1.1.0" 64 | //> using lib "org.slf4j:slf4j-nop:2.0.9" 65 | // --------------------- 66 | 67 | jassh.SSH.once("127.0.0.1", "test", "testtest") { ssh => 68 | println(ssh.execute("""echo "Hello World from $(hostname)" """)) 69 | } 70 | ``` 71 | 72 | ## Persisted shell session 73 | 74 | ```scala 75 | // --------------------- 76 | //> using scala "3.3.1" 77 | //> using dep "fr.janalyse::janalyse-ssh:1.1.0" 78 | //> using lib "org.slf4j:slf4j-nop:2.0.9" 79 | // --------------------- 80 | 81 | jassh.SSH.shell("localhost", "test") { sh => 82 | import sh.* 83 | println(s"initial directory is $pwd") 84 | cd("/tmp") 85 | println(s"now it is $pwd") 86 | println(echo("Hello world !")) 87 | } 88 | ``` 89 | 90 | ## Shell session to an SSH enabled PowerShell Server (windows) 91 | This functions much the same as a regular SSH connection, but many of the unix like commands are not supported and the terminal behaves differently 92 | ```scala 93 | import fr.janalyse.ssh._ 94 | 95 | val settings = SSHOptions(host = host, username=user, password = pass, prompt = Some(prompt), timeout = timeout) 96 | val session = SSH(settings) 97 | 98 | val shell = session.newPowerShell 99 | 100 | println(shell.ls) 101 | println(shell.pwd) 102 | ``` 103 | 104 | ## SSH Configuration notes 105 | 106 | To turn on/off ssh root direct access or sftp ssh subsystem. 107 | ``` 108 | Subsystem sftp ... (add or remove comment) 109 | PermitRootLogin yes or no (of course take care of security constraints) 110 | ``` 111 | 112 | AIX SSHD CONFIGURATION : 113 | ``` 114 | vi /system/products/openssh/conf/sshd_config 115 | /etc/rc.d/rc2.d/S99sshd reload 116 | ``` 117 | 118 | LINUX SSHD CONFIGURATION 119 | ``` 120 | vi /etc/ssh/sshd_config 121 | /etc/init.d/sshd reload 122 | ``` 123 | 124 | SOLARIS SSHD CONFIGURATION 125 | ``` 126 | vi /usr/local/etc/ssh/sshd_config 127 | svcadm restart ssh 128 | ``` 129 | 130 | MAC OS X CONFIGURATION 131 | ``` 132 | sudo vi /etc/sshd_config 133 | sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist 134 | ``` 135 | -------------------------------------------------------------------------------- /RELEASE-NOTES.md: -------------------------------------------------------------------------------- 1 | # JASSH - JANALYSE-SSH - SCALA SSH API 2 | 3 | Crosson David - crosson.david@gmail.com 4 | 5 | ## Remarks & caveats: 6 | 7 | ``` 8 | => ssh persisted shell session operations must be executed within the same thread, 9 | (this is jassh.jar default behavior as it transparently add the -Yrepl-sync) 10 | do not span a persisted shell session across several threads => it may generates exception 11 | 12 | So be careful when using REPL with default config, as each "evaluation" is done within a new thread ! 13 | Workaround : Start the interpreter (REPL) with the "-Yrepl-sync" option. 14 | 15 | No problem with SBT as a scala console started from SBT will execute all its entries in the same thread ! 16 | No problem in scala scripts. 17 | 18 | => SCP operations can't retrieve special file such as /proc/cpuinfo, because their size are not known ! 19 | Workarounds : use SFTP OR use a command such as "cat /proc/cpuinfo". 20 | (The last one is the "best workaround", will work in all cases) 21 | 22 | => Be aware of the fact that SFTP SSH Channel may be not available, 23 | so prefer SCP to maximize scripts / code portability from one system to an other. 24 | Looks like linux SSHD comes with SFTP by default, but not AIX. 25 | --> Since 0.9.5-b3 , SSH transfert operations comes with an automatic fallback mechanism with priority to scp 26 | So prefer using SSH transfert operations, over SSHFtp transferts operations. 27 | 28 | => TAKE CARE OF HOW MANY SESSIONS CAN BE MANAGED SIMULTANEOUSLY. Check sshd configuration 29 | MaxStartups = 10 (default) the maximum number of concurrent unauthenticated connections to the SSH daemon 30 | MaxSessions = 10 (default) the maximum number of open sessions permitted per network connection 31 | 32 | => ?? With such MaxStartups configuration (at least with OpenSSH_6.9p1-hpn14v5, OpenSSL 1.0.1p 9 Jul 2015) : 33 | ?? MaxStartups 10:30:100 34 | ?? --> Random failures may occurs ?? more frequently ?? than with just "MaxStartups 10" ?? 35 | ?? com.jcraft.jsch.JSchException: session is down 36 | ?? at com.jcraft.jsch.Channel.sendChannelOpen(Channel.java:762) 37 | ?? com.jcraft.jsch.JSchException: Session.connect: java.net.SocketException: Connection reset 38 | ?? at com.jcraft.jsch.Session.connect(Session.java:558) 39 | 40 | => AIX sshd & SSHExecChannel (no persistence) doesn't work well when virtual tty is used 41 | (execWithPty must be keep to false, this is the default value) 42 | strange behavior : when SFtp subchannel is enabled, the maximum number of ExecChannel in // decrease... 43 | 44 | => Take care of system limits for sshd (nofile & nproc) 45 | Check /etc/security/limits.conf for linux systems 46 | if nproc max process/threads limit has been reached for the user you want to connect with, 47 | you'll get a failure 48 | 49 | => Password expiration may ask you for a new password, so you can be blocked waiting for a result that never comes. 50 | 51 | => Remember that some operations may require a TTY (or let's rather say a virtual TTY) or behave 52 | differently with or without a TTY/VTTY (sudo, mysql, ...) 53 | 54 | ``` 55 | 56 | ## Major changes 57 | 58 | ### 1.1.0 (2023-11-17) 59 | 60 | - switch to jsch fork from @mwiede :) 61 | - dependency updates 62 | - fix tests for execution on NixOS 63 | - fix various code issues reported by scala 3.3 compiler :) 64 | 65 | ### 1.0.0 (2021-05-03) 66 | 67 | - scala3 support 68 | 69 | ### 0.10.4 (2019-07-14) 70 | 71 | - fix integer overflow issue while sending big files / mlahia (https://github.com/mlahia) 72 | - Add support for scala 2.13 73 | - update library dependencies 74 | - update sbt tools (plugins) 75 | - update scala releases 76 | 77 | ### 0.10.2 (2017-09-29) 78 | 79 | - Software updates (scala, scalatest, commons-compress) 80 | - SSHConnectionManager added 81 | + Take care this is a first implementation 82 | - moved to sbt 1.0 + SBT plugins updates 83 | 84 | ### 0.10.1 (2017-04-03) 85 | 86 | - sbt release integration 87 | - moved to maven central 88 | - scala 2.12 support 89 | 90 | ### 0.9.20 (2016-06-24) 91 | 92 | - AllOperations trait added : for generic operations that requires both executions and transferts 93 | + `rreceive` method added to recursively copy a remote directory to a local destination 94 | + `rsend` method added to recursively copy a local directory to a remote destination 95 | - SSHShell : 96 | + `pid` method added 97 | + `catData` is now scp based ! 98 | + now inherits from SSHScp and is able to perform file transfert operations 99 | + now comes with AllOperations trait instead of ShellOperations 100 | - SSH : 101 | + now comes with AllOperations trait instead of both ShellOperations and TransfertOperations 102 | - ShellOperations : 103 | + `which` method added 104 | - jsch rekey operation disabled => it generates random "session is down" ssh error ! 105 | the same for ciphers... 106 | - dependencies update : 107 | + sbt assembly 0.14.1 108 | + commons-compress 1.11 109 | + sl4j-api 1.7.21 110 | + scalatest 2.2.6 111 | + scala 2.10.6 & 2.11.8 112 | + jsch 0.1.54 113 | 114 | ### 0.9.19 (2015-09-22) 115 | 116 | - ShellOperations : sudoNoPasswordTest renamed to sudoSuMinusOnlyWithoutPasswordTest 117 | - ShellOperations : sudo operations moved to SSHShell class 118 | - SSHShell : catData method added 119 | - SSHShell : sudoSuMinusOnlyWithPasswordTest method added 120 | - SSHShell : sudoSuMinusWithCommandTest method added 121 | - SSHShell : executeWithExpects quick'n dirty implementation 122 | - SSHOptions : 123 | + sshUserDir sshKeyFile parameters removed. 124 | + replaced by identities parameter which is prefilled 125 | with found identifies in $HOME/.ssh/ such as id_rsa, id_dsa, id_ecdsa, identity, ... 126 | + SSHOptions.addIdentity can be used to add a new identity, new ones are added first 127 | + SSHOptions(identities=SSHIdentity(...)::Nil) can be use to specify a particular identity 128 | - Merged : Added cd, rm, and rmdir to sftp coming from : mgregson (https://github.com/mgregson) 129 | - scala 2.11.7 130 | - jsch 0.1.53 131 | - ses.setConfig("PreferredAuthentications", "publickey,keyboard-interactive,password") 132 | added as suggested by herbinator (https://github.com/herbinator) 133 | 134 | ### 0.9.18 (2015-03-22) 135 | 136 | - jsch 0.1.52 137 | - scalalogging usage removed because of api incompatibilities between scala 2.10 and 2.11 !! 138 | and because no support for scala2.10 in scala-logging 3.x 139 | replaced by slf4j-api 140 | - junit test dependency removed 141 | - onejar subproject, sbt assembly release update (0.13) 142 | 143 | ### 0.9.17 (2015-03-22) 144 | 145 | - ShellOperations : mkcd added (mkdir && cd tied together) 146 | - ShellOperations : mkdir now returns true if successfull 147 | - ShellOperations : rmdir now returns true if successfull 148 | - more tests for ShellOperations, increased coverage => with various related fixes 149 | - Depend on scalalogging instead of scalalogging-slf4j : mgregson (https://github.com/mgregson) 150 | 151 | ### 0.9.16 (2015-03-18) 152 | 153 | - ShellOperations : echo added 154 | - ShellOperations : alive added 155 | - become tests ignored 156 | 157 | ### 0.9.15 (2015-03-18) 158 | 159 | - SSHShell : become enhancements (su - or sudo su - support) 160 | - ShellOperations : sudoNoPasswordTest added 161 | - ShellOperations : dirname added 162 | - ShellOperations : basename added 163 | - ShellOperations : lastModified fix because of millis 164 | - ShellOperations : id added 165 | - ShellOperations : touch added 166 | - SSH through tunnel support added (ProxyHTTP, ProxySocks4, ProxySocks5) 167 | - scala 2.11.6 168 | - commons-compress 1.9 169 | - scala-test 2.2.1 170 | - sudoNoPasswordTest test enhancement for older sudo command releases (without -n option) 171 | 172 | ### 0.9.14 (2014-09-23) 173 | 174 | - small test fix on test shell command 175 | - pull request merged : add Travis CI configuration file from zaneli authored on 29 Apr 176 | - scala 2.11.2 177 | - commons-compress 1.8.1 178 | - ShellOperations : lastModified darwin implementation added + test case 179 | - ShellOperations : disableHistory method added + test case 180 | - ShellOperationsTest class added. 181 | - small tests cleanup and improvements 182 | - shell history can now be processed 183 | - SSHReact class first implementation, it allows you to interact with a running command 184 | (for example send the password at the right moment, or enter values required by a read command) 185 | - SSHReact test class added 186 | - new shell commands : 187 | + pidof : get pid of all processes matching the given command line regular expression 188 | + disableHistory : to not impact current user shell commands history 189 | + uptime : get the uptime of the server 190 | 191 | ### 0.9.13 (2014-05-31) 192 | 193 | - scala 2.11 support added 194 | - scala-logging-slf4j 2.1.2 195 | - scala-io 0.4.3 196 | - scalatest 2.1.5 197 | - jsch 0.1.51 198 | - and various impact changes 199 | 200 | ### 0.9.12 (2014-01-20) 201 | 202 | - sbt-eclipse 2.4.0 203 | - sbt-assembly 0.10.2 204 | - scalatest 2.0 205 | - scala 2.10.3 206 | - commons-compress 1.7 207 | 208 | ### 0.9.11 (2013-09-24) 209 | 210 | - receiveNcompress was not using localbasename parameter with already compressed remote files. 211 | - sbt-eclipse 2.3.0 212 | - fix from "Shashank Jain" that extends the cipher list. 213 | none,aes128-cbc,aes192-cbc,aes256-cbc,3des-cbc,blowfish-cbc,aes128-ctr,aes192-ctr,aes256-ctr 214 | - ciphers parameter added to SSHOptions in order to allow custom ciphers list 215 | 216 | ### 0.9.10 (2013-07-03) 217 | 218 | - DONE : add su support / with password prompt recognition... 219 | SSHShell.become method added, allow to become someoneelse. 220 | typical usage : from an ordinary user become the root user 221 | - SomeHelp class added in tests, in order to share common stuff. 222 | - various test classes enhancements 223 | - SSHShell timeout on current command (send ^C to break current processing and give back the prompt) 224 | - exit code accessible through a new method named executeWithStatus (thanks Alex Biehl for the suggestion) 225 | - SSHAPI.scala file split finished, SSHAPI.scala file deleted 226 | - imports cleaned up, source file copyright & license header added 227 | - fix : ls on an empty directory was returning a collection containing an empty string 228 | - fix : SSHFtp.receive was not flushing... receiveNcompress was broken with SSHFtp, now it is OK. 229 | - fix : break on timeout may block. resultsQueue.poll with timeout instead of a forever resultsQueue.take 230 | - new method in TransfertOperations : putFromStream(data: java.io.InputStream, howmany:Int, remoteDestination: String) 231 | - jsch 0.1.50 232 | - sbteclipse-plugin 2.2.0 233 | - scala 2.10.2 234 | - sbt assembly 0.9.0 235 | 236 | ### 0.9.9 (2013-05-09) 237 | 238 | - scala 2.10.1 239 | - scalalogging 1.0.1 240 | - sbt assembly 0.8.8 241 | - sbt eclipse 2.1.2 242 | - removing parenthesis to cd to allow executing "cd" to go back to home directory 243 | - SSHExec timeout implemented BUT not yet clean 244 | + SSHTimeoutException class added 245 | - receiveNcompress method added to TransfertOperations (gz) 246 | - new dependency : "org.apache.commons" % "commons-compress" 247 | - SSHOptions - removing second parameter list, host comes back to the first and unik parameter list. 248 | (Two parameters list for SSHOptions was a bad idea) 249 | 250 | ### 0.9.8 (2013-02-22) 251 | 252 | - jsch session is now configured with tcp keep alive of 2s 253 | (setServerAliveInterval(2000)) 254 | - AIX sha1sum & md5sum fix. 255 | - new commands : 256 | + env 257 | + osid (with new trait OS and objects AIX, Linux, Darwin, SunOS) 258 | + rm 259 | + rmdir 260 | + mkdir 261 | + arch 262 | + kill 263 | - new test cases 264 | - "du" fix for AIX (not supporting -d or --max-depth option) 265 | - "ps" fix for AIX 266 | - SSHOptions new option : execWithPty = false (by default) 267 | tell if exec should use a virtual tty or not 268 | (Feature : ChannelExec use of virtual tty must be configurable, as without performance are quite better) 269 | - onejar subproject contains now a simplified launcher 270 | with default scala options : 271 | -Yrepl-sync -usejavacp 272 | -nocompdaemon -savecompiled 273 | -deprecation 274 | 275 | ### 0.9.7 (2013-02-10) 276 | 277 | - scala >=2.10 is now mandatory 278 | - ChannelExec now is using a virtual tty by default 279 | - new commands : 280 | + fsFreeSpace 281 | + fileRights 282 | + du 283 | - minor updates for darwin (mac os x) support 284 | - ps() command enhancements 285 | + OS process modeling => LinuxProcess, AIXProcess, SunOSProcess, DarwinProcess 286 | + LinuxProcessState, DarwinProcessState 287 | + ps test case added 288 | - logging support added (using scala-logging) 289 | 290 | ### 0.9.6 (2013-01-07) 291 | 292 | - scala 2.10.0 support added 293 | 294 | ### 0.9.5-b3 (2012-11-26) 295 | 296 | - general transfert methods (available in SSH class) are now using automatic fallback, if SCP fails, then SFTP will be 297 | tryied. 298 | 299 | ### 0.9.5-b2 (2012-11-05) 300 | 301 | - JSCH updated to 0.1.49 302 | - now using sbt 0.12.1 303 | - now using scalatest 2.0-M5 304 | - now using sbteclipse 2.1.0 305 | - now using sbt assembly 0.8.5 306 | - add support for scala 2.10.0-RC2 307 | - rexec.scala example script added 308 | - Issue 1 Fixed: ssh keys reported by jendap 309 | SSHOptions new parameter : sshKeyFile: Option[String]=None, // if None, will look for default names. (sshUserDir is 310 | used) 311 | - new helper methods : 312 | => ps : to get the list of running processes 313 | => cat file : to get the content of a file through the cat command. 314 | Useful when trying to get special linux file content 315 | - remote2Local(host:String, hport:Int) without local port specified; 316 | the port is automatically chosen and returned 317 | - get access to a remote SSH through current SSH session 318 | SSH.remote(options:SSHOptions):SSH 319 | SSH.remote(remotePort:Int, options:SSHOptions) 320 | - SSHOptions API CHANGE, a second parameter list have been added, host parameter moved from first one to the second one 321 | GOAL : Allow simple creation of partial function with SSHOptions, to share ssh options between several connections... 322 | - TransferOperations new methods : 323 | send(filename: String) 324 | receive(filename: String) 325 | - ShellOpterations new methods : 326 | notExists(filename: String): Boolean 327 | - CommonOperations trait added, inherited by both TransfertOperations & ShellOperations 328 | define the following method : 329 | localmd5sum(filename:String):Option[String] 330 | 331 | ### 0.9.3 (2012-07-04) 332 | 333 | - now using sbt-assembly 0.8.3 334 | - fixes relatives to implicit conversions with SSHPassword 335 | - fixes relatives to implicit conversion to SSHCommand and SSHBatch 336 | - For SSHBatch : execute, executeAndTrim, executeAndTrimSplit 337 | renamed to : executeAll, executeAllAndTrim, executeAllAndTrimSplit 338 | - Using Iterable instead of List 339 | - external (package) usage tests completed (ExternalSSHAPITest.scala) 340 | - small fix about how private key passphrase is taken into account (when pub-key auth is used) 341 | 342 | ### 0.9.2 (2012-06-28) 343 | 344 | - date '+%Y-%m-%d %H:%M:%S %z' %z and %Z gives the same result on AIX, this result corresponds to linux %Z 345 | So modifying code to use %Z instead of %z. 346 | Now using GMT, "date -u '+%Y-%m-%d %H:%M:%S %Z'" in order to everything work well in all cases. 347 | - SSH.once(Option[SSHOptions]) fix linked to Option type result not at the right place 348 | - New test source file : ExternalSSHAPITest.scala => Testing the API from an external package 349 | - Fixed : minor problem with script when invoking jajmx.SSH... or fr.janalyse.sh.SSH... without imports... 350 | 351 | ### 0.9.1 (2012-06-27) 352 | 353 | - SSH tunneling fix, cleanup, and scaladocumented 354 | - Intricated SSH tunneling test added (self intrication, to simplify test case) 355 | 356 | ### 0.9.0 357 | 358 | - now using sbt-assembly 0.8.1 359 | - now using scalatest 0.8 360 | - SSHCommand, SSHBatch methods ! renamed 361 | - new helper methods : 362 | test, exists, isFile, isDirectory, isExecutable 363 | - findAfterDate & date helper fix !! 364 | Shell.date -> remote system time zone is now taken into account 365 | - Test cases fixes : 366 | Forcing parallelism to 6 ! for test case "Simultaenous SSH operations" 367 | - Code factorization : 368 | => ShellOperations trait added. Inherited by SSH and SSHShell. 369 | => TransferOperations trait added. Inherited by SSH and SSHFtp 370 | - SCP supported, for no-persistent transferts sessions, SCP is now used by default (instead of SFTP) 371 | (e.g. : SSH class transfert operation is now using SCP by default). 372 | - noneCipher switch added to SSHOptions for higher performance SCP transfert (true by default) 373 | (http://www.psc.edu/index.php/hpn-ssh) 374 | - transfert (receive) tests added 375 | Reference time on a local system: 500Mb using 5 SCP command (100Mb/cmd) takes on the same system 8.7s (~62Mo/s by 376 | file) 377 | [info] - file transfert performances (with content loaded in memory) 378 | [info] + Bytes rate : 38,6Mb/s 500Mb in 12,9s for 5 files - byterates using SCP 379 | [info] + Bytes rate : 44,9Mb/s 500Mb in 11,1s for 5 files - byterates using SCP (with none cipher) 380 | [info] + Bytes rate : 38,5Mb/s 500Mb in 13,0s for 5 files - byterates using SFTP 381 | [info] + Bytes rate : 46,0Mb/s 500Mb in 10,9s for 5 files - byterates using SFTP (with none cipher) 382 | [info] + Bytes rate : 39,5Mb/s 500Mb in 12,7s for 5 files - byterates using SFTP (session reused 383 | [info] + Bytes rate : 46,7Mb/s 500Mb in 10,7s for 5 files - byterates using SFTP (session reused, with none cipher) 384 | [info] + Bytes rate : 29,5Mb/s 500Mb in 16,9s for 500 files - byterates using SCP 385 | [info] + Bytes rate : 32,1Mb/s 500Mb in 15,6s for 500 files - byterates using SCP (with none cipher) 386 | [info] + Bytes rate : 26,7Mb/s 500Mb in 18,7s for 500 files - byterates using SFTP 387 | [info] + Bytes rate : 29,5Mb/s 500Mb in 16,9s for 500 files - byterates using SFTP (with none cipher) 388 | [info] + Bytes rate : 37,7Mb/s 500Mb in 13,3s for 500 files - byterates using SFTP (session reused) 389 | [info] + Bytes rate : 43,7Mb/s 500Mb in 11,4s for 500 files - byterates using SFTP (session reused, with none 390 | cipher) 391 | - Code cleanup & Scaladocumenting 392 | - SSH compression now supported 393 | - For easier SSH Tunneling, new methods are now available : 394 | + def remote2Local(rport:Int, lhost:String, lport:Int) 395 | + def local2Remote(lport:Int, rhost:String, rport:Int) 396 | 397 | ### 0.8.0 (2012-05-28) 398 | 399 | - now using sbt 0.11.3 400 | - now using sbteclipse 2.1.0-RC1 401 | - Set of new method to help with commons remote commands : 402 | fileSize, md5sum, sha1sum, uname, ls, pwd, cd(*), hostname, date, findAfterDate 403 | (*) of course only for shell sessions 404 | - JSCH updated to 0.1.48 405 | - md5sum method added to SSHTools object 406 | - manage well connect timeout (default = 30s) and general socket timeout (default = 5mn) 407 | 408 | ### 0.7.4 409 | 410 | - SSHPassword toString method added (return the password) 411 | - updated for scala 2.9.2 support 412 | - scalatest 1.7.2 413 | - no more support for scala 2.8.1 & 2.8.2 414 | 415 | ### 0.7.3 416 | 417 | - JCSH updated to release 0.1.47 418 | - SSHOptions now contains an extra field "name" which allow user to friendly identify a remote ssh system 419 | - SSHOptions password type is now of SSHPassword type instead of String. 420 | Implicit conversions is provided from String, Option[String] 421 | - SSHShell batch method renamed to execute 422 | 423 | ### 0.7.2 (2012-04-12)) 424 | 425 | - added a package object jassh to define shortcuts to fr.janalyse.ssh.SSH class and object 426 | - SSHOptions, host parameter is now in first position ! 427 | 428 | ### 0.7.1 (2012-04-03) 429 | 430 | - fix big issue with SSHShell results separator process. => not seen using localhost tests => Must add remote tests !! 431 | 432 | ### 0.7.0 (2012-04-01) 433 | 434 | - Added new method to SSH : newShell & newSftp for user to manage themselves shell and sftp session 435 | - Some internal changes to SSHExec class, in order to try to remove actor dependency. Mixing actors systems looks 436 | problematic 437 | - SSHShell new implementation, no more actors used, better performances and behavior, ... (throughput : 504 cmd/s using 438 | persistency) 439 | - SSHExec last result line is no longer lost 440 | - SSHOptions : new parameter : "prompt" to enable custom shell or console command to be use. 441 | prompt provide to SSHShell the way to separate command results 442 | - SSHOptions : connectionTimeout renamed into timeout 443 | - Various cleanup and enhancements 444 | - Tests : compare performances persistent SSHShell versus SSHExec commands throughputs 445 | - SSH : Add an execute immediate method which rely on SSHExec, not SSHShell ! (throughput : 62cmd/s) 446 | execOnce & execOnceAndTrim 447 | - SSHExec : Do not rely on DaemonActor/Actor anymore 448 | - SSHShell : Removed init Thread.sleep => Better performances (throughput : 37 cmd/s instead 1cmd/s) 449 | - SSH.connect becomes SSH.once 450 | - Removing apply in SSH class as it may encourage bad usage, and close not called 451 | 452 | ### 0.6.0 453 | 454 | - update jsch to 0.1.46 455 | - update sbteclipse plugin to 2.0.0 456 | - update sbtassembly plugin to 0.7.3 457 | - background ssh execution API changes (run method) 458 | - temporary hack to remove CPU overhead within run method 459 | 460 | ### 0.5.1 (2012-01-10) 461 | 462 | ### 0.0.0 (2011-11-25) 463 | 464 | - first project commit (SVN) 465 | - scala 2.8 ! 466 | -------------------------------------------------------------------------------- /TODO-LIST.md: -------------------------------------------------------------------------------- 1 | 2 | # TODO LIST 3 | 4 | - TODO : Add tests with a remote host, in addition to localhost 5 | - TODO : "ls --format=single-column" may to be replaced by "ls | cat" 6 | - TODO : Check if "date -u '+%Y-%m-%d %H:%M:%S %Z'" is supported on all *nix (Checked OK for : AIX, Linux, SunOS) 7 | - TODO : BSD, Cygwin support 8 | - TODO : trying to find a solution with SCP and some special linux file (with unknown file size) 9 | - TODO : manage when sftp submodule is not available => throw the right exception 10 | - INPG : add sudo support / with password prompt recognition... 11 | - TODO : Fixing new bug linked to jsch and newer release of openssh : 12 | + [info] com.jcraft.jsch.JSchException: Algorithm negotiation fail 13 | + [info] at com.jcraft.jsch.Session.receive_kexinit(Session.java:583) 14 | + [info] at com.jcraft.jsch.Session.connect(Session.java:320) 15 | ``` 16 | => temporary Work around : change sshd_config and add supported 17 | jsck algo : KexAlgorithms diffie-hellman-group1-sha1 18 | => More information available here : 19 | http://stackoverflow.com/questions/26424621/algorithm-negotiation-fail-ssh-in-jenkins 20 | http://www.programmingforliving.com/2014/10/com.jcraft.jsch.JSchException-Algorithm-negotiation-fail.html 21 | ``` 22 | 23 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "janalyse-ssh" 2 | organization := "fr.janalyse" 3 | description := "High level scala SSH API for easy and fast operations on remote servers." 4 | 5 | licenses += "Apache 2" -> url(s"http://www.apache.org/licenses/LICENSE-2.0.txt") 6 | 7 | scalaVersion := "3.5.1" 8 | crossScalaVersions := Seq("2.13.15", "3.5.1") 9 | 10 | scalacOptions ++= Seq("-deprecation", "-unchecked", "-feature") 11 | 12 | libraryDependencies ++= Seq( 13 | // "com.jcraft" % "jsch" % "0.1.55", // no longer maintained 14 | "com.github.mwiede" % "jsch" % "0.2.20", // drop-in replacement with enhancements 15 | "org.apache.commons" % "commons-compress" % "1.27.1", 16 | "org.slf4j" % "slf4j-api" % "2.0.16", 17 | "org.scalatest" %% "scalatest" % "3.2.19" % Test, 18 | "ch.qos.logback" % "logback-classic" % "1.5.8" % Test, 19 | "org.scala-lang.modules" %% "scala-parallel-collections" % "1.0.4" % Test 20 | ) 21 | 22 | // Mandatory as tests are also used for performances testing... 23 | Test / parallelExecution := false 24 | 25 | console / initialCommands := 26 | """ 27 | |import fr.janalyse.ssh._ 28 | |import java.io.File 29 | |""".stripMargin 30 | 31 | homepage := Some(url("https://github.com/dacr/jassh")) 32 | scmInfo := Some(ScmInfo(url(s"https://github.com/dacr/jassh"), s"git@github.com:dacr/jassh.git")) 33 | developers := List( 34 | Developer( 35 | id = "dacr", 36 | name = "David Crosson", 37 | email = "crosson.david@gmail.com", 38 | url = url("https://github.com/dacr") 39 | ) 40 | ) 41 | -------------------------------------------------------------------------------- /cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | find . -name "*~" -exec rm {} \; 4 | for d in . onejar ; do 5 | (cd $d ; rm -fr target project/target project/boot project/project nohup.out .settings .cache .classpath .project) 6 | done 7 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.2 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.4.0") 2 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") 3 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.11.3") 4 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.3") 5 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.13.0") 6 | -------------------------------------------------------------------------------- /publish.sbt: -------------------------------------------------------------------------------- 1 | pomIncludeRepository := { _ => false } 2 | publishMavenStyle := true 3 | Test / publishArtifact := false 4 | releaseCrossBuild := true 5 | versionScheme := Some("semver-spec") 6 | 7 | publishTo := { 8 | // For accounts created after Feb 2021: 9 | // val nexus = "https://s01.oss.sonatype.org/" 10 | val nexus = "https://oss.sonatype.org/" 11 | if (isSnapshot.value) Some("snapshots" at nexus + "content/repositories/snapshots") 12 | else Some("releases" at nexus + "service/local/staging/deploy/maven2") 13 | } 14 | 15 | releasePublishArtifactsAction := PgpKeys.publishSigned.value 16 | 17 | releaseTagComment := s"Releasing ${(ThisBuild / version).value}" 18 | releaseCommitMessage := s"Setting version to ${(ThisBuild / version).value}" 19 | releaseNextCommitMessage := s"[ci skip] Setting version to ${(ThisBuild / version).value}" 20 | 21 | import ReleaseTransformations.* 22 | releaseProcess := Seq[ReleaseStep]( 23 | checkSnapshotDependencies, 24 | inquireVersions, 25 | runClean, 26 | runTest, 27 | setReleaseVersion, 28 | commitReleaseVersion, 29 | tagRelease, 30 | publishArtifacts, 31 | releaseStepCommand("sonatypeReleaseAll"), 32 | setNextVersion, 33 | commitNextVersion, 34 | pushChanges 35 | ) 36 | -------------------------------------------------------------------------------- /scripts/dummy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | jassh.SSH.shell("localhost", "test", "testtest") { sh => 5 | print(sh.execute("""echo "Hello World from `hostname`" """)) 6 | } 7 | -------------------------------------------------------------------------------- /scripts/futuretest: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | // jassh.jar can be downloaded here : http://code.google.com/p/janalyse-ssh/ 5 | 6 | import jassh._ 7 | 8 | import concurrent._ 9 | import duration._ 10 | import scala.util._ 11 | 12 | val parallelismLevel=if (args.size>0) args(0).toInt else 50 13 | 14 | /* For local test : 15 | * /etc/ssh/sshd_config 16 | * Increase : 17 | * MaxStartups 10 -> 60 (parallelismLevel + delta) 18 | * MaxSessions 10 -> 10 no change, because just 1 session / connection is used 19 | * 20 | * The restart sshd 21 | */ 22 | 23 | val remotehosts = (1 to 1000) map { num => 24 | SSHOptions("127.0.0.1", username="test", password="testtest", name=Some(s"host#$num")) 25 | } 26 | 27 | 28 | // ----------------------------------------------------------------------- 29 | // For ForkJoinPool JDK >= 7 Required Await.ready mandatory in that case 30 | //implicit val customEC = ExecutionContext.fromExecutorService( 31 | // new java.util.concurrent.ForkJoinPool(parallelismLevel) 32 | //) 33 | 34 | // ----------------------------------------------------------------------- 35 | // For any JDK >= 5 36 | import java.util.concurrent.{ Executors, ThreadPoolExecutor, TimeUnit } 37 | implicit val customEC = ExecutionContext.fromExecutorService( 38 | Executors.newCachedThreadPool() match { 39 | case e: ThreadPoolExecutor => 40 | e.setCorePoolSize(parallelismLevel) 41 | //Too allow a quick exit from this script because default value is 60s 42 | e.setKeepAliveTime(2, TimeUnit.SECONDS) 43 | e 44 | case x => x 45 | } 46 | ) 47 | // ----------------------------------------------------------------------- 48 | 49 | 50 | 51 | val futuresResults = remotehosts.map { rh => 52 | future { 53 | SSH.once(rh) { ssh => 54 | ssh.execute(s"""sleep 1 ; echo 'Hello from ${rh.name getOrElse "default"}'""") 55 | } 56 | } 57 | } 58 | 59 | //futuresResults.foreach( _ onSuccess { case x:String => println(x)}) 60 | 61 | val allFuture = Future.sequence(futuresResults) 62 | 63 | allFuture onComplete { _ match { 64 | case Failure(err) => println(err.getMessage) 65 | case Success(messages) => println(messages.size) 66 | } 67 | } 68 | 69 | // May me mandatory to avoid exit before the end of processing (depends of used ExecutionContext) 70 | // (Mandatory with ForkJoinPool) 71 | //Await.ready(allFuture, Duration.Inf) 72 | -------------------------------------------------------------------------------- /scripts/helloworld: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | 5 | jassh.SSH.shell("localhost", "test", "testtest") { sh => 6 | print(sh.execute("""echo "Hello World from `hostname`" """)) 7 | } 8 | 9 | -------------------------------------------------------------------------------- /scripts/powershelltest: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | 5 | import fr.janalyse.ssh._ 6 | 7 | val user = "username" 8 | val pass = "password" 9 | val host = "host" 10 | val timeout = 10 * 1000 11 | 12 | val settings = SSHOptions(host = host, username=user, password = pass, timeout = timeout) 13 | val session = SSH(settings) 14 | 15 | val shell = session.newPowerShell 16 | 17 | println(shell.ls) 18 | 19 | println(shell.pwd) 20 | -------------------------------------------------------------------------------- /scripts/remote-vmstat: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | val host=if (args.size>0) args(0) else "localhost" 5 | val user=if (args.size>1) args(1) else util.Properties.userName 6 | val freq=if (args.size>2) args(2) else "2" 7 | val numb=if (args.size>3) args(3) else "10" 8 | val vmstatcmd="vmstat %s %s".format(freq, numb) 9 | 10 | import fr.janalyse.ssh._ 11 | 12 | SSH.once(host, user) { 13 | _.run(vmstatcmd, {case ExecPart(content) => println(content) case _ => }).waitForEnd 14 | } 15 | -------------------------------------------------------------------------------- /scripts/rexec: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | 5 | import fr.janalyse.ssh._ 6 | import util.Properties 7 | 8 | if (args.size<2) { 9 | println("""usage : rexec command [user[:password]@]host[:port] ...""") 10 | println(""" Of course prefer public key authentication, default behavior if no password is provided """) 11 | println(""" example : rexec.scala "hostname" 192.168.1.10 toto@192.168.1.11 toto@192.168.1.12:22""") 12 | System.exit(0) 13 | } 14 | 15 | // host | host:port | username@host |username:password@host | username@host:port | ... 16 | val serverRE="""(?:(\w+)(?:[:](.*))?@)?((?:(?:\d+[.]){3}\d+)|(?:\w+))(?:[:](\d+))?""".r 17 | 18 | val cmd2exec=args.head 19 | val servers = args.tail map { 20 | case serverRE(user, password, host, port) => 21 | SSHOptions( 22 | host = host, 23 | username = Option(user).getOrElse(Properties.userName), 24 | password = SSHPassword(Option(password)), 25 | port = Option(port).map(_.toInt).getOrElse(22) 26 | ) 27 | case notUnderstood => 28 | throw new RuntimeException("Couln'd understand remote host description : "+notUnderstood) 29 | } 30 | 31 | 32 | def rexec(server:SSHOptions, cmd2exec:String):String = 33 | SSH.once(server)(_.execute(cmd2exec)) 34 | .split("\n") 35 | .map("%8s@%-16s: %s".format(server.username, server.host, _)) 36 | .mkString("\n") 37 | 38 | // Of course this doesn't not show the intermediate results 39 | val ret = servers.toStream.map(rexec(_, cmd2exec)) 40 | for (res <- ret.par) println(res) 41 | 42 | -------------------------------------------------------------------------------- /scripts/shortest: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | 5 | println(jassh.SSH.shell("localhost", "test", "testtest") { _.uname}) 6 | 7 | -------------------------------------------------------------------------------- /scripts/simplefuturetest: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec java -jar jassh.jar "$0" "$@" 3 | !# 4 | // jassh.jar can be downloaded here : http://code.google.com/p/janalyse-ssh/ 5 | 6 | import jassh._ 7 | import concurrent._ 8 | import duration._ 9 | import scala.util._ 10 | 11 | val remotehosts = (1 to 10) map { num => 12 | SSHOptions("127.0.0.1", username="test", password="testtest", name=Some(s"host#$num")) 13 | } 14 | 15 | // Custom executor, with this executor no need for a final Await.ready 16 | import java.util.concurrent.{ Executors, ThreadPoolExecutor, TimeUnit } 17 | implicit val customEC = ExecutionContext.fromExecutorService( 18 | Executors.newCachedThreadPool() match { 19 | case e: ThreadPoolExecutor => 20 | //Too allow a quick exit from this script because default value is 60s 21 | e.setKeepAliveTime(2, TimeUnit.SECONDS) 22 | e 23 | case x => x 24 | } 25 | ) 26 | 27 | val futures = remotehosts.map { rh => 28 | future { 29 | SSH.once(rh) { ssh => 30 | ssh.execute(s"""sleep 1 ; echo 'Hello from ${rh.name getOrElse "default"}'""") 31 | } 32 | } 33 | } 34 | 35 | val onefuture = Future.sequence(futures) 36 | 37 | onefuture onComplete { 38 | case Failure(err) => println(err.getMessage) 39 | case Success(messages) => println(messages.mkString("\n")) 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/AllOperations.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import java.io.File 4 | 5 | trait AllOperations extends ShellOperations with TransfertOperations { 6 | 7 | /** 8 | * Recursively get a remote directory to a local destination 9 | * @param remote remote path, file or directory. 10 | * @param dest local destination directory, it it doesn't exist then it is created 11 | */ 12 | def rreceive(remote:String, dest:File): Unit = { 13 | def worker(curremote: String, curdest: File):Unit = { 14 | if (isDirectory(curremote)) { 15 | for { 16 | found <- ls(curremote) 17 | newremote = curremote + "/" + found 18 | newdest = new File(curdest, found) 19 | } { 20 | curdest.mkdirs 21 | worker(curremote=newremote, curdest=newdest) 22 | } 23 | } else receive(curremote, curdest) 24 | } 25 | worker(curremote=remote, curdest=dest) 26 | } 27 | 28 | /** 29 | * Recursively send a local directory to a remote destination 30 | * @param src local path, file or directory 31 | * @param remote remote destination directory, if it doesn't exist then it is created 32 | */ 33 | def rsend(src:File, remote:String): Unit = { 34 | def worker(cursrc: File, curremote: String): Unit = { 35 | if (cursrc.isDirectory) { 36 | for { 37 | found <- cursrc.listFiles 38 | newsrc = new File(cursrc, found.getName) 39 | newremote = curremote + "/" + found.getName 40 | } { 41 | mkdir(curremote) 42 | worker(cursrc=newsrc, curremote=newremote) 43 | } 44 | } else send(cursrc, curremote) 45 | } 46 | worker(cursrc=src, curremote=remote) 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/CommonOperations.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | trait CommonOperations { 20 | 21 | private def streamMd5sum(input: java.io.InputStream): String = { 22 | val bis = new java.io.BufferedInputStream(input) 23 | val buffer = new Array[Byte](1024) 24 | val md5 = java.security.MessageDigest.getInstance("MD5") 25 | Stream.continually(bis.read(buffer)).takeWhile(_ != -1).foreach(md5.update(buffer, 0, _)) 26 | md5.digest().map(0xFF & _).map { "%02x".format(_) }.foldLeft("") { _ + _ } 27 | } 28 | private def fileMd5sum(file: java.io.File): String = streamMd5sum(new java.io.FileInputStream(file)) 29 | 30 | /** 31 | * locale file md5sum 32 | * @param filename file name 33 | * @return md5sum as an optional String, or None if filename was not found 34 | */ 35 | def localmd5sum(filename: String): Option[String] = 36 | Option(filename) 37 | .map(f=> new java.io.File(f)) 38 | .filter(_.exists()) 39 | .map(fileMd5sum _) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/ExecResult.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | sealed trait ExecResult 20 | case class ExecPart(content:String) extends ExecResult 21 | case class ExecEnd(rc:Int) extends ExecResult 22 | object ExecTimeout extends ExecResult 23 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/Expect.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | case class Expect( 4 | when: (String) => Boolean, 5 | send: String 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/OS.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | sealed trait OS 20 | 21 | object Linux extends OS 22 | object AIX extends OS 23 | object Darwin extends OS 24 | object SunOS extends OS 25 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/PowerShellOperations.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import java.text.SimpleDateFormat 20 | import java.util.Date 21 | 22 | /** 23 | * ShellOperations defines generic shell operations and common shell commands shortcuts 24 | */ 25 | trait PowerShellOperations extends SSHLazyLogging { 26 | 27 | /** 28 | * Execute the current command and return the result as a string 29 | * @param cmd command to be executed 30 | * @return result string 31 | */ 32 | def execute(cmd: SSHCommand): String 33 | 34 | /** 35 | * Execute the current command and return the result as a trimmed string 36 | * @param cmd command to be executed 37 | * @return result string 38 | */ 39 | def executeAndTrim(cmd: SSHCommand): String = execute(cmd).trim() 40 | 41 | /** 42 | * Execute the current command and return the result as a trimmed splitted string 43 | * @param cmd command to be executed 44 | * @return result string 45 | */ 46 | def executeAndTrimSplit(cmd: SSHCommand): Iterable[String] = execute(cmd).trim().split("\r?\n") 47 | 48 | 49 | 50 | /** 51 | * who am I ? 52 | * @return current user name 53 | */ 54 | def whoami: String = executeAndTrim("whoami") 55 | 56 | /** 57 | * List files in specified directory 58 | * @return current directory files as an Iterable 59 | */ 60 | def ls(): String = execute("ls") 61 | 62 | /** 63 | * List files in specified directory 64 | * @param dirname directory to look into 65 | * @return current directory files as an Iterable 66 | */ 67 | def ls(dirname: String): Iterable[String] = { 68 | //executeAndTrimSplit("""ls --format=single-column "%s" """.format(dirname)) 69 | executeAndTrimSplit("""ls "%s" """.format(dirname)).filter(_.nonEmpty) 70 | } 71 | 72 | /** 73 | * Get current working directory 74 | * @return current directory 75 | */ 76 | def pwd(): String = executeAndTrim("pwd") 77 | 78 | /** 79 | * Change current working directory to home directory 80 | * Of course this requires a persistent shell session to be really useful... 81 | */ 82 | def cd: Unit = { execute("cd") } 83 | 84 | /** 85 | * Change current working directory to the specified directory 86 | * Of course this requires a persistent shell session to be really useful... 87 | * @param dirname directory name 88 | */ 89 | def cd(dirname: String): Unit = { execute(s"""cd "$dirname" """) } 90 | 91 | /** 92 | * Get remote host name 93 | * @return host name 94 | */ 95 | def hostname: String = executeAndTrim("""hostname""") 96 | 97 | /** 98 | * Get remote date, as a java class Date instance (minimal resolution = 1 second) 99 | * Note PowerShell returns %Z as "-05" but Java expects "-0500" 100 | * @return The remote system current date as a java Date class instance 101 | */ 102 | def date(): Date = { 103 | val d = executeAndTrim("date -u '+%Y-%m-%d %H:%M:%S %Z00'") 104 | dateSDF.parse(d) 105 | } 106 | private lazy val dateSDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z") 107 | 108 | /** 109 | * Get the content of a file 110 | * @param filename get the content of this filename 111 | * @return file content 112 | */ 113 | def cat(filename: String): String = execute("cat %s".format(filename)) 114 | 115 | /** 116 | * Get contents of a list of files 117 | * @param filenames get the content of this list of filenames 118 | * @return files contents concatenation 119 | */ 120 | def cat(filenames: List[String]): String = execute("cat %s".format(filenames.mkString(" "))) 121 | 122 | /** 123 | * get current SSH options 124 | * @return used ssh options 125 | */ 126 | def options: SSHOptions 127 | 128 | 129 | /** 130 | * kill specified processes 131 | */ 132 | def kill(pids: Iterable[Int]):Unit = { execute(s"""kill -9 ${pids.mkString(" ")}""") } 133 | 134 | /** 135 | * delete a file 136 | */ 137 | def rm(file: String):Unit = { rm(file::Nil) } 138 | 139 | /** 140 | * delete files 141 | */ 142 | def rm(files: Iterable[String]):Unit = { execute(s"""rm -f ${files.mkString("'", "' '", "'")}""") } 143 | 144 | /** 145 | * delete directory (directory must be empty) 146 | */ 147 | def rmdir(dir: String):Unit = { rmdir(dir::Nil)} 148 | 149 | /** 150 | * delete directories (directories must be empty) 151 | */ 152 | def rmdir(dirs: Iterable[String]):Unit = { execute(s"""rmdir ${dirs.mkString("'", "' '", "'")}""") } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/Process.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | trait Process { 20 | val pid: Int 21 | val ppid: Int 22 | val user: String 23 | val cmdline: String 24 | private val tokens = cmdline.split("""\s+""").toList filter { 25 | _.size > 0 26 | } 27 | val cmd = tokens.head 28 | val args = tokens.tail.toList 29 | } 30 | 31 | case class ProcessTime(days: Int, hours: Int, minutes: Int, seconds: Int) { 32 | val ellapsedInS = days * 24 * 3600 + hours * 3600 + minutes * 60 + seconds 33 | } 34 | 35 | object ProcessTime { 36 | def apply(spec: String): ProcessTime = { 37 | val re1 = """(\d+)""".r 38 | val re2 = """(\d+):(\d+)""".r 39 | val re3 = """(\d+):(\d+):(\d+)""".r 40 | val re4 = """(\d+)-(\d+):(\d+):(\d+)""".r 41 | spec match { 42 | case re1(s) => ProcessTime(0, 0, 0, s.toInt) 43 | case re2(m, s) => ProcessTime(0, 0, m.toInt, s.toInt) 44 | case re3(h, m, s) => ProcessTime(0, h.toInt, m.toInt, s.toInt) 45 | case re4(d, h, m, s) => ProcessTime(d.toInt, h.toInt, m.toInt, s.toInt) 46 | case _ => ProcessTime(0, 0, 0, 0) 47 | } 48 | } 49 | } 50 | 51 | trait ProcessState { 52 | val name: String 53 | } 54 | 55 | case class LinuxProcessState( 56 | name: String, 57 | extra: String 58 | ) extends ProcessState 59 | 60 | object LinuxProcessState { 61 | val states = Map( 62 | 'D' -> "UninterruptibleSleep", 63 | 'R' -> "Running", 64 | 'S' -> "InterruptibleSleep", 65 | 'T' -> "Stopped", 66 | 'W' -> "Paging", // paging (not valid since the 2.6.xx kernel) 67 | 'X' -> "Dead", 68 | 'Z' -> "Zombie" 69 | ) 70 | 71 | def fromSpec(spec: String): LinuxProcessState = { 72 | val name = spec.headOption.flatMap(states get _) getOrElse "UnknownState" 73 | val extra = if (spec.size > 0) spec.tail else "" 74 | new LinuxProcessState(name, extra) 75 | } 76 | } 77 | 78 | 79 | case class DarwinProcessState( 80 | name: String, 81 | extra: String 82 | ) extends ProcessState 83 | 84 | object DarwinProcessState { 85 | val states = Map( 86 | 'I' -> "Idle", // Marks a process that is idle (sleeping for longer than about 20 seconds). 87 | 'R' -> "Running", // Marks a runnable process. 88 | 'S' -> "Sleeping", // Marks a process that is sleeping for less than about 20 seconds. 89 | 'T' -> "Stopped", // Marks a stopped process. 90 | 'U' -> "UninterruptibleSleep", // Marks a process in uninterruptible wait. 91 | 'Z' -> "Zombie" // Marks a dead process (a ``zombie''). 92 | 93 | ) 94 | 95 | def fromSpec(spec: String): DarwinProcessState = { 96 | val name = spec.headOption.flatMap(states get _) getOrElse "UnknownState" 97 | val extra = if (spec.size > 0) spec.tail else "" 98 | new DarwinProcessState(name, extra) 99 | } 100 | } 101 | 102 | 103 | case class AIXProcess( 104 | pid: Int, 105 | ppid: Int, 106 | user: String, 107 | cmdline: String 108 | ) extends Process 109 | 110 | case class SunOSProcess( 111 | pid: Int, 112 | ppid: Int, 113 | user: String, 114 | cmdline: String 115 | ) extends Process 116 | 117 | 118 | case class LinuxProcess( 119 | pid: Int, 120 | ppid: Int, 121 | user: String, 122 | state: LinuxProcessState, 123 | rss: Int, // ResidentSizeSize (Ko) 124 | vsz: Int, // virtual memory size of the process (Ko) 125 | etime: ProcessTime, // Ellapsed time since start [DD-]hh:mm:ss 126 | cputime: ProcessTime, // CPU time used since start [[DD-]hh:]mm:ss 127 | cmdline: String 128 | ) extends Process 129 | 130 | 131 | case class DarwinProcess( 132 | pid: Int, 133 | ppid: Int, 134 | user: String, 135 | state: DarwinProcessState, 136 | rss: Int, // ResidentSizeSize (Ko) 137 | vsz: Int, // virtual memory size of the process (Ko) 138 | etime: ProcessTime, // Ellapsed time since start [DD-]hh:mm:ss 139 | cputime: ProcessTime, // CPU time used since start [[DD-]hh:]mm:ss 140 | cmdline: String 141 | ) extends Process 142 | 143 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHBatch.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import language.implicitConversions 4 | 5 | /** 6 | * SSHBatch class models ssh batch (in fact a list of commands) 7 | * @author David Crosson 8 | */ 9 | case class SSHBatch(cmdList: Iterable[String]) 10 | 11 | /** 12 | * SSHBatch object implicit conversions container 13 | * @author David Crosson 14 | */ 15 | object SSHBatch { 16 | implicit def stringListToBatchList(cmdList: Iterable[String]): SSHBatch = new SSHBatch(cmdList) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHCommand.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import language.implicitConversions 4 | 5 | /** 6 | * SSHCommand class models ssh command 7 | * @author David Crosson 8 | */ 9 | case class SSHCommand(cmd: String) 10 | 11 | /** 12 | * SSHCommand object implicit conversions container 13 | * @author David Crosson 14 | */ 15 | object SSHCommand { 16 | implicit def stringToCommand(cmd: String): SSHCommand = new SSHCommand(cmd) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHConnectionManager.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import com.jcraft.jsch.ProxyHTTP 4 | import org.slf4j.LoggerFactory 5 | 6 | 7 | sealed trait EndPoint { 8 | val host:String 9 | val port:Int 10 | } 11 | 12 | case class ProxyEndPoint(host:String, 13 | port:Int=ProxyEndPoint.defaultPort 14 | ) extends EndPoint 15 | object ProxyEndPoint { 16 | val defaultPort=3128 17 | } 18 | 19 | case class SshEndPoint(host:String, 20 | username:String=SshEndPoint.defaultUserName, 21 | port:Int=SshEndPoint.defaultPort 22 | ) extends EndPoint 23 | object SshEndPoint { 24 | val defaultUserName=scala.util.Properties.userName 25 | val defaultPort=22 26 | } 27 | 28 | 29 | case class AccessPath(name:String, endpoints:List[EndPoint]) 30 | 31 | 32 | /** 33 | * Abstraction layer over complex ssh connections (direct, proxytunnel, intricated ssh tunnels) 34 | * Everything is done lazily, already established connections are cached and automatically checked 35 | * @param accesses 36 | */ 37 | class SSHConnectionManager(accesses:List[AccessPath]) { 38 | val logger = LoggerFactory.getLogger(getClass) 39 | val accessesByName=accesses.groupBy(_.name).mapValues(_.head) 40 | 41 | case class Bounce(localEndPoint:SshEndPoint, associatedSSH:SSH) 42 | private var bouncers = Map.empty[List[EndPoint], Bounce] 43 | private var sshers = Map.empty[List[EndPoint], SSH] 44 | 45 | def pooledIntricate[T](access:AccessPath)(that: SSH => T):T = synchronized { 46 | val aname = access.name 47 | // TODO : manage resiliency, autoclose 48 | def worker(remainingEndPoints: Iterable[EndPoint], 49 | previousPosition: List[EndPoint]=Nil, 50 | localEndPoint: Option[SshEndPoint] = None, 51 | through: Option[ProxyEndPoint] = None): SSH = { 52 | remainingEndPoints.headOption match { 53 | // ---------------------------------------------------------------- 54 | case Some(endpoint: ProxyEndPoint) => 55 | val currentPosition = previousPosition:+endpoint 56 | worker(remainingEndPoints.tail, currentPosition, localEndPoint, Some(endpoint)) 57 | // ---------------------------------------------------------------- 58 | case Some(endpoint: SshEndPoint) if localEndPoint.isDefined => // intricate tunnel 59 | val currentPosition = previousPosition:+endpoint 60 | val bounce = bouncers.get(currentPosition) match { 61 | case Some(bounce) => 62 | if (logger.isDebugEnabled()) logger.debug(s"$aname : reuse bounce for ${endpoint.username}@${endpoint.host}~${endpoint.port}") 63 | bounce 64 | case None => 65 | if (logger.isDebugEnabled()) logger.debug(s"$aname : create bounce for ${endpoint.username}@${endpoint.host}~${endpoint.port}") 66 | val proxy = through.map(p => new ProxyHTTP(p.host, p.port)) 67 | val opts = SSHOptions(localEndPoint.get.host, username = localEndPoint.get.username, port = localEndPoint.get.port, proxy = proxy) 68 | val ssh = SSH(opts) 69 | val newPort = ssh.remote2Local(endpoint.host, endpoint.port) 70 | val newLocalEndPoint = SshEndPoint("127.0.0.1", username = endpoint.username, port = newPort) 71 | val bounce = Bounce(newLocalEndPoint, ssh) 72 | bouncers += currentPosition->bounce 73 | bounce 74 | } 75 | worker(remainingEndPoints.tail, currentPosition, Some(bounce.localEndPoint)) 76 | // ----------------------------------------------------------------> 77 | case Some(endpoint: SshEndPoint) => // first tunnel 78 | val currentPosition = previousPosition:+endpoint 79 | val bounce = bouncers.get(currentPosition) match { 80 | case Some(bounce) => 81 | if (logger.isDebugEnabled()) logger.debug(s"$aname : reuse bounce for ${endpoint.username}@${endpoint.host}~${endpoint.port}") 82 | bounce 83 | case None => 84 | if (logger.isDebugEnabled()) logger.debug(s"$aname : create bounce for ${endpoint.username}@${endpoint.host}~${endpoint.port}") 85 | val proxy = through.map(p => new ProxyHTTP(p.host, p.port)) 86 | val opts = SSHOptions(endpoint.host, username = endpoint.username, port = endpoint.port, proxy = proxy) 87 | val ssh = SSH(opts) 88 | val newPort = ssh.remote2Local("127.0.0.1", 22) 89 | val newLocalEndPoint = SshEndPoint("127.0.0.1", username = endpoint.username, port = newPort) 90 | val bounce = Bounce(newLocalEndPoint, ssh) 91 | bouncers += currentPosition->bounce 92 | bounce 93 | } 94 | worker(remainingEndPoints.tail, currentPosition, Some(bounce.localEndPoint)) 95 | 96 | // ---------------------------------------------------------------- 97 | case None if localEndPoint.isDefined => 98 | val position = previousPosition 99 | 100 | sshers.get(position) match { 101 | case None => 102 | val endpoint = localEndPoint.get 103 | if (logger.isDebugEnabled()) logger.debug(s"$aname : create terminal endpoint for ${endpoint.username}@${endpoint.host}~${endpoint.port}") 104 | val opts=SSHOptions(endpoint.host, username=endpoint.username, port=endpoint.port) 105 | val ssh = SSH(opts) 106 | sshers += position->ssh 107 | ssh 108 | case Some(ssh) => 109 | if (logger.isDebugEnabled()) logger.debug(s"$aname : reuse terminal endpoint for ${ssh.options.username}@${ssh.options.host}~${ssh.options.port}") 110 | ssh 111 | } 112 | 113 | // ---------------------------------------------------------------- 114 | case None => 115 | throw new RuntimeException("Empty ssh path") 116 | } 117 | } 118 | 119 | val ssh = worker(access.endpoints) 120 | that(ssh) 121 | } 122 | 123 | 124 | def ssh[T](name:String) (withSSH: SSH => T):Option[T] = { 125 | accessesByName.get(name).map{ access => 126 | //SSHConnectionManager.intricate(access) { withSSH } 127 | pooledIntricate(access)(withSSH) 128 | } 129 | } 130 | 131 | def shell[T](name:String) (withShell : SSHShell => T):Option[T] = { 132 | accessesByName.get(name).map{ access => 133 | //SSHConnectionManager.intricate(access) { ssh => ssh.shell(withShell) } 134 | pooledIntricate(access)(_.shell(withShell)) 135 | } 136 | } 137 | 138 | def close():Unit = { 139 | for {bouncer <- bouncers.values} { 140 | try {bouncer.associatedSSH.close()} catch { 141 | case ex:Exception => 142 | } 143 | } 144 | for {ssher <- sshers.values} { 145 | try {ssher.close()} catch { 146 | case ex:Exception => 147 | } 148 | } 149 | } 150 | 151 | override def finalize(): Unit = { 152 | try { 153 | close() 154 | } finally { 155 | super.finalize() 156 | } 157 | } 158 | } 159 | 160 | object SSHConnectionManager { 161 | def apply(accesses:List[AccessPath]):SSHConnectionManager = new SSHConnectionManager(accesses) 162 | 163 | /** 164 | * rebuild the connection access path (proxytunnels, intricated tunnels) each time it is called and 165 | * execute that code. Here the intrication is kept only while executing the given lambda ! 166 | * @param access access path specification 167 | * @param that lambda to execute 168 | * @tparam T result type returned by the lambda expression 169 | * @return lambda result 170 | */ 171 | def intricate[T](access:AccessPath)(that: SSH => T):T = { 172 | def worker(endpoints: Iterable[EndPoint], 173 | localEndPoint: Option[SshEndPoint] = None, 174 | through: Option[ProxyEndPoint] = None): T = { 175 | endpoints.headOption match { 176 | case Some(endpoint: ProxyEndPoint) => 177 | worker(endpoints.tail, localEndPoint, Some(endpoint)) 178 | // ---------------------------------------------------------------- 179 | case Some(endpoint: SshEndPoint) if localEndPoint.isDefined => // intricate tunnel 180 | val proxy = through.map(p => new ProxyHTTP(p.host, p.port)) 181 | val opts = SSHOptions(localEndPoint.get.host, username = endpoint.username, port = localEndPoint.get.port, proxy = proxy) 182 | SSH.once(opts) { ssh => 183 | val newPort = ssh.remote2Local(endpoint.host, endpoint.port) 184 | val newLocalEndPoint = SshEndPoint("127.0.0.1", username = endpoint.username, port = newPort) 185 | worker(endpoints.tail, Some(newLocalEndPoint)) 186 | } 187 | // ---------------------------------------------------------------- 188 | case Some(endpoint: SshEndPoint) => // first tunnel 189 | val proxy = through.map(p => new ProxyHTTP(p.host, p.port)) 190 | val opts = SSHOptions(endpoint.host, username = endpoint.username, port = endpoint.port, proxy = proxy) 191 | SSH.once(opts) { ssh => 192 | val newPort = ssh.remote2Local("127.0.0.1", 22) 193 | val newLocalEndPoint = SshEndPoint("127.0.0.1", username = endpoint.username, port = newPort) 194 | worker(endpoints.tail, Some(newLocalEndPoint)) 195 | } 196 | // ---------------------------------------------------------------- 197 | case None if localEndPoint.isDefined => 198 | val opts = SSHOptions(localEndPoint.get.host, username = localEndPoint.get.username, port = localEndPoint.get.port) 199 | SSH.once(opts) { 200 | that 201 | } 202 | // ---------------------------------------------------------------- 203 | case None => 204 | throw new RuntimeException("Empty ssh path") 205 | } 206 | } 207 | worker(access.endpoints) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHExec.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import com.jcraft.jsch.ChannelExec 4 | import java.nio.charset.Charset 5 | import java.nio.ByteBuffer 6 | import java.io.{InputStream, BufferedInputStream, InterruptedIOException} 7 | 8 | class SSHExec(cmd: String, out: ExecResult => Any, err: ExecResult => Any)(implicit ssh: SSH) { 9 | 10 | private val (channel, stdout, stderr, stdin) = { 11 | val ch = ssh.jschsession().openChannel("exec").asInstanceOf[ChannelExec] 12 | ch.setCommand(s" $cmd".getBytes()) 13 | val stdout = ch.getInputStream 14 | val stderr = ch.getErrStream 15 | val stdin = ch.getOutputStream 16 | ch.setPty(ssh.options.execWithPty) 17 | ch.connect(ssh.options.connectTimeout.toInt) 18 | (ch, stdout, stderr, stdin) 19 | } 20 | private val stdoutThread = InputStreamThread(channel, stdout, out) 21 | private val stderrThread = InputStreamThread(channel, stderr, err) 22 | private val timeoutThread = TimeoutManagerThread(ssh.options.timeout) { 23 | stdoutThread.interrupt() 24 | stderrThread.interrupt() 25 | } 26 | 27 | def giveInputLine(line: String): Unit = { 28 | stdin.write(line.getBytes()) 29 | stdin.write("\n".getBytes()) 30 | stdin.flush() 31 | } 32 | 33 | def waitForEnd: Unit = { 34 | stdoutThread.join() 35 | stderrThread.join() 36 | if (timeoutThread.interruptedStatus) throw new InterruptedException("Timeout Reached") 37 | close() 38 | } 39 | 40 | def close(): Unit = { 41 | stdin.close() 42 | stdoutThread.interrupt() 43 | stderrThread.interrupt() 44 | channel.disconnect 45 | timeoutThread.interrupt() 46 | } 47 | 48 | private class TimeoutManagerThread(timeout: Long)(todo: => Any) extends Thread { 49 | var interruptedStatus = false 50 | 51 | override def run(): Unit = { 52 | if (timeout > 0) { 53 | try { 54 | Thread.sleep(timeout) 55 | interruptedStatus = true 56 | todo 57 | } catch { 58 | case e: InterruptedException => 59 | } 60 | } 61 | } 62 | } 63 | 64 | private object TimeoutManagerThread { 65 | def apply(timeout: Long)(todo: => Any): TimeoutManagerThread = { 66 | val thread = new TimeoutManagerThread(timeout)(todo) 67 | thread.start() 68 | thread 69 | } 70 | } 71 | 72 | private class InputStreamThread(channel: ChannelExec, input: InputStream, output: ExecResult => Any) extends Thread { 73 | override def run(): Unit = { 74 | val bufsize = 16 * 1024 75 | val charset = Charset.forName(ssh.options.charset) 76 | val binput = new BufferedInputStream(input) 77 | val bytes = Array.ofDim[Byte](bufsize) 78 | val buffer = ByteBuffer.allocate(bufsize) 79 | val appender = new StringBuilder() 80 | var eofreached = false 81 | try { 82 | while (!eofreached) { 83 | // Notes : It is important to try to read something even available == 0 in order to be able to get EOF message ! 84 | // Notes : After some tests, looks like jsch input stream is probably line oriented... so no need to use available ! 85 | val howmany = binput.read(bytes, 0, bufsize /*if (available < bufsize) available else bufsize*/) 86 | if (howmany == -1) eofreached = true 87 | if (howmany > 0) { 88 | buffer.put(bytes, 0, howmany) 89 | buffer.flip() 90 | val cbOut = charset.decode(buffer) 91 | buffer.compact() 92 | appender.append(cbOut.toString) 93 | var s = 0 94 | var e = 0 95 | while (e != -1) { 96 | e = appender.indexOf("\n", s) 97 | if (e >= 0) { 98 | output(ExecPart(appender.substring(s, e))) 99 | s = e + 1 100 | } 101 | } 102 | appender.delete(0, s) 103 | } 104 | } // && !channel.isEOF() && !channel.isClosed()) // => This old test is not good as data may remaining on the stream 105 | if (appender.nonEmpty) output(ExecPart(appender.toString())) 106 | output(ExecEnd(channel.getExitStatus())) 107 | } catch { 108 | case e: InterruptedIOException => 109 | output(ExecTimeout) 110 | case e: InterruptedException => 111 | output(ExecTimeout) 112 | } 113 | } 114 | } 115 | 116 | private object InputStreamThread { 117 | def apply(channel: ChannelExec, input: InputStream, output: ExecResult => Any): InputStreamThread = { 118 | val newthread = new InputStreamThread(channel, input, output) 119 | newthread.start() 120 | newthread 121 | } 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHFtp.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import com.jcraft.jsch.{ChannelSftp, SftpATTRS, SftpException} 4 | import java.io._ 5 | import java.nio.charset.Charset 6 | 7 | import scala.io.{BufferedSource, Codec} 8 | import collection.JavaConverters._ 9 | 10 | object SSHFtp { 11 | /** 12 | * A representation of the entries in a directory listing. 13 | */ 14 | case class LsEntry(filename: String, longname: String, attrs: SftpATTRS) 15 | } 16 | 17 | class SSHFtp(implicit ssh: SSH) extends TransfertOperations with SSHLazyLogging { 18 | private val channel: ChannelSftp = { 19 | //jschftpchannel.connect(link.connectTimeout) 20 | val ch = ssh.jschsession().openChannel("sftp").asInstanceOf[ChannelSftp] 21 | ch.connect(ssh.options.connectTimeout.toInt) 22 | ch 23 | } 24 | 25 | def close(): Unit = { 26 | channel.quit() 27 | channel.disconnect() 28 | } 29 | 30 | override def get(filename: String): Option[String] = { 31 | try { 32 | implicit val codec: Codec = new scala.io.Codec(Charset.forName(ssh.options.charset)) 33 | Some(new BufferedSource(channel.get(filename)).mkString) 34 | } catch { 35 | case e: SftpException if e.id == 2 => None // File doesn't exist 36 | case _: IOException => None 37 | } 38 | } 39 | 40 | override def getBytes(filename: String): Option[Array[Byte]] = { 41 | try { 42 | Some(SSHTools.inputStream2ByteArray(channel.get(filename))) 43 | } catch { 44 | case e: SftpException if e.id == 2 => None // File doesn't exist 45 | case _: IOException => None 46 | } 47 | } 48 | 49 | /** 50 | * get remote file content as an optional InputStream 51 | * @param filename file content to get 52 | * @return Some content or None if file was not found 53 | */ 54 | def getStream(filename: String): Option[InputStream] = { 55 | try { 56 | Some(channel.get(filename)) 57 | } catch { 58 | case e: SftpException if e.id == 2 => None // File doesn't exist 59 | case _: IOException => None 60 | } 61 | } 62 | 63 | /** 64 | * Rename a remote file or directory 65 | * @param origin Original remote file name 66 | * @param dest Destination (new) remote file name 67 | */ 68 | def rename(origin: String, dest: String): Unit = { 69 | channel.rename(origin, dest) 70 | } 71 | 72 | /** 73 | * List contents of a remote directory 74 | * @param path The path of the directory on the remote system 75 | */ 76 | def ls(path: String): List[SSHFtp.LsEntry] = 77 | channel.ls(path) 78 | .asScala 79 | //.map(_.asInstanceOf[ChannelSftp.LsEntry]) 80 | .map(entry => SSHFtp.LsEntry(entry.getFilename, entry.getLongname, entry.getAttrs)) 81 | .toList 82 | 83 | /** 84 | * Remove a file from a remote system 85 | * @param path The path of the remote file to remove 86 | */ 87 | def rm(path: String): Unit = channel.rm(path) 88 | 89 | /** 90 | * Remove a directory from a remote system 91 | * @param path The path of the directory to remove 92 | */ 93 | def rmdir(path: String): Unit = channel.rmdir(path) 94 | 95 | /** 96 | * Creates a directory on a remote system 97 | * @param path The name of the directory to create 98 | */ 99 | def mkdir(path: String): Unit = channel.mkdir(path) 100 | 101 | /** 102 | * Change file modes 103 | * @param permissions The new permissions in octal string format 104 | * @param path The path of the remote file or directory to change permissions 105 | */ 106 | def chmod(permissions: String, path: String): Unit = chmod(Integer.parseInt(permissions, 8), path) 107 | 108 | /** 109 | * Change file modes 110 | * @param permissions The new permissions 111 | * @param path The path of the remote file or directory to change permissions 112 | */ 113 | def chmod(permissions: Int, path: String): Unit = channel.chmod(permissions, path) 114 | 115 | /** 116 | * Change file owner 117 | * @param uid New Owner User Identifier 118 | * @param path The path of the remote file or directory to change owner 119 | */ 120 | def chown(uid: Int, path: String): Unit = channel.chown(uid, path) 121 | 122 | /** 123 | * Change file group 124 | * @param gid New Owner Group Identifier 125 | * @param path The path of the remote file or directory to change group 126 | */ 127 | def chgrp(gid: Int, path: String): Unit = channel.chgrp(gid, path) 128 | 129 | /** 130 | * Change the working directory on the remote system 131 | * @param path The new working directory, relative to the current one 132 | */ 133 | def cd(path: String): Unit = channel.cd(path) 134 | 135 | /** 136 | * Print Working Directory: returns the current remote directory in absolute form. 137 | */ 138 | def pwd() : String = channel.pwd() 139 | 140 | /** 141 | * converts a remote path to its absolute (and to a certain degree canonical) version. 142 | */ 143 | def realpath(path: String) : String = channel.realpath(path) 144 | 145 | override def receive(remoteFilename: String, outputStream: OutputStream):Unit = { 146 | try { 147 | channel.get(remoteFilename, outputStream) 148 | } catch { 149 | case e: SftpException if e.id == 2 => 150 | logger.warn(s"File '$remoteFilename' doesn't exist") 151 | throw e 152 | case e: IOException => 153 | logger.error(s"can't receive $remoteFilename", e) 154 | throw e 155 | case e: Exception => 156 | logger.error(s"can't receive $remoteFilename", e) 157 | throw e 158 | } finally { 159 | outputStream.close() 160 | } 161 | } 162 | 163 | override def put(data: String, remoteFilename: String): Unit = { 164 | channel.put(new ByteArrayInputStream(data.getBytes(ssh.options.charset)), remoteFilename) 165 | } 166 | 167 | override def putBytes(data: Array[Byte], remoteFilename: String): Unit = { 168 | channel.put(new ByteArrayInputStream(data), remoteFilename) 169 | } 170 | 171 | override def putFromStream(data: java.io.InputStream, howmany:Int, remoteDestination: String): Unit = { 172 | putFromStream(data, remoteDestination) // In that case howmany is ignored ! 173 | } 174 | 175 | def putFromStream(data: java.io.InputStream, remoteDestination: String): Unit = { 176 | channel.put(data, remoteDestination) 177 | } 178 | 179 | override def send(localFile: File, remoteFilename: String): Unit = { 180 | channel.put(new FileInputStream(localFile), remoteFilename) 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHLazyLogging.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import org.slf4j._ 4 | 5 | trait SSHLazyLogging { 6 | val logger: Logger = LoggerFactory.getLogger(getClass) 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHOptions.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import scala.util.{ Properties => SP } 4 | import java.io.File.{ separator => FS } 5 | 6 | import com.jcraft.jsch._ 7 | 8 | 9 | /** 10 | * SSHIdentity 11 | */ 12 | case class SSHIdentity( 13 | privkey:String, 14 | passphrase:SSHPassword=NoPassword //If not set, will use the default global passphrase if available 15 | ) 16 | 17 | 18 | /** 19 | * SSHOptions stores all ssh parameters 20 | * @author David Crosson 21 | */ 22 | case class SSHOptions( 23 | host:String="localhost", 24 | username: String = util.Properties.userName, 25 | password: SSHPassword = NoPassword, 26 | passphrase: SSHPassword = NoPassword, 27 | name: Option[String] = None, 28 | port: Int = 22, 29 | prompt: Option[String] = None, 30 | timeout: Long = 0, 31 | connectTimeout: Long = 30000, 32 | retryCount: Int = 5, 33 | retryDelay: Int = 2000, 34 | identities: List[SSHIdentity]=SSHOptions.defaultIdentities, 35 | charset: String = "ISO-8859-15", 36 | noneCipher: Boolean = false, 37 | compress: Option[Int] = None, 38 | execWithPty:Boolean = false, // Sometime some command doesn't behave the same with or without tty, cf mysql 39 | //ciphers:Array[String]="none,aes128-cbc,aes192-cbc,aes256-cbc,3des-cbc,blowfish-cbc,aes128-ctr,aes192-ctr,aes256-ctr".split(","), 40 | ciphers:Array[String]="aes128-ctr,aes128-cbc,3des-ctr,3des-cbc,blowfish-cbc,aes192-ctr,aes192-cbc,aes256-ctr,aes256-cbc".split(","), 41 | proxy:Option[Proxy]=None, 42 | sessionConfig: Map[String, String] = Map.empty, 43 | openSSHConfig: Option[String] = None, 44 | knownHostsFile: Option[String] = None, 45 | historize:Boolean = false 46 | ) { 47 | //val keyfiles2lookup = sshKeyFile ++ List("id_rsa", "id_dsa") // ssh key search order (from sshUserDir) 48 | def compressed: SSHOptions = this.copy(compress=Some(5)) 49 | def viaProxyHttp(host:String, port:Int=80): SSHOptions = this.copy(proxy = Some(new ProxyHTTP(host,port))) 50 | def viaProxySOCKS4(host:String, port:Int=1080): SSHOptions = this.copy(proxy = Some(new ProxySOCKS4(host,port))) 51 | def viaProxySOCKS5(host:String, port:Int=1080): SSHOptions = this.copy(proxy = Some(new ProxySOCKS5(host,port))) 52 | def addIdentity(identity:SSHIdentity): SSHOptions = this.copy(identities = identity::identities) 53 | } 54 | 55 | object SSHOptions { 56 | val defaultPrivKeyFilenames=List( 57 | "identity", 58 | "id_dsa", 59 | "id_ecdsa", 60 | "id_ed25519", 61 | "id_rsa" 62 | ) 63 | val defaultIdentities= 64 | defaultPrivKeyFilenames 65 | .map(SP.userHome + FS + ".ssh" + FS + _) 66 | .map(SSHIdentity(_)) 67 | } 68 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHPassword.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import language.implicitConversions 4 | 5 | /** 6 | * SSHPassword class models a password, that may be given or not 7 | * @author David Crosson 8 | */ 9 | case class SSHPassword(password: Option[String]) { 10 | override def toString: String = password getOrElse "" 11 | } 12 | 13 | /** 14 | * NoPassword object to be used when no password is given 15 | * @author David Crosson 16 | */ 17 | object NoPassword extends SSHPassword(None) 18 | 19 | /** 20 | * SSHPassword object implicit conversions container 21 | * @author David Crosson 22 | */ 23 | object SSHPassword { 24 | implicit def string2password(pass: String): SSHPassword = pass match { 25 | case "" => NoPassword 26 | case password => SSHPassword(Some(pass)) 27 | } 28 | implicit def stringOpt2password(passopt: Option[String]): SSHPassword = passopt match { 29 | case Some(password) => string2password(password) 30 | case None => NoPassword 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHPowerShell.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import java.io._ 4 | import com.jcraft.jsch.ChannelShell 5 | import java.util.concurrent.ArrayBlockingQueue 6 | 7 | class SSHPowerShell(implicit ssh: SSH) extends PowerShellOperations { 8 | 9 | override def execute(cmd: SSHCommand): String = { 10 | synchronized { 11 | sendCommand(cmd.cmd.replace('\n',' ')) 12 | fromServer.getResponse() 13 | } 14 | } 15 | 16 | private val defaultPrompt = """_T-:+""" 17 | val prompt: String = ssh.options.prompt getOrElse defaultPrompt 18 | 19 | val options: SSHOptions = ssh.options 20 | 21 | private val (channel, toServer, fromServer) = { 22 | var ch: ChannelShell = ssh.jschsession().openChannel("shell").asInstanceOf[ChannelShell] 23 | ch.setPtyType("dumb") 24 | ch.setXForwarding(false) 25 | 26 | val pos = new PipedOutputStream() 27 | val pis = new PipedInputStream(pos) 28 | val toServer = new Producer(pos) 29 | ch.setInputStream(pis) 30 | 31 | val fromServer = new ConsumerOutputStream() 32 | ch.setOutputStream(fromServer) 33 | 34 | ch.connect(ssh.options.connectTimeout.toInt) 35 | 36 | (ch, toServer, fromServer) 37 | } 38 | 39 | def close(): Unit = { 40 | fromServer.close() 41 | toServer.close() 42 | channel.disconnect() 43 | } 44 | 45 | private def shellInit() = { 46 | toServer.send(s"""function prompt {"$prompt"}""") 47 | 48 | //Must read output twice to get through the set prompt command echo and then the initial prompt 49 | fromServer.getResponse() 50 | fromServer.getResponse() 51 | } 52 | 53 | private var doInit = true 54 | private def sendCommand(cmd: String): Unit = { 55 | if (doInit) { 56 | shellInit() 57 | doInit = false 58 | } 59 | toServer.send(cmd) 60 | } 61 | // ----------------------------------------------------------------------------------- 62 | class Producer(output: OutputStream) { 63 | private def sendChar(char: Int):Unit = { 64 | output.write(char) 65 | output.flush() 66 | } 67 | private def sendString(cmd: String):Unit = { 68 | output.write(cmd.getBytes) 69 | nl() 70 | output.flush() 71 | } 72 | def send(cmd: String):Unit = { sendString(cmd) } 73 | 74 | def break():Unit = { sendChar(3) } // Ctrl-C 75 | def exit():Unit = { sendChar(4) } // Ctrl-D 76 | def excape():Unit = { sendChar(27) } // ESC 77 | def nl():Unit = { sendChar(10) } // LF or NEWLINE or ENTER or Ctrl-J 78 | def cr():Unit = { sendChar(13) } // CR 79 | 80 | def close():Unit = { output.close() } 81 | } 82 | 83 | // ----------------------------------------------------------------------------------- 84 | // Output from remote server to here 85 | class ConsumerOutputStream() extends OutputStream { 86 | import java.util.concurrent.TimeUnit 87 | 88 | private val resultsQueue = new ArrayBlockingQueue[String](10) 89 | 90 | def hasResponse(): Boolean = resultsQueue.size > 0 91 | 92 | def getResponse(timeout: Long = ssh.options.timeout): String = { 93 | if (timeout == 0L) resultsQueue.take() 94 | else { 95 | resultsQueue.poll(timeout, TimeUnit.MILLISECONDS) match { 96 | case null => 97 | toServer.break() 98 | //val output = resultsQueue.take() => Already be blocked with this wait instruction... 99 | val output = resultsQueue.poll(5, TimeUnit.SECONDS) match { 100 | case null => "**no return value - couldn't break current operation**" 101 | case x => x 102 | } 103 | throw new SSHTimeoutException(output, "") // We couldn't distinguish stdout from stderr within a shell session 104 | case x => x 105 | } 106 | } 107 | } 108 | 109 | private val consumerAppender = new StringBuilder(8192) 110 | private val promptSize = prompt.length 111 | 112 | def write(b: Int):Unit = { 113 | if (b != 13) { //CR removed... CR is always added by JSCH !!!! 114 | val ch = b.toChar 115 | consumerAppender.append(ch) // TODO - Add charset support 116 | 117 | if (consumerAppender.endsWith(prompt)) { 118 | val promptIndex = consumerAppender.size - promptSize 119 | val firstNlIndex = consumerAppender.indexOf("\n") 120 | val result = consumerAppender.substring(firstNlIndex + 1, promptIndex) 121 | resultsQueue.put(result) 122 | consumerAppender.clear 123 | } 124 | } 125 | } 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHReact.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import java.io._ 4 | import com.jcraft.jsch.ChannelShell 5 | import java.util.concurrent.ArrayBlockingQueue 6 | 7 | class SSHReact(val timeout:Long)(implicit ssh: SSH) { 8 | val options: SSHOptions = ssh.options 9 | 10 | // send command to the shell 11 | def react(that: SSHCommand): SSHReact = { 12 | toServer.send(that.cmd) 13 | this 14 | } 15 | 16 | // react on key 17 | def onFirst(key:String, write:String):SSHReact = { 18 | this 19 | } 20 | 21 | // consume line until false 22 | def consumeLine( it: String => Boolean):SSHReact = { 23 | this 24 | } 25 | 26 | 27 | 28 | 29 | private def createReadyMessage = "ready-" + System.currentTimeMillis() 30 | private val defaultPrompt = """_T-:+""" 31 | private val customPromptGiven = ssh.options.prompt.isDefined 32 | private val prompt = ssh.options.prompt getOrElse defaultPrompt 33 | 34 | private val (channel, toServer, fromServer) = { 35 | var ch: ChannelShell = ssh.jschsession().openChannel("shell").asInstanceOf[ChannelShell] 36 | ch.setPtyType("dumb") 37 | ch.setXForwarding(false) 38 | //ch.setEnv("COLUMNS", "500") // Can't be use, by default PermitUserEnvironment=no in sshd_config 39 | 40 | val pos = new PipedOutputStream() 41 | val pis = new PipedInputStream(pos) 42 | val toServer = new Producer(pos) 43 | ch.setInputStream(pis) 44 | 45 | val fromServer = new ConsumerOutputStream(customPromptGiven) // if the customPrompt is given, we consider we're ready to send/receive commands 46 | ch.setOutputStream(fromServer) 47 | 48 | ch.connect(ssh.options.connectTimeout.toInt) 49 | 50 | (ch, toServer, fromServer) 51 | } 52 | 53 | def close(): Unit = { 54 | fromServer.close() 55 | toServer.close() 56 | channel.disconnect() 57 | } 58 | 59 | private def shellInit(): String = { 60 | if (ssh.options.prompt.isEmpty) { 61 | // if no prompt is given we assume that a standard sh/bash/ksh shell is used 62 | val readyMessage = createReadyMessage 63 | fromServer.setReadyMessage(readyMessage) 64 | toServer.send("unset LS_COLORS") 65 | toServer.send("unset EDITOR") 66 | toServer.send("unset PAGER") 67 | toServer.send("COLUMNS=500") 68 | toServer.send("PS1='%s'".format(defaultPrompt)) 69 | toServer.send("history -d $((HISTCMD-2)) && history -d $((HISTCMD-1))") // Previous command must be hidden 70 | //toServer.sendCommand("set +o emacs") // => Makes everything not working anymore, JSCH problem ? 71 | //toServer.sendCommand("set +o vi") // => Makes everything not working anymore, JSCH problem ? 72 | toServer.send("echo '%s'".format(readyMessage)) // ' are important to distinguish between the command and the result 73 | fromServer.waitReady() 74 | fromServer.getResponse() // ready response 75 | } else { 76 | fromServer.waitReady() 77 | fromServer.getResponse() // For the initial prompt 78 | } 79 | } 80 | 81 | private var doInit = true 82 | private def sendCommand(cmd: String): Unit = { 83 | if (doInit) { 84 | shellInit() 85 | doInit = false 86 | } 87 | toServer.send(cmd) 88 | } 89 | // ----------------------------------------------------------------------------------- 90 | class Producer(output: OutputStream) { 91 | private def sendChar(char: Int):Unit = { 92 | output.write(char) 93 | output.flush() 94 | } 95 | private def sendString(cmd: String):Unit ={ 96 | output.write(cmd.getBytes) 97 | nl() 98 | output.flush() 99 | } 100 | def send(cmd: String):Unit = { sendString(cmd) } 101 | 102 | def break():Unit = { sendChar(3) } // Ctrl-C 103 | def exit():Unit = { sendChar(4) } // Ctrl-D 104 | def excape():Unit = { sendChar(27) } // ESC 105 | def nl():Unit = { sendChar(10) } // LF or NEWLINE or ENTER or Ctrl-J 106 | def cr():Unit = { sendChar(13) } // CR 107 | 108 | def close():Unit = { output.close() } 109 | } 110 | 111 | // ----------------------------------------------------------------------------------- 112 | class ConsumerOutputStream(checkReady: Boolean) extends OutputStream { 113 | import java.util.concurrent.TimeUnit 114 | 115 | private val resultsQueue = new ArrayBlockingQueue[String](10) 116 | 117 | def hasResponse(): Boolean = resultsQueue.size > 0 118 | 119 | def getResponse(timeout: Long = ssh.options.timeout): String = { 120 | if (timeout == 0L) resultsQueue.take() 121 | else { 122 | resultsQueue.poll(timeout, TimeUnit.MILLISECONDS) match { 123 | case null => 124 | toServer.break() 125 | //val output = resultsQueue.take() => Already be blocked with this wait instruction... 126 | val output = resultsQueue.poll(5, TimeUnit.SECONDS) match { 127 | case null => "**no return value - couldn't break current operation**" 128 | case x => x 129 | } 130 | throw new SSHTimeoutException(output, "") // We couldn't distinguish stdout from stderr within a shell session 131 | case x => x 132 | } 133 | } 134 | } 135 | 136 | def setReadyMessage(newReadyMessage: String): Unit = { 137 | ready = checkReady 138 | readyMessage = newReadyMessage 139 | readyMessageQuotePrefix = "'" + newReadyMessage 140 | } 141 | private var readyMessage = "" 142 | private var ready = checkReady 143 | private val readyQueue = new ArrayBlockingQueue[String](1) 144 | def waitReady():Unit = { 145 | if (!ready) readyQueue.take() 146 | } 147 | private var readyMessageQuotePrefix = "'" + readyMessage 148 | private val promptEqualPrefix = "=" + prompt 149 | 150 | private val consumerAppender = new StringBuilder(8192) 151 | private val promptSize = prompt.length 152 | private val lastPromptChars = prompt.takeRight(2) 153 | private var searchForPromptIndex = 0 154 | 155 | /* 156 | * Take are the following is executed from an internal JSCH thread 157 | */ 158 | def write(b: Int):Unit = { 159 | if (b != 13) { //CR removed... CR is always added by JSCH !!!! 160 | val ch = b.toChar 161 | consumerAppender.append(ch) // TODO - Add charset support 162 | if (!ready) { // We want the response and only the response, not the echoed command, that's why the quote is prefixed 163 | if (consumerAppender.endsWith(readyMessage) && 164 | !consumerAppender.endsWith(readyMessageQuotePrefix)) { 165 | // wait for at least some results, will tell us that the ssh cnx is ready 166 | ready = true 167 | readyQueue.put("ready") 168 | } 169 | } else { 170 | if (consumerAppender.endsWith(lastPromptChars) 171 | && consumerAppender.endsWith(prompt) 172 | && !consumerAppender.endsWith(promptEqualPrefix)) { // END OF RESULTS FOR CURRENT COMMAND 173 | val promptIndex = consumerAppender.size - promptSize 174 | val firstNlIndex = consumerAppender.indexOf("\n") 175 | val result = consumerAppender.substring(firstNlIndex + 1, promptIndex) 176 | resultsQueue.put(result) 177 | searchForPromptIndex = 0 178 | consumerAppender.clear 179 | } else { 180 | searchForPromptIndex = consumerAppender.size - promptSize 181 | if (searchForPromptIndex < 0) searchForPromptIndex = 0 182 | } 183 | } 184 | } 185 | } 186 | } 187 | 188 | } 189 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHRemoteFile.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import language.implicitConversions 4 | 5 | /** 6 | * SSHRemoteFile class models a file on the remote system 7 | * @author David Crosson 8 | */ 9 | case class SSHRemoteFile(remoteFilename: String) { 10 | def get(implicit ssh: SSH): Option[String] = { 11 | ssh.ftp { _ get remoteFilename } 12 | } 13 | def put(data: String)(implicit ssh: SSH): Unit = { 14 | ssh.ftp { _ put (data, remoteFilename) } 15 | } 16 | def >>(toLocalFilename: String)(implicit ssh: SSH): Unit = { 17 | ssh.ftp { _.receive(remoteFilename, toLocalFilename) } 18 | } 19 | def <<(fromLocalFilename: String)(implicit ssh: SSH): Unit = { 20 | ssh.ftp { _.send(fromLocalFilename, remoteFilename) } 21 | } 22 | } 23 | 24 | /** 25 | * SSHRemoteFile object implicit conversions container 26 | * @author David Crosson 27 | */ 28 | object SSHRemoteFile { 29 | implicit def stringToRemoteFile(filename: String):SSHRemoteFile = new SSHRemoteFile(filename) 30 | } 31 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHScp.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import java.io._ 4 | import com.jcraft.jsch.ChannelExec 5 | 6 | 7 | 8 | class SSHScp(implicit ssh: SSH) extends TransfertOperations { 9 | 10 | override def get(remoteFilename: String): Option[String] = { 11 | getBytes(remoteFilename).map(new String(_, ssh.options.charset)) 12 | } 13 | 14 | override def getBytes(remoteFilename: String): Option[Array[Byte]] = { 15 | var filesBuffer = Map.empty[String, ByteArrayOutputStream] 16 | def filename2outputStream(filename: String) = { 17 | val newout = new ByteArrayOutputStream() 18 | filesBuffer += filename -> newout 19 | newout 20 | } 21 | remoteFile2OutputStream(remoteFilename, filename2outputStream) match { 22 | case 0 => None 23 | case 1 => Some(filesBuffer.values.head.toByteArray) 24 | case _ => throw new RuntimeException("Want one file, but several files were found ! (%s)".format(filesBuffer.keys.mkString(","))) 25 | } 26 | } 27 | 28 | override def receive(remoteFilename: String, outputStream: OutputStream):Unit = { 29 | def filename2outputStream(filename: String) = outputStream // just One file supported 30 | remoteFile2OutputStream(remoteFilename, filename2outputStream) match { 31 | case 0 => throw new RuntimeException("Remote file name '%s' not found".format(remoteFilename)) 32 | case 1 => // OK 33 | case _ => throw new RuntimeException("Want one file, but several files were found for '%s'".format(remoteFilename)) 34 | } 35 | } 36 | 37 | override def put(data: String, remoteDestination: String):Unit = { 38 | putBytes(data.getBytes(ssh.options.charset), remoteDestination) 39 | } 40 | 41 | override def putBytes(data: Array[Byte], remoteDestination: String):Unit = { 42 | val sz = data.length 43 | val linput = new ByteArrayInputStream(data) 44 | val parts = remoteDestination.split("/") 45 | val rfilename = parts.last 46 | val rDirectory = if (parts.init.length == 0) "." else parts.init.mkString("/") 47 | 48 | inputStream2remoteFile(linput, sz, rfilename, rDirectory) 49 | } 50 | 51 | override def putFromStream(data: java.io.InputStream, howmany:Int, remoteDestination: String):Unit = { 52 | val parts = remoteDestination.split("/") 53 | val rfilename = parts.last 54 | val rDirectory = if (parts.init.length == 0) "." else parts.init.mkString("/") 55 | 56 | inputStream2remoteFile(data, howmany, rfilename, rDirectory) 57 | 58 | } 59 | 60 | override def send(fromLocalFile: File, remoteDestination: String):Unit = { 61 | val sz = fromLocalFile.length 62 | val linput = new FileInputStream(fromLocalFile) 63 | val parts = remoteDestination.split("/", -1) 64 | val rfilename = if (parts.last.length == 0) fromLocalFile.getName else parts.last 65 | val rDirectory = if (parts.init.length == 0) "." else parts.init.mkString("/") 66 | 67 | inputStream2remoteFile(linput, sz, rfilename, rDirectory) 68 | } 69 | 70 | /** 71 | * upload a local input stream to a remote destination 72 | * @param localinput the input stream from which we read data 73 | * @param datasize amount of data to send (in bytes) 74 | * @param remoteFilename remote file name to use (just a filename, not a path, shouln't contain any path separator) 75 | * @param remoteDirectory remote destination directory for our file 76 | */ 77 | 78 | def inputStream2remoteFile( 79 | localinput: InputStream, 80 | datasize: Long, 81 | remoteFilename: String, 82 | remoteDirectory: String):Unit = { 83 | val ch = ssh.jschsession().openChannel("exec").asInstanceOf[ChannelExec] 84 | try { 85 | ch.setCommand("""scp -p -t "%s"""".format(remoteDirectory)) 86 | val sin = new BufferedInputStream(ch.getInputStream) 87 | val sout = ch.getOutputStream 88 | ch.connect(ssh.options.connectTimeout.toInt) 89 | 90 | checkAck(sin) 91 | 92 | // send "C0644 filesize filename", where filename should not include '/' 93 | //println("******"+remoteFilename+" "+remoteDirectory) 94 | val command = "C0644 %d %s\n".format(datasize, remoteFilename) // TODO take into account remote file rights 95 | sout.write(command.getBytes("US-ASCII")) 96 | sout.flush() 97 | 98 | checkAck(sin) 99 | 100 | val bis = new BufferedInputStream(localinput) 101 | /* 102 | val chk = { 103 | var readCount=0L 104 | (x:Int) => { 105 | readCount+=1 106 | readCount <= datasize & x >= 0 107 | } 108 | } 109 | if (datasize>0) Stream.continually(bis.read()).takeWhile(chk(_)).foreach(sout.write(_)) 110 | */ 111 | var writtenBytes = 0L 112 | while (writtenBytes < datasize) { 113 | val c = bis.read() 114 | if (c >= 0) { 115 | sout.write(c) 116 | writtenBytes += 1 117 | } 118 | } 119 | bis.close() 120 | 121 | // send '\0' 122 | sout.write(Array[Byte](0x00)) 123 | sout.flush() 124 | 125 | checkAck(sin) 126 | 127 | } finally { 128 | if (ch.isConnected) ch.disconnect() 129 | } 130 | } 131 | 132 | /** 133 | * lookup for remote files, for each found file send the content to 134 | * an OutputStream created using the specified builder 135 | * @param remoteFilenameMask file name or file mask 136 | * @return number of found files 137 | */ 138 | 139 | def remoteFile2OutputStream( 140 | remoteFilenameMask: String, 141 | outputStreamBuilder: String => OutputStream): Int = { 142 | val ch = ssh.jschsession().openChannel("exec").asInstanceOf[ChannelExec] 143 | try { 144 | ch.setCommand("""scp -f "%s"""".format(remoteFilenameMask)) 145 | val sin = new BufferedInputStream(ch.getInputStream) 146 | val sout = ch.getOutputStream 147 | ch.connect(ssh.options.connectTimeout.toInt) 148 | 149 | sout.write(0) 150 | sout.flush() 151 | 152 | var count = 0 153 | val buf = new StringBuilder() // Warning : Mutable state, take care 154 | def bufAppend(x: Int):Unit = { buf.append(x.asInstanceOf[Char]) } 155 | def bufReset():Unit = { buf.setLength(0) } 156 | def bufStr: String = buf.toString 157 | 158 | while (checkAck(sin) == 'C') { 159 | val fileRights = new Array[Byte](5) 160 | sin.read(fileRights, 0, 5) 161 | 162 | bufReset() 163 | Stream.continually(sin.read()).takeWhile(_ != ' ').foreach(bufAppend) 164 | val fz = bufStr.toLong 165 | 166 | bufReset() 167 | Stream.continually(sin.read()).takeWhile(_ != 0x0a).foreach(bufAppend) 168 | val filename = bufStr 169 | 170 | //println(remoteFilenameMask+ " " + count + " " + new String(fileRights)+ " '"+ filename + "' #" + fz) 171 | 172 | sout.write(0) 173 | sout.flush() 174 | 175 | val fos = new BufferedOutputStream(outputStreamBuilder(filename), 8192) 176 | 177 | /* 178 | val chk = { 179 | var readCount=0L 180 | (x:Int) => { 181 | readCount+=1 182 | readCount <= fz && x >= 0 183 | } 184 | } 185 | if (fz>0) Stream.continually(sin.read()).takeWhile(chk(_)).foreach(fos.write(_)) 186 | */ 187 | 188 | var writtenBytes = 0L 189 | while (writtenBytes < fz) { 190 | val c = sin.read() 191 | if (c >= 0) { 192 | fos.write(c) 193 | writtenBytes += 1 194 | } 195 | } 196 | 197 | fos.close() 198 | 199 | count += 1 200 | 201 | checkAck(sin) 202 | sout.write(0) 203 | sout.flush() 204 | } 205 | 206 | count 207 | } finally { 208 | if (ch.isConnected) ch.disconnect() 209 | } 210 | } 211 | 212 | private def checkAck(in: InputStream): Int = { 213 | def consumeMessage(): Unit = { 214 | val sb = new StringBuffer() 215 | Stream.continually(in.read()) 216 | .takeWhile(x => (x != '\n') && (x != -1)) 217 | .foreach(x => sb.append(x.asInstanceOf[Char])) 218 | } 219 | in.read() match { 220 | case 1 => throw new RuntimeException("SSH transfert protocol error " + consumeMessage()) 221 | case 2 => throw new RuntimeException("SSH transfert protocol fatal error " + consumeMessage()) 222 | case x => x 223 | } 224 | } 225 | 226 | def close():Unit = {} 227 | 228 | } 229 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHShell.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import java.io._ 4 | import com.jcraft.jsch.ChannelShell 5 | import java.util.concurrent.ArrayBlockingQueue 6 | 7 | class SSHShell(implicit ssh: SSH) extends SSHScp with AllOperations { 8 | 9 | 10 | /** 11 | * Returns the current shell process identifier 12 | * @return current shell PID 13 | */ 14 | def pid:Int = execute("echo $$").trim.toInt 15 | 16 | /** 17 | * Does the command "sudo su -" without password works ? 18 | * 19 | * This typical usage that maximizes compatibilities across various linux is to pipe the 20 | * command to the sudo -S su - 21 | * 22 | * BUT with this usage you loose the TTY, so interactive commands such as the shell are no more possible 23 | * and you directly get back to previous sh. 24 | * 25 | * Options such as -k, -A, -p ... may not be supported everywhere. 26 | * 27 | * Some notes : 28 | * BAD because we want to test the su 29 | * sudo -n echo OK 2>/dev/null 30 | * 31 | * BAD because with older linux, -n option was not available 32 | * sudo -n su - -c "echo OK" 2>/dev/null 33 | * 34 | * ~GOOD but NOK if only su - is allowed 35 | * echo | sudo -S su - -c echo "OK" 2>/dev/null 36 | * 37 | * GOOD 38 | * echo "echo OK" | sudo -S su - 2>/dev/null 39 | * @return true if just "sudo su -" is possible without password for current user 40 | */ 41 | def sudoSuMinusOnlyWithoutPasswordTest(): Boolean = { 42 | val testedmsg = "SUDOOK" 43 | execute(s"""echo "echo $testedmsg" | sudo -S su - 2>/dev/null""").trim.contains(testedmsg) 44 | } 45 | 46 | /** 47 | * Does the command sudo su - works with the current user password ? 48 | * while preserving the TTY stdin ! 49 | * @return true if OK 50 | */ 51 | def sudoSuMinusOnlyWithPasswordTest(): Boolean = { 52 | val prompt = "password:" 53 | val sudosu = s""" SUDO_PROMPT="$prompt" sudo -S su -""" 54 | val expect = Expect(_.endsWith(prompt), options.password.password.getOrElse("")+"\n") 55 | val (_, rc) = executeWithExpects(sudosu, expect::Nil) 56 | val result = rc==0 && whoami == "root" 57 | if (result) execute("exit") 58 | result 59 | } 60 | 61 | /** 62 | * Does the command sudo "su - -c theGivenCommand" works ? 63 | * Transparently with or without password 64 | * @return true if it works 65 | */ 66 | def sudoSuMinusWithCommandTest(cmd: String = "whoami"): Boolean = { 67 | val password = options.password.password.getOrElse("") 68 | val scriptname = ".custom-askpass-" + (scala.math.random * 10000000L).toLong 69 | val script = 70 | s""" 71 | |echo '$password' 72 | |#self destruction 73 | |rm -f $$HOME/$scriptname 74 | |""".stripMargin 75 | catData(script, s"""$$HOME/$scriptname""") 76 | execute(s"""chmod u+x $$HOME/$scriptname""") 77 | execute(s"""$$HOME/$scriptname | SUDO_PROMPT="" sudo -S su - -c "$cmd" >/dev/null 2>&1 ; echo $$?""") 78 | .trim 79 | .equals("0") 80 | } 81 | 82 | /** 83 | * write some data to the specified filespec 84 | * @param filespec the file to write to 85 | * @return true if data was written to the given file destination 86 | */ 87 | override def catData(data: String, filespec: String): Boolean = { 88 | synchronized { 89 | if (execute(s"""touch '$filespec' >/dev/null 2>&1 ; echo $$?""").trim().equals("0")) { 90 | put(data, filespec) 91 | get(filespec) match { 92 | case None => false 93 | case Some(content) if content == data => true 94 | case _ => false 95 | } 96 | } else { 97 | false 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * 104 | * @return the command result and the number of consumed expects 105 | */ 106 | def executeWithExpects(cmd: SSHCommand, expects: List[Expect]): (String, Int) = { 107 | try { 108 | fromServer.setExpects(expects) 109 | val result = execute(cmd) 110 | val sz = fromServer.expectsRemaining() 111 | (result, sz) 112 | } finally { 113 | fromServer.resetExpects() 114 | } 115 | } 116 | 117 | override def execute(cmd: SSHCommand): String = { 118 | synchronized { 119 | if (options.historize) sendCommand(s"${cmd.cmd}") 120 | else sendCommand(s" ${cmd.cmd}") 121 | fromServer.getResponse() 122 | } 123 | } 124 | 125 | override def executeWithStatus(cmd: SSHCommand): Tuple2[String, Int] = { 126 | synchronized { 127 | val result = execute(cmd) 128 | val rc = executeAndTrim("echo $?").toInt 129 | (result, rc) 130 | } 131 | } 132 | 133 | def becomeWithSU(someoneelse: String, password: Option[String] = None): Boolean = { 134 | val curuser = whoami 135 | if (curuser == "root") { 136 | execute(" LANG=en; export LANG") 137 | sendCommand(s" su - $someoneelse") 138 | Thread.sleep(2000) // TODO - TO BE IMPROVED 139 | shellInit() 140 | } else if (password.isDefined) { 141 | execute(" LANG=en; export LANG") 142 | sendCommand(s" su - $someoneelse") 143 | Thread.sleep(2000) // TODO - TO BE IMPROVED 144 | try { 145 | password.foreach { it => toServer.send(it) } 146 | Thread.sleep(1000) 147 | } finally { 148 | shellInit() 149 | } 150 | } 151 | whoami == someoneelse 152 | } 153 | def becomeWithSUDO(someoneelse: String): Boolean = { 154 | val curuser = whoami 155 | if (sudoSuMinusOnlyWithoutPasswordTest()) { 156 | execute(" LANG=en; export LANG") 157 | sendCommand(s" sudo -n su - $someoneelse") 158 | shellInit() 159 | } else { 160 | execute(" LANG=en; export LANG") 161 | sendCommand(s" sudo -S su - $someoneelse") 162 | Thread.sleep(2000) // TODO - TO BE IMPROVED 163 | try { 164 | if (curuser != "root") { // do not use whoami here as we are in transitional state... 165 | options.password.password.foreach { it => toServer.send(it) } 166 | Thread.sleep(1000) 167 | } 168 | } finally { 169 | shellInit() 170 | } 171 | } 172 | whoami == someoneelse 173 | } 174 | /** 175 | * Become someoneelse on the current shell session, first the command 176 | * will try (if new user password is given) su - newuser then if unsuccessful 177 | * it will try the sudo su - approach, in that case it is the current user 178 | * pass that will be used, new user password will be ignored. 179 | * 180 | * @param someoneelse become this new user 181 | * @param password new user password 182 | * @return true if operation is successfull, the current user is the new one 183 | */ 184 | def become(someoneelse: String, password: Option[String] = None): Boolean = { 185 | if (whoami != someoneelse) { 186 | synchronized { 187 | becomeWithSU(someoneelse, password) || 188 | becomeWithSUDO(someoneelse) 189 | } 190 | } else true 191 | } 192 | 193 | private def createReadyMessage: String = "ready-" + System.currentTimeMillis() 194 | private val defaultPrompt = """_T-:+""" 195 | private val customPromptGiven = ssh.options.prompt.isDefined 196 | val prompt: String = ssh.options.prompt getOrElse defaultPrompt 197 | 198 | val options: SSHOptions = ssh.options 199 | 200 | private val (channel, toServer, fromServer) = { 201 | var ch: ChannelShell = ssh.jschsession().openChannel("shell").asInstanceOf[ChannelShell] 202 | ch.setPtyType("dumb") 203 | ch.setXForwarding(false) 204 | //ch.setEnv("COLUMNS", "500") // Can't be use, by default PermitUserEnvironment=no in sshd_config 205 | 206 | val pos = new PipedOutputStream() 207 | val pis = new PipedInputStream(pos) 208 | val toServer = new Producer(pos) 209 | ch.setInputStream(pis) 210 | 211 | val fromServer = new ConsumerOutputStream(customPromptGiven) // if the customPrompt is given, we consider we're ready to send/receive commands 212 | ch.setOutputStream(fromServer) 213 | 214 | ch.connect(ssh.options.connectTimeout.toInt) 215 | 216 | (ch, toServer, fromServer) 217 | } 218 | 219 | override def close(): Unit = { 220 | super.close() // to close SCP session 221 | fromServer.close() 222 | toServer.close() 223 | channel.disconnect() 224 | } 225 | 226 | private def shellInit(): String = { 227 | if (ssh.options.prompt.isEmpty) { 228 | // if no prompt is given we assume that a standard sh/bash/ksh shell is used 229 | val readyMessage = createReadyMessage 230 | fromServer.setReadyMessage(readyMessage) 231 | toServer.send(" TERM=dumb; export TERM") 232 | toServer.send(" unset LS_COLORS") 233 | toServer.send(" unset COLOR_TERM") 234 | toServer.send(" unset EDITOR") 235 | toServer.send(" unset PAGER") 236 | toServer.send(" COLUMNS=500") 237 | toServer.send(" SUDO_PS1='%s'".format(defaultPrompt)) // MUST BE REMOVED FROM THE HISTORY !! 238 | toServer.send(" PS1='%s'".format(defaultPrompt)) // MUST BE REMOVED FROM THE HISTROY !! 239 | //toServer.send("history -d $((HISTCMD-3)) && history -d $((HISTCMD-2)) && history -d $((HISTCMD-1))") // REMOVING DEDICATED PS1 VAR EN COMMANDS 240 | //toServer.sendCommand("set +o emacs") // => Makes everything not working anymore, JSCH problem ? 241 | //toServer.sendCommand("set +o vi") // => Makes everything not working anymore, JSCH problem ? 242 | toServer.send(" echo '%s'".format(readyMessage)) // ' are important to distinguish between the command and the result 243 | fromServer.waitReady() 244 | fromServer.getResponse() // ready response 245 | } else { 246 | fromServer.waitReady() 247 | fromServer.getResponse() // For the initial prompt 248 | } 249 | } 250 | 251 | private var doInit = true 252 | private def sendCommand(cmd: String): Unit = { 253 | if (doInit) { 254 | shellInit() 255 | doInit = false 256 | } 257 | toServer.send(cmd) 258 | } 259 | 260 | // ----------------------------------------------------------------------------------- 261 | class Producer(output: OutputStream) { 262 | private def sendChar(char: Int):Unit = { 263 | output.write(char) 264 | output.flush() 265 | } 266 | private def sendString(cmd: String):Unit = { 267 | output.write(cmd.getBytes) 268 | nl() 269 | output.flush() 270 | } 271 | def send(cmd: String):Unit = { sendString(cmd) } 272 | def write(str: String):Unit = { 273 | output.write(str.getBytes) 274 | output.flush() 275 | } 276 | 277 | def brk():Unit = { sendChar(3) } // Ctrl-C 278 | def eot():Unit = { sendChar(4) } // Ctrl-D - End of Transmission 279 | def esc():Unit = { sendChar(27) } // ESC 280 | def nl():Unit = { sendChar(10) } // LF or NEWLINE or ENTER or Ctrl-J 281 | def cr():Unit = { sendChar(13) } // CR 282 | 283 | def close():Unit = { output.close() } 284 | } 285 | 286 | // ----------------------------------------------------------------------------------- 287 | class ConsumerOutputStream(checkReady: Boolean) extends OutputStream { 288 | import java.util.concurrent.TimeUnit 289 | 290 | private var currentExpects = List.empty[Expect] 291 | def setExpects(expects: List[Expect]): Unit = { currentExpects = expects } 292 | def expectsRemaining(): Int = currentExpects.size 293 | def resetExpects(): Unit = { currentExpects = List.empty } 294 | 295 | private val resultsQueue: ArrayBlockingQueue[String] = new ArrayBlockingQueue[String](10) 296 | 297 | def hasResponse(): Boolean = resultsQueue.size > 0 298 | 299 | def getResponse(timeout: Long = ssh.options.timeout): String = { 300 | if (timeout == 0L) resultsQueue.take() 301 | else { 302 | resultsQueue.poll(timeout, TimeUnit.MILLISECONDS) match { 303 | case null => 304 | toServer.brk() 305 | //val output = resultsQueue.take() => Already be blocked with this wait instruction... 306 | val output = resultsQueue.poll(5, TimeUnit.SECONDS) match { 307 | case null => "**no return value - couldn't break current operation**" 308 | case x => x 309 | } 310 | throw new SSHTimeoutException(output, "") // We couldn't distinguish stdout from stderr within a shell session 311 | case x => x 312 | } 313 | } 314 | } 315 | 316 | def setReadyMessage(newReadyMessage: String): Unit = { 317 | ready = checkReady 318 | readyMessage = newReadyMessage 319 | readyMessageQuotePrefix = "'" + newReadyMessage 320 | } 321 | private var readyMessage = "" 322 | private var ready = checkReady 323 | private val readyQueue = new ArrayBlockingQueue[String](1) 324 | def waitReady():Unit = { 325 | if (!ready) readyQueue.take() 326 | } 327 | private var readyMessageQuotePrefix = "'" + readyMessage 328 | private val promptEqualPrefix = "=" + prompt 329 | 330 | private val consumerAppender = new StringBuilder(8192) 331 | private val promptSize = prompt.length 332 | private val lastPromptChars = prompt.takeRight(2) 333 | private var searchForPromptIndex = 0 334 | 335 | final def write(b: Int):Unit = { 336 | if (b != 13) { //CR removed... CR is always added by JSCH !!!! 337 | val ch = b.toChar 338 | consumerAppender.append(ch) // TODO - Add charset support 339 | if (!ready) { // We want the response and only the response, not the echoed command, that's why the quote is prefixed 340 | if (consumerAppender.endsWith(readyMessage) && 341 | !consumerAppender.endsWith(readyMessageQuotePrefix)) { 342 | // wait for at least some results, will tell us that the ssh cnx is ready 343 | ready = true 344 | readyQueue.put("ready") 345 | } 346 | } else { 347 | if (currentExpects.nonEmpty 348 | && currentExpects.head.when(consumerAppender.toString()) // TODO : Bad perf ! 349 | ) { 350 | toServer.write(currentExpects.head.send) 351 | currentExpects = currentExpects.tail 352 | } 353 | if (consumerAppender.endsWith(lastPromptChars) 354 | && consumerAppender.endsWith(prompt) 355 | && !consumerAppender.endsWith(promptEqualPrefix)) { 356 | val promptIndex = consumerAppender.size - promptSize 357 | val firstNlIndex = consumerAppender.indexOf("\n") 358 | val result = consumerAppender.substring(firstNlIndex + 1, promptIndex) 359 | resultsQueue.put(result) 360 | searchForPromptIndex = 0 361 | consumerAppender.clear 362 | } else { 363 | searchForPromptIndex = consumerAppender.size - promptSize 364 | if (searchForPromptIndex < 0) searchForPromptIndex = 0 365 | } 366 | } 367 | } 368 | } 369 | 370 | // final def consumerAppenderendsWith(str:String):Boolean = { 371 | // val from= consumerAppender.size - str.length 372 | // val idx = consumerAppender.lastIndexOf(str, if (from>=0) from else 0) 373 | // idx!= -1 && consumerAppender.size-idx == str.length 374 | // } 375 | 376 | } 377 | 378 | } -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHTimeoutException.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | class SSHTimeoutException(val stdout:String, val stderr:String) extends Exception("SSH Timeout") { 4 | 5 | } -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHTools.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import java.io._ 4 | import scala.io.BufferedSource 5 | 6 | object SSHTools { 7 | def md5sum(str: String): String = { 8 | md5sum(new ByteArrayInputStream(str.getBytes())) // TODO : Warning manage charsets... 9 | } 10 | def md5sum(input: InputStream): String = { 11 | val bis = new BufferedInputStream(input) 12 | val buf = new Array[Byte](1024) 13 | val md5 = java.security.MessageDigest.getInstance("MD5") 14 | Stream.continually(bis.read(buf)).takeWhile(_ != -1).foreach(md5.update(buf, 0, _)) 15 | md5.digest().map(0xFF & _).map { "%02x".format(_) }.foldLeft("") { _ + _ } 16 | } 17 | def getFile(filename: String): String = { 18 | new BufferedSource(new FileInputStream(filename)).mkString 19 | } 20 | def getRawFile(filename: String): Array[Byte] = { 21 | inputStream2ByteArray(new FileInputStream(filename)) 22 | } 23 | def inputStream2ByteArray(input: InputStream): Array[Byte] = { 24 | val fos = new ByteArrayOutputStream(65535) 25 | val bfos = new BufferedOutputStream(fos, 16384) 26 | val bis = new BufferedInputStream(input) 27 | val buffer = new Array[Byte](8192) 28 | try { 29 | Stream.continually(bis.read(buffer)) 30 | .takeWhile(_ != -1) 31 | .foreach(bfos.write(buffer, 0, _)) 32 | } finally { 33 | bfos.close 34 | fos.close 35 | } 36 | fos.toByteArray 37 | } 38 | def basename(name: String, ext: String): String = if (name contains ext) name.substring(0, name.indexOf(ext)) else name 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/SSHUserInfo.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import com.jcraft.jsch.{UserInfo, UIKeyboardInteractive} 4 | 5 | /* Attention 6 | * - L'option PasswordAuthentication doit être à "yes" sinon impossible de s'authentifier 7 | * (Configuration au niveau du serveur SSH) SSI on n'implemente pas "promptKeyboardInteractive" 8 | * 9 | */ 10 | case class SSHUserInfo(password: Option[String] = None, passphrase: Option[String] = None) extends UserInfo with UIKeyboardInteractive { 11 | override def getPassphrase(): String = passphrase getOrElse "" 12 | override def getPassword(): String = password getOrElse "" 13 | override def promptPassword(message: String) = true 14 | override def promptPassphrase(message: String) = true 15 | override def promptYesNo(message: String) = true 16 | override def showMessage(message: String): Unit = {} 17 | override def promptKeyboardInteractive(destination: String, name: String, instruction: String, prompt: Array[String], echo: Array[Boolean]): Array[String] = Array(getPassword()) 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/ShellOperations.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import java.text.SimpleDateFormat 20 | import java.util.Date 21 | import java.util.Locale 22 | import scala.util.matching.Regex 23 | 24 | /** ShellOperations defines generic shell operations and common shell commands shortcuts 25 | */ 26 | trait ShellOperations extends CommonOperations with SSHLazyLogging { 27 | 28 | /** Execute the current command and return the result as a string 29 | * @param cmd 30 | * command to be executed 31 | * @return 32 | * result string 33 | */ 34 | def execute(cmd: SSHCommand): String 35 | 36 | /** Execute the current command and return the result as a string and exit code tuple 37 | * @param cmd 38 | * command to be executed 39 | * @return 40 | * A tuple made of the result string and the exit code 41 | */ 42 | def executeWithStatus(cmd: SSHCommand): Tuple2[String, Int] 43 | 44 | /** Execute the current batch (list of commands) and return the result as a string collection 45 | * @param cmds 46 | * batch to be executed 47 | * @return 48 | * result string collection 49 | */ 50 | @deprecated("", "0.9.14") 51 | def executeAll(cmds: SSHBatch): Iterable[String] = cmds.cmdList.map((cmd: String) => execute(cmd)) 52 | 53 | /** Execute the current command and pass the result to the given code 54 | * @param cmd 55 | * command to be executed 56 | * @param cont 57 | * continuation code 58 | */ 59 | @deprecated("", "0.9.14") 60 | def executeAndContinue(cmd: SSHCommand, cont: String => Unit): Unit = cont(execute(cmd)) 61 | 62 | /** Execute the current command and return the result as a trimmed string 63 | * @param cmd 64 | * command to be executed 65 | * @return 66 | * result string 67 | */ 68 | def executeAndTrim(cmd: SSHCommand): String = execute(cmd).trim() 69 | 70 | /** Execute the current command and return the result as a trimmed splitted string 71 | * @param cmd 72 | * command to be executed 73 | * @return 74 | * result string 75 | */ 76 | def executeAndTrimSplit(cmd: SSHCommand): Iterable[String] = execute(cmd).trim().split("\r?\n") 77 | 78 | /** Execute the current batch (list of commands) and return the result as a string collection 79 | * @param cmds 80 | * batch to be executed 81 | * @return 82 | * result trimmed string collection 83 | */ 84 | @deprecated("", "0.9.14") 85 | def executeAllAndTrim(cmds: SSHBatch): Iterable[String] = executeAll(cmds.cmdList) map { _.trim } 86 | 87 | /** Execute the current batch (list of commands) and return the result as a string collection 88 | * @param cmds 89 | * batch to be executed 90 | * @return 91 | * result trimmed splitted string collection 92 | */ 93 | @deprecated("", "0.9.14") 94 | def executeAllAndTrimSplit(cmds: SSHBatch): Iterable[Array[String]] = executeAll(cmds.cmdList) map { _.trim.split("\r?\n") } 95 | 96 | /** Disable shell history, the goal is to not add noises to your shell history, to keep your shell commands history clean. 97 | */ 98 | def disableHistory(): Unit = { 99 | execute("unset HISTFILE") 100 | execute("HISTSIZE=0") 101 | } 102 | 103 | /** Remote file size in bytes 104 | * @param filename 105 | * file name 106 | * @return 107 | * optional file size, or None if filename was not found 108 | */ 109 | def fileSize(filename: String): Option[Long] = 110 | genoptcmd(s"""ls -ld "$filename" """).map(_.split("""\s+""")(4).toLong) 111 | 112 | /** Remote file last modified date (TZ is taken into account) 113 | * @param filename 114 | * file name 115 | * @return 116 | * optional date, or None if filename was not found 117 | */ 118 | def lastModified(filename: String): Option[Date] = { 119 | osid match { 120 | // ------------------------------------------------------- 121 | case Linux => 122 | // 2013-02-27 18:08:51.252312190 +0100 123 | genoptcmd(s"""stat -c '%y' '$filename' """) 124 | .map { lmLinuxFix } 125 | // ------------------------------------------------------- 126 | case Darwin => 127 | // Modify: 2014-07-03 21:43:08 CEST 128 | genoptcmd(s"""stat -t '%Y-%m-%d %H:%M:%S %Z' -x '$filename' | grep Modify""") 129 | .map { _.split(":", 2).drop(1).mkString.trim } 130 | .map { lmDarwinSDF.parse } 131 | // ------------------------------------------------------- 132 | case AIX => 133 | // Last modified: Fri Apr 30 09:10:22 DFT 2010 134 | genoptcmd(s"""istat '$filename' | grep "Last modified" """) 135 | .map { _.split(":", 2).drop(1).mkString.trim } 136 | .map { _.replaceFirst("DFT", "CET") } // Because DFT is a specific AIX TZ naming for CET !!! 137 | .map { lmAixSDF.parse } 138 | // ------------------------------------------------------- 139 | case _ => ??? 140 | } 141 | } 142 | private def lmLinuxFix(input: String): Date = { 143 | input match { 144 | case lmLinuxDateRE(date, time, millis, tz) => 145 | val shortenmillis = millis.take(3) // TAKE CARE only take first 3 digits of 432070011 !!!! 146 | lmLinuxSDF.parse(s"$date $time.$shortenmillis $tz") 147 | } 148 | } 149 | private val lmLinuxDateRE = """(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})[.,](\d+) (.*)""".r 150 | private val lmLinuxSDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.S Z") // TAKE CARE only take first 3 digits of 432070011 !!!! 151 | private val lmDarwinSDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z") 152 | private val lmAixSDF = new SimpleDateFormat("EEE MMM dd HH:mm:ss z yyyy", Locale.US) 153 | 154 | /** Remote tree file size in kilobytes 155 | * @param filename 156 | * file name 157 | * @return 158 | * optional file tree size in kilobytes, or None if filename was not found 159 | */ 160 | def du(filename: String): Option[Long] = { 161 | genoptcmd(s"""du -k "$filename" | tail -1""") 162 | .flatMap(_.split("""\s+""", 2).headOption) 163 | .map(_.toLong) 164 | } 165 | 166 | /** Remote file md5sum 167 | * @param filename 168 | * file name 169 | * @return 170 | * md5sum as an optional String, or None if filename was not found 171 | */ 172 | def md5sum(filename: String): Option[String] = { 173 | osid match { 174 | case Darwin => genoptcmd(s"""md5 "$filename" """).map(_.split("=", 2)(1).trim) 175 | case AIX => genoptcmd(s"""csum -h MD5 "$filename" """).map(_.split("""\s+""")(0).trim) 176 | case _ => genoptcmd(s"""md5sum "$filename" """).map(_.split("""\s+""")(0).trim) 177 | } 178 | } 179 | 180 | /** Remote file sha1sum 181 | * @param filename 182 | * file name 183 | * @return 184 | * sha1sum as an optional String, or None if filename was not found 185 | */ 186 | def sha1sum(filename: String): Option[String] = 187 | osid match { 188 | case Darwin => genoptcmd(s"""shasum "$filename" """).map(_.split("""\s+""")(0)) 189 | case AIX => genoptcmd(s"""csum -h SHA1 "$filename" """).map(_.split("""\s+""")(0).trim) 190 | case _ => genoptcmd(s"""sha1sum "$filename" """).map(_.split("""\s+""")(0)) 191 | } 192 | 193 | /** who am I ? 194 | * @return 195 | * current user name 196 | */ 197 | def whoami: String = executeAndTrim("whoami") 198 | 199 | /** *nix system name (Linux, AIX, SunOS, ...) 200 | * @return 201 | * remote *nix system name 202 | */ 203 | def uname: String = executeAndTrim("""uname 2>/dev/null""") 204 | 205 | /** *nix os name (linux, aix, sunos, darwin, ...) 206 | * @return 207 | * remote *nix system name 208 | */ 209 | def osname: String = uname.toLowerCase() 210 | 211 | /** *nix os name (linux, aix, sunos, darwin, ...) 212 | * @return 213 | * remote *nix system name 214 | */ 215 | def osid: OS = osname match { 216 | case "linux" => Linux 217 | case "aix" => AIX 218 | case "darwin" => Darwin 219 | case "sunos" => SunOS 220 | } 221 | 222 | /** remote environment variables 223 | * @return 224 | * map of environment variables 225 | */ 226 | def env: Map[String, String] = { 227 | for { 228 | line <- execute("env").split("""\n""") 229 | result <- EnvRE.findFirstIn(line).collect { case EnvRE(key, value) => key -> value } 230 | } yield result 231 | }.toMap 232 | 233 | private val EnvRE = """([^=]+)=(.*)""".r 234 | 235 | /** List files in specified directory 236 | * @return 237 | * current directory files as an Iterable 238 | */ 239 | def ls(): Iterable[String] = ls(".") 240 | 241 | /** List files in specified directory 242 | * @param dirname 243 | * directory to look into 244 | * @return 245 | * current directory files as an Iterable 246 | */ 247 | def ls(dirname: String): Iterable[String] = { 248 | // executeAndTrimSplit("""ls --format=single-column "%s" """.format(dirname)) 249 | executeAndTrimSplit("""ls "%s" | cat """.format(dirname)).filter(_.nonEmpty) 250 | } 251 | 252 | /** Get current working directory 253 | * @return 254 | * current directory 255 | */ 256 | def pwd: String = executeAndTrim("pwd") 257 | 258 | /** Change current working directory to home directory Of course this requires a persistent shell session to be really useful... 259 | */ 260 | def cd: Unit = { execute("cd") } 261 | 262 | /** Change current working directory to the specified directory Of course this requires a persistent shell session to be really useful... 263 | * @param dirname 264 | * directory name 265 | */ 266 | def cd(dirname: String): Unit = { execute(s"""cd "$dirname" """) } 267 | 268 | /** Get remote host name 269 | * @return 270 | * host name 271 | */ 272 | def hostname: String = executeAndTrim("""hostname""") 273 | 274 | /** Get remote date, as a java class Date instance (minimal resolution = 1 second) 275 | * @return 276 | * The remote system current date as a java Date class instance 277 | */ 278 | def date(): Date = { 279 | val d = executeAndTrim("date -u '+%Y-%m-%d %H:%M:%S %Z'") 280 | dateSDF.parse(d) 281 | } 282 | private lazy val dateSDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z") 283 | 284 | /** Get the content of a file 285 | * @param filename 286 | * get the content of this filename 287 | * @return 288 | * file content 289 | */ 290 | def cat(filename: String) = execute("cat %s".format(filename)) 291 | 292 | /** Get contents of a list of files 293 | * @param filenames 294 | * get the content of this list of filenames 295 | * @return 296 | * files contents concatenation 297 | */ 298 | def cat(filenames: List[String]) = execute("cat %s".format(filenames.mkString(" "))) 299 | 300 | /** data to specified filespec 301 | */ 302 | def catData(data: String, filespec: String): Boolean 303 | 304 | /** Find file modified after the given date (Warning, minimal resolution = 1 minute) 305 | * @param root 306 | * Search for file from this root directory 307 | * @param after 308 | * Date parameter 309 | * @return 310 | * list of paths (relative to root) modified after the specified date 311 | */ 312 | def findAfterDate(root: String, after: Date): Iterable[String] = { 313 | def ellapsedInMn(thatDate: Date): Long = (date().getTime - thatDate.getTime) / 1000 / 60 314 | // deprecated : // def ellapsedInMn(thatDate:Date):Long = (new Date().getTime - thatDate.getTime)/1000/60 315 | val findpattern = osid match { 316 | case Linux | AIX => """find %s -follow -type f -mmin '-%d' 2>/dev/null""" // "%s" => %s to enable file/dir patterns 317 | case SunOS => throw new RuntimeException("SunOS not supported - find command doesn't support -mmin parameter") 318 | case _ => """find %s -type f -mmin '-%d' 2>/dev/null""" 319 | } 320 | val findcommand = findpattern.format(root, ellapsedInMn(after)) 321 | executeAndTrimSplit(findcommand) 322 | } 323 | 324 | /** Generic test (man test, for arguments) 325 | * @param that 326 | * condition 327 | * @return 328 | * True if condition is met 329 | */ 330 | def test(that: String): Boolean = { 331 | val cmd = """test %s ; echo $?""".format(that) 332 | executeAndTrim(cmd).toInt == 0 333 | } 334 | 335 | /** Does specified filename exist ? 336 | * @param filename 337 | * file name 338 | * @return 339 | * True if file exists 340 | */ 341 | def exists(filename: String): Boolean = testFile("-e", filename) 342 | 343 | /** Does specified filename not exist ? 344 | * @param filename 345 | * file name 346 | * @return 347 | * True if file does'nt exist 348 | */ 349 | def notExists(filename: String): Boolean = !exists(filename) 350 | 351 | /** Is file name a directory 352 | * @param filename 353 | * file name 354 | * @return 355 | * True if file is a directory 356 | */ 357 | def isDirectory(filename: String): Boolean = testFile("-d", filename) 358 | 359 | /** Is file name a regular file 360 | * @param filename 361 | * file name 362 | * @return 363 | * True if file is a regular file 364 | */ 365 | def isFile(filename: String): Boolean = testFile("-f", filename) 366 | 367 | /** Is filename executable ? 368 | * @param filename 369 | * file name 370 | * @return 371 | * True if file is executable 372 | */ 373 | def isExecutable(filename: String): Boolean = testFile("-x", filename) 374 | 375 | /** get current SSH options 376 | * @return 377 | * used ssh options 378 | */ 379 | def options: SSHOptions 380 | 381 | /** list active processes of unix like systems 382 | * @return 383 | * system processes list 384 | */ 385 | def ps(): List[Process] = { 386 | def processLinesToMap(pscmd: String, format: String): List[Map[String, String]] = { 387 | val fields = format.split(",") 388 | executeAndTrimSplit(pscmd).toList.tail // Removing header line 389 | .map(_.trim) 390 | .map(_.split("""\s+""", fields.size)) 391 | .filter(_.size == fields.size) 392 | .map(fields zip _) 393 | .map(_.toMap) 394 | } 395 | osid match { 396 | case Linux => 397 | val format = "pid,ppid,user,stat,vsz,rss,etime,cputime,cmd" 398 | val cmd = s"ps -eo $format | grep -v grep | cat" 399 | 400 | processLinesToMap(cmd, format).map { m => 401 | LinuxProcess( 402 | pid = m("pid").toInt, 403 | ppid = m("ppid").toInt, 404 | user = m("user"), 405 | state = LinuxProcessState.fromSpec(m("stat")), 406 | rss = m("rss").toInt, 407 | vsz = m("vsz").toInt, 408 | etime = ProcessTime(m("etime")), 409 | cputime = ProcessTime(m("cputime")), 410 | cmdline = m("cmd") 411 | ) 412 | } 413 | case AIX => 414 | val format = "pid,ppid,ruser,args" 415 | val cmd = s"ps -eo $format | grep -v grep | cat" 416 | processLinesToMap(cmd, format).map { m => 417 | AIXProcess( 418 | pid = m("pid").toInt, 419 | ppid = m("ppid").toInt, 420 | user = m("ruser"), 421 | cmdline = m("args") 422 | ) 423 | } 424 | case SunOS => 425 | val format = "pid,ppid,ruser,args" 426 | val cmd = s"ps -eo $format | grep -v grep | cat" 427 | processLinesToMap(cmd, format).map { m => 428 | SunOSProcess( 429 | pid = m("pid").toInt, 430 | ppid = m("ppid").toInt, 431 | user = m("ruser"), 432 | cmdline = m("args") 433 | ) 434 | } 435 | case Darwin => 436 | val format = "pid,ppid,user,state,vsz,rss,etime,cputime,args" 437 | val cmd = s"ps -eo $format | grep -v grep | cat" 438 | processLinesToMap(cmd, format).map { m => 439 | DarwinProcess( 440 | pid = m("pid").toInt, 441 | ppid = m("ppid").toInt, 442 | user = m("user"), 443 | state = DarwinProcessState.fromSpec(m("state")), 444 | rss = m("rss").toInt, 445 | vsz = m("vsz").toInt, 446 | etime = ProcessTime(m("etime")), 447 | cputime = ProcessTime(m("cputime")), 448 | cmdline = m("args") 449 | ) 450 | } 451 | case x => 452 | logger.error(s"Unsupported operating system $x for ps method") 453 | List.empty[Process] 454 | } 455 | } 456 | 457 | /** get pid of all processes matching the given command line regular expression 458 | */ 459 | def pidof(regex: Regex): List[Int] = { 460 | ps().filter { p => regex.findFirstIn(p.cmd).isDefined }.map(_.pid) 461 | } 462 | 463 | /** File system remaining space in MB 464 | * @return 465 | * fs freespace in Mb if the path is valid 466 | */ 467 | def fsFreeSpace(path: String): Option[Double] = { 468 | osid match { 469 | case Linux | AIX | Darwin => 470 | executeAndTrimSplit(s"""df -Pm '${path}'""").drop(1).headOption.flatMap { line => 471 | line.split("""\s+""").toList.drop(3).headOption.map(_.toDouble) 472 | } 473 | case x => 474 | logger.error(s"Unsupported operating system $x for fsFreeSpace method") 475 | None 476 | } 477 | } 478 | 479 | /** get file rights string (such as 'drwxr-xr-x') 480 | * @return 481 | * rights string 482 | */ 483 | def fileRights(path: String): Option[String] = { 484 | osid match { 485 | case Linux => 486 | executeAndTrim(s"test '${path}' && stat --format '%A' '${path}'") match { 487 | case "" => None 488 | case x => Some(x) 489 | } 490 | case AIX | Darwin => 491 | executeAndTrim(s"test '${path}' && ls -lad '${path}'") match { 492 | case "" => None 493 | case x => x.split("""\s+""", 2).headOption 494 | } 495 | case x => 496 | logger.error(s"Unsupported operating system $x for fileRights method") 497 | None 498 | } 499 | } 500 | 501 | /** kill specified processes 502 | */ 503 | def kill(pids: Iterable[Int]): Unit = { execute(s"""kill -9 ${pids.mkString(" ")}""") } 504 | 505 | /** delete a file 506 | */ 507 | def rm(file: String): Unit = { rm(file :: Nil) } 508 | 509 | /** delete files 510 | */ 511 | def rm(files: Iterable[String]): Unit = { execute(s"""rm -f ${files.mkString("'", "' '", "'")}""") } 512 | 513 | /** delete directory (directory must be empty) 514 | */ 515 | def rmdir(dir: String): Boolean = { rmdir(dir :: Nil) } 516 | 517 | /** delete directories (directories must be empty) 518 | */ 519 | def rmdir(dirs: Iterable[String]): Boolean = { executeAndTrim(s"""rmdir ${dirs.mkString("'", "' '", "'")} && echo $$?""") == "0" } 520 | 521 | /** get server architecture string 522 | * @return 523 | * server architecture 524 | */ 525 | def arch = execute("arch") 526 | 527 | /** Create a new directory 528 | * @return 529 | * true if the directory was successfully created 530 | */ 531 | def mkdir(dirname: String): Boolean = { executeAndTrim(s"""mkdir -p '$dirname' && echo $$?""") == "0" } 532 | 533 | /** Create a new directory and enter it 534 | * @return 535 | * true if the directory was successfully created and we were able to enter it 536 | */ 537 | def mkcd(dirname: String): Boolean = { executeAndTrim(s"""mkdir -p '$dirname' && cd '$dirname' && echo $$?""") == "0" } 538 | 539 | /** get host up time, example formats : Linux : 21:34:17 up 33 min, 5 users, load average: 0.18, 0.27, 0.30 21:29:38 up 473 days, 22:21, 1 user, load average: 0.09, 0.04, 0.00 Darwin : 21:28 up 53 540 | * mins, 3 users, load averages: 1.40 1.49 1.52 541 | */ 542 | def uptime: String = { 543 | execute("(LANG=en; uptime)") 544 | } 545 | 546 | /** get dir name 547 | * @param name 548 | * a filename 549 | * @return 550 | * directory name 551 | */ 552 | def dirname(name: String) = executeAndTrim(s"""dirname "$name"""") 553 | 554 | /** get base name 555 | * @param name 556 | * a name 557 | * @return 558 | * base name 559 | */ 560 | def basename(name: String) = executeAndTrim(s"""basename "$name"""") 561 | 562 | /** get base name 563 | * @param name 564 | * a name 565 | * @param suffix 566 | * filename suffix 567 | * @return 568 | * base name 569 | */ 570 | def basename(name: String, suffix: String) = execute(s"""basename "$name" "$suffix"""") 571 | 572 | /** touch 573 | * @param files 574 | * list of files to touch 575 | */ 576 | def touch(files: String*): Unit = { execute(s"""touch ${files.mkString("'", "' '", "'")}""") } 577 | 578 | /** Get used id information 579 | * @return 580 | * user id data 581 | */ 582 | def id: String = execute("id") 583 | 584 | /** echo something and return back what has been printed 585 | * @param message 586 | * to print 587 | * @return 588 | * printed message 589 | */ 590 | def echo(message: String) = execute(s"""echo $message""") 591 | 592 | /** get dir name 593 | * @return 594 | * directory name 595 | */ 596 | def alive(): Boolean = echo("ALIVE").contains("ALIVE") 597 | 598 | /** get command location on filesystem, based on current PATH 599 | * @param command 600 | * the name of the command 601 | * @return 602 | * full command path option or None if the command is not in the current PATH 603 | */ 604 | def which(command: String): Option[String] = { 605 | execute(s"""which $command""").trim match { 606 | case "" => None 607 | case location => Some(location) 608 | } 609 | } 610 | 611 | // ========================================================================================== 612 | 613 | /** internal helper method 614 | */ 615 | private def genoptcmd(cmd: String): Option[String] = { 616 | executeAndTrim("""%s 2>/dev/null""".format(cmd)) match { 617 | case "" => None 618 | case str => Some(str) 619 | } 620 | } 621 | 622 | /** Generic test usage 623 | */ 624 | private def testFile(testopt: String, filename: String): Boolean = { 625 | val cmd = """test %s "%s" ; echo $?""".format(testopt, filename) 626 | executeAndTrim(cmd).toInt == 0 627 | } 628 | 629 | } 630 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/TransfertOperations.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import java.io.File 4 | import java.io.{OutputStream,FileOutputStream} 5 | 6 | /** 7 | * TransfertOperations defines generic data transfer operations over SCP or SFTP 8 | */ 9 | trait TransfertOperations extends CommonOperations { 10 | /** 11 | * get remote file content as an optional String 12 | * @param remoteFilename file content to get 13 | * @return Some content or None if file was not found 14 | */ 15 | def get(remoteFilename: String): Option[String] 16 | 17 | /** 18 | * get remote file content as an optional bytes array 19 | * @param remoteFilename file content to get 20 | * @return Some content or None if file was not found 21 | */ 22 | def getBytes(remoteFilename: String): Option[Array[Byte]] 23 | 24 | /** 25 | * Copy a remote file to a local one 26 | * @param remoteFilename Source file name (on remote system) 27 | * @param outputStream Destination stream (local system) 28 | */ 29 | def receive(remoteFilename: String, outputStream: OutputStream):Unit 30 | 31 | /** 32 | * Copy a remote file to a local one 33 | * @param remoteFilename Source file name (on remote system) 34 | * @param toLocalFile Destination file (local system) 35 | */ 36 | def receive(remoteFilename: String, toLocalFile: File):Unit = { 37 | receive(remoteFilename, new FileOutputStream(toLocalFile)) 38 | } 39 | 40 | /** 41 | * Copy a remote file to a local one 42 | * @param remoteFilename Source file name (on remote system) 43 | * @param localFilename Destination file name (local system) 44 | */ 45 | def receive(remoteFilename: String, localFilename: String):Unit = { 46 | receive(remoteFilename, new File(localFilename)) 47 | } 48 | 49 | /** 50 | * Copy and compress (if required) a remote file to a local one 51 | * @param remoteFilename Source file name (on remote system) 52 | * @param localFilename Destination filename (without compressed extension) 53 | * @return local file used 54 | */ 55 | def receiveNcompress(remoteFilename:String, localFilename:String):File = { 56 | val dest = new File(localFilename) 57 | if (dest.isDirectory()) { 58 | val basename = remoteFilename.split("/+").last 59 | receiveNcompress(remoteFilename, dest, basename) 60 | } else { 61 | val destdir = Option(dest.getParentFile()).filter(_.exists()).getOrElse(new File(".")) 62 | val basename = dest.getName() 63 | receiveNcompress(remoteFilename, destdir, basename) 64 | } 65 | } 66 | /** 67 | * Copy and compress (if required) a remote file to a local one 68 | * @param remoteFilename Source file name (on remote system) 69 | * @param localDirectory Destination directory 70 | * @param localFilename Destination file name (local system), compressed extension may be added to it 71 | * @return local file used 72 | */ 73 | def receiveNcompress(remoteFilename:String, localDirectory:File, localBasename:String):File = { 74 | val (outputStream, localFile) = if (compressedCheck(remoteFilename).isDefined) { 75 | //val destfilename = remoteFilename.split("/+").last 76 | val local = new File(localDirectory, localBasename) 77 | val output = new FileOutputStream(local) 78 | (output, local) 79 | } else { 80 | import org.apache.commons.compress.compressors.CompressorStreamFactory 81 | val destfilename = if (localBasename.endsWith(".gz")) localBasename else localBasename+".gz" 82 | val local = new File(localDirectory, destfilename) 83 | val output = new FileOutputStream(local) 84 | val compressedOutput = new CompressorStreamFactory().createCompressorOutputStream(CompressorStreamFactory.GZIP, output) 85 | (compressedOutput, local) 86 | } 87 | receive(remoteFilename, outputStream) 88 | localFile 89 | } 90 | private def compressedCheck(filename: String):Option[String] = { 91 | val GZ = """.*[.]gz$""".r 92 | val XZ = """.*[.]xz$""".r 93 | val BZ = """.*[.](?:(?:bz2)|(?:bzip2))""".r 94 | filename.toLowerCase match { 95 | case GZ() => Some("gz") 96 | case BZ() => Some("bz") 97 | case XZ() => Some("xz") 98 | case _ => None 99 | } 100 | } 101 | 102 | /** 103 | * Copy a remote file to a local one using the same filename 104 | * @param filename file name 105 | */ 106 | def receive(filename: String):Unit = { 107 | receive(filename, new File(filename)) 108 | } 109 | 110 | /** 111 | * upload string content to a remote file, if file already exists, it is overwritten 112 | * @param data content to upload in the remote file 113 | * @param remoteDestination remote destination 114 | */ 115 | def put(data: String, remoteDestination: String):Unit 116 | 117 | /** 118 | * upload bytes array content to a remote file, if file already exists, it is overwritten 119 | * @param data content to upload in the remote file 120 | * @param remoteDestination remote destination 121 | */ 122 | def putBytes(data: Array[Byte], remoteDestination: String):Unit 123 | 124 | /** 125 | * upload bytes coming from the input stream to a remote file, if file already exists, it is overwritten 126 | * @param data input stream 127 | * @param howmany how much data to write to remote destination 128 | * @param remoteDestination remote destination 129 | */ 130 | def putFromStream(data: java.io.InputStream, howmany:Int, remoteDestination: String):Unit 131 | 132 | /** 133 | * Copy a local file to a remote one 134 | * @param fromLocalFilename Source file name (local system) 135 | * @param remoteDestination Destination file name (on remote system) 136 | */ 137 | def send(fromLocalFilename: String, remoteDestination: String):Unit = { 138 | send(new File(fromLocalFilename), remoteDestination) 139 | } 140 | 141 | /** 142 | * Copy a local file to a remote one using the same name 143 | * @param filename file name 144 | */ 145 | def send(filename: String):Unit = { 146 | send(new File(filename), filename) 147 | } 148 | 149 | /** 150 | * Copy a local file to a remote one 151 | * @param fromLocalFile Source file (local system) 152 | * @param remoteDestination Destination file name (on remote system) 153 | */ 154 | def send(fromLocalFile: File, remoteDestination: String):Unit 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/main/scala/fr/janalyse/ssh/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse 18 | 19 | package object ssh { 20 | //implicit def stringToCommand(cmd: String) = new SSHCommand(cmd) 21 | //implicit def stringListToBatchList(cmdList: Iterable[String]) = new SSHBatch(cmdList) 22 | //implicit def stringToRemoteFile(filename: String) = new SSHRemoteFile(filename) 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/jassh/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package object jassh { 18 | type SSH = fr.janalyse.ssh.SSH 19 | val SSH = fr.janalyse.ssh.SSH 20 | type SSHOptions = fr.janalyse.ssh.SSHOptions 21 | val SSHOptions = fr.janalyse.ssh.SSHOptions 22 | //implicit def stringToCommand(cmd: String) = new fr.janalyse.ssh.SSHCommand(cmd) 23 | //implicit def stringListToBatchList(cmdList: Iterable[String]) = new fr.janalyse.ssh.SSHBatch(cmdList) 24 | //implicit def stringToRemoteFile(filename: String) = new fr.janalyse.ssh.SSHRemoteFile(filename) 25 | } 26 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | %-5level - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/test/resources/setup_travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Create test user 4 | sudo useradd -p `perl -e "print(crypt('testtest', 'AB'));"` test 5 | 6 | # Install ssh 7 | sudo apt-get update -qq 8 | #sudo apt-get install -qq libssh2-1-dev openssh-client openssh-server 9 | sudo apt-get install -qq openssh-client openssh-server 10 | 11 | sudo /etc/init.d/ssh start 12 | 13 | # Generate and Register keys 14 | ssh-keygen -t rsa -f ~/.ssh/id_rsa -N "" -q 15 | cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys 16 | ssh-keyscan -t rsa 127.0.0.1 >> ~/.ssh/known_hosts 17 | ssh-keyscan -t rsa localhost >> ~/.ssh/known_hosts 18 | sudo cp src/test/resources/sshconfig ~/.ssh/config 19 | sudo chmod 644 ~/.ssh/config 20 | 21 | sudo mkdir -p ~/../test/.ssh 22 | sudo cp ~/.ssh/id_rsa.pub ~/../test/.ssh/authorized_keys 23 | sudo cp ~/.ssh/known_hosts ~/../test/.ssh/ 24 | sudo chown -R test:test ~/../test 25 | sudo chmod 644 ~/../test/.ssh/* 26 | sudo chmod 755 ~/../test/.ssh 27 | 28 | sudo restart ssh 29 | -------------------------------------------------------------------------------- /src/test/resources/sshconfig: -------------------------------------------------------------------------------- 1 | Host localhost 2 | HostName localhost 3 | IdentityFile ~/.ssh/id_rsa 4 | User test 5 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/BecomeTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 18 | package fr.janalyse.ssh 19 | 20 | class BecomeTest extends SomeHelp { 21 | 22 | ignore("'sudo su -' no password test") { 23 | SSH.shell(sshopts) {sh => 24 | sh.sudoSuMinusOnlyWithoutPasswordTest() should equal(false) 25 | } 26 | } 27 | 28 | ignore("'su - -c acommand' test") { 29 | import util.Properties.{userName=>user} 30 | val opts = SSHOptions("127.0.0.1", username=user, timeout=10000) 31 | SSH.shell(opts) {sh => 32 | sh.sudoSuMinusWithCommandTest() should equal(false) 33 | } 34 | } 35 | 36 | ignore("become tests") { 37 | import util.Properties.{userName=>user} 38 | info("For this test to success, you must allow current user to be able ") 39 | info(" to log on localhost without password authentication") 40 | info("(use ssh-keygen to generate your ssh keys, if not already done)") 41 | info("cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys") 42 | info("ssh localhost # Should not ask you a password") 43 | 44 | val opts = SSHOptions("127.0.0.1", username=user, timeout=10000) 45 | SSH.shell(opts) {sh => 46 | sh.become("test", Some("testtest")) should equal(true) 47 | sh.whoami should equal("test") 48 | } 49 | } 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/CatTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import scala.io.Source 20 | import scala.util.Properties 21 | import java.io.File 22 | import java.io.IOException 23 | import org.scalatest.OptionValues._ 24 | 25 | class CatTest extends SomeHelp { 26 | 27 | //========================================================================================================== 28 | test("catData test") { 29 | SSH.shell(sshopts) { sh => 30 | import sh._ 31 | rm("checkthat") 32 | catData("hello\nworld", "checkthat") 33 | cat("checkthat").trim should equal("hello\nworld") 34 | } 35 | } 36 | 37 | 38 | 39 | } 40 | 41 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/CompressedTransfertTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import java.io.File 20 | import org.scalatest.OptionValues._ 21 | import scala.io.Source 22 | 23 | class CompressedTransfertTest extends SomeHelp { 24 | 25 | info(s"Those tests require to have a user named '${sshopts.username}' with password '${sshopts.password}' on ${sshopts.host}") 26 | 27 | test("simple") { 28 | val content = "Hello world" 29 | val testedfile = "testme-tobecompressed.txt" 30 | val gztestedfile = testedfile + ".gz" 31 | val gztestedfileMD5 = "38570c70a362855368dd8c5f25a157f7" 32 | 33 | SSH.ftp(sshopts) { _.put(content, testedfile) } 34 | SSH.ftp(sshopts) { _.get(testedfile) } should equal(Some(content)) 35 | def doclean = { 36 | new File(testedfile).delete() 37 | new File(gztestedfile).delete() 38 | } 39 | // Now let's test the compressed feature 40 | doclean 41 | SSH.once(sshopts) { ssh => 42 | ssh.receive(testedfile, testedfile) 43 | Source.fromFile(testedfile).mkString should equal(content) 44 | ssh.receiveNcompress(testedfile, testedfile) 45 | new File(gztestedfile).exists should equal(true) 46 | ssh.localmd5sum(gztestedfile) should equal(Some(gztestedfileMD5)) 47 | } 48 | doclean 49 | SSH.shellAndFtp(sshopts) { (_, ftp) => 50 | ftp.receive(testedfile, testedfile) 51 | Source.fromFile(testedfile).mkString should equal(content) 52 | ftp.receiveNcompress(testedfile, testedfile) 53 | new File(gztestedfile).exists should equal(true) 54 | ftp.localmd5sum(gztestedfile) should equal(Some(gztestedfileMD5)) 55 | } 56 | doclean 57 | SSH.ftp(sshopts) { ftp => 58 | ftp.receive(testedfile, testedfile) 59 | Source.fromFile(testedfile).mkString should equal(content) 60 | ftp.receiveNcompress(testedfile, testedfile) 61 | new File(gztestedfile).exists should equal(true) 62 | ftp.localmd5sum(gztestedfile) should equal(Some(gztestedfileMD5)) 63 | } 64 | doclean 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/ExpectTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | class ExpectTest extends SomeHelp { 20 | 21 | ignore("expects test") { 22 | SSH.shell(sshopts) { sh => 23 | import sh._ 24 | val expect = Expect(_.endsWith("prompt="), "hello\n") 25 | executeWithExpects("""echo -n 'prompt='; read""",expect::Nil) should equal( ("prompt=hello\n",0) ) 26 | } 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/ExternalSSHAPITest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh.external 18 | 19 | class ExternalSSHAPITest extends fr.janalyse.ssh.SomeHelp { 20 | 21 | info(s"Those tests require to have a user named '${sshopts.username}' with password '${sshopts.password}' on ${sshopts.host}") 22 | 23 | // ------------------------------------------------------------------- 24 | // -- With a global import 25 | { 26 | import jassh._ 27 | 28 | test("Hello 1") { 29 | SSH.once(sshopts) { 30 | _.executeAndTrim("echo 'hello'") 31 | } should equal("hello") 32 | } 33 | 34 | test("Hello 2") { 35 | SSH.shell(sshopts) { 36 | _.executeAndTrim("echo 'hello'") 37 | } should equal("hello") 38 | } 39 | 40 | test("Hello 3") { 41 | import sshopts.{host, username => user, password => pass} 42 | SSH.shell(host, user, password = pass) { 43 | _.executeAndTrim("echo 'hello'") 44 | } should equal("hello") 45 | } 46 | } 47 | 48 | 49 | // ------------------------------------------------------------------- 50 | // -- Without any jassh imports 51 | { 52 | test("Hello 4") { 53 | fr.janalyse.ssh.SSH.once(sshopts) { 54 | _.executeAllAndTrim(List("echo 'hello'")) 55 | } should equal(List("hello")) 56 | } 57 | test("Hello 5") { 58 | jassh.SSH.once(sshopts) { 59 | _.executeAllAndTrim(List("echo 'hello'")) 60 | } should equal(List("hello")) 61 | } 62 | } 63 | 64 | 65 | } 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/SSHAPITest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import scala.io.Source 20 | import scala.util.Properties 21 | import java.io.File 22 | import java.io.IOException 23 | import org.scalatest.OptionValues._ 24 | 25 | import scala.collection.parallel.immutable.ParVector 26 | 27 | class SSHAPITest extends SomeHelp { 28 | 29 | //========================================================================================================== 30 | test("One line exec with automatic resource close") { 31 | SSH.once(sshopts) { _.execute("expr 1 + 1").trim } should equal("2") 32 | SSH.once(sshopts) { _.executeAndTrim("expr 1 + 1") } should equal("2") 33 | //SSH.once(sshopts) { _.execute("echo 1" :: "echo 2" :: Nil) }.map(_.trim) should equal("1" :: "2" :: Nil) 34 | val year = SSH.once(sshopts) { _.executeAndTrim("expr 1 + 10").toInt } 35 | year should equal(11) 36 | } 37 | //========================================================================================================== 38 | test("Execution & file transferts within the same ssh session") { 39 | SSH.once(sshopts) { ssh => 40 | val rfile = "HelloWorld.txt" 41 | val lfile = "/tmp/sshtest.txt" 42 | def clean = { 43 | f(rfile).delete 44 | f(lfile).delete 45 | } 46 | clean 47 | 48 | val msg = ssh.execute("echo -n 'Hello %s'".format(util.Properties.userName)) 49 | 50 | ssh.put(msg, rfile) 51 | 52 | (ssh get rfile) should equal(Some(msg)) 53 | 54 | ssh.receive(rfile, lfile) 55 | 56 | Source.fromFile(lfile).getLines().next() should equal(msg) 57 | } 58 | } 59 | 60 | //========================================================================================================== 61 | test("Execution & file transferts within the same sh & ftp persisted session") { 62 | SSH.shellAndFtp(sshopts) { (sh, ftp) => 63 | val rfile = "HelloWorld.txt" 64 | val lfile = "/tmp/sshtest.txt" 65 | def clean = { 66 | f(rfile).delete 67 | f(lfile).delete 68 | } 69 | clean 70 | 71 | val msg = sh.execute("echo -n 'Hello %s'".format(util.Properties.userName)) 72 | 73 | ftp.put(msg, rfile) 74 | 75 | (ftp get rfile) should equal(Some(msg)) 76 | 77 | ftp.receive(rfile, lfile) 78 | 79 | Source.fromFile(lfile).getLines().next() should equal(msg) 80 | } 81 | } 82 | 83 | //========================================================================================================== 84 | test("shell coherency check") { 85 | SSH.shell(sshopts) { sh => 86 | (1 to 100) foreach { i => 87 | sh.executeAndTrim("echo ta" + i) should equal("ta" + i) 88 | sh.executeAndTrim("echo ga" + i) should equal("ga" + i) 89 | } 90 | } 91 | } 92 | 93 | //========================================================================================================== 94 | ignore("shell coherency check with long command lines (in //)") { 95 | SSH.once(sshopts) { ssh => 96 | (ParVector()++(1 to 10)) foreach { i => 97 | ssh.shell { sh => 98 | def mkmsg(base: String) = base * 100 + i 99 | sh.executeAndTrim("echo %s".format(mkmsg("Z"))) shouldBe mkmsg("Z") 100 | sh.executeAndTrim("echo %s".format(mkmsg("ga"))) shouldBe mkmsg("ga") 101 | sh.executeAndTrim("echo %s".format(mkmsg("PXY"))) shouldBe mkmsg("PXY") 102 | sh.executeAndTrim("echo %s".format(mkmsg("GLoups"))) shouldBe mkmsg("GLoups") 103 | } 104 | } 105 | } 106 | } 107 | //========================================================================================================== 108 | test("SSHShell : Bad performances obtained without persistent schell ssh channel (autoclose)") { 109 | val howmany = 200 110 | for { 111 | (opts, comment) <- (sshopts, "") :: (sshopts.copy(execWithPty = true), "with VTY") :: Nil 112 | } { 113 | SSH.once(opts) { ssh => 114 | val (dur, _) = howLongFor { 115 | for (i <- 1 to howmany) { ssh.shell(_.execute("ls -d /tmp && echo 'done'")) } 116 | } 117 | val throughput = howmany.doubleValue() / dur * 1000 118 | info(f"Performance using shell without channel persistency : $throughput%.1f cmd/s $comment") 119 | } 120 | } 121 | } 122 | //========================================================================================================== 123 | test("SSHShell : Best performance is achieved with mutiple command within the same shell channel (autoclose)") { 124 | val howmany = 5000 125 | for { 126 | (opts, comment) <- (sshopts, "") :: (sshopts.copy(execWithPty = true), "with VTY") :: Nil 127 | } { 128 | SSH.once(opts) { 129 | _.shell { sh => 130 | val (dur, _) = howLongFor { 131 | for (i <- 1 to howmany) { 132 | sh.execute("ls -d /tmp && echo 'done'") 133 | } 134 | } 135 | val throughput = howmany.doubleValue() / dur * 1000 136 | info(f"Performance using with channel persistency : $throughput%.1f cmd/s $comment%s") 137 | } 138 | } 139 | } 140 | } 141 | //========================================================================================================== 142 | test("SSHExec : performances obtained using exec ssh channel (no persistency)") { 143 | val howmany = 200 144 | for { 145 | (opts, comment) <- (sshopts, "") :: (sshopts.copy(execWithPty = true), "with VTY") :: Nil 146 | } { 147 | SSH.once(opts) { ssh => 148 | val (dur, _) = howLongFor { 149 | for (i <- 1 to howmany) { ssh execOnce "ls -d /tmp && echo 'done'" } 150 | } 151 | val throughput = howmany.doubleValue() / dur * 1000 152 | info(f"Performance using exec ssh channel (no persistency) : $throughput%.1f cmd/s $comment") 153 | } 154 | } 155 | } 156 | //========================================================================================================== 157 | test("Start a remote process in background") { 158 | import fr.janalyse.ssh.SSH 159 | SSH.once(sshopts) { ssh => 160 | 161 | var x = List.empty[String] 162 | 163 | def receiver(result: ExecResult):Unit= { result match { case ExecPart(d) => x = x :+ d case _ => } } 164 | val executor = ssh.run("for i in 1 2 3 4 5 ; do echo hello$1 ; done", receiver) 165 | 166 | executor.waitForEnd 167 | 168 | x.zipWithIndex map { case (l, i) => info("%d : %s".format(i, l)) } 169 | x.size should equal(5) 170 | } 171 | } 172 | 173 | //========================================================================================================== 174 | test("Usage case example - for tutorial") { 175 | import fr.janalyse.ssh.SSH 176 | SSH.once(sshopts) { ssh => 177 | 178 | val uname = ssh executeAndTrim "uname -a" 179 | val fsstatus = ssh.execute("df -m") 180 | val fmax = ssh get "/etc/lsb-release" // Warning SCP only work with regular file 181 | 182 | ssh.shell { sh => // For higher performances 183 | val hostname = sh.executeAndTrim("hostname") 184 | val files = sh.execute("find /usr/lib/") 185 | } 186 | ssh.ftp { ftp => // For higher performances 187 | val cpuinfo = ftp.get("/proc/cpuinfo") 188 | val meminfo = ftp.get("/proc/meminfo") 189 | } 190 | // output streaming 191 | def receiver(result: ExecResult):Unit = { result match { case ExecPart(m) => info(s"received :$m") case _ => } } 192 | val executor = ssh.run("for i in 1 2 3 ; do echo hello$i ; done", receiver) 193 | executor.waitForEnd 194 | } 195 | } 196 | 197 | //========================================================================================================== 198 | test("Simultaenous SSH operations") { 199 | val started = System.currentTimeMillis() 200 | val cnxinfos = ParVector(sshopts, sshopts, sshopts, sshopts, sshopts) 201 | val sshs = cnxinfos map { SSH(_) } 202 | 203 | //sshs.tasksupport = new ForkJoinTaskSupport(new scala.concurrent.forkjoin.ForkJoinPool(6)) 204 | 205 | val unames = sshs map { ssh => scala.concurrent.blocking {ssh.execute("date; sleep 5") }} 206 | info(unames.mkString("----")) 207 | 208 | (System.currentTimeMillis() - started) should be < (8000L) //(and not 5s * 5 = 25s) 209 | } 210 | 211 | //========================================================================================================== 212 | test("Simplified persistent ssh shell usage") { 213 | SSH.shell(defaultHost, defaultUsername, defaultPassword) { sh => 214 | sh.execute("ls -la") 215 | sh.execute("uname") 216 | } 217 | } 218 | 219 | //========================================================================================================== 220 | test("Simplified persistent ssh shell and ftp usage") { 221 | SSH.shellAndFtp(sshopts) { (sh, ftp) => 222 | sh.execute("ls") 223 | sh.execute("uname") 224 | ftp.get("/proc/stat") 225 | ftp.get("/proc/vmstat") 226 | } 227 | } 228 | 229 | //========================================================================================================== 230 | test("simplified usage with sshOptions as Option") { // TODO - not OK for Darwin 231 | val cnxinfo = Some(sshopts) 232 | val stat = SSH.once(cnxinfo) { _.get("/dev/null") }.flatten 233 | 234 | stat should not equal (None) 235 | 236 | stat.get.size should equal(0) 237 | } 238 | 239 | //========================================================================================================== 240 | test("file transfert performances (with content loaded in memory)") { 241 | val testfile = "test-transfert" 242 | 243 | def withSCP(filename: String, ssh: SSH, howmany: Int, sizeKb: Int):Unit = { 244 | for (_ <- 1 to howmany) 245 | ssh.getBytes(filename).map(_.length) should equal(Some(sizeKb * 1024)) 246 | } 247 | def withSFTP(filename: String, ssh: SSH, howmany: Int, sizeKb: Int):Unit = { 248 | for (_ <- 1 to howmany) 249 | ssh.ftp(_.getBytes(filename)).map(_.length) should equal(Some(sizeKb * 1024)) 250 | } 251 | def withReusedSFTP(filename: String, ssh: SSH, howmany: Int, sizeKb: Int):Unit = { 252 | ssh.ftp { ftp => 253 | for (_ <- 1 to howmany) 254 | ftp.getBytes(filename).map(_.length) should equal(Some(sizeKb * 1024)) 255 | } 256 | } 257 | 258 | def toTest(thattest: (String, SSH, Int, Int) => Unit, 259 | howmany: Int, 260 | sizeKb: Int, 261 | comments: String)(ssh: SSH) = { 262 | ssh.execute("dd count=%d bs=1024 if=/dev/zero of=%s".format(sizeKb, testfile)) 263 | val (d, _) = howLongFor { 264 | thattest(testfile, ssh, howmany, sizeKb) 265 | } 266 | info("Bytes rate : %.1fMb/s %dMb in %.1fs for %d files - %s".format(howmany * sizeKb * 1000L / d / 1024d, sizeKb * howmany / 1024, d / 1000d, howmany, comments)) 267 | } 268 | 269 | val withCipher = sshopts.copy(noneCipher = false) 270 | val noneCipher = sshopts.copy(noneCipher = true) 271 | 272 | SSH.once(withCipher)(toTest(withSCP, 3, 10 * 1024, "byterates using SCP")) 273 | SSH.once(noneCipher)(toTest(withSCP, 3, 10 * 1024, "byterates using SCP (with none cipher)")) 274 | SSH.once(withCipher)(toTest(withSFTP, 3, 10 * 1024, "byterates using SFTP")) 275 | SSH.once(noneCipher)(toTest(withSFTP, 3, 10 * 1024, "byterates using SFTP (with none cipher)")) 276 | SSH.once(withCipher)(toTest(withReusedSFTP, 3, 10 * 1024, "byterates using SFTP (session reused")) 277 | SSH.once(noneCipher)(toTest(withReusedSFTP, 3, 10 * 1024, "byterates using SFTP (session reused, with none cipher)")) 278 | 279 | SSH.once(withCipher)(toTest(withSCP, 100, 1024, "byterates using SCP")) 280 | SSH.once(noneCipher)(toTest(withSCP, 100, 1024, "byterates using SCP (with none cipher)")) 281 | SSH.once(withCipher)(toTest(withSFTP, 100, 1024, "byterates using SFTP")) 282 | SSH.once(noneCipher)(toTest(withSFTP, 100, 1024, "byterates using SFTP (with none cipher)")) 283 | SSH.once(withCipher)(toTest(withReusedSFTP, 100, 1024, "byterates using SFTP (session reused)")) 284 | SSH.once(noneCipher)(toTest(withReusedSFTP, 100, 1024, "byterates using SFTP (session reused, with none cipher)")) 285 | } 286 | 287 | //========================================================================================================== 288 | test("ssh compression") { 289 | val testfile = "test-transfert" 290 | 291 | def withSCP(filename: String, ssh: SSH, howmany: Int, sizeKb: Int):Unit = { 292 | for (_ <- 1 to howmany) 293 | ssh.getBytes(filename).map(_.length) shouldBe Some(sizeKb * 1024) 294 | } 295 | def withSFTP(filename: String, ssh: SSH, howmany: Int, sizeKb: Int):Unit = { 296 | for (_ <- 1 to howmany) 297 | ssh.ftp(_.getBytes(filename)).map(_.length) shouldBe Some(sizeKb * 1024) 298 | } 299 | def withReusedSFTP(filename: String, ssh: SSH, howmany: Int, sizeKb: Int):Unit = { 300 | ssh.ftp { ftp => 301 | for (_ <- 1 to howmany) 302 | ftp.getBytes(filename).map(_.length) shouldBe Some(sizeKb * 1024) 303 | } 304 | } 305 | 306 | def toTest(thattest: (String, SSH, Int, Int) => Unit, 307 | howmany: Int, 308 | sizeKb: Int, 309 | comments: String)(ssh: SSH):Unit = { 310 | ssh.execute("dd count=%d bs=1024 if=/dev/zero of=%s".format(sizeKb, testfile)) 311 | val (d, _) = howLongFor { 312 | thattest(testfile, ssh, howmany, sizeKb) 313 | } 314 | info("Bytes rate : %.1fMb/s %dMb in %.1fs for %d files - %s".format(howmany * sizeKb * 1000L / d / 1024d, sizeKb * howmany / 1024, d / 1000d, howmany, comments)) 315 | } 316 | 317 | val withCompress = sshopts.copy(compress = None) 318 | val noCompress = sshopts.copy(compress = Some(9)) 319 | 320 | SSH.once(withCompress)(toTest(withReusedSFTP, 1, 100 * 1024, "byterates using SFTP (max compression)")) 321 | SSH.once(noCompress)(toTest(withReusedSFTP, 1, 100 * 1024, "byterates using SFTP (no compression)")) 322 | } 323 | 324 | //========================================================================================================== 325 | test("tunneling test remote->local") { 326 | SSH.once(defaultHost, defaultUsername, defaultPassword, port = 22) { ssh1 => 327 | ssh1.remote2Local(22022, defaultHost, 22) 328 | SSH.once(defaultHost, defaultUsername, defaultPassword, port = 22022) { ssh2 => 329 | ssh2.executeAndTrim("echo 'works'") should equal("works") 330 | } 331 | } 332 | } 333 | 334 | //========================================================================================================== 335 | test("tunneling test local->remote") { 336 | SSH.once(defaultHost, defaultUsername, defaultPassword, port = 22) { ssh1 => 337 | ssh1.local2Remote(33033, defaultHost, 22) 338 | SSH.once(defaultHost, defaultUsername, defaultPassword, port = 33033) { ssh2 => 339 | ssh2.executeAndTrim("echo 'works'") should equal("works") 340 | } 341 | } 342 | } 343 | 344 | //========================================================================================================== 345 | test("tunneling test intricated tunnels") { 346 | // From host/port, bring back locally remote fhost/fport to local host using tport. 347 | case class Sub(host: String, port: Int, fhost: String, fport: Int, tport: Int) 348 | 349 | // We simulate bouncing between 9 SSH hosts, using SSH tunnel intrication 350 | // A:22 -> B:22 -> C:22 -> D:22 -> E:22 -> F:22 -> G:22 -> H:22 -> I:22 351 | // All "foreign" hosts become directly accessible using new ssh local ports 352 | // A->10022, B-> 10023, ... Z->10030, so now I (and all others) are direcly accessible from local ssh client host 353 | val intricatedPath = Iterable( 354 | Sub("localhost", 22, "127.0.0.1", 22, 10022), // A 355 | Sub("localhost", 10022, "127.0.0.1", 22, 10023), // B 356 | Sub("localhost", 10023, "127.0.0.1", 22, 10024), // C 357 | Sub("localhost", 10024, "127.0.0.1", 22, 10025), // D 358 | Sub("localhost", 10025, "127.0.0.1", 22, 10026), // E 359 | Sub("localhost", 10026, "127.0.0.1", 22, 10027), // F 360 | Sub("localhost", 10027, "127.0.0.1", 22, 10028), // G 361 | Sub("localhost", 10028, "127.0.0.1", 22, 10029), // H 362 | Sub("localhost", 10029, "127.0.0.1", 22, 10030) // I 363 | ) 364 | 365 | def intricate[T](path: Iterable[Sub], curSSHPort: Int = 22)(proc: (SSH) => T): T = { 366 | path.headOption match { 367 | case Some(curSub) => 368 | SSH.once(curSub.host, defaultUsername, defaultPassword, port = curSub.port) { ssh => 369 | ssh.remote2Local(curSub.tport, curSub.fhost, curSub.fport) 370 | intricate(path.tail, curSub.tport)(proc) 371 | } 372 | case None => 373 | SSH.once("localhost", defaultUsername, defaultPassword, port = curSSHPort) { ssh => 374 | proc(ssh) 375 | } 376 | } 377 | } 378 | 379 | // Build the intricated tunnels and execute a ssh command on the farthest host (I) 380 | val result = intricate(intricatedPath) { ssh => 381 | ssh.executeAndTrim("echo 'Hello intricated world'") 382 | } 383 | 384 | result should equal("Hello intricated world") 385 | } 386 | 387 | //========================================================================================================== 388 | test("remote ssh sessions (ssh tunneling ssh") { 389 | val rssh = SSH(sshopts).remote(sshopts) 390 | rssh.options.port should not equals (22) 391 | 392 | rssh.executeAndTrim("echo 'hello'") should equal("hello") 393 | 394 | rssh.close() 395 | } 396 | //========================================================================================================== 397 | test("SCP/SFTP and special system file") { 398 | SSH.once(sshopts) { ssh => 399 | val r = ssh.get("/dev/null") 400 | r should not equal (None) 401 | r.get should equal("") 402 | } 403 | } 404 | 405 | //========================================================================================================== 406 | test("sharing SSH options...") { 407 | val common = (h: String) => SSHOptions(h, username = "test", password = "testtest") 408 | 409 | SSH.once(common("localhost")) { _.executeAndTrim("echo 'hello'") should equal("hello") } 410 | 411 | } 412 | 413 | //========================================================================================================== 414 | test("env command test") { 415 | SSH.shell(sshopts) { sh => 416 | sh.execute("export ABC=1") 417 | sh.execute("export XYZ=999") 418 | val envmap = sh.env 419 | envmap.keys should (contain("ABC") and contain("XYZ")) 420 | } 421 | } 422 | 423 | //========================================================================================================== 424 | test("exit code tests") { 425 | SSH.once(sshopts) { ssh => 426 | val (_, rc) = ssh.executeWithStatus("(echo toto ; exit 2)") 427 | rc should equal(2) 428 | } 429 | 430 | SSH.shell(sshopts) { sh => 431 | val (_, rc) = sh.executeWithStatus("(echo toto ; exit 3)") 432 | rc should equal(3) 433 | } 434 | } 435 | 436 | //========================================================================================================== 437 | test("fred test") { 438 | SSH.shell(sshopts) { sh => 439 | sh.execute("who") should include("test") 440 | } 441 | } 442 | 443 | } 444 | 445 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/SSHConnectionManagerTest.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import scala.io.Source 4 | import scala.util.Properties 5 | import org.scalatest.OptionValues._ 6 | 7 | 8 | class SSHConnectionManagerTest extends SomeHelp { 9 | 10 | ignore("basic") { 11 | val lh="127.0.0.1" 12 | val aps = List( 13 | AccessPath("test1", SshEndPoint(lh, "test")::Nil), 14 | AccessPath("test2", SshEndPoint(lh, "test")::SshEndPoint(lh, "test")::Nil), 15 | AccessPath("test3", SshEndPoint(lh, "test")::SshEndPoint(lh, "test")::SshEndPoint(lh, "test")::Nil) 16 | //AccessPath("test4", ProxyEndPoint(lh,3128)::SshEndPoint(lh, "test")::Nil), 17 | //AccessPath("test5", ProxyEndPoint(lh,3128)::SshEndPoint(lh, "test")::SshEndPoint(lh, "test")::Nil), 18 | //AccessPath("test6", ProxyEndPoint(lh,3128)::SshEndPoint(lh, "test")::SshEndPoint(lh, "test")::SshEndPoint(lh, "test")::Nil), 19 | ) 20 | val cm = SSHConnectionManager(aps) 21 | 22 | def go: Unit = for { 23 | name <- aps.map(_.name) 24 | } { 25 | cm.shell(name) { _.execute("echo 1").trim should equal("1") } 26 | cm.shell(name) { sh => 27 | val msg = s"OK for $name" 28 | info(s"testing access path $name with message '$msg'") 29 | sh.execute(s"echo '$msg'").trim should equal(msg) 30 | } 31 | } 32 | 33 | info("FIRST") 34 | go 35 | info("SECOND") 36 | go 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/SSHReactTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import org.scalatest.OptionValues._ 20 | 21 | class SSHReactTest extends SomeHelp { 22 | 23 | 24 | ignore("react attempts") { 25 | SSH.once(sshopts) { implicit ssh => 26 | val sh = new SSHReact(timeout=5000L) 27 | 28 | sh.react("echo -n 'age='") 29 | .react("read age") 30 | .onFirst("age=", "32") 31 | .react("echo my age is $age") 32 | .consumeLine(line => false) 33 | 34 | } 35 | } 36 | 37 | } 38 | 39 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/ShellHistoryTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import org.scalatest.OptionValues._ 20 | 21 | class ShellHistoryTest extends SomeHelp { 22 | 23 | test("shell disable history test") { 24 | SSH.shell(sshopts.copy(historize = true)) { sh => 25 | import sh._ 26 | val hfile = ".test_history" 27 | sh.execute(s"HISTFILE=~/$hfile; set -o history") 28 | sh.execute(s"history -w") 29 | if (exists(hfile)) { 30 | val msgBefore = s"shell history before test $now" 31 | val msgAfter = s"shell history after test $now" 32 | sh.execute(s"echo $msgBefore") 33 | sh.execute("history 10 | grep 'shell history'") should include(msgBefore) 34 | disableHistory() 35 | sh.execute(s"echo $msgAfter") 36 | sh.execute("history 10 | grep 'shell history'") should not include (msgBefore) 37 | sh.execute("history 10 | grep 'shell history'") should not include (msgAfter) 38 | } 39 | } 40 | } 41 | 42 | // TODO : improvements to be done within shell engine 43 | test("shell history test") { 44 | SSH.shell(sshopts.copy(historize = true)) { sh => 45 | import sh._ 46 | sh.execute("history 10") 47 | whoami should equal(sshopts.username) 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/ShellOperationsTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import org.scalatest.OptionValues._ 20 | 21 | class ShellOperationsTest extends SomeHelp { 22 | 23 | //========================================================================================================== 24 | test("helper methods") { 25 | val testfile="sshapitest.dummy" 26 | val testdir="sshapitest-dummydir" 27 | val started = now 28 | 29 | SSH.shell(sshopts) {sh => 30 | 31 | // create a dummy file and dummy directory 32 | sh.execute("echo -n 'toto' > %s".format(testfile)) 33 | 34 | val homedir = sh.executeAndTrim("pwd") 35 | val rhostname = sh.executeAndTrim("hostname") 36 | 37 | // now tests the utilities methods 38 | import sh._ 39 | mkdir(testdir) should equal(true) 40 | uname.toLowerCase should (equal("linux") or equal("darwin") or equal("aix") or equal("sunos")) 41 | osname should (equal("linux") or equal("darwin") or equal("aix") or equal("sunos")) 42 | whoami should equal(sshopts.username) 43 | osid should (equal(Linux) or equal(Darwin) or equal(AIX) or equal(SunOS)) 44 | id should include("test") 45 | arch should not be empty 46 | env.size should be > (0) 47 | hostname should equal(rhostname) 48 | fileSize(testfile) should equal(Some(4)) 49 | md5sum(testfile) should equal(Some("f71dbe52628a3f83a77ab494817525c6")) 50 | md5sum(testfile) should equal(Some(SSHTools.md5sum("toto"))) 51 | sha1sum(testfile) should equal(Some("0b9c2625dc21ef05f6ad4ddf47c5f203837aa32c")) 52 | ls should contain(testfile) 53 | cd(testdir) 54 | pwd should equal(homedir+"/"+testdir) 55 | cd 56 | pwd should equal(homedir) 57 | sh.test("1 = 1") should equal(true) 58 | sh.test("1 = 2") should equal(false) 59 | isFile(testfile) should equal(true) 60 | isDirectory(testfile) should equal(false) 61 | exists(testfile) should equal(true) 62 | exists(testdir) should equal(true) 63 | isExecutable(testfile) should equal(false) 64 | findAfterDate(".", started).size should (be >=(1) and be <=(3)) // because of .bash_history 65 | val reftime = now.getTime 66 | date().getTime should (be>(reftime-5000) and be<(reftime+5000)) 67 | fsFreeSpace("/tmp") shouldBe defined 68 | fileRights("/tmp") shouldBe defined 69 | ps().filter(_.cmdline contains "java").size should be >(0) 70 | du("/bin").value should be >(0L) 71 | cat(testfile) should include("toto") 72 | rm(testfile) 73 | notExists(testfile) should equal(true) 74 | rmdir(testdir) should equal(true) 75 | mkcd(testdir) should equal(true) 76 | basename(pwd) should equal(testdir) 77 | dirname(pwd) should equal(homedir) 78 | touch("hello") 79 | exists("hello") should equal(true) 80 | rm("hello") 81 | cd("..") 82 | rmdir(testdir) should equal(true) 83 | notExists(testdir) should equal(true) 84 | echo("hello") should equal("hello\n") 85 | alive() should equal(true) 86 | which("ls").value should endWith("bin/ls") 87 | dirname(which("ls").value) should endWith("/bin") 88 | } 89 | } 90 | 91 | test("shell disable history test") { 92 | SSH.shell(sshopts.copy(historize=true)) {sh => 93 | import sh._ 94 | val hfile = ".test_history" 95 | sh.execute(s"HISTFILE=~/$hfile; set -o history") 96 | sh.execute(s"history -w") 97 | if (exists(hfile)) { 98 | val msgBefore = s"shell history before test $now" 99 | val msgAfter = s"shell history after test $now" 100 | sh.execute(s"echo $msgBefore") 101 | sh.execute("history | grep 'shell history'") should include(msgBefore) 102 | disableHistory() 103 | sh.execute(s"echo $msgAfter") 104 | sh.execute("history | grep 'shell history'") should not include(msgBefore) 105 | sh.execute("history | grep 'shell history'") should not include(msgAfter) 106 | } 107 | } 108 | } 109 | 110 | // TODO : improvements to be done within shell engine 111 | test("shell history test") { 112 | SSH.shell(sshopts.copy(historize=true)) {sh => 113 | import sh._ 114 | sh.execute("history") 115 | whoami should equal(sshopts.username) 116 | } 117 | } 118 | 119 | 120 | // TODO : something wrong is happening with travis test platform 121 | ignore("last modified tests") { 122 | val testfile="sshapitestZZ.dummy" 123 | SSH.shell(sshopts) {sh => 124 | import sh._ 125 | // create a dummy file and dummy directory 126 | sh.execute("echo -n 'toto' > %s".format(testfile)) 127 | val testfilereftime = now.getTime 128 | 129 | val lm = lastModified(testfile).map(_.getTime) 130 | lm.value should (be>(testfilereftime-5000) and be<(testfilereftime+5000)) 131 | 132 | rm(testfile) 133 | } 134 | } 135 | 136 | 137 | test("more ls and mkdir tests") { 138 | SSH.shell(sshopts) {sh => 139 | import sh._ 140 | sh.execute("rm -fr ~/truc ~/machin") 141 | 142 | mkdir("truc") should equal(true) 143 | ls("truc").size should equal(0) 144 | rmdir("truc"::Nil) should equal(true) 145 | mkcd("machin") should equal(true) 146 | pwd.split("/").last should equal("machin") 147 | cd("..") 148 | rmdir("machin") should equal(true) 149 | } 150 | } 151 | 152 | 153 | } 154 | 155 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/SmallTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014-2015 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import scala.io.Source 20 | import scala.util.Properties 21 | import java.io.File 22 | import java.io.IOException 23 | import org.scalatest.OptionValues._ 24 | 25 | class SmallTest extends SomeHelp { 26 | 27 | //========================================================================================================== 28 | test("very small test") { 29 | SSH.once(sshopts) { _.execute("echo hello").trim } should equal("hello") 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/SomeHelp.scala: -------------------------------------------------------------------------------- 1 | package fr.janalyse.ssh 2 | 3 | import org.scalatest.funsuite._ 4 | import org.scalatest.matchers.should 5 | 6 | trait SomeHelp extends AnyFunSuite with should.Matchers { 7 | val defaultUsername = "test" 8 | val defaultPassword = "testtest" 9 | val defaultHost = "127.0.0.1" 10 | val sshopts = SSHOptions(defaultHost, username = defaultUsername, password = defaultPassword) 11 | 12 | info(s"Those tests require to have a user named '${sshopts.username}' with password '${sshopts.password}' on ${sshopts.host}") 13 | 14 | def f(filename: String) = new java.io.File(filename) 15 | 16 | def now = new java.util.Date() 17 | 18 | def howLongFor[T](what: => T): (Long, T) = { 19 | val begin = System.currentTimeMillis() 20 | val result = what 21 | val end = System.currentTimeMillis() 22 | (end - begin, result) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/StabilityTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014-2015 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package fr.janalyse.ssh 18 | 19 | import scala.io.Source 20 | import scala.util.Properties 21 | import java.io.File 22 | import java.io.IOException 23 | import org.scalatest.OptionValues._ 24 | 25 | class StabilityTest extends SomeHelp { 26 | 27 | //========================================================================================================== 28 | test("stability test") { 29 | info("Will fail with OpenSSH_6.9p1-hpn14v5, OpenSSL 1.0.1p 9 Jul 2015 (gentoo kernel 4.0.5)") 30 | info("Will fail with OpenSSH_6.9p1-hpn14v5, OpenSSL 1.0.2d 9 Jul 2015 (gentoo kernel 4.0.5)") 31 | info("Won't fail with OpenSSH_6.2p2, OSSLShim 0.9.8r 8 Dec 2011 (Mac OS X 10.10.5)") 32 | val max = 200 33 | var failed = 0 34 | val sshopts = SSHOptions("127.0.0.1", username = "test", password = "testtest") 35 | for { x <- 1 to max } { 36 | try { 37 | SSH.once(sshopts) { _.execute("true") } 38 | } catch { 39 | case ex: Exception => 40 | info(s"Failed on '$ex' for #$x / $max") 41 | failed += 1 42 | } 43 | } 44 | info(s"Failed $failed times for $max attempts (${(failed * 100d / max).toInt}%)") 45 | failed should equal(0) 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /src/test/scala/fr/janalyse/ssh/TimeoutTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 David Crosson 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 18 | package fr.janalyse.ssh 19 | 20 | class TimeoutTest extends SomeHelp { 21 | 22 | info(s"Those tests require to have a user named '${sshopts.username}' with password '${sshopts.password}' on ${sshopts.host}") 23 | 24 | test("timeout tests") { // TODO : not working, make timeout possible with too long running remote command; (^C is already possible)!! 25 | val opts = sshopts.copy(timeout=7000, connectTimeout=2000) 26 | SSH.once(opts) {ssh => 27 | ssh.executeAndTrim("sleep 4; echo 'ok'") should equal("ok") 28 | intercept[SSHTimeoutException] { 29 | ssh.executeAndTrim("sleep 10; echo 'ok'") 30 | } 31 | } 32 | } 33 | 34 | test("timeout tests with shell SSH session") { // TODO : not working, make timeout possible with too long running remote command; (^C is already possible)!! 35 | val opts = sshopts.copy(timeout=7000, connectTimeout=2000) 36 | SSH.shell(opts) {sh => 37 | sh.executeAndTrim("sleep 4; echo 'ok'") should equal("ok") 38 | intercept[SSHTimeoutException] { 39 | sh.executeAndTrim("sleep 10; echo 'ok'") 40 | } 41 | sh.executeAndTrim("echo 'good'") should equal("good") 42 | } 43 | } 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / version := "1.1.2-SNAPSHOT" 2 | --------------------------------------------------------------------------------