├── .gitignore ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── README.rst ├── build.sbt ├── lib └── shikhar-sshj-v0.6.1-0-g42dddc7.zip ├── notes ├── 0.5.0.markdown ├── 0.6.0.markdown ├── 0.6.2.markdown ├── 0.6.3.markdown └── about.markdown ├── project ├── build.properties └── plugins.sbt └── src ├── main └── scala │ └── com │ └── decodified │ └── scalassh │ ├── Command.scala │ ├── HostConfig.scala │ ├── PasswordProducer.scala │ ├── SSH.scala │ ├── ScpTransferable.scala │ ├── SshClient.scala │ ├── SshLogin.scala │ ├── StreamCopier.scala │ └── package.scala └── test ├── resources ├── agent.com ├── enc-keyfile.com ├── illegal-line.com ├── invalid-login-type.com ├── keyfile.com ├── logback.xml ├── missing-user.com ├── password.com └── setup_travis.sh └── scala └── com └── decodified └── scalassh ├── HostFileConfigSpec.scala └── SshClientSpec.scala /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | project/boot/ 3 | target/ 4 | lib_managed/ 5 | src_managed/ 6 | test-output/ 7 | *.iml -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | scala: 4 | - 2.10.4 5 | - 2.11.2 6 | 7 | jdk: 8 | - openjdk7 9 | - oraclejdk7 10 | 11 | before_script: 12 | - sh src/test/resources/setup_travis.sh 13 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Version 0.8.0 (2016-06-15) 2 | -------------------------- 3 | - Added support for SSH agent login (thx to Laurent Comparet) 4 | - Fixed connection hang after SCP upload/download (thx to Tomasz) 5 | - Upgraded to sshj 0.16.0, bcprov 1.54, jsch.agentproxy 0.0.9, jzlib 1.1.3 and slf4j 1.7.21 6 | - Changed package to com.veact 7 | 8 | Version 0.7.0 (2014-10-15) 9 | -------------------------- 10 | - Upgraded to sshj 0.10.0 and slf4j 1.7.7 11 | - Cross-published against Scala 2.10.4 and Scala 2.11.2 12 | - Dropped support for Scala 2.9 13 | - Fixed possible RuntimeException in HostConfig (thx to HairyFotr) 14 | - Fixed potential logging of user password (thx to Joseph Price) 15 | - Added SFTP and SCP capabilities (thx to Philip Cali) 16 | - Added PTY configuration for SSH sessions (thx to Dan Osipov) 17 | 18 | 19 | Version 0.6.4 (2013-07-23) 20 | -------------------------- 21 | - Upgraded to sshj 0.9.0 and slf4j 1.7.5 22 | - Cross-published against Scala 2.9.3 and Scala 2.10.2 23 | 24 | 25 | Version 0.6.3 (2012-10-26) 26 | -------------------------- 27 | - Upgraded to sshj 0.8.1 and slf4j 1.7.2 28 | - Cross-published against Scala 2.9.2 and Scala 2.10.0-RC1 29 | - Added support for `fingerprint = any` host config setting 30 | 31 | 32 | Version 0.6.2 (2012-06-20) 33 | -------------------------- 34 | - Added support for configuring groups of hosts with one host file or resource 35 | - Added resolution of '~' in keyfile location 36 | 37 | 38 | Version 0.6.0 (2012-05-30) 39 | -------------------------- 40 | - Upgraded to 41 | - sshj 0.8.0 42 | - jzlib 1.1.1 43 | - Added option for loading the private key file from the classpath 44 | - Disabled cross-path publishing 45 | 46 | 47 | Version 0.5.0 (2012-02-06) 48 | -------------------------- 49 | first public release 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **scala-ssh** is a Scala_ library providing remote shell access via SSH. 2 | It builds on SSHJ_ to provide the following features: 3 | 4 | * Remote execution of one or more shell commands 5 | * Access to ``stdin``, ``stdout``, ``stderr`` and exitcode of remote shell commands 6 | * Authentication via password, public key or agent 7 | * Host key verification via ``known_hosts`` file or explicit fingerprint 8 | * Convenient configuration of remote host properties via config file, resource or directly in code 9 | * Scala-idiomatic API 10 | 11 | 12 | Installation 13 | ------------ 14 | 15 | The latest release is **0.8.0** and is built against Scala 2.10 and Scala 2.11. 16 | It is available from Maven Central. If you use SBT_ you can pull in the *scala-ssh* artifacts with:: 17 | 18 | libraryDependencies += "com.veact" %% "scala-ssh" % "0.8.0" 19 | 20 | SSHJ_ uses SLF4J_ for logging, so you might want to also add logback_ to your dependencies:: 21 | 22 | libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.1.7" 23 | 24 | Additionally, in many cases you will need the following two artifacts, which provide additional cypher and compression 25 | support:: 26 | 27 | libraryDependencies ++= Seq( 28 | "org.bouncycastle" % "bcprov-jdk16" % "1.54", 29 | "com.jcraft" % "jzlib" % "1.1.3" 30 | ) 31 | 32 | 33 | Usage 34 | ----- 35 | 36 | The highest-level API element provided by *scala-ssh* is the ``SSH`` object. You use it like this:: 37 | 38 | SSH("example.com") { client => 39 | client.exec("ls -a").right.map { result => 40 | println("Result:\n" + result.stdOutAsString()) 41 | } 42 | } 43 | 44 | This establishes an SSH connection to host ``example.com`` and gives you an ``SshClient`` instance that you can use 45 | to execute one or more commands on the host. 46 | ``SSH.apply`` has a second (optional) parameter of type ``HostConfigProvider``, which is essentially a function 47 | returning a ``HostConfig`` instance for a given hostname. A ``HostConfig`` looks like this:: 48 | 49 | case class HostConfig( 50 | login: SshLogin, 51 | hostName: String = "", 52 | port: Int = 22, 53 | connectTimeout: Option[Int] = None, 54 | connectionTimeout: Option[Int] = None, 55 | commandTimeout: Option[Int] = None, 56 | enableCompression: Boolean = false, 57 | hostKeyVerifier: HostKeyVerifier = ..., 58 | sshjConfig: Config = ... 59 | ) 60 | 61 | It provides all the details required for properly establishing an SSH connection. 62 | If you don't provide an explicit ``HostConfigProvider`` the default one will be used. For every hostname you pass to the 63 | ``SSH.apply`` method this default ``HostConfigProvider`` expects a file ``~/.scala-ssh/{hostname}``, which contains the 64 | properties of a ``HostConfig`` in a simple config file format (see below for details). The ``HostResourceConfig`` object 65 | gives you alternative ``HostConfigProvider`` implementations that read the host config from classpath resources. 66 | 67 | If the file ``~/.scala-ssh/{hostname}`` (or the classpath resource ``{hostname}``) doesn't exist *scala-ssh* looks for 68 | more general files (or resources) in the following way: 69 | 70 | 1. As long as the first segment of the host name (up to the first ``.``) contains one or more digits replace the 71 | rightmost of these with ``X`` and look for a respectively named file or resource. Repeat until no digits left. 72 | 2. Drop all characters up to (and including) the first ``.`` from the host name and look for a respectively named file 73 | or resource. 74 | 3. Repeat from 1. as long as there are characters left. 75 | 76 | This means that for a host with name ``node42.tier1.example.com`` the following locations (either under 77 | ``~/.scala-ssh/`` or the classpath, depending on the ``HostConfigProvider``) are tried: 78 | 79 | 1. ``node42.tier1.example.com`` 80 | 2. ``node4X.tier1.example.com`` 81 | 3. ``nodeXX.tier1.example.com`` 82 | 4. ``tier1.example.com`` 83 | 5. ``tierX.example.com`` 84 | 6. ``example.com`` 85 | 7. ``com`` 86 | 87 | 88 | Host Config File Format 89 | ----------------------- 90 | 91 | A host config file is a UTF8-encoded text file containing ``key = value`` pairs, one per line. Blank lines and lines 92 | starting with a ``#`` character are ignored. This is an example file:: 93 | 94 | # simple password-based config 95 | login-type = password 96 | username = bob 97 | password = 123 98 | command-timeout = 5000 99 | enable-compression = yes 100 | 101 | These key are defined: 102 | 103 | login-type 104 | required, can be either ``password`` or ``keyfile`` 105 | 106 | host-name 107 | optional, if not given the name of the config file is assumed to be the hostname 108 | 109 | port 110 | optional, the default value is ``22`` 111 | 112 | username 113 | required 114 | 115 | password 116 | required for login-type ``password``, ignored otherwise 117 | 118 | keyfile 119 | optionally specifies the location of the user keyfile to use with login-type ``keyfile``, 120 | if not given the default files ``~/.ssh/id_rsa`` and ``~/.ssh/id_dsa`` are tried, ignored for login-type ``password``, 121 | if the filename starts with a ``+`` the file is searched in addition to the default locations, if the filename starts 122 | with ``classpath:`` it is interpreted as the name of a classpath resource holding the private key 123 | 124 | passphrase 125 | optionally specifies the passphrase for the keyfile, if not given the keyfile is assumed to be unencrypted, 126 | ignored for login-type ``password`` 127 | 128 | connect-timeout 129 | optionally specifies the number of milli-seconds that a connection request has to succeed in before triggering a 130 | timeout error, default value is 'no timeout' 131 | 132 | connection-timeout 133 | optionally specifies the number of milli-seconds that an idle connection is held open before being closed due due to 134 | idleness, default value is 'no timeout' 135 | 136 | command-timeout 137 | optionally specifies the number of milli-seconds that a pending response to an issued command is waited for before 138 | triggering a timeout error, default value is 'no timeout' 139 | 140 | enable-compression 141 | optionally adds ``zlib`` compression to preferred compression algorithms, there is no guarantee that it will be 142 | successfully negotiatied, requires ``jzlib`` on the classpath (see 'installation' chapter) above, default is 'no' 143 | 144 | fingerprint 145 | optionally specifies the fingerprint of the public host key to verify in standard SSH format 146 | (e.g. ``4b:69:6c:72:6f:79:20:77:61:73:20:68:65:72:65:21``), if not given the standard ``~/.ssh/known_hosts`` or 147 | ``~/.ssh/known_hosts2`` files will be searched for a matching entry, fingerprint verification can be entirely disabled 148 | by setting ``fingerprint = any`` 149 | 150 | Troubleshoting 151 | -------------- 152 | 153 | Java Cryptography Extension Policy Files 154 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 155 | 156 | To use this library it might be neccessary that you install the Java Cryptography Extension Policy 157 | Files from the JDK additional downloads section. Make sure they are installed, especially if you 158 | encounter exceptions like this: 159 | 160 | net.schmizz.sshj.common.SSHRuntimeException: null 161 | at net.schmizz.sshj.common.Buffer.readPublicKey(Buffer.java:432) ~[sshj-0.12.0.jar:na] 162 | at net.schmizz.sshj.transport.kex.AbstractDHG.next(AbstractDHG.java:108) ~[sshj-0.12.0.jar:na] 163 | at net.schmizz.sshj.transport.KeyExchanger.handle(KeyExchanger.java:352) ~[sshj-0.12.0.jar:na] 164 | at net.schmizz.sshj.transport.TransportImpl.handle(TransportImpl.java:487) ~[sshj-0.12.0.jar:na] 165 | at net.schmizz.sshj.transport.Decoder.decode(Decoder.java:107) ~[sshj-0.12.0.jar:na] 166 | at net.schmizz.sshj.transport.Decoder.received(Decoder.java:175) ~[sshj-0.12.0.jar:na] 167 | at net.schmizz.sshj.transport.Reader.run(Reader.java:61) ~[sshj-0.12.0.jar:na] 168 | Caused by: java.security.GeneralSecurityException: java.security.spec.InvalidKeySpecException: key spec not recognised 169 | at net.schmizz.sshj.common.KeyType$3.readPubKeyFromBuffer(KeyType.java:146) ~[sshj-0.12.0.jar:na] 170 | at net.schmizz.sshj.common.Buffer.readPublicKey(Buffer.java:430) ~[sshj-0.12.0.jar:na] 171 | ... 6 common frames omitted 172 | Caused by: java.security.spec.InvalidKeySpecException: key spec not recognised 173 | at org.bouncycastle.jcajce.provider.asymmetric.util.BaseKeyFactorySpi.engineGeneratePublic(Unknown Source) ~[bcprov-jdk15on-1.52.jar:1.52.0] 174 | at org.bouncycastle.jcajce.provider.asymmetric.ec.KeyFactorySpi.engineGeneratePublic(Unknown Source) ~[bcprov-jdk15on-1.52.jar:1.52.0] 175 | at java.security.KeyFactory.generatePublic(KeyFactory.java:334) ~[na:1.8.0_05] 176 | at net.schmizz.sshj.common.KeyType$3.readPubKeyFromBuffer(KeyType.java:144) ~[sshj-0.12.0.jar:na] 177 | ... 7 common frames omitted 178 | 179 | 180 | License 181 | ------- 182 | 183 | *scala-ssh* is licensed under `APL 2.0`_. 184 | 185 | 186 | Patch Policy 187 | ------------ 188 | 189 | Feedback and contributions to the project, no matter what kind, are always very welcome. 190 | However, patches can only be accepted from their original author. 191 | Along with any patches, please state that the patch is your original work and that you license the work to the 192 | *scala-ssh* project under the project’s open source license. 193 | 194 | 195 | .. _Scala: http://www.scala-lang.org/ 196 | .. _sshj: https://github.com/hierynomus/sshj 197 | .. _SBT: https://github.com/harrah/xsbt/wiki 198 | .. _SLF4J: http://www.slf4j.org/ 199 | .. _logback: http://logback.qos.ch/ 200 | .. _APL 2.0: http://www.apache.org/licenses/LICENSE-2.0 201 | 202 | 203 | Credits 204 | ------- 205 | 206 | This project was originally created and maintained by [Mathias Doenitz](https://github.com/sirthias). 207 | 208 | 209 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import scalariform.formatter.preferences._ 2 | 3 | name := "scala-ssh" 4 | 5 | version := "0.8.0" 6 | 7 | organization := "com.veact" 8 | 9 | organizationHomepage := Some(new URL("http://veact.com")) 10 | 11 | description := "A Scala library providing remote shell access via SSH" 12 | 13 | homepage := Some(new URL("https://github.com/veact/scala-ssh")) 14 | 15 | startYear := Some(2011) 16 | 17 | licenses := Seq("Apache 2" -> new URL("http://www.apache.org/licenses/LICENSE-2.0.txt")) 18 | 19 | scalaVersion := "2.11.8" 20 | 21 | scalacOptions ++= Seq("-feature", "-language:implicitConversions", "-unchecked", "-deprecation", "-encoding", "utf8") 22 | 23 | libraryDependencies ++= Seq( 24 | "com.hierynomus" % "sshj" % "0.16.0", 25 | "org.slf4j" % "slf4j-api" % "1.7.21", 26 | "org.bouncycastle" % "bcprov-jdk15on" % "1.54" % "provided", 27 | "com.jcraft" % "jzlib" % "1.1.3" % "provided", 28 | "com.jcraft" % "jsch.agentproxy.sshj" % "0.0.9", 29 | "com.jcraft" % "jsch.agentproxy.connector-factory" % "0.0.9", 30 | "ch.qos.logback" % "logback-classic" % "1.1.7" % "test", 31 | "org.specs2" %% "specs2" % "2.5" % "test") 32 | 33 | resolvers += "Scalaz Bintray Repo" at "http://dl.bintray.com/scalaz/releases" 34 | 35 | scalariformSettings 36 | 37 | ScalariformKeys.preferences := ScalariformKeys.preferences.value 38 | .setPreference(RewriteArrowSymbols, true) 39 | .setPreference(AlignParameters, true) 40 | .setPreference(AlignSingleLineCaseStatements, true) 41 | .setPreference(DoubleIndentClassDeclaration, true) 42 | .setPreference(PreserveDanglingCloseParenthesis, true) 43 | 44 | /////////////// 45 | // publishing 46 | /////////////// 47 | 48 | crossScalaVersions := Seq("2.10.6", "2.11.8") 49 | 50 | publishMavenStyle := true 51 | 52 | publishTo <<= version { v: String => 53 | val nexus = "https://oss.sonatype.org/" 54 | if (v.trim.endsWith("SNAPSHOT")) Some("snapshots" at nexus + "content/repositories/snapshots") 55 | else Some("releases" at nexus + "service/local/staging/deploy/maven2") 56 | } 57 | 58 | pomIncludeRepository := { _ => false } 59 | 60 | pomExtra := 61 | 62 | git@github.com:veact/scala-ssh.git 63 | scm:git:git@github.com:veact/scala-ssh.git 64 | 65 | 66 | 67 | sirthias 68 | Mathias Doenitz 69 | 70 | 71 | laurentco 72 | Laurent Comparet 73 | 74 | 75 | bphelan 76 | Benjamin Phelan 77 | 78 | 79 | -------------------------------------------------------------------------------- /lib/shikhar-sshj-v0.6.1-0-g42dddc7.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/veact/scala-ssh/691449362b4fb0f20bb9982ca26997450d6d0b65/lib/shikhar-sshj-v0.6.1-0-g42dddc7.zip -------------------------------------------------------------------------------- /notes/0.5.0.markdown: -------------------------------------------------------------------------------- 1 | _scala-ssh_ is a Scala library providing remote shell access via SSH. 2 | It builds on [sshj][] to provide the following features: 3 | 4 | * Remote execution of one or more shell commands 5 | * Access to `stdin`, `stdout`, `stderr` and exitcode of remote shell commands 6 | * Authentication via password or public key 7 | * Host key verification via `known_hosts` file or explicit fingerprint 8 | * Convenient configuration of remote host properties via config file, resource or directly in code 9 | * Scala-idiomatic API 10 | 11 | [sshj]: https://github.com/shikhar/sshj 12 | -------------------------------------------------------------------------------- /notes/0.6.0.markdown: -------------------------------------------------------------------------------- 1 | This is a maintenance release. 2 | Changes since 0.5.0: 3 | 4 | - Upgraded to sshj 0.8.0 and jzlib 1.1.1 5 | - Added option for loading the private key file from the classpath 6 | - Disabled cross-path publishing 7 | -------------------------------------------------------------------------------- /notes/0.6.2.markdown: -------------------------------------------------------------------------------- 1 | This is a minor improvements release. 2 | Changes since 0.6.0: 3 | 4 | - Added support for configuring groups of hosts with one host file or resource 5 | - Added resolution of '~' in keyfile location 6 | -------------------------------------------------------------------------------- /notes/0.6.3.markdown: -------------------------------------------------------------------------------- 1 | This is a minor improvements release. 2 | Changes since 0.6.2: 3 | 4 | - Upgraded to sshj 0.8.1 and slf4j 1.7.2 5 | - Cross-published against Scala 2.9.2 and Scala 2.10.0-RC1 6 | - Added support for `fingerprint = any` host config setting 7 | -------------------------------------------------------------------------------- /notes/about.markdown: -------------------------------------------------------------------------------- 1 | [scala-ssh](https://github.com/sirthias/scala-ssh) is a Scala library providing remote shell access via SSH. 2 | It builds on [sshj](https://github.com/shikhar/sshj) to provide the following features: 3 | 4 | * Remote execution of one or more shell commands 5 | * Access to `stdin`, `stdout`, `stderr` and exitcode of remote shell commands 6 | * Authentication via password or public key 7 | * Host key verification via `known_hosts` file or explicit fingerprint 8 | * Convenient configuration of remote host properties via config file, resource or directly in code 9 | * Scala-idiomatic API 10 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.11 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" % "sbt-scalariform" % "1.3.0") 2 | -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/Command.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import net.schmizz.sshj.connection.channel.direct.Session 20 | import java.io.{ FileInputStream, File, ByteArrayInputStream, InputStream } 21 | 22 | case class Command(command: String, input: CommandInput = CommandInput.NoInput, timeout: Option[Int] = None) 23 | 24 | object Command { 25 | implicit def string2Command(cmd: String) = Command(cmd) 26 | } 27 | 28 | case class CommandInput(inputStream: Option[InputStream]) 29 | 30 | object CommandInput { 31 | lazy val NoInput = CommandInput(None) 32 | implicit def apply(input: String, charsetName: String = "UTF8"): CommandInput = apply(input.getBytes(charsetName)) 33 | implicit def apply(input: Array[Byte]): CommandInput = apply(Some(new ByteArrayInputStream(input))) 34 | implicit def apply(input: InputStream): CommandInput = apply(Some(input)) 35 | def fromFile(file: String): CommandInput = fromFile(new File(file)) 36 | def fromFile(file: File): CommandInput = new FileInputStream(file) 37 | def fromResource(resource: String): CommandInput = getClass.getClassLoader.getResourceAsStream(resource) 38 | } 39 | 40 | class CommandResult(val channel: Session.Command) { 41 | def stdErrStream: InputStream = channel.getErrorStream 42 | def stdOutStream: InputStream = channel.getInputStream 43 | lazy val stdErrBytes = new StreamCopier().emptyToByteArray(stdErrStream) 44 | lazy val stdOutBytes = new StreamCopier().emptyToByteArray(stdOutStream) 45 | def stdErrAsString(charsetname: String = "utf8") = new String(stdErrBytes, charsetname) 46 | def stdOutAsString(charsetname: String = "utf8") = new String(stdOutBytes, charsetname) 47 | lazy val exitSignal: Option[String] = Option(channel.getExitSignal).map(_.toString) 48 | lazy val exitCode: Option[Int] = Option(channel.getExitStatus) 49 | lazy val exitErrorMessage: Option[String] = Option(channel.getExitErrorMessage) 50 | } -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/HostConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import net.schmizz.sshj.{ DefaultConfig, Config } 20 | import io.Source 21 | import java.io.{ IOException, File } 22 | import HostKeyVerifiers._ 23 | import java.security.PublicKey 24 | import net.schmizz.sshj.common.SecurityUtils 25 | import net.schmizz.sshj.connection.channel.direct.PTYMode 26 | import net.schmizz.sshj.transport.verification.{ OpenSSHKnownHosts, HostKeyVerifier } 27 | import annotation.tailrec 28 | 29 | trait HostConfigProvider extends (String ⇒ Validated[HostConfig]) 30 | 31 | object HostConfigProvider { 32 | implicit def login2HostConfigProvider(login: SshLogin) = new HostConfigProvider { 33 | def apply(host: String) = Right(HostConfig(login = login, hostName = host)) 34 | } 35 | implicit def hostConfig2HostConfigProvider(config: HostConfig) = new HostConfigProvider { 36 | def apply(host: String) = Right(if (config.hostName.isEmpty) config.copy(hostName = host) else config) 37 | } 38 | } 39 | 40 | case class HostConfig( 41 | login: SshLogin, 42 | hostName: String = "", 43 | port: Int = 22, 44 | connectTimeout: Option[Int] = None, 45 | connectionTimeout: Option[Int] = None, 46 | commandTimeout: Option[Int] = None, 47 | enableCompression: Boolean = false, 48 | hostKeyVerifier: HostKeyVerifier = KnownHosts.right.toOption.getOrElse(DontVerify), 49 | ptyConfig: Option[PTYConfig] = None, 50 | sshjConfig: Config = HostConfig.DefaultSshjConfig) 51 | 52 | case class PTYConfig(term: String, cols: Int, rows: Int, width: Int, height: Int, modes: java.util.Map[PTYMode, Integer]) 53 | 54 | object HostConfig { 55 | lazy val DefaultSshjConfig = new DefaultConfig 56 | } 57 | 58 | abstract class FromStringsHostConfigProvider extends HostConfigProvider { 59 | def rawLines(host: String): Validated[(String, TraversableOnce[String])] 60 | 61 | def apply(host: String) = { 62 | rawLines(host).right.flatMap { 63 | case (source, lines) ⇒ 64 | splitToMap(lines, source).right.flatMap { settings ⇒ 65 | login(settings, source).right.flatMap { login ⇒ 66 | optIntSetting("port", settings, source).right.flatMap { port ⇒ 67 | optIntSetting("connect-timeout", settings, source).right.flatMap { connectTimeout ⇒ 68 | optIntSetting("connection-timeout", settings, source).right.flatMap { connectionTimeout ⇒ 69 | optIntSetting("command-timeout", settings, source).right.flatMap { commandTimeout ⇒ 70 | optBoolSetting("enable-compression", settings, source).right.flatMap { enableCompression ⇒ 71 | setting("fingerprint", settings, source).right.map(forFingerprint).left.flatMap(_ ⇒ KnownHosts).right.map { verifier ⇒ 72 | HostConfig( 73 | login, 74 | hostName = setting("host-name", settings, source).right.toOption.getOrElse(host), 75 | port = port.getOrElse(22), 76 | connectTimeout = connectTimeout, 77 | connectionTimeout = connectionTimeout, 78 | commandTimeout = commandTimeout, 79 | enableCompression = enableCompression.getOrElse(false), 80 | hostKeyVerifier = verifier 81 | ) 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | private def login(settings: Map[String, String], source: String) = { 94 | setting("login-type", settings, source).right.flatMap { 95 | case "password" ⇒ passwordLogin(settings, source) 96 | case "keyfile" ⇒ keyfileLogin(settings, source) 97 | case "agent" ⇒ agentLogin(settings, source) 98 | case x ⇒ Left("Illegal login-type setting '%s' in host config '%s': expecting either 'password' or 'keyfile'".format(x, source)) 99 | } 100 | } 101 | 102 | private def passwordLogin(settings: Map[String, String], source: String) = { 103 | setting("username", settings, source).right.flatMap { user ⇒ 104 | setting("password", settings, source).right.map { pass ⇒ 105 | PasswordLogin(user, pass) 106 | } 107 | } 108 | } 109 | 110 | private def keyfileLogin(settings: Map[String, String], source: String) = { 111 | import PublicKeyLogin._ 112 | setting("username", settings, source).right.map { user ⇒ 113 | val keyfile = setting("keyfile", settings, source).right.toOption 114 | val passphrase = setting("passphrase", settings, source).right.toOption 115 | PublicKeyLogin( 116 | user, 117 | passphrase.map(SimplePasswordProducer), 118 | keyfile.map { 119 | case kf if kf.startsWith("+") ⇒ kf.tail :: DefaultKeyLocations 120 | case kf ⇒ kf :: Nil 121 | }.getOrElse(DefaultKeyLocations).map( 122 | _.replaceFirst("^~/", System.getProperty("user.home") + '/').replace('/', File.separatorChar) 123 | ) 124 | ) 125 | } 126 | } 127 | 128 | private def agentLogin(settings: Map[String, String], source: String) = { 129 | val user = setting("username", settings, source).right.toOption 130 | val host = setting("host", settings, source).right.toOption 131 | Right(AgentLogin(user.getOrElse(System.getProperty("user.home")), host.getOrElse("localhost"))) 132 | } 133 | 134 | private def setting(key: String, settings: Map[String, String], source: String) = { 135 | settings.get(key) match { 136 | case Some(user) ⇒ Right(user) 137 | case None ⇒ Left("Host config '%s' is missing required setting '%s'".format(source, key)) 138 | } 139 | } 140 | 141 | private def optIntSetting(key: String, settings: Map[String, String], source: String) = { 142 | setting(key, settings, source) match { 143 | case Right(value) ⇒ 144 | try Right(Some(value.toInt)) 145 | catch { 146 | case _: NumberFormatException ⇒ Left(("Value '%s' for setting '%s' in host config '%s' " + 147 | "is not a legal integer").format(value, key, source)) 148 | } 149 | case Left(_) ⇒ Right(None) 150 | } 151 | } 152 | 153 | private def optBoolSetting(key: String, settings: Map[String, String], source: String) = { 154 | setting(key, settings, source) match { 155 | case Right("yes" | "YES" | "true" | "TRUE") ⇒ Right(Some(true)) 156 | case Right(value) ⇒ Left("Value '%s' for setting '%s' in host config '%s' is not a legal integer".format(value, key, source)) 157 | case Left(_) ⇒ Right(None) 158 | } 159 | } 160 | 161 | private def splitToMap(lines: TraversableOnce[String], source: String) = { 162 | ((Right(Map.empty): Validated[Map[String, String]]) /: lines) { 163 | case (Right(map), line) if line.nonEmpty && line.charAt(0) != '#' ⇒ 164 | line.indexOf('=') match { 165 | case -1 ⇒ Left("Host config '%s' contains illegal line:\n%s".format(source, line)) 166 | case ix ⇒ Right(map + (line.substring(0, ix).trim -> line.substring(ix + 1).trim)) 167 | } 168 | case (result, _) ⇒ result 169 | } 170 | } 171 | } 172 | 173 | object HostFileConfig { 174 | lazy val DefaultHostFileDir = System.getProperty("user.home") + File.separator + ".scala-ssh" 175 | def apply(): HostConfigProvider = apply(DefaultHostFileDir) 176 | def apply(hostFilesDir: String): HostConfigProvider = new FromStringsHostConfigProvider { 177 | def rawLines(host: String) = { 178 | val locations = searchLocations(host).map(name ⇒ new File(hostFilesDir + File.separator + name)) 179 | locations.find(_.exists) match { 180 | case Some(file) ⇒ 181 | try Right(file.getAbsolutePath -> Source.fromFile(file, "utf8").getLines()) 182 | catch { case e: IOException ⇒ Left("Could not read host file '%s' due to %s".format(file, e)) } 183 | case None ⇒ 184 | Left(("Host files '%s' not found, either provide one or use a concrete HostConfig, PasswordLogin, " + 185 | "PublicKeyLogin or AgentLogin").format(locations.mkString("', '"))) 186 | } 187 | } 188 | } 189 | 190 | def searchLocations(name: String): Stream[String] = { 191 | if (name.isEmpty) Stream.empty 192 | else name #:: { 193 | val dotIx = name.indexOf('.') 194 | @tailrec def findDigit(i: Int): Int = if (i < 0 || name.charAt(i).isDigit) i else findDigit(i - 1) 195 | val digitIx = findDigit(if (dotIx > 0) dotIx - 1 else name.length - 1) 196 | if (digitIx >= 0 && digitIx < dotIx) 197 | searchLocations(name.updated(digitIx, 'X')) 198 | else if (dotIx > 0) 199 | searchLocations(name.substring(dotIx + 1)) 200 | else Stream.empty 201 | } 202 | } 203 | } 204 | 205 | object HostResourceConfig { 206 | def apply(): HostConfigProvider = apply("") 207 | def apply(resourceBase: String): HostConfigProvider = new FromStringsHostConfigProvider { 208 | def rawLines(host: String) = { 209 | val locations = HostFileConfig.searchLocations(host).map(resourceBase + _) 210 | locations.map { r ⇒ 211 | r -> { 212 | val inputStream = getClass.getClassLoader.getResourceAsStream(r) 213 | try new StreamCopier().emptyToString(inputStream).split("\n").toList 214 | catch { case _: Exception ⇒ null } 215 | } 216 | }.find(_._2 != null) match { 217 | case Some(result) ⇒ Right(result) 218 | case None ⇒ 219 | Left(("Host resources '%s' not found, either provide one or use a concrete HostConfig, PasswordLogin, " + 220 | "PublicKeyLogin or AgentLogin").format(locations.mkString("', '"))) 221 | } 222 | } 223 | } 224 | } 225 | 226 | object HostKeyVerifiers { 227 | lazy val DontVerify = new HostKeyVerifier { 228 | def verify(hostname: String, port: Int, key: PublicKey) = true 229 | } 230 | lazy val KnownHosts = { 231 | val sshDir = System.getProperty("user.home") + File.separator + ".ssh" + File.separator 232 | fromKnownHostsFile(new File(sshDir + "known_hosts")).left.flatMap { error1 ⇒ 233 | fromKnownHostsFile(new File(sshDir + "known_hosts2")).left.map(error1 + " and " + _) 234 | } 235 | } 236 | def fromKnownHostsFile(knownHostsFile: File): Validated[HostKeyVerifier] = { 237 | if (knownHostsFile.exists()) { 238 | try { Right(new OpenSSHKnownHosts(knownHostsFile)) } 239 | catch { case e: Exception ⇒ Left("Could not read %s due to %s".format(knownHostsFile, e)) } 240 | } else Left(knownHostsFile.toString + " not found") 241 | } 242 | def forFingerprint(fingerprint: String) = fingerprint match { 243 | case "any" | "ANY" ⇒ DontVerify 244 | case fp ⇒ new HostKeyVerifier { 245 | def verify(hostname: String, port: Int, key: PublicKey) = SecurityUtils.getFingerprint(key) == fp 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/PasswordProducer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import net.schmizz.sshj.userauth.password.{ Resource, PasswordFinder } 20 | 21 | trait PasswordProducer extends PasswordFinder 22 | 23 | case class SimplePasswordProducer(password: String) extends PasswordProducer { 24 | def reqPassword(resource: Resource[_]) = password.toCharArray 25 | def shouldRetry(resource: Resource[_]) = false 26 | } 27 | 28 | object PasswordProducer { 29 | implicit def string2PasswordProducer(password: String) = SimplePasswordProducer(password) 30 | 31 | implicit def func2PasswordProducer(producer: String ⇒ String) = new PasswordProducer { 32 | def reqPassword(resource: Resource[_]) = producer(resource.toString).toCharArray 33 | def shouldRetry(resource: Resource[_]) = false 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/SSH.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | object SSH { 20 | def apply[T](host: String, configProvider: HostConfigProvider = HostFileConfig())(body: SshClient ⇒ Result[T]): Validated[T] = { 21 | SshClient(host, configProvider).right.flatMap { client ⇒ 22 | val result = { 23 | try { body(client).result } 24 | catch { case e: Exception ⇒ Left(e.toString) } 25 | } 26 | client.close() 27 | result 28 | } 29 | } 30 | 31 | case class Result[T](result: Validated[T]) 32 | 33 | object Result extends LowerPriorityImplicits { 34 | implicit def validated2Result[T](value: Validated[T]) = Result(value) 35 | } 36 | private[SSH] abstract class LowerPriorityImplicits { 37 | implicit def any2Result[T](value: T) = Result(Right(value)) 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/ScpTransferable.scala: -------------------------------------------------------------------------------- 1 | package com.decodified.scalassh 2 | 3 | import net.schmizz.sshj.sftp.SFTPClient 4 | import net.schmizz.sshj.xfer.scp.SCPFileTransfer 5 | import net.schmizz.sshj.xfer.TransferListener 6 | import net.schmizz.sshj.xfer.LoggingTransferListener 7 | 8 | trait ScpTransferable { 9 | self: SshClient ⇒ 10 | 11 | def sftp[T](fun: SFTPClient ⇒ T): Validated[T] = 12 | authenticatedClient.right.flatMap { client ⇒ 13 | protect("SFTP client failed") { 14 | val ftpClient = client.newSFTPClient() 15 | try fun(ftpClient) 16 | finally ftpClient.close() 17 | } 18 | } 19 | 20 | def fileTransfer[T](fun: SCPFileTransfer ⇒ T)(implicit listener: TransferListener = new LoggingTransferListener()): Validated[T] = 21 | authenticatedClient.right.flatMap { client ⇒ 22 | protect("SCP file transfer failed") { 23 | val transfer = client.newSCPFileTransfer() 24 | transfer.setTransferListener(listener) 25 | fun(transfer) 26 | } 27 | } 28 | 29 | def upload(localPath: String, remotePath: String)(implicit listener: TransferListener = new LoggingTransferListener()): Validated[Unit] = 30 | fileTransfer(_.upload(localPath, remotePath))(listener) 31 | 32 | def download(remotePath: String, localPath: String)(implicit listener: TransferListener = new LoggingTransferListener()): Validated[Unit] = 33 | fileTransfer(_.download(remotePath, localPath))(listener) 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/SshClient.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import java.util.concurrent.TimeUnit 20 | import java.io.{ InputStream, FileNotFoundException, FileInputStream } 21 | import org.slf4j.LoggerFactory 22 | import net.schmizz.sshj.SSHClient 23 | import net.schmizz.sshj.connection.channel.direct.Session 24 | import net.schmizz.sshj.userauth.keyprovider.KeyProvider 25 | import net.schmizz.sshj.userauth.method.AuthMethod 26 | import com.jcraft.jsch.agentproxy._ 27 | import com.jcraft.jsch.agentproxy.connector._ 28 | import com.jcraft.jsch.agentproxy.sshj._ 29 | import scala.collection.JavaConversions._ 30 | import scala.util.{ Try, Success, Failure } 31 | import scala.io.Source 32 | 33 | class SshClient(val config: HostConfig) extends ScpTransferable { 34 | lazy val log = LoggerFactory.getLogger(getClass) 35 | lazy val endpoint = config.hostName + ':' + config.port 36 | lazy val authenticatedClient = connect(client).right.flatMap(authenticate) 37 | val client = createClient(config) 38 | 39 | def exec(command: Command): Validated[CommandResult] = { 40 | authenticatedClient.right.flatMap { client ⇒ 41 | startSession(client).right.flatMap { session ⇒ 42 | execWithSession(command, session) 43 | } 44 | } 45 | } 46 | 47 | def execPTY(command: Command): Validated[CommandResult] = { 48 | authenticatedClient.right.flatMap { client ⇒ 49 | startSession(client).right.flatMap { session ⇒ 50 | config.ptyConfig.fold { session.allocateDefaultPTY() } { ptyConf ⇒ 51 | session.allocatePTY(ptyConf.term, ptyConf.cols, ptyConf.rows, ptyConf.width, ptyConf.height, ptyConf.modes) 52 | } 53 | execWithSession(command, session) 54 | } 55 | } 56 | } 57 | 58 | def execWithSession(command: Command, session: Session): Validated[CommandResult] = { 59 | log.info("Executing SSH command on {}: \"{}\"", Seq(endpoint, command.command): _*) 60 | protect("Could not execute SSH command on") { 61 | val channel = session.exec(command.command) 62 | command.input.inputStream.foreach(new StreamCopier().copy(_, channel.getOutputStream)) 63 | command.timeout orElse config.commandTimeout match { 64 | case Some(timeout) ⇒ channel.join(timeout, TimeUnit.MILLISECONDS) 65 | case None ⇒ channel.join() 66 | } 67 | new CommandResult(channel) 68 | } 69 | } 70 | 71 | protected def createClient(config: HostConfig): SSHClient = 72 | make(new SSHClient(config.sshjConfig)) { client ⇒ 73 | config.connectTimeout.foreach(client.setConnectTimeout) 74 | config.connectionTimeout.foreach(client.setTimeout) 75 | client.addHostKeyVerifier(config.hostKeyVerifier) 76 | if (config.enableCompression) client.useCompression() 77 | } 78 | 79 | protected def connect(client: SSHClient): Validated[SSHClient] = { 80 | require(!client.isConnected) 81 | protect("Could not connect to") { 82 | log.info("Connecting to {} ...", endpoint) 83 | client.connect(config.hostName, config.port) 84 | client 85 | } 86 | } 87 | 88 | protected def authenticate(client: SSHClient): Validated[SSHClient] = { 89 | def keyProviders(locations: List[String], passProducer: PasswordProducer): List[KeyProvider] = { 90 | def inputStream(location: String): Option[InputStream] = { 91 | if (location.startsWith("classpath:")) { 92 | val resource = location.substring("classpath:".length) 93 | Option(getClass.getClassLoader.getResourceAsStream(resource)) 94 | .orElse(throw new RuntimeException("Classpath resource '" + resource + "' containing private key could not be found")) 95 | } else { 96 | try Some(new FileInputStream(location)) 97 | catch { case _: FileNotFoundException ⇒ None } 98 | } 99 | } 100 | locations.flatMap { location ⇒ 101 | inputStream(location).map { stream ⇒ 102 | val privateKey = Source.fromInputStream(stream).getLines().mkString("\n") 103 | client.loadKeys(privateKey, null, passProducer) 104 | } 105 | } match { 106 | case Nil ⇒ sys.error("None of the configured keyfiles exists: " + locations.mkString(", ")) 107 | case x ⇒ x 108 | } 109 | } 110 | 111 | def agentProxyAuthMethods = { 112 | def authMethods(agent: AgentProxy): Seq[AuthMethod] = agent.getIdentities().map(new AuthAgent(agent, _)) 113 | def agentProxy: Try[AgentProxy] = agentConnector map (new AgentProxy(_)) 114 | def agentConnector: Try[Connector] = Try { ConnectorFactory.getDefault().createConnector() } 115 | agentProxy map authMethods match { 116 | case Success(m) ⇒ m 117 | case Failure(e) ⇒ throw new RuntimeException("Agent proxy could not be initialized", e) 118 | } 119 | } 120 | 121 | require(client.isConnected && !client.isAuthenticated) 122 | log.info("Authenticating to {} using {} ...", Seq(endpoint, config.login.user): _*) 123 | config.login match { 124 | case PasswordLogin(user, passProducer) ⇒ 125 | protect("Could not authenticate (with password) to") { 126 | client.authPassword(user, passProducer) 127 | client 128 | } 129 | case PublicKeyLogin(user, passProducer, keyfileLocations) ⇒ 130 | protect("Could not authenticate (with keyfile) to") { 131 | client.authPublickey(user, keyProviders(keyfileLocations, passProducer.orNull): _*) 132 | client 133 | } 134 | case AgentLogin(user, host) ⇒ 135 | protect("Could not authenticate (with agent proxy) to") { 136 | client.auth(user, agentProxyAuthMethods) 137 | client 138 | } 139 | } 140 | } 141 | 142 | protected def startSession(client: SSHClient): Validated[Session] = { 143 | require(client.isConnected && client.isAuthenticated) 144 | protect("Could not start SSH session on") { 145 | client.startSession() 146 | } 147 | } 148 | 149 | def close() { 150 | log.info("Closing connection to {} ...", endpoint) 151 | client.close() 152 | } 153 | 154 | protected def protect[T](errorMsg: ⇒ String)(f: ⇒ T): Validated[T] = { 155 | try { Right(f) } 156 | catch { case e: Exception ⇒ Left("%s %s due to %s".format(errorMsg, endpoint, e)) } 157 | } 158 | } 159 | 160 | object SshClient { 161 | def apply(host: String, configProvider: HostConfigProvider = HostFileConfig()): Validated[SshClient] = { 162 | configProvider(host).right.map(new SshClient(_)) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/SshLogin.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import java.io.File 20 | 21 | sealed trait SshLogin { 22 | def user: String 23 | } 24 | 25 | case class PasswordLogin(user: String, passProducer: PasswordProducer) extends SshLogin 26 | 27 | case class PublicKeyLogin( 28 | user: String, 29 | passProducer: Option[PasswordProducer], 30 | keyfileLocations: List[String]) extends SshLogin 31 | 32 | object PublicKeyLogin { 33 | lazy val DefaultKeyLocations = "~/.ssh/id_rsa" :: "~/.ssh/id_dsa" :: Nil 34 | def apply(user: String): PublicKeyLogin = 35 | apply(user, None, DefaultKeyLocations) 36 | def apply(user: String, keyfileLocations: String*): PublicKeyLogin = 37 | PublicKeyLogin(user, None, keyfileLocations.toList) 38 | def apply(user: String, passProducer: PasswordProducer, keyfileLocations: List[String]): PublicKeyLogin = 39 | PublicKeyLogin(user, Some(passProducer), keyfileLocations) 40 | } 41 | 42 | case class AgentLogin(user: String, host: String) extends SshLogin 43 | 44 | object AgentLogin { 45 | def apply(): AgentLogin = AgentLogin(System.getProperty("user.name"), "localhost") 46 | def apply(user: String): AgentLogin = AgentLogin(user, "localhost") 47 | } -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/StreamCopier.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import annotation.tailrec 20 | import java.io.{ ByteArrayOutputStream, OutputStream, InputStream } 21 | 22 | final class StreamCopier(bufferSize: Int = 4096) { 23 | private val buffer = new Array[Byte](bufferSize) 24 | 25 | @tailrec 26 | def copy(in: InputStream, out: OutputStream) { 27 | val bytes = in.read(buffer) 28 | if (bytes > 0) { 29 | out.write(buffer, 0, bytes) 30 | copy(in, out) 31 | } else { 32 | in.close() 33 | out.close() 34 | } 35 | } 36 | 37 | def emptyToString(inputStream: InputStream, charset: String = "UTF8") = { 38 | new String(emptyToByteArray(inputStream), charset) 39 | } 40 | 41 | def emptyToByteArray(inputStream: InputStream) = { 42 | val output = new ByteArrayOutputStream() 43 | copy(inputStream, output) 44 | output.toByteArray 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/main/scala/com/decodified/scalassh/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified 18 | 19 | package object scalassh { 20 | type Validated[T] = Either[String, T] 21 | 22 | def make[A, U](a: A)(f: A ⇒ U): A = { f(a); a } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/resources/agent.com: -------------------------------------------------------------------------------- 1 | # test host file 2 | login-type = agent 3 | username = bob 4 | enable-compression = yes -------------------------------------------------------------------------------- /src/test/resources/enc-keyfile.com: -------------------------------------------------------------------------------- 1 | # test host file 2 | login-type = keyfile 3 | username = alice 4 | keyfile = /some/file 5 | passphrase = superSecure -------------------------------------------------------------------------------- /src/test/resources/illegal-line.com: -------------------------------------------------------------------------------- 1 | # test host file 2 | login-type = fancy pants 3 | this line triggers an error! 4 | username = alice 5 | keyfile = /some/file -------------------------------------------------------------------------------- /src/test/resources/invalid-login-type.com: -------------------------------------------------------------------------------- 1 | # test host file 2 | login-type = fancy pants 3 | username = alice 4 | keyfile = /some/file -------------------------------------------------------------------------------- /src/test/resources/keyfile.com: -------------------------------------------------------------------------------- 1 | # test host file 2 | login-type = keyfile 3 | username = alice 4 | keyfile = /some/file 5 | host-name = xyz.special.com 6 | port = 30 -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | System.out 6 | 7 | %date{MM/dd HH:mm:ss.SSS} %-5level[%thread] %logger{1} - %msg%n 8 | 9 | 10 | 11 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/test/resources/missing-user.com: -------------------------------------------------------------------------------- 1 | # test host file 2 | login-type = password 3 | password = 123 -------------------------------------------------------------------------------- /src/test/resources/password.com: -------------------------------------------------------------------------------- 1 | # test host file 2 | login-type = password 3 | username = bob 4 | password = 123 5 | enable-compression = yes -------------------------------------------------------------------------------- /src/test/resources/setup_travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Install OpenSSH 4 | sudo apt-get update -qq 5 | sudo apt-get install -qq libssh2-1-dev openssh-client openssh-server 6 | sudo start ssh 7 | 8 | # Generate and Register keys 9 | ssh-keygen -t rsa -f ~/.ssh/id_rsa -N "" -q 10 | cat ~/.ssh/id_rsa.pub >>~/.ssh/authorized_keys 11 | ssh-keyscan -t rsa localhost >>~/.ssh/known_hosts 12 | 13 | # Create files for unit test 14 | mkdir ~/.scala-ssh 15 | echo localhost > ~/.scala-ssh/.testhost 16 | cat < ~/.scala-ssh/localhost 17 | login-type = keyfile 18 | username = $USER 19 | keyfile = ~/.ssh/id_rsa 20 | EOF 21 | -------------------------------------------------------------------------------- /src/test/scala/com/decodified/scalassh/HostFileConfigSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import org.specs2.mutable.Specification 20 | 21 | class HostFileConfigSpec extends Specification { 22 | 23 | "Depending on the host file the HostFileConfig should produce a proper" >> { 24 | "PasswordLogin" ! { 25 | config("password.com") === Right(HostConfig(PasswordLogin("bob", "123"), "password.com", enableCompression = true)) 26 | } 27 | "unencrypted PublicKeyLogin" ! { 28 | config("keyfile.com") === Right(HostConfig(PublicKeyLogin("alice", "/some/file"), "xyz.special.com", port = 30)) 29 | } 30 | "encrypted PublicKeyLogin" ! { 31 | config("enc-keyfile.com") === Right(HostConfig(PublicKeyLogin("alice", "superSecure", "/some/file" :: Nil), "enc-keyfile.com")) 32 | } 33 | "AgentLogin" ! { 34 | config("agent.com") === Right(HostConfig(AgentLogin("bob"), "agent.com", enableCompression = true)) 35 | } 36 | "error message if the file is missing" ! { 37 | config("non-existing.net").left.get === "Host resources 'non-existing.net', 'net' not found, either " + 38 | "provide one or use a concrete HostConfig, PasswordLogin, PublicKeyLogin or AgentLogin" 39 | } 40 | "error message if the login-type is invalid" ! { 41 | config("invalid-login-type.com").left.get must startingWith("Illegal login-type setting 'fancy pants'") 42 | } 43 | "error message if the username is missing" ! { 44 | config("missing-user.com").left.get must endWith("is missing required setting 'username'") 45 | } 46 | "error message if the host file contains an illegal line" ! { 47 | config("illegal-line.com").left.get must endWith("contains illegal line:\nthis line triggers an error!") 48 | } 49 | } 50 | 51 | "The sequence of searched config locations for host `node42.tier1.example.com`" should 52 | "be as described in the README" ! { 53 | HostFileConfig.searchLocations("node42.tier1.example.com").toList === 54 | "node42.tier1.example.com" :: 55 | "node4X.tier1.example.com" :: 56 | "nodeXX.tier1.example.com" :: 57 | "tier1.example.com" :: 58 | "tierX.example.com" :: 59 | "example.com" :: 60 | "com" :: Nil 61 | } 62 | 63 | val config = HostResourceConfig() 64 | } 65 | -------------------------------------------------------------------------------- /src/test/scala/com/decodified/scalassh/SshClientSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Mathias Doenitz 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 com.decodified.scalassh 18 | 19 | import org.specs2.Specification 20 | import java.io.File 21 | import java.io.FileWriter 22 | import io.Source 23 | import Source.{ fromFile ⇒ open } 24 | import org.specs2.execute.{ Failure, FailureException } 25 | 26 | class SshClientSpec extends Specification { 27 | sequential 28 | 29 | def is = 30 | "The SshClient should be able to" ^ 31 | "properly connect to the test host and fetch a directory listing" ! simpleTest ^ 32 | "properly connect to the test host and execute three independent commands" ! threeCommandsTest ^ 33 | "properly upload to the test host" ! fileUploadTest ^ 34 | "properly download to the test host" ! fileDownloadTest 35 | 36 | def simpleTest = { 37 | SSH(testHostName) { client ⇒ 38 | client.exec("ls -a").right.map { result ⇒ 39 | result.stdOutAsString() + "|" + result.stdErrAsString() 40 | } 41 | }.right.get must startWith(".\n..\n") 42 | } 43 | 44 | def threeCommandsTest = { 45 | SSH(testHostName) { client ⇒ 46 | client.exec("ls").right.flatMap { res1 ⇒ 47 | println("OK 1") 48 | client.exec("dfssgsdg").right.flatMap { res2 ⇒ 49 | println("OK 2") 50 | client.exec("uname").right.map { res3 ⇒ 51 | println("OK 3") 52 | (res1.exitCode, res2.exitCode, res3.exitCode) 53 | } 54 | } 55 | } 56 | } mustEqual Right((Some(0), Some(127), Some(0))) 57 | } 58 | 59 | def fileUploadTest = { 60 | val testFile = make(new File(testFileName)) { file ⇒ 61 | val writer = new FileWriter(file) 62 | writer.write(testText) 63 | writer.close() 64 | } 65 | 66 | SSH(testHostName) { client ⇒ 67 | try client.upload(testFile.getAbsolutePath, testFileName).right.flatMap { _ ⇒ 68 | client.exec("cat " + testFileName).right.map { result ⇒ 69 | testFile.delete() 70 | result.stdOutAsString() 71 | } 72 | } 73 | finally client.close() 74 | } mustEqual Right(testText) 75 | } 76 | 77 | def fileDownloadTest = { 78 | SSH(testHostName) { client ⇒ 79 | try client.download(testFileName, testFileName).right.map { _ ⇒ 80 | make(open(testFileName).getLines.mkString) { _ ⇒ 81 | new File(testFileName).delete() 82 | } 83 | } 84 | finally client.close() 85 | } mustEqual Right(testText) 86 | } 87 | 88 | lazy val testFileName = "testUpload.txt" 89 | lazy val testText = "Hello, Scala SSH!" 90 | 91 | lazy val testHostName = { 92 | val fileName = HostFileConfig.DefaultHostFileDir + File.separator + ".testhost" 93 | try { 94 | Source.fromFile(fileName).getLines().toList.head 95 | } catch { 96 | case e: Exception ⇒ throw FailureException(Failure(("Could not find file '%s', you need to create it holding " + 97 | "nothing but the name of the test host you would like to run your tests against!").format(fileName), e.toString)) 98 | } 99 | } 100 | } 101 | --------------------------------------------------------------------------------