├── .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 |
--------------------------------------------------------------------------------