├── .circleci ├── config.yml └── config.yml~ ├── .github └── CODEOWNERS ├── .gitignore ├── .travis.yml.foo ├── ORIGINATOR ├── README.md ├── ReleaseNotes.md ├── brick.clj ├── profiles.clj ├── project.clj ├── src └── clj_ssh │ ├── agent.clj │ ├── cli.clj │ ├── keychain.clj │ ├── reflect.clj │ ├── ssh.clj │ └── ssh │ └── protocols.clj └── test ├── clj_ssh ├── cli_test.clj ├── ssh_test.clj ├── test_keys.clj └── test_utils.clj ├── log4j.xml └── logback.xml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | workflows: 4 | build-deploy: 5 | jobs: 6 | - build: 7 | filters: 8 | tags: 9 | only: /.*/ 10 | 11 | - deploy: 12 | requires: 13 | - build 14 | filters: 15 | branches: 16 | ignore: /.*/ 17 | tags: 18 | only: /Release-.*/ 19 | context: 20 | - CLOJARS_DEPLOY 21 | 22 | jobs: 23 | build: 24 | machine: 25 | image: ubuntu-2004:2023.07.1 26 | 27 | resource_class: medium 28 | 29 | working_directory: ~/repo 30 | 31 | environment: 32 | LEIN_ROOT: "true" 33 | # Customize the JVM maximum heap limit 34 | JVM_OPTS: -Xmx3200m 35 | 36 | steps: 37 | - checkout 38 | 39 | # Download and cache dependencies 40 | - restore_cache: 41 | keys: 42 | - v1-dependencies-{{ checksum "project.clj" }} 43 | # fallback to using the latest cache if no exact match is found 44 | - v1-dependencies- 45 | 46 | - run: 47 | name: Setup environment 48 | command: sudo apt-get update && sudo apt-get install -y openjdk-8-jdk leiningen 49 | 50 | - run: 51 | name: Fetch project dependencies 52 | command: lein deps 53 | 54 | - save_cache: 55 | paths: 56 | - ~/.m2 57 | key: v1-dependencies-{{ checksum "project.clj" }} 58 | 59 | - run: 60 | name: Setup tests 61 | command: | 62 | [[ -f ~/.ssh/id_rsa ]] || ssh-keygen -N "" -f ~/.ssh/id_rsa 63 | cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys 64 | ssh-keygen -f ~/.ssh/clj_ssh -t rsa -C "key for test clj-ssh" -N "" 65 | ssh-keygen -f ~/.ssh/clj_ssh_pp -t rsa -C "key for test clj-ssh" -N "clj-ssh" 66 | echo "from=\"127.0.0.1,localhost,0.0.0.0\" $(cat ~/.ssh/clj_ssh.pub)" >> ~/.ssh/authorized_keys 67 | echo "from=\"127.0.0.1,localhost,0.0.0.0\" $(cat ~/.ssh/clj_ssh_pp.pub)" >> ~/.ssh/authorized_keys 68 | eval $(ssh-agent) 69 | cat \<< EOF > pp 70 | #!/bin/sh 71 | echo "clj-ssh" 72 | EOF 73 | chmod +x pp 74 | export SSH_ASKPASS=./pp 75 | export DISPLAY=1 76 | setsid ssh-add ~/.ssh/clj_ssh_pp < /dev/null >/dev/null 2>&1 77 | 78 | - run: 79 | name: Run tests 80 | command: lein test 81 | 82 | deploy: 83 | machine: 84 | image: ubuntu-2004:2023.07.1 85 | 86 | resource_class: medium 87 | 88 | working_directory: ~/repo 89 | 90 | environment: 91 | LEIN_ROOT: "true" 92 | # Customize the JVM maximum heap limit 93 | JVM_OPTS: -Xmx3200m 94 | 95 | steps: 96 | - checkout 97 | 98 | - run: 99 | name: Setup environment 100 | command: sudo apt-get update && sudo apt-get install -y openjdk-8-jdk leiningen 101 | 102 | # Download and cache dependencies 103 | - restore_cache: 104 | keys: 105 | - v1-dependencies-{{ checksum "project.clj" }} 106 | # fallback to using the latest cache if no exact match is found 107 | - v1-dependencies- 108 | 109 | # Download and cache dependencies 110 | - restore_cache: 111 | keys: 112 | - v1-dependencies-{{ checksum "project.clj" }} 113 | # fallback to using the latest cache if no exact match is found 114 | - v1-dependencies- 115 | 116 | - run: 117 | name: Install babashka 118 | command: | 119 | curl -s https://raw.githubusercontent.com/borkdude/babashka/master/install -o install.sh 120 | sudo bash install.sh 121 | rm install.sh 122 | - run: 123 | name: Install deployment-script 124 | command: | 125 | curl -s https://raw.githubusercontent.com/clj-commons/infra/main/deployment/circle-maybe-deploy.bb -o circle-maybe-deploy.bb 126 | chmod a+x circle-maybe-deploy.bb 127 | 128 | - run: lein deps 129 | 130 | - run: 131 | name: Setup GPG signing key 132 | command: | 133 | sudo apt-get update 134 | sudo apt-get install -y make gnupg 135 | GNUPGHOME="$HOME/.gnupg" 136 | export GNUPGHOME 137 | mkdir -p "$GNUPGHOME" 138 | chmod 0700 "$GNUPGHOME" 139 | 140 | echo "$GPG_KEY" \ 141 | | base64 --decode --ignore-garbage \ 142 | | gpg --batch --allow-secret-key-import --import 143 | 144 | gpg --keyid-format LONG --list-secret-keys 145 | 146 | - save_cache: 147 | paths: 148 | - ~/.m2 149 | key: v1-dependencies-{{ checksum "project.clj" }} 150 | - run: 151 | name: Deploy 152 | command: | 153 | GPG_TTY=$(tty) 154 | export GPG_TTY 155 | echo $GPG_TTY 156 | ./circle-maybe-deploy.bb lein deploy clojars 157 | 158 | -------------------------------------------------------------------------------- /.circleci/config.yml~: -------------------------------------------------------------------------------- 1 | # Clojure CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-clojure/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/clojure:lein-2.7.1 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | LEIN_ROOT: "true" 21 | # Customize the JVM maximum heap limit 22 | JVM_OPTS: -Xmx3200m 23 | 24 | steps: 25 | - checkout 26 | 27 | # Download and cache dependencies 28 | - restore_cache: 29 | keys: 30 | - v1-dependencies-{{ checksum "project.clj" }} 31 | # fallback to using the latest cache if no exact match is found 32 | - v1-dependencies- 33 | 34 | - run: lein deps 35 | 36 | - save_cache: 37 | paths: 38 | - ~/.m2 39 | key: v1-dependencies-{{ checksum "project.clj" }} 40 | 41 | # run tests! 42 | - run: lein test -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @slipset 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | target 3 | *jar 4 | lib 5 | classes 6 | autodoc/** 7 | doc/** 8 | .lein-* 9 | .nrepl-* 10 | -------------------------------------------------------------------------------- /.travis.yml.foo: -------------------------------------------------------------------------------- 1 | language: clojure 2 | lein: lein2 3 | before_script: 4 | - ssh-keygen -N "" -f ~/.ssh/id_rsa 5 | - cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys 6 | - ssh-keygen -f ~/.ssh/clj_ssh -t rsa -C "key for test clj-ssh" -N "" 7 | - ssh-keygen -f ~/.ssh/clj_ssh_pp -t rsa -C "key for test clj-ssh" -N "clj-ssh" 8 | - echo "from=\"127.0.0.1,localhost,0.0.0.0\" $(cat ~/.ssh/clj_ssh.pub)" >> ~/.ssh/authorized_keys 9 | - echo "from=\"127.0.0.1,localhost,0.0.0.0\" $(cat ~/.ssh/clj_ssh_pp.pub)" >> ~/.ssh/authorized_keys 10 | - eval $(ssh-agent) 11 | - echo "clj-ssh" > pp 12 | - chmod +x pp 13 | - setsid ssh-add ~/.ssh/clj_ssh_pp < pp 14 | script: lein2 test 15 | after_success: 16 | - lein2 pallet-release push 17 | env: 18 | global: 19 | secure: eOBqYhJhOJMtRiMKs9ZgG4pEHFy7YqiBZ5NUEWUYD6qav6sMRHqqR5F04NRI37SmnIupzeTChqfRgX0DOwHeTl4u+QJnwRDH2z3avu75FbtZWgiGrxzE39SESpVj/zsyDrEUzT7ZiMayXKyNa3ObiJ8vBUFT7x/OZyRp/1rJxHU= 20 | -------------------------------------------------------------------------------- /ORIGINATOR: -------------------------------------------------------------------------------- 1 | @hugoduncan 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Clojars Project](https://img.shields.io/clojars/v/clj-commons/clj-ssh.svg)](https://clojars.org/clj-commons/clj-ssh) 3 | [![cljdoc badge](https://cljdoc.org/badge/clj-commons/clj-ssh)](https://cljdoc.org/d/clj-commons/clj-ssh/CURRENT) 4 | [![CircleCI](https://circleci.com/gh/clj-commons/clj-ssh.svg?style=svg)](https://circleci.com/gh/clj-commons/clj-ssh) 5 | 6 | # clj-ssh 7 | 8 | SSH in clojure. Uses [jsch](https://github.com/mwiede/jsch). 9 | (See section RSA Private Key format if using openssl generated keys) 10 | 11 | ## Usage 12 | 13 | ### REPL 14 | 15 | The `clj-ssh.cli` namespace provides some functions for ease of use at the REPL. 16 | 17 | ```clj 18 | (use 'clj-ssh.cli) 19 | ``` 20 | 21 | Use `ssh` to execute a command, say `ls`, on a remote host "my-host", 22 | 23 | ```clj 24 | (ssh "my-host" "ls") 25 | => {:exit 0 :out "file1\nfile2\n" :err "") 26 | ``` 27 | 28 | By default this will use the system ssh-agent to obtain your ssh keys, and it 29 | uses your current username, but this can be specified: 30 | 31 | ```clj 32 | (ssh "my-host" "ls" :username "remote-user") 33 | => {:exit 0 :out "file1\nfile2\n" :err "") 34 | ``` 35 | 36 | Strict host key checking can be turned off: 37 | 38 | ```clj 39 | (default-session-options {:strict-host-key-checking :no}) 40 | ``` 41 | 42 | SFTP is also supported. For example, to copy a local file to a remote host 43 | "my-host": 44 | 45 | ```clj 46 | (sftp "my-host" :put "/from/this/path" "to/this/path") 47 | ``` 48 | 49 | Note that any sftp commands that change the state of the sftp session (such as 50 | cd) do not work with the simplified interface, as a new session is created each 51 | time. 52 | 53 | If your key has a passphrase, you will need to explicitly add your key either to 54 | the system's ssh-agent, or to clj-ssh's ssh-agent with the appropriate 55 | `add-identity` call. 56 | 57 | ### Non REPL 58 | 59 | The `clj-ssh.ssh` namespace should be used for SSH from functional code. 60 | 61 | ```clj 62 | (let [agent (ssh-agent {})] 63 | (let [session (session agent "host-ip" {:strict-host-key-checking :no})] 64 | (with-connection session 65 | (let [result (ssh session {:in "echo hello"})] 66 | (println (result :out))) 67 | (let [result (ssh session {:cmd "ls"})] 68 | (println (second result))))))) 69 | ``` 70 | 71 | The above example shows using `:in` to pass commands to a shell, and using 72 | `:cmd` to exec a command without a shell. When using `:cmd` you can still pass 73 | a stream or a string to `:in` to be used as the process' standard input. 74 | 75 | By default, the system ssh-agent is used, which means the ssh keys you use at 76 | the command line level should automatically be picked up (this should also work 77 | with `pageant` on windows). 78 | 79 | You can forward the ssh-agent, which allows you to run ssh based commands on the 80 | remote host using the credentials in your local ssh-agent: 81 | 82 | ```clj 83 | (let [agent (ssh-agent {})] 84 | (let [session (session agent "host-ip" {:strict-host-key-checking :no})] 85 | (with-connection session 86 | (let [result (ssh session {:in "ssh somehost ls" :agent-forwarding true})] 87 | (println (result :out)))))) 88 | ``` 89 | 90 | If you prefer not to use the system ssh-agent, or one is not running on your 91 | system, then a local, isolated ssh-agent can be used. 92 | 93 | ```clj 94 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 95 | (add-identity agent {:private-key-path "/user/name/.ssh/id_rsa"}) 96 | (let [session (session agent "host-ip" {:strict-host-key-checking :no})] 97 | (with-connection session 98 | (let [result (ssh session {:in "echo hello"})] 99 | (println (result :out))))) 100 | ``` 101 | 102 | SFTP is supported: 103 | 104 | ```clj 105 | (let [agent (ssh-agent {})] 106 | (let [session (session agent "host-ip" {:strict-host-key-checking :no})] 107 | (with-connection session 108 | (let [channel (ssh-sftp session)] 109 | (with-channel-connection channel 110 | (sftp channel {} :cd "/remote/path") 111 | (sftp channel {} :put "/some/file" "filename")))))) 112 | ``` 113 | 114 | SSH tunneling is also supported: 115 | 116 | ```clj 117 | (let [agent (ssh-agent {})] 118 | (let [session (session agent "host-ip" {:strict-host-key-checking :no})] 119 | (with-connection session 120 | (with-local-port-forward [session 8080 80] 121 | (comment do something with port 8080 here))))) 122 | ``` 123 | 124 | Jump hosts can be used with the `jump-session`. Once the session is 125 | connected, the `the-session` function can be used to obtain a session 126 | that can be used with `ssh-exec`, etc. The `the-session` function can 127 | be used on a session returned by `session`, so you can write code that 128 | works with both a `jump-session` session and a single host session. 129 | 130 | ```clj 131 | (let [s (jump-session 132 | (ssh-agent {}) 133 | [{:hostname "host1" :username "user" 134 | :strict-host-key-checking :no} 135 | {:hostname "final-host" :username "user" 136 | :strict-host-key-checking :no}] 137 | {})] 138 | (with-connection s 139 | (ssh-exec (the-session s) "ls" "" "" {})) 140 | ``` 141 | 142 | 143 | 144 | ## Documentation 145 | 146 | [Annotated source](http:/hugoduncan.github.com/clj-ssh/0.5/annotated/uberdoc.html). 147 | [API](http:/hugoduncan.github.com/clj-ssh/0.5/api/index.html). 148 | 149 | ## RSA Private Key Format and clj-ssh 150 | 151 | There have been changes to the header of RSA Private Keys. With the upgrade of 152 | com.jcraft/jsch to "0.1.55", the older openssh headers work with ssh will throw 153 | an authentication failure. 154 | 155 | Older format 156 | ``` 157 | -----BEGIN OPENSSH PRIVATE KEY-----` 158 | ``` 159 | 160 | New RSA format 161 | ``` 162 | -----BEGIN RSA PRIVATE KEY----- 163 | ``` 164 | 165 | Old private keys can be easily converted to the new format, through the use of 166 | ssh-keygen's passphrase changing command. This will change the file in place. 167 | ``` 168 | ssh-keygen -p -f privateKeyFile -m pem -P passphrase -N passphrase 169 | ``` 170 | The -m flag will force the file to pem format, fixing the header. 171 | The -P (for old passphrase) and -N (new passphrase) can be ommitted to generate 172 | an interactive query instead. 173 | (enter "" at either -P or -N to identify no passphrase) 174 | 175 | ### Note: clj-ssh key generation 176 | clj-ssh does have the ability to generate the public / private key pairs for both 177 | RSA and DSA (found in clj-ssh.ssh/generate-keypair). 178 | 179 | Unlike ssh-keygen, the RSA passphrase on the private key will be limited to 180 | DES-EDE3-CBC DEK format to encrypt/decrypt the passphrase if created within clj-ssh. 181 | ssh-keygen will likely use what is standard in your operating system's crypto suite, 182 | (e.g. AES-128-CBC) 183 | 184 | 185 | ## FAQ 186 | 187 | Q: What does 188 | "4: Failure @ com.jcraft.jsch.ChannelSftp.throwStatusError(ChannelSftp.java:2289)" 189 | during an sftp transfer signify? 190 | 191 | A: Probably a disk full, or permission error. 192 | 193 | ### TTY's and background processes 194 | 195 | Some useful links about ssh and background processes: 196 | 197 | - [Snail book](http://www.snailbook.com/faq/background-jobs.auto.html) 198 | - [ssh -t question on stackoverflow](http://stackoverflow.com/questions/14679178/why-does-ssh-wait-for-my-subshells-without-t-and-kill-them-with-t) 199 | - [sudo and tty question on stackoverflow](http://stackoverflow.com/questions/8441637/to-run-sudo-commands-on-a-ec2-instance) 200 | 201 | Thanks to [Ryan Stradling](http://github.com/rstradling) for these. 202 | 203 | ## Installation 204 | 205 | Via [clojars](http://clojars.org) and 206 | [Leiningen](http://github.com/technomancy/leiningen). 207 | 208 | :dependencies [org.clj-commons/clj-ssh "0.6.2"] 209 | 210 | or your favourite maven repository aware tool. 211 | 212 | ## Tests 213 | 214 | The test rely on several keys being authorized on localhost: 215 | 216 | ```shell 217 | ssh-keygen -f ~/.ssh/clj_ssh -t rsa -C "key for test clj-ssh" -N "" 218 | ssh-keygen -f ~/.ssh/clj_ssh_pp -t rsa -C "key for test clj-ssh" -N "clj-ssh" 219 | cp ~/.ssh/authorized_keys ~/.ssh/authorized_keys.bak 220 | echo "from=\"localhost\" $(cat ~/.ssh/clj_ssh.pub)" >> ~/.ssh/authorized_keys 221 | echo "from=\"localhost\" $(cat ~/.ssh/clj_ssh_pp.pub)" >> ~/.ssh/authorized_keys 222 | ``` 223 | 224 | The `clj_ssh_pp` key should have a passphrase, and should be registered with your `ssh-agent`. 225 | 226 | ```shell 227 | ssh-add ~/.ssh/clj_ssh_pp 228 | ``` 229 | 230 | On OS X, use: 231 | 232 | ```shell 233 | ssh-add -K ~/.ssh/clj_ssh_pp 234 | ``` 235 | 236 | ## Other Libraries 237 | 238 | For plain `ftp`, you might want to look at [clj-ftp](https://github.com/miner/clj-ftp). 239 | 240 | ## License 241 | 242 | Copyright © 2012 Hugo Duncan 243 | 244 | Licensed under [EPL](http://www.eclipse.org/legal/epl-v10.html) 245 | -------------------------------------------------------------------------------- /ReleaseNotes.md: -------------------------------------------------------------------------------- 1 | ## 0.6.4 2 | 3 | - Moved jsch to version 0.2.12 4 | 5 | ## 0.6.3 6 | 7 | - Moved jsch to version 0.2.11 8 | 9 | ## 0.6.2 10 | 11 | - Moved jsch to supported fork from com.github.mwiede/jsch version 0.2.9 12 | 13 | ## 0.5.16 14 | 15 | - Moved jsch to version 0.1.55 16 | 17 | - Moved clojure tools.logging to 1.2.4 18 | 19 | - Moved logback-classic to 1.4.7 20 | 21 | - Changed ssh/generate-keypair to now match jsch API 22 | (and remove setPassphrase method which is listed as depricated) 23 | 24 | - Included section in readme.md regarding RSA header issues and 25 | compatibilities 26 | 27 | ## 0.5.14 28 | 29 | - Remove println from scp code 30 | 31 | ## 0.5.13 32 | 33 | - Make clojure dependency have provided scope 34 | Closes #25 35 | 36 | - Fix race condition in ssh-exec function when command finishes before we 37 | enter with-channel-connection 38 | 39 | - ssh/download: fix :recursive and :preserve flags arity errors. 40 | Recursive still doesn't work correctly: clj-ssh writes every file into the 41 | same output. 42 | 43 | - Fixed recursive scp invocation. 44 | 45 | - fix trace/tracef mixup. 46 | 47 | - Fix missing parens 48 | 49 | ## 0.5.12 50 | 51 | - Update jsch and jsch agentproxy 52 | jsch 0.1.53 agentproxy-version 0.0.9 53 | 54 | - Remove extra arg to throw 55 | This makes clj-ssh compatible with Clojure 1.8, which does not allow the 56 | extra argument to throw. 57 | 58 | ## 0.5.11 59 | 60 | - Lock known hosts file 61 | Try and prevent concurrent read and writes to the known hosts file. 62 | 63 | - Fix :out :stream in the cli namespace 64 | Caller becomes responsible for closing the session. 65 | 66 | Closes #29 67 | 68 | ## 0.5.10 69 | 70 | - Fix session? predicate 71 | 72 | - Factor out ssh-exec-proc and ssh-shell-proc 73 | These provide a lower level interface with more flexible stream handling. 74 | 75 | - Add java.awt.headless to default java opts 76 | 77 | ## 0.5.9 78 | 79 | - Publish new jars 80 | The 0.5.8 jars were not correctly published and are identical to 0.5.7. 81 | 82 | ## 0.5.8 83 | 84 | - Enable introspection of sessions 85 | Adds the session? predicate for testing for a Session object, an the 86 | session-hostname and session-port functions for querying a session. 87 | 88 | - Enable copying identities between agents 89 | The copy-identities can be used to copy identities from one agent to 90 | another. This is useful to allow copying identities from a system agent 91 | to a non system agent. 92 | 93 | - Add support for jump hosts 94 | The jump-session function is used to obtain a session that can be 95 | connected across jump hosts. 96 | 97 | The `the-session` function is added to obtain a jsch session from a 98 | connected jump-session, or a connected jsch Session, and can be used in 99 | code that wants to support jump sessions. 100 | 101 | - Add fingerprint function on keypairs 102 | 103 | - Allow keypair construction from just a public key 104 | 105 | - Update tools.logging to 0.2.6 106 | 107 | - Update to jsch 0.1.51 108 | Adds support for private keys in PKCS#8 format. 109 | 110 | Fixes several session crash issues. 111 | 112 | - Update to jsch.agentproxy 0.0.7 113 | 114 | ## 0.5.7 115 | 116 | - Update to jsch.agentproxy 0.0.6 117 | 118 | ## 0.5.6 119 | 120 | - Allow generate-keypair to write key files 121 | 122 | - Factor out keypair generation 123 | Simplifies add-identity by factoring out the keypair creation. 124 | 125 | - Update to jsch 0.1.50 126 | 127 | ## 0.5.5 128 | 129 | - Wrap open-channel exceptions 130 | When .openChannel throws an exception, wrap it in an ex-info exception. 131 | This allows easier procession of the exceptions in consuming code. 132 | 133 | ## 0.5.4 134 | 135 | - Ensure literal keys get a non-blank comment 136 | When adding a literal key, use "Added by clj-ssh" as the comment. 137 | 138 | - Ensure literal key strings are handled correctly 139 | 140 | 141 | ## 0.5.3 142 | 143 | - Add ssh/exit-status for querying exit code 144 | 145 | - Add type hints to session functions 146 | 147 | ## 0.5.2 148 | 149 | - Update to official jsch.agentproxy relase jars 150 | 151 | ## 0.5.1 152 | 153 | - Fix adding key string identities for ssh-agent 154 | Adding string based ssh keys to an ssh-agent was broken. 155 | 156 | ## 0.5.0 157 | 158 | - Require clojure 1.4.0 159 | Drops usage of slingshot. 160 | 161 | ## 0.4.4 162 | 163 | - Make ssh key test more robust 164 | The add-identity-test ssh-agent case was failing for no apparent reason. 165 | 166 | - Remove some reflection warnings 167 | 168 | ## 0.4.3 169 | 170 | - Use passphrase when adding key to agent, and honour the key name 171 | 172 | ## 0.4.2 173 | 174 | - Update to jsch 0.1.49 and enable adding keys to ssh-agent. Support for 175 | adding passphrase-less keys only. 176 | 177 | ## 0.4.1 178 | 179 | - add option to ssh-agent for :known-hosts-path. Fixes #16. 180 | 181 | - remap log levels to be less verbose by default. Fixes #17 182 | 183 | ## 0.4.0 184 | 185 | - Split out clj-ssh.cli 186 | 187 | clj-ssh.ssh is designed for composability and programmatic use. It takes 188 | map arguments for options and is fully functional. 189 | 190 | clj-ssh.cli is intended to simplify repl usage. It takes variadic 191 | arguments for options and uses dynamic vars to provide defaults. 192 | 193 | ## 0.3.3 194 | 195 | - Add a :agent-forwarding option 196 | A boolean value is passed with :agent-forwarding to clj-ssh.ssh/ssh. 197 | 198 | - Add support for system ssh-agent 199 | Support the system ssh-agent (or pageant on windows when using putty) via 200 | jsch-agent-proxy. Introduces a new agent function, clj-ssh.ssh/ssh-agent. 201 | 202 | ## 0.3.2 203 | 204 | - Add remote port forwarding support 205 | 206 | - Fix documentation for with-local-port-forward 207 | 208 | - Allow specification of session options as strings 209 | This should allow options like: 210 | (default-session-options {"GSSAPIAuthentication" "no"}) 211 | 212 | ## 0.3.1 213 | 214 | - Allow clj-ssh to work with a wide range of slingshot versions 215 | Tested with slingshot 0.2.0 and 0.10.1 216 | 217 | - Added SSH tunneling. 218 | 219 | ## 0.3.0 220 | 221 | * Changes 222 | 223 | - Remove use of monolithic contrib 224 | In preparation for clojure 1.3. Use slingshot instead of 225 | contrib.condition. Use local copy of contrib.reflect. 226 | 227 | - Switch to tools.logging 228 | 229 | - Allow specification of PipedInputStream buffer size 230 | The buffer size (in bytes) for the piped stream used to implement the 231 | :stream option for :out. If the ssh commands generate a high volume of 232 | output, then this buffer size can become a bottleneck. The buffer size 233 | can be specified by binding *piped-stream-buffer-size*, and defaults to 234 | 10Kb. 235 | 236 | - Add scp-from and scp-to, for copy files over ssh-exec 237 | Add support for scp using ssh-exec. copies files from a remote machine. 238 | copies to a remote machine. 239 | 240 | - Drop clojure 1.1.0 support 241 | -------------------------------------------------------------------------------- /brick.clj: -------------------------------------------------------------------------------- 1 | (defbrick clj-ssh 2 | :images {:vmfest [{:image {:os-family :ubuntu :os-version-matches "12.04" 3 | :os-64-bit true} 4 | :selectors [:default]}]}) 5 | -------------------------------------------------------------------------------- /profiles.clj: -------------------------------------------------------------------------------- 1 | {:dev 2 | {:dependencies [[ch.qos.logback/logback-classic "1.4.7"]] 3 | :aliases {"test" ["with-profile" 4 | "clojure-1.4.0:clojure-1.5.1:clojure-1.6.0:clojure-1.7.0:clojure-1.8.0:clojure-1.9.0:clojure-1.10.0:clojure-1.10.1:clojure-1.10.2:clojure-1.10.3:clojure-1.11.0:clojure-1.11.1" 5 | "test"]}} 6 | :clojure-1.4.0 {:dependencies [[org.clojure/clojure "1.4.0"]]} 7 | :clojure-1.5.1 {:dependencies [[org.clojure/clojure "1.5.1"]]} 8 | :clojure-1.6.0 {:dependencies [[org.clojure/clojure "1.6.0"]]} 9 | :clojure-1.7.0 {:dependencies [[org.clojure/clojure "1.7.0"]]} 10 | :clojure-1.8.0 {:dependencies [[org.clojure/clojure "1.8.0"]]} 11 | :clojure-1.9.0 {:dependencies [[org.clojure/clojure "1.9.0"]]} 12 | :clojure-1.10.0 {:dependencies [[org.clojure/clojure "1.10.0"]]} 13 | :clojure-1.10.1 {:dependencies [[org.clojure/clojure "1.10.1"]]} 14 | :clojure-1.10.2 {:dependencies [[org.clojure/clojure "1.10.2"]]} 15 | :clojure-1.10.3 {:dependencies [[org.clojure/clojure "1.10.3"]]} 16 | :clojure-1.11.0 {:dependencies [[org.clojure/clojure "1.11.0"]]} 17 | :clojure-1.11.1 {:dependencies [[org.clojure/clojure "1.11.1"]]} 18 | 19 | 20 | } 21 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject org.clj-commons/clj-ssh 2 | (or (System/getenv "PROJECT_VERSION") "0.6.0-SNAPSHOT") 3 | :description "Library for using SSH from clojure." 4 | :url "https://github.com/clj-commons/clj-ssh" 5 | :license {:name "Eclipse Public License" 6 | :url "http://www.eclipse.org/legal/epl-v10.html"} 7 | :deploy-repositories [["clojars" {:url "https://repo.clojars.org" 8 | :username :env/clojars_username 9 | :password :env/clojars_org_clj_commons_password 10 | :sign-releases true}]] 11 | 12 | :dependencies [[org.clojure/tools.logging "1.2.4" 13 | :exclusions [org.clojure/clojure]] 14 | [com.github.mwiede/jsch "0.2.15"] 15 | [net.java.dev.jna/jna "5.14.0"] 16 | [com.kohlschutter.junixsocket/junixsocket-core "2.8.3" :extension "pom"]] 17 | :jvm-opts ["-Djava.awt.headless=true"] 18 | :profiles {:provided {:dependencies [[org.clojure/clojure "1.10.1"]]}}) 19 | 20 | -------------------------------------------------------------------------------- /src/clj_ssh/agent.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.agent 2 | "Agent integration (using jsch-agent-proxy)" 3 | (:require 4 | [clojure.tools.logging :as logging]) 5 | (:import 6 | [com.jcraft.jsch 7 | JSch AgentProxyException AgentIdentityRepository 8 | PageantConnector SSHAgentConnector JUnixSocketFactory])) 9 | 10 | (defn sock-agent-connector 11 | [] 12 | (try 13 | (let [con (SSHAgentConnector.)] 14 | (when (.isAvailable con) 15 | con)) 16 | (catch AgentProxyException e 17 | (logging/warnf 18 | e "Failed to load JNA connector, although SSH_AUTH_SOCK is set")))) 19 | 20 | (defn pageant-connector 21 | [] 22 | (try 23 | (let [con (PageantConnector.)] 24 | (when (.isAvailable con) 25 | con)) 26 | (catch AgentProxyException e 27 | (logging/warn 28 | e "Failed to load Pageant connector, although running on windows")))) 29 | 30 | (defn connect 31 | "Connect the specified jsch object to the system ssh-agent." 32 | [^JSch jsch] 33 | (when-let [connector (or (sock-agent-connector) (pageant-connector))] 34 | (doto jsch 35 | ;(.setConfig "PreferredAuthentications" "publickey") 36 | (.setIdentityRepository (AgentIdentityRepository. connector))))) 37 | -------------------------------------------------------------------------------- /src/clj_ssh/cli.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.cli 2 | "Provides a REPL friendly interface for clj-ssh. 3 | 4 | (use 'clj-ssh.cli) 5 | 6 | Use `ssh` to execute a command, say `ls`, on a remote host \"my-host\", 7 | 8 | (ssh \"my-host\" \"ls\") 9 | => {:exit 0 :out \"file1\\nfile2\\n\" :err \"\") 10 | 11 | By default this will use the system ssh-agent to obtain your ssh keys, and it 12 | uses your current username, but this can be specified: 13 | 14 | (ssh \"my-host\" \"ls\" :username \"remote-user\") 15 | => {:exit 0 :out \"file1\\nfile2\\n\" :err \"\") 16 | 17 | Strict host key checking can be turned off: 18 | 19 | (default-session-options {:strict-host-key-checking :no}) 20 | 21 | SFTP is also supported. For example, to copy a local file to a remote host 22 | \"my-host\": 23 | 24 | (sftp \"my-host\" :put \"/from/this/path\" \"to/this/path\") 25 | 26 | Note that any sftp commands that change the state of the sftp session (such as 27 | cd) do not work with the simplified interface, as a new session is created each 28 | time. 29 | 30 | If your key has a passphrase, you will need to explicitly add your key either to 31 | the system's ssh-agent, or to clj-ssh's ssh-agent with the appropriate 32 | `add-identity` call." 33 | (:require 34 | [clj-ssh.ssh :as ssh] 35 | [clojure.string :as string])) 36 | 37 | ;;; Agent 38 | (def ^{:doc "SSH agent used to manage identities." :dynamic true} 39 | *ssh-agent* (ssh/ssh-agent {})) 40 | 41 | (defmacro with-ssh-agent 42 | "Bind an ssh-agent for use as identity manager. An existing agent instance is 43 | passed as the first argument." 44 | [agent & body] 45 | `(binding [*ssh-agent* ~agent] ~@body)) 46 | 47 | ;;; Identities 48 | (defn has-identity? 49 | "Check if the given identity is present." 50 | [name] (ssh/has-identity? *ssh-agent* name)) 51 | 52 | (defn add-identity 53 | "Add an identity to the agent. 54 | 55 | :private-key A string specifying the private key 56 | :public-key A string specifying the public key 57 | :private-key-path A string specifying a path to the private key 58 | :public-key-path A string specifying a path to the public key 59 | :identity A jsch Identity object (see make-identity) 60 | :passphrase A byte array containing the passphrase" 61 | [& {:keys [agent 62 | ^String name 63 | ^String public-key 64 | ^String private-key 65 | public-key-path 66 | private-key-path 67 | ^Identity identity 68 | ^bytes passphrase] 69 | :or {agent *ssh-agent*} 70 | :as options}] 71 | {:pre [(map? options)]} 72 | (ssh/add-identity agent (dissoc options :agent))) 73 | 74 | (defn add-identity-with-keychain 75 | "Add a private key, only if not already known, using the keychain to obtain 76 | a passphrase if required" 77 | [& {:keys [agent 78 | ^String name 79 | ^String public-key-path 80 | ^String private-key-path 81 | ^Identity identity 82 | ^bytes passphrase] 83 | :or {agent *ssh-agent*} 84 | :as options}] 85 | (ssh/add-identity-with-keychain agent (dissoc options :agent))) 86 | 87 | ;;; Session 88 | (def 89 | ^{:doc "Default SSH options" 90 | :dynamic true} 91 | *default-session-options* {}) 92 | 93 | (defmacro with-default-session-options 94 | "Set the default session options" 95 | [options & body] 96 | `(binding [*default-session-options* ~options] 97 | ~@body)) 98 | 99 | (defn default-session-options 100 | "Set the default session options" 101 | [options] 102 | {:pre [(map? options)]} 103 | (alter-var-root #'*default-session-options* #(identity %2) options)) 104 | 105 | (defn- default-session [agent hostname options] 106 | (ssh/session agent hostname (merge *default-session-options* options))) 107 | 108 | (defn session 109 | "Start a SSH session. 110 | Requires hostname. you can also pass values for :username, :password and :port 111 | keys. All other option key pairs will be passed as SSH config options." 112 | [hostname & {:keys [port username agent password] 113 | :or {agent *ssh-agent* port 22} 114 | :as options}] 115 | (default-session agent hostname (dissoc options :agent))) 116 | 117 | ;;; Operations 118 | (defn- parse-args 119 | "Takes a seq of 'ssh' arguments and returns a map of option keywords 120 | to option values." 121 | [args] 122 | (loop [[arg :as args] args 123 | opts {:args []}] 124 | (if-not args 125 | opts 126 | (if (keyword? arg) 127 | (recur (nnext args) (assoc opts arg (second args))) 128 | (recur (next args) (update-in opts [:args] conj arg)))))) 129 | 130 | (defn ssh 131 | "Execute commands over ssh. 132 | 133 | Options are: 134 | 135 | :cmd specifies a command to exec. If no cmd is given, a shell is started 136 | and input is taken from :in. 137 | :in specifies input to the remote shell. A string or a stream. 138 | 139 | :out specify :stream to obtain a an [inputstream shell] 140 | specify :bytes to obtain a byte array 141 | or specify a string with an encoding specification for a 142 | result string. 143 | In the case of :stream, the shell can be polled for connected 144 | status, and the session (in the :session key of the return value) 145 | must be disconnected by the caller. 146 | :username username to use for authentication 147 | :password password to use for authentication 148 | :port port to use if no session specified 149 | 150 | sh returns a map of 151 | :exit => sub-process's exit code 152 | :out => sub-process's stdout (as byte[] or String) 153 | :err => sub-process's stderr (as byte[] or String)" 154 | [hostname & args] 155 | (let [{:keys [cmd in out username password port ssh-agent args] 156 | :or {ssh-agent *ssh-agent*} 157 | :as options} (parse-args args) 158 | session (default-session ssh-agent hostname options) 159 | arg (if (seq args) 160 | (merge {:cmd (string/join " " args)} options) 161 | options)] 162 | (if (= :stream (:out options)) 163 | (do 164 | (ssh/connect session) 165 | (assoc (ssh/ssh session arg) 166 | :session session)) 167 | (ssh/with-connection session 168 | (ssh/ssh session arg))))) 169 | 170 | (defn sftp 171 | "Execute SFTP commands. 172 | 173 | sftp host-or-session cmd & options 174 | 175 | cmd specifies a command to exec. Valid commands are: 176 | :ls 177 | :put 178 | :get 179 | :chmod 180 | :chown 181 | :chgrp 182 | :cd 183 | :lcd 184 | :pwd 185 | :lpwd 186 | :rm 187 | :rmdir 188 | :stat 189 | :symlink 190 | 191 | Options are 192 | :username username to use for authentication 193 | :password password to use for authentication 194 | :port port to use if no session specified 195 | :with-monitor 196 | :modes" 197 | [hostname cmd & args] 198 | (let [{:keys [ssh-agent args] 199 | :or {ssh-agent *ssh-agent*} 200 | :as options} (parse-args args) 201 | session (default-session ssh-agent hostname options)] 202 | (ssh/with-connection session 203 | (apply ssh/sftp session (dissoc options :args) cmd args)))) 204 | 205 | ;;; Keypairs 206 | (defn generate-keypair 207 | "Generate a keypair, returned as [private public] byte arrays. 208 | Valid types are :rsa and :dsa. key-size is in bytes. passphrase 209 | can be a string or byte array." 210 | ([key-type key-size passphrase] 211 | (ssh/generate-keypair *ssh-agent* key-type key-size passphrase))) 212 | -------------------------------------------------------------------------------- /src/clj_ssh/keychain.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.keychain 2 | "Primitive keychain support for clj-ssh. Only implemented on OSX at the 3 | moment." 4 | (:require 5 | [clojure.tools.logging :as logging] 6 | [clojure.java.shell :as shell])) 7 | 8 | (defn ask-passphrase [path] 9 | (if-let [console (. System console)] 10 | (do (print "Passphrase for" path ": ") 11 | (.readPassword console)) 12 | (throw (ex-info "No means to ask for passphrase" 13 | {:type :clj-ssh/no-passphrase-available})))) 14 | 15 | (defmulti keychain-passphrase "Obtain password for path" 16 | (fn [system path] system)) 17 | 18 | (defmethod keychain-passphrase :default 19 | [system path] 20 | (logging/warn "Passphrase required, but no keychain implemented.") 21 | (ask-passphrase path)) 22 | 23 | (defmethod keychain-passphrase "Mac OS X" 24 | [system path] 25 | (let [result (shell/sh 26 | "/usr/bin/security" "find-generic-password" "-a" 27 | (format "%s" path) 28 | "-g")] 29 | (when (zero? (result :exit)) 30 | (when-let [^String pw (second 31 | (re-find #"password: \"(.*)\"" (result :err)))] 32 | (.getBytes pw "UTF-8"))))) 33 | 34 | (defn passphrase 35 | "Obtain a passphrase for the given key path" 36 | [path] 37 | (keychain-passphrase (System/getProperty "os.name") path)) 38 | -------------------------------------------------------------------------------- /src/clj_ssh/reflect.clj: -------------------------------------------------------------------------------- 1 | ;;;; taken from clojure.contrib.reflect 1.2.0 2 | 3 | ;;; Copyright (c) 2010 Stuart Halloway & Contributors. All rights 4 | ;;; reserved. The use and distribution terms for this software are 5 | ;;; covered by the Eclipse Public License 1.0 6 | ;;; (http://opensource.org/licenses/eclipse-1.0.php) which can be 7 | ;;; found in the file epl-v10.html at the root of this distribution. 8 | ;;; By using this software in any fashion, you are agreeing to be 9 | ;;; bound by the terms of this license. You must not remove this 10 | ;;; notice, or any other, from this software. 11 | 12 | (ns clj-ssh.reflect) 13 | 14 | (defn call-method 15 | "Calls a private or protected method. 16 | 17 | params is a vector of classes which correspond to the arguments to 18 | the method e 19 | 20 | obj is nil for static methods, the instance object otherwise. 21 | 22 | The method-name is given a symbol or a keyword (something Named)." 23 | [^Class klass method-name params obj & args] 24 | (-> klass (.getDeclaredMethod (name method-name) 25 | (into-array Class params)) 26 | (doto (.setAccessible true)) 27 | (.invoke obj (into-array Object args)))) 28 | 29 | (defn get-field 30 | "Access to private or protected field. field-name is a symbol or 31 | keyword." 32 | [^Class klass field-name obj] 33 | (-> 34 | klass 35 | (.getDeclaredField (name field-name)) 36 | (doto (.setAccessible true)) 37 | (.get obj))) 38 | -------------------------------------------------------------------------------- /src/clj_ssh/ssh.clj: -------------------------------------------------------------------------------- 1 | (ns ^{:author "Hugo Duncan"} 2 | clj-ssh.ssh 3 | "API for using SSH in clojure. 4 | 5 | ## Usage 6 | 7 | (use 'clj-ssh.ssh) 8 | 9 | (let [agent (ssh-agent {})] 10 | (add-identity agent {:private-key-path path-to-private-key}) 11 | (let [session (session agent hostname :strict-host-key-checking :no)] 12 | (with-connection session 13 | (let [result (ssh session {:in commands-string})] 14 | (println (:out result))) 15 | (let [result (ssh session {:cmd some-cmd-string})] 16 | (println (:out result)))))) 17 | 18 | (let [agent (ssh-agent {})] 19 | (let [session (session agent \"localhost\" {:strict-host-key-checking :no})] 20 | (with-connection session 21 | (let [channel (ssh-sftp session)] 22 | (with-channel-connection channel 23 | (sftp channel :cd \"/remote/path\") 24 | (sftp channel :put \"/some/file\" \"filename\"))))))" 25 | (:require 26 | [clj-ssh.agent :as agent] 27 | [clj-ssh.keychain :as keychain] 28 | [clj-ssh.reflect :as reflect] 29 | [clj-ssh.ssh.protocols :as protocols] 30 | [clojure.java.io :as io] 31 | [clojure.string :as string] 32 | [clojure.tools.logging :as logging]) 33 | (:import 34 | [java.io 35 | File InputStream OutputStream StringReader 36 | FileInputStream FileOutputStream 37 | ByteArrayInputStream ByteArrayOutputStream 38 | PipedInputStream PipedOutputStream] 39 | [com.jcraft.jsch 40 | JSch Session Channel ChannelShell ChannelExec ChannelSftp JSchException 41 | Identity IdentityFile IdentityRepository Logger KeyPair LocalIdentityRepository])) 42 | 43 | ;;; forward jsch's logging to java logging 44 | (def ^{:dynamic true} 45 | ssh-log-levels 46 | (atom 47 | {com.jcraft.jsch.Logger/DEBUG :trace 48 | com.jcraft.jsch.Logger/INFO :debug 49 | com.jcraft.jsch.Logger/WARN :warn 50 | com.jcraft.jsch.Logger/ERROR :error 51 | com.jcraft.jsch.Logger/FATAL :fatal})) 52 | 53 | (deftype SshLogger 54 | [log-level] 55 | com.jcraft.jsch.Logger 56 | (isEnabled 57 | [_ level] 58 | (>= level log-level)) 59 | (log 60 | [_ level message] 61 | (logging/log "clj-ssh.ssh" (@ssh-log-levels level) nil message))) 62 | 63 | (JSch/setLogger (SshLogger. com.jcraft.jsch.Logger/DEBUG)) 64 | 65 | ;;; Helpers 66 | (defn- ^String file-path [string-or-file] 67 | (if (string? string-or-file) 68 | string-or-file 69 | (.getPath ^File string-or-file))) 70 | 71 | (defn ^String capitalize 72 | "Converts first character of the string to upper-case." 73 | [^String s] 74 | (if (< (count s) 2) 75 | (.toUpperCase s) 76 | (str (.toUpperCase ^String (subs s 0 1)) 77 | (subs s 1)))) 78 | 79 | (defn- camelize [^String a] 80 | (apply str (map capitalize (.split a "-")))) 81 | 82 | (defn- ^String as-string [arg] 83 | (cond 84 | (symbol? arg) (name arg) 85 | (keyword? arg) (name arg) 86 | :else (str arg))) 87 | 88 | (def ^java.nio.charset.Charset ascii 89 | (java.nio.charset.Charset/forName "US-ASCII")) 90 | 91 | (def ^java.nio.charset.Charset utf-8 92 | (java.nio.charset.Charset/forName "UTF-8")) 93 | 94 | (defn- ^{:tag (Class/forName "[B")} as-bytes 95 | "Return arg as a byte array. arg must be a string or a byte array." 96 | [arg] 97 | (if (string? arg) 98 | (.getBytes ^String arg ascii) 99 | arg)) 100 | 101 | (defn ssh-agent? 102 | "Predicate to test for an ssh-agent." 103 | [object] (instance? JSch object)) 104 | 105 | ;;; Session extension 106 | 107 | ;; This is only relevant if you want to support using jump hosts. If 108 | ;; you do, the you should always use the `the-session` to get the jsch 109 | ;; session object once connected. 110 | 111 | ;; This is here since JSch Session has a package scoped constructor, 112 | ;; and doesn't implement an interface, so provides no means for 113 | ;; extending it. 114 | 115 | (extend-protocol protocols/Session 116 | Session 117 | (connect 118 | ([session] (.connect session)) 119 | ([session timeout] (.connect session timeout))) 120 | (connected? [session] (.isConnected session)) 121 | (disconnect [session] (.disconnect session)) 122 | (session [session] session)) 123 | 124 | (defn ^Session the-session 125 | "Return the JSch session for the given session." 126 | [session] 127 | (protocols/session session)) 128 | 129 | (defn session? 130 | "Predicate to test for a session" 131 | [x] 132 | (satisfies? protocols/Session x)) 133 | 134 | ;;; Agent 135 | (def ^:private hosts-file 136 | "Something to lock to tray and prevent concurrent updates/reads to 137 | hosts file." 138 | (Object.)) 139 | 140 | (defn ssh-agent 141 | "Create a ssh-agent. By default a system ssh-agent is preferred." 142 | [{:keys [use-system-ssh-agent ^String known-hosts-path] 143 | :or {use-system-ssh-agent true 144 | known-hosts-path (str (. System getProperty "user.home") 145 | "/.ssh/known_hosts")}}] 146 | (let [agent (JSch.)] 147 | (when use-system-ssh-agent 148 | (agent/connect agent)) 149 | (when known-hosts-path 150 | (locking hosts-file 151 | (.setKnownHosts agent known-hosts-path))) 152 | agent)) 153 | 154 | ;;; Identities 155 | (defn has-identity? 156 | "Check if the given identity is present." 157 | [^JSch agent name] 158 | (some #(= name %) (.getIdentityNames agent))) 159 | 160 | (defn ^Identity make-identity 161 | "Create a JSch identity. This can be used to check whether the key is 162 | encrypted." 163 | [^JSch agent ^String private-key-path ^String public-key-path] 164 | (logging/tracef "Make identity %s %s" private-key-path public-key-path) 165 | (.addIdentity agent private-key-path public-key-path nil) 166 | (.lastElement (.getIdentities (.getIdentityRepository agent)))) 167 | 168 | (defn ^KeyPair keypair 169 | "Return a KeyPair object for the given options. 170 | 171 | :private-key A string specifying the private key 172 | :public-key A string specifying the public key 173 | :private-key-path A string specifying a path to the private key 174 | :public-key-path A string specifying a path to the public key 175 | :passphrase A byte array containing the passphrase 176 | :comment A comment for the key" 177 | [^JSch agent {:keys [^String public-key 178 | ^String private-key 179 | ^String public-key-path 180 | ^String private-key-path 181 | ^String comment 182 | ^bytes passphrase] 183 | :as options}] 184 | {:pre [(map? options)]} 185 | (cond 186 | private-key 187 | (let [^KeyPair keypair 188 | (KeyPair/load agent (as-bytes private-key) (as-bytes public-key))] 189 | (when passphrase 190 | (.decrypt keypair passphrase)) 191 | (.setPublicKeyComment keypair comment) 192 | keypair) 193 | 194 | public-key 195 | (let [^KeyPair keypair (KeyPair/load agent nil (as-bytes public-key))] 196 | (.setPublicKeyComment keypair comment) 197 | keypair) 198 | 199 | (and public-key-path private-key-path) 200 | (let [keypair (KeyPair/load agent private-key-path public-key-path)] 201 | (when passphrase 202 | (.decrypt keypair passphrase)) 203 | (.setPublicKeyComment keypair comment) 204 | keypair) 205 | 206 | private-key-path 207 | (let [keypair (KeyPair/load agent private-key-path)] 208 | (when passphrase 209 | (.decrypt keypair passphrase)) 210 | (.setPublicKeyComment keypair comment) 211 | keypair) 212 | 213 | :else 214 | (throw 215 | (ex-info 216 | "Don't know how to create keypair" 217 | {:reason :do-not-know-how-to-create-keypair 218 | :args options})))) 219 | 220 | (defn fingerprint 221 | "Return a keypair's fingerprint." 222 | [^KeyPair keypair] 223 | (.getFingerPrint keypair)) 224 | 225 | (defn copy-identities 226 | [^JSch from-agent ^JSch to-agent] 227 | (let [^IdentityRepository ir (.getIdentityRepository from-agent)] 228 | (doseq [^Identity id (.getIdentities ir)] 229 | (.addIdentity to-agent id nil)))) 230 | 231 | ;; JSch's IdentityFile has a private constructor that would let us avoid this 232 | ;; were it public. 233 | (deftype KeyPairIdentity [^JSch jsch ^String identity ^KeyPair kpair] 234 | Identity 235 | (^boolean setPassphrase [_ ^bytes passphrase] (.. kpair (decrypt passphrase))) 236 | (getPublicKeyBlob [_] (.. kpair getPublicKeyBlob)) 237 | (^bytes getSignature [_ ^bytes data] (.. kpair (getSignature data))) 238 | (^bytes getSignature [_ ^bytes data ^String alg] (.. kpair (getSignature data alg))) 239 | (getAlgName [_] 240 | (.getKeyTypeString kpair)) 241 | (getName [_] identity) 242 | (isEncrypted [_] (.. kpair isEncrypted)) 243 | (clear [_] (.. kpair dispose))) 244 | 245 | (defn add-identity 246 | "Add an identity to the agent. The identity is passed with the :identity 247 | keyword argument, or constructed from the other keyword arguments. 248 | 249 | :private-key A string specifying the private key 250 | :public-key A string specifying the public key 251 | :private-key-path A string specifying a path to the private key 252 | :public-key-path A string specifying a path to the public key 253 | :identity A jsch Identity object (see make-identity) 254 | :passphrase A byte array containing the passphrase" 255 | [^JSch agent {:keys [^String name 256 | ^String public-key 257 | ^String private-key 258 | ^String public-key-path 259 | ^String private-key-path 260 | ^Identity identity 261 | ^bytes passphrase] 262 | :as options}] 263 | {:pre [(map? options)]} 264 | (let [^String comment (or name private-key-path public-key) 265 | ^Identity identity 266 | (or identity 267 | (KeyPairIdentity. 268 | agent comment (keypair agent (assoc options :comment comment))))] 269 | (.addIdentity agent identity passphrase))) 270 | 271 | (defn add-identity-with-keychain 272 | "Add a private key, only if not already known, using the keychain to obtain 273 | a passphrase if required" 274 | [^JSch agent {:keys [^String name 275 | ^String public-key-path 276 | ^String private-key-path 277 | ^Identity identity 278 | ^bytes passphrase] 279 | :as options}] 280 | (logging/debugf 281 | "add-identity-with-keychain has-identity? %s" (has-identity? agent name)) 282 | (when-not (has-identity? agent name) 283 | (let [name (or name private-key-path) 284 | public-key-path (or public-key-path (str private-key-path ".pub")) 285 | identity (if private-key-path 286 | (make-identity 287 | agent 288 | (file-path private-key-path) 289 | (file-path public-key-path)))] 290 | (logging/debugf 291 | "add-identity-with-keychain is-encrypted? %s" (.isEncrypted identity)) 292 | (if (.isEncrypted identity) 293 | (if-let [passphrase (keychain/passphrase private-key-path)] 294 | (add-identity agent (assoc options :passphrase passphrase)) 295 | (do 296 | (logging/error "Passphrase required, but none findable.") 297 | (throw 298 | (ex-info 299 | (str "Passphrase required for key " name ", but none findable.") 300 | {:reason :passphrase-not-found 301 | :key-name name})))) 302 | (add-identity agent options))))) 303 | 304 | ;;; Sessions 305 | (defn- init-session 306 | "Initialise options on a session" 307 | [^Session session ^String password options] 308 | (when password 309 | (.setPassword session password)) 310 | (doseq [[k v :as option] options] 311 | (.setConfig 312 | session 313 | (if (string? k) 314 | k 315 | (camelize (as-string k))) 316 | (as-string v)))) 317 | 318 | (defn- ^Session session-impl 319 | [^JSch agent hostname username port ^String password options] 320 | (doto (.getSession agent username hostname port) 321 | (init-session password options))) 322 | 323 | (defn- session-options 324 | [options] 325 | (dissoc options :username :port :password :agent)) 326 | 327 | (defn ^Session session 328 | "Start a SSH session. 329 | Requires hostname. You can also pass values for :username, :password and :port 330 | keys. All other option key pairs will be passed as SSH config options." 331 | [^JSch agent hostname 332 | {:keys [port username password] :or {port 22} :as options}] 333 | (session-impl 334 | agent hostname 335 | (or username (System/getProperty "user.name")) 336 | port 337 | password 338 | (session-options options))) 339 | 340 | (defn ^String session-hostname 341 | "Return the hostname for a session" 342 | [^Session session] 343 | (.getHost session)) 344 | 345 | (defn ^int session-port 346 | "Return the port for a session" 347 | [^Session session] 348 | (.getPort session)) 349 | 350 | (defn forward-remote-port 351 | "Start remote port forwarding" 352 | ([^Session session remote-port local-port ^String local-host] 353 | (.setPortForwardingR 354 | session (int remote-port) local-host (int local-port))) 355 | ([session remote-port local-port] 356 | (forward-remote-port session remote-port local-port "localhost"))) 357 | 358 | (defn unforward-remote-port 359 | "Remove remote port forwarding" 360 | [^Session session remote-port] 361 | (.delPortForwardingR session remote-port)) 362 | 363 | (defmacro with-remote-port-forward 364 | "Creates a context in which a remote SSH tunnel is established for the 365 | session. (Use after the connection is opened.)" 366 | [[session remote-port local-port & [local-host & _]] & body] 367 | `(try 368 | (forward-remote-port 369 | ~session ~remote-port ~local-port ~(or local-host "localhost")) 370 | ~@body 371 | (finally 372 | (unforward-remote-port ~session ~remote-port)))) 373 | 374 | (defn forward-local-port 375 | "Start local port forwarding. Returns the actual local port." 376 | ([^Session session local-port remote-port remote-host] 377 | (.setPortForwardingL session local-port remote-host remote-port)) 378 | ([session local-port remote-port] 379 | (forward-local-port session local-port remote-port "localhost"))) 380 | 381 | (defn unforward-local-port 382 | "Remove local port forwarding" 383 | [^Session session local-port] 384 | (.delPortForwardingL session local-port)) 385 | 386 | (defmacro with-local-port-forward 387 | "Creates a context in which a local SSH tunnel is established for the session. 388 | (Use after the connection is opened.)" 389 | [[session local-port remote-port & [remote-host & _]] & body] 390 | `(try 391 | (forward-local-port 392 | ~session ~local-port ~remote-port ~(or remote-host "localhost")) 393 | ~@body 394 | (finally 395 | (unforward-local-port ~session ~local-port)))) 396 | 397 | (defn connect 398 | "Connect a session." 399 | ([session] 400 | (locking hosts-file 401 | (protocols/connect session))) 402 | ([session timeout] 403 | (locking hosts-file 404 | (protocols/connect session timeout)))) 405 | 406 | (defn disconnect 407 | "Disconnect a session." 408 | [session] 409 | (protocols/disconnect session) 410 | (when-let [^Thread t (and (instance? Session session) 411 | (reflect/get-field 412 | com.jcraft.jsch.Session 'connectThread session))] 413 | (when (.isAlive t) 414 | (.interrupt t)))) 415 | 416 | (defn connected? 417 | "Predicate used to test for a connected session." 418 | [session] 419 | (protocols/connected? session)) 420 | 421 | (defmacro with-connection 422 | "Creates a context in which the session is connected. Ensures the session is 423 | disconnected on exit." 424 | [session & body] 425 | `(let [session# ~session] 426 | (try 427 | (when-not (connected? session#) 428 | (connect session#)) 429 | ~@body 430 | (finally 431 | (disconnect session#))))) 432 | 433 | ;;; Jump Hosts 434 | (defn- jump-connect [agent hosts sessions timeout] 435 | (let [host (first hosts) 436 | s (session agent (:hostname host) (dissoc host :hostname)) 437 | throw-e (fn [e s] 438 | (throw 439 | (ex-info 440 | (str "Failed to connect " 441 | (.getUserName s) "@" 442 | (.getHost s) ":" 443 | (.getPort s) 444 | " " (pr-str (into [] (.getIdentityNames agent))) 445 | " " (pr-str hosts)) 446 | {:hosts hosts} 447 | e)))] 448 | (swap! sessions (fnil conj []) s) 449 | (try 450 | (connect s timeout) 451 | (catch Exception e (throw-e e s))) 452 | (.setDaemonThread s true) 453 | (loop [hosts (rest hosts) 454 | prev-s s] 455 | (if-let [{:keys [hostname port username password] 456 | :or {port 22} 457 | :as options} 458 | (first hosts)] 459 | (let [p (forward-local-port prev-s 0 port hostname) 460 | options (-> options 461 | (dissoc :hostname) 462 | (assoc :port p)) 463 | s (session agent "localhost" options)] 464 | (.setDaemonThread s true) 465 | (.setHostKeyAlias s hostname) 466 | (swap! sessions conj s) 467 | (try 468 | (connect s timeout) 469 | (catch Exception e (throw-e e s))) 470 | (recur (rest hosts) s)))))) 471 | 472 | (defn- jump-connected? [sessions] 473 | (seq @sessions)) 474 | 475 | (defn- jump-disconnect 476 | [sessions] 477 | (doseq [s (reverse @sessions)] 478 | (.disconnect s)) 479 | (reset! sessions nil)) 480 | 481 | (defn- jump-the-session 482 | [sessions] 483 | (assert (jump-connected? sessions) "not connected") 484 | (last @sessions)) 485 | 486 | (deftype JumpHostSession [agent hosts sessions timeout] 487 | protocols/Session 488 | (connect [session] (protocols/connect session timeout)) 489 | (connect [session timeout] (jump-connect agent hosts sessions timeout)) 490 | (connected? [session] (jump-connected? sessions)) 491 | (disconnect [session] (jump-disconnect sessions)) 492 | (session [session] (jump-the-session sessions))) 493 | 494 | ;; http://www.jcraft.com/jsch/examples/JumpHosts.java.html 495 | (defn jump-session 496 | "Connect via a sequence of jump hosts. Returns a session. Once the 497 | session is connected, use `the-session` to get a jsch Session object. 498 | 499 | Each host is a map with :hostname, :username, :password and :port 500 | keys. All other key pairs in each host map will be passed as SSH 501 | config options." 502 | [^JSch agent hosts {:keys [timeout]}] 503 | (when-not (seq hosts) 504 | (throw (ex-info "Must provide at least one host to connect to" 505 | {:hosts hosts}))) 506 | (JumpHostSession. agent hosts (atom []) (or timeout 0))) 507 | 508 | ;;; Channels 509 | (defn connect-channel 510 | "Connect a channel." 511 | [^Channel channel] 512 | (.connect channel)) 513 | 514 | (defn disconnect-channel 515 | "Disconnect a session." 516 | [^Channel channel] 517 | (.disconnect channel)) 518 | 519 | (defn connected-channel? 520 | "Predicate used to test for a connected channel." 521 | [^Channel channel] 522 | (.isConnected channel)) 523 | 524 | (defmacro with-channel-connection 525 | "Creates a context in which the channel is connected. Ensures the channel is 526 | disconnected on exit." 527 | [channel & body] 528 | `(let [channel# ~channel] 529 | (try 530 | (when-not (connected-channel? channel#) 531 | (connect-channel channel#)) 532 | ~@body 533 | (finally 534 | (disconnect-channel channel#))))) 535 | 536 | (defn open-channel 537 | "Open a channel of the specified type in the session." 538 | [^Session session session-type] 539 | (try 540 | (.openChannel session (name session-type)) 541 | (catch JSchException e 542 | (let [msg (.getMessage e)] 543 | (cond 544 | (= msg "session is down") 545 | (throw (ex-info (format "clj-ssh open-channel failure: %s" msg) 546 | {:type :clj-ssh/open-channel-failure 547 | :reason :clj-ssh/session-down} 548 | e)) 549 | (= msg "channel is not opened.") 550 | (throw (ex-info 551 | (format 552 | "clj-ssh open-channel failure: %s (possible session timeout)" 553 | msg) 554 | {:type :clj-ssh/open-channel-failure 555 | :reason :clj-ssh/channel-open-failed} 556 | e)) 557 | :else (throw (ex-info (format "clj-ssh open-channel failure: %s" msg) 558 | {:type :clj-ssh/open-channel-failure 559 | :reason :clj-ssh/unknown} 560 | e))))))) 561 | 562 | (defn sftp-channel 563 | "Open a SFTP channel in the session." 564 | [^Session session] 565 | (open-channel session :sftp)) 566 | 567 | (defn exec-channel 568 | "Open an Exec channel in the session." 569 | [^Session session] 570 | (open-channel session :exec)) 571 | 572 | (defn shell-channel 573 | "Open a Shell channel in the session." 574 | [^Session session] 575 | (open-channel session :shell)) 576 | 577 | (defn exit-status 578 | "Return the exit status of a channel." 579 | [^Channel channel] 580 | (.getExitStatus channel)) 581 | 582 | (def 583 | ^{:dynamic true 584 | :doc (str "The buffer size (in bytes) for the piped stream used to implement 585 | the :stream option for :out. If your ssh commands generate a high volume of 586 | output, then this buffer size can become a bottleneck. You might also 587 | increase the frequency with which you read the output stream if this is an 588 | issue.")} 589 | *piped-stream-buffer-size* (* 1024 10)) 590 | 591 | (defn- streams-for-out 592 | [out] 593 | (if (= :stream out) 594 | (let [os (PipedOutputStream.)] 595 | [os (PipedInputStream. os (int *piped-stream-buffer-size*))]) 596 | [(ByteArrayOutputStream.) nil])) 597 | 598 | (defn- streams-for-in 599 | [] 600 | (let [os (PipedInputStream. (int *piped-stream-buffer-size*))] 601 | [os (PipedOutputStream. os)])) 602 | 603 | (defn string-stream 604 | "Return an input stream with content from the string s." 605 | [^String s] 606 | {:pre [(string? s)]} 607 | (ByteArrayInputStream. (.getBytes s utf-8))) 608 | 609 | (defn ssh-shell-proc 610 | "Run a ssh-shell." 611 | [^Session session in {:keys [agent-forwarding pty out err] :as opts}] 612 | {:pre [in]} 613 | (let [^ChannelShell shell (open-channel session :shell)] 614 | (doto shell 615 | (.setInputStream in false)) 616 | (when out 617 | (.setOutputStream shell out)) 618 | (when (contains? opts :pty) 619 | (.setPty shell (boolean (opts :pty)))) 620 | (when (contains? opts :agent-forwarding) 621 | (.setAgentForwarding shell (boolean (opts :agent-forwarding)))) 622 | (connect-channel shell) 623 | {:channel shell 624 | :out (or out (.getInputStream shell)) 625 | :in (or in (.getOutputStream shell))})) 626 | 627 | (defn ssh-shell 628 | "Run a ssh-shell." 629 | [^Session session in out opts] 630 | (let [[out-stream out-inputstream] (streams-for-out out) 631 | resp (ssh-shell-proc 632 | session 633 | (if (string? in) (string-stream (str in ";exit $?;\n")) in) 634 | (merge {:out out-stream} opts)) 635 | ^ChannelShell shell (:channel resp)] 636 | (if out-inputstream 637 | {:channel shell :out-stream out-inputstream} 638 | (with-channel-connection shell 639 | (while (connected-channel? shell) 640 | (Thread/sleep 100)) 641 | {:exit (.getExitStatus shell) 642 | :out (if (= :bytes out) 643 | (.toByteArray ^ByteArrayOutputStream out-stream) 644 | (.toString out-stream))})))) 645 | 646 | (defn ssh-exec-proc 647 | "Run a command via exec, returning a map with the process streams." 648 | [^Session session ^String cmd 649 | {:keys [agent-forwarding pty in out err] :as opts}] 650 | (let [^ChannelExec exec (open-channel session :exec)] 651 | (doto exec 652 | (.setCommand cmd) 653 | (.setInputStream in false)) 654 | (when (contains? opts :pty) 655 | (.setPty exec (boolean (opts :pty)))) 656 | (when (contains? opts :agent-forwarding) 657 | (.setAgentForwarding exec (boolean (opts :agent-forwarding)))) 658 | 659 | (when out 660 | (.setOutputStream exec out)) 661 | (when err 662 | (.setErrStream exec err)) 663 | (let [resp {:channel exec 664 | :out (or out (.getInputStream exec)) 665 | :err (or err (.getErrStream exec)) 666 | :in (or in (.getOutputStream exec))}] 667 | (connect-channel exec) 668 | resp))) 669 | 670 | (defn ssh-exec 671 | "Run a command via ssh-exec." 672 | [^Session session ^String cmd in out opts] 673 | (let [[^PipedOutputStream out-stream 674 | ^PipedInputStream out-inputstream] (streams-for-out out) 675 | [^PipedOutputStream err-stream 676 | ^PipedInputStream err-inputstream] (streams-for-out out) 677 | proc (ssh-exec-proc 678 | session cmd 679 | (merge 680 | {:in (if (string? in) (string-stream in) in) 681 | :out out-stream 682 | :err err-stream} 683 | opts)) 684 | ^ChannelExec exec (:channel proc)] 685 | (if out-inputstream 686 | {:channel exec 687 | :out-stream out-inputstream 688 | :err-stream err-inputstream} 689 | (do (while (connected-channel? exec) 690 | (Thread/sleep 100)) 691 | {:exit (.getExitStatus exec) 692 | :out (if (= :bytes out) 693 | (.toByteArray ^ByteArrayOutputStream out-stream) 694 | (.toString out-stream)) 695 | :err (if (= :bytes out) 696 | (.toByteArray ^ByteArrayOutputStream err-stream) 697 | (.toString err-stream))})))) 698 | 699 | (defn ssh 700 | "Execute commands over ssh. 701 | 702 | Options are: 703 | 704 | :cmd specifies a command string to exec. If no cmd is given, a shell 705 | is started and input is taken from :in. 706 | :in specifies input to the remote shell. A string or a stream. 707 | :out specify :stream to obtain a an [inputstream shell] 708 | specify :bytes to obtain a byte array 709 | or specify a string with an encoding specification for a 710 | result string. In the case of :stream, the shell can 711 | be polled for connected status. 712 | 713 | sh returns a map of 714 | :exit => sub-process's exit code 715 | :out => sub-process's stdout (as byte[] or String) 716 | :err => sub-process's stderr (as byte[] or String)" 717 | [session {:keys [cmd in out] :as options}] 718 | (let [connected (connected? session)] 719 | (try 720 | (when-not connected 721 | (connect session)) 722 | (if cmd 723 | (ssh-exec session cmd in out (dissoc options :in :out :cmd)) 724 | (ssh-shell session in out (dissoc options :in :out :cmd)))))) 725 | 726 | (defn ssh-sftp 727 | "Obtain a connected ftp channel." 728 | [^Session session] 729 | {:pre (connected? session)} 730 | (let [channel (open-channel session :sftp)] 731 | (connect-channel channel) 732 | channel)) 733 | 734 | (defmacro memfn-varargs [name klass] 735 | `(fn [^{:tag ~klass} target# args#] 736 | (condp = (count args#) 737 | 0 (. target# (~name)) 738 | 1 (. target# (~name (first args#))) 739 | 2 (. target# (~name (first args#) (second args#))) 740 | 3 (. target# (~name (first args#) (second args#) (nth args# 2))) 741 | 4 (. target# 742 | (~name (first args#) (second args#) (nth args# 2) (nth args# 3))) 743 | 5 (. target# 744 | (~name (first args#) (second args#) (nth args# 2) (nth args# 3) 745 | (nth args# 4))) 746 | (throw 747 | (java.lang.IllegalArgumentException. 748 | (str "Too many arguments passed. Limit 5, passed " (count args#))))))) 749 | 750 | (def sftp-modemap { :overwrite ChannelSftp/OVERWRITE 751 | :resume ChannelSftp/RESUME 752 | :append ChannelSftp/APPEND }) 753 | 754 | (defn ssh-sftp-cmd 755 | "Command on a ftp channel." 756 | [^ChannelSftp channel cmd args options] 757 | (case cmd 758 | :ls (.ls channel (or (first args) ".")) 759 | :cd (.cd channel (first args)) 760 | :lcd (.lcd channel (first args)) 761 | :chmod (.chmod channel (first args) (second args)) 762 | :chown (.chown channel (first args) (second args)) 763 | :chgrp (.chgrp channel (first args) (second args)) 764 | :pwd (.pwd channel) 765 | :lpwd (.lpwd channel) 766 | :rm (.rm channel (first args)) 767 | :rmdir (.rmdir channel (first args)) 768 | :mkdir (.mkdir channel (first args)) 769 | :stat (.stat channel (first args)) 770 | :lstat (.lstat channel (first args)) 771 | :rename (.rename channel (first args) (second args)) 772 | :symlink (.symlink channel (first args) (second args)) 773 | :readlink (.readlink channel (first args)) 774 | :realpath (.realpath channel (first args)) 775 | :get-home (.getHome channel) 776 | :get-server-version (.getServerVersion channel) 777 | :get-extension (.getExtension channel (first args)) 778 | :get (let [args (if (options :with-monitor) 779 | (conj args (options :with-monitor)) 780 | args) 781 | args (if (options :mode) 782 | (conj args (sftp-modemap (options :mode))) 783 | args)] 784 | ((memfn-varargs get ChannelSftp) channel args)) 785 | :put (let [args (if (options :with-monitor) 786 | (conj args (options :with-monitor)) 787 | args) 788 | args (if (options :mode) 789 | (conj args (sftp-modemap (options :mode))) 790 | args)] 791 | ((memfn-varargs put ChannelSftp) channel args)) 792 | (throw 793 | (java.lang.IllegalArgumentException. (str "Unknown SFTP command " cmd))))) 794 | 795 | (defn sftp 796 | "Execute SFTP commands. 797 | 798 | sftp host-or-session options cmd & args 799 | 800 | cmd specifies a command to exec. Valid commands are: 801 | :ls 802 | :put 803 | :get 804 | :chmod 805 | :chown 806 | :chgrp 807 | :cd 808 | :lcd 809 | :pwd 810 | :lpwd 811 | :rm 812 | :rmdir 813 | :stat 814 | :symlink" 815 | [session-or-channel {:keys [with-monitor modes] :as opts} cmd & args] 816 | (let [channel-given (instance? com.jcraft.jsch.ChannelSftp session-or-channel) 817 | session-given (instance? com.jcraft.jsch.Session session-or-channel) 818 | session (when session-given session-or-channel) 819 | channel (if channel-given 820 | session-or-channel 821 | (ssh-sftp session))] 822 | (try 823 | (when (and session (not (connected? session))) 824 | (connect session)) 825 | (ssh-sftp-cmd channel cmd (vec args) opts) 826 | (finally 827 | (when-not channel-given 828 | (disconnect-channel channel)) 829 | (when-not (or session-given channel-given) 830 | (disconnect session)))))) 831 | 832 | (defn- scp-send-ack 833 | "Send acknowledgement to the specified output stream" 834 | ([^OutputStream out] (scp-send-ack out 0)) 835 | ([^OutputStream out code] 836 | (.write out (byte-array [(byte code)])) 837 | (.flush out))) 838 | 839 | (defn- scp-receive-ack 840 | "Check for an acknowledgement byte from the given input stream" 841 | [^InputStream in] 842 | (let [code (.read in)] 843 | (when-not (zero? code) 844 | (throw 845 | (ex-info 846 | (format 847 | "clj-ssh scp failure: %s" 848 | (case code 849 | 1 "scp error" 850 | 2 "scp fatal error" 851 | -1 "disconnect error" 852 | "unknown error")) 853 | {:type :clj-ssh/scp-failure}))))) 854 | 855 | (defn- scp-send-command 856 | "Send command to the specified output stream" 857 | [^OutputStream out ^InputStream in ^String cmd-string] 858 | (.write out (.getBytes (str cmd-string "\n"))) 859 | (.flush out) 860 | (logging/tracef "Sent command %s" cmd-string) 861 | (scp-receive-ack in) 862 | (logging/trace "Received ACK")) 863 | 864 | (defn- scp-receive-command 865 | "Receive command on the specified input stream" 866 | [^OutputStream out ^InputStream in] 867 | (let [buffer-size 1024 868 | buffer (byte-array buffer-size)] 869 | (let [cmd (loop [offset 0] 870 | (let [n (.read in buffer offset (- buffer-size offset))] 871 | (logging/tracef 872 | "scp-receive-command: %s" 873 | (String. buffer (int 0) (int (+ offset n)))) 874 | (if (= \newline (char (aget buffer (+ offset n -1)))) 875 | (String. buffer (int 0) (int (+ offset n))) 876 | (recur (+ offset n)))))] 877 | (logging/tracef "Received command %s" cmd) 878 | (scp-send-ack out) 879 | (logging/trace "Sent ACK") 880 | cmd))) 881 | 882 | (defn- scp-copy-file 883 | "Send acknowledgement to the specified output stream" 884 | [^OutputStream send ^InputStream recv ^File file {:keys [mode buffer-size preserve] 885 | :or {mode 0644 buffer-size 1492 preserve false}}] 886 | 887 | (when preserve 888 | (scp-send-command 889 | send recv 890 | (format "P%d 0 %d 0" (.lastModified file) (.lastModified file)))) 891 | (scp-send-command 892 | send recv 893 | (format "C%04o %d %s" mode (.length file) (.getName file))) 894 | (logging/tracef "Sending %s" (.getAbsolutePath file)) 895 | (io/copy file send :buffer-size buffer-size) 896 | (scp-send-ack send) 897 | (logging/trace "Receiving ACK after send") 898 | (scp-receive-ack recv)) 899 | 900 | (defn- scp-copy-dir 901 | "Send acknowledgement to the specified output stream" 902 | [send recv ^File dir {:keys [dir-mode] :or {dir-mode 0755} :as options}] 903 | (logging/tracef "Sending directory %s" (.getAbsolutePath dir)) 904 | (scp-send-command 905 | send recv 906 | (format "D%04o 0 %s" dir-mode (.getName dir))) 907 | (doseq [^File file (.listFiles dir)] 908 | (cond 909 | (.isFile file) (scp-copy-file send recv file options) 910 | (.isDirectory file) (scp-copy-dir send recv file options))) 911 | (scp-send-command send recv "E")) 912 | 913 | (defn- scp-files 914 | [paths recursive] 915 | (let [f (if recursive 916 | #(File. ^String %) 917 | (fn [^String path] 918 | (let [file (File. path)] 919 | (when (.isDirectory file) 920 | (throw 921 | (ex-info 922 | (format 923 | "Copy of dir %s requested without recursive flag" path) 924 | {:type :clj-ssh/scp-directory-copy-requested}))) 925 | file)))] 926 | (map f paths))) 927 | 928 | (defn session-cipher-none 929 | "Reset the session to use no cipher" 930 | [^Session session] 931 | (logging/trace "Set session to prefer none cipher") 932 | (doto session 933 | (.setConfig 934 | "cipher.s2c" "none,aes128-cbc,3des-cbc,blowfish-cbc") 935 | (.setConfig 936 | "cipher.c2s" "none,aes128-cbc,3des-cbc,blowfish-cbc") 937 | (.rekey))) 938 | 939 | (defn scp-parse-times 940 | [cmd] 941 | (let [s (StringReader. cmd)] 942 | (.skip s 1) ;; skip T 943 | (let [scanner (java.util.Scanner. s) 944 | mtime (.nextLong scanner) 945 | zero (.nextInt scanner) 946 | atime (.nextLong scanner)] 947 | [mtime atime]))) 948 | 949 | (defn scp-parse-copy 950 | [cmd] 951 | (let [s (StringReader. cmd)] 952 | (.skip s 1) ;; skip C or D 953 | (let [scanner (java.util.Scanner. s) 954 | mode (.nextInt scanner 8) 955 | length (.nextLong scanner) 956 | filename (.next scanner)] 957 | [mode length filename]))) 958 | 959 | (defn scp-sink-file 960 | "Sink a file" 961 | [^OutputStream send ^InputStream recv 962 | ^File file mode length {:keys [buffer-size] :or {buffer-size 2048}}] 963 | (logging/tracef "Sinking %d bytes to file %s" length (.getPath file)) 964 | (let [buffer (byte-array buffer-size)] 965 | (with-open [file-stream (FileOutputStream. file)] 966 | (loop [length length] 967 | (let [size (.read recv buffer 0 (min length buffer-size))] 968 | (when (pos? size) 969 | (.write file-stream buffer 0 size)) 970 | (when (and (pos? size) (< size length)) 971 | (recur (- length size)))))) 972 | (scp-receive-ack recv) 973 | (logging/trace "Received ACK after sink of file") 974 | (scp-send-ack send) 975 | (logging/trace "Sent ACK after sink of file"))) 976 | 977 | (defn scp-sink 978 | "Sink scp commands to file" 979 | [^OutputStream send ^InputStream recv ^File file times {:as options}] 980 | (let [cmd (scp-receive-command send recv)] 981 | (case (first cmd) 982 | \C (let [[mode length ^String filename] (scp-parse-copy cmd) 983 | file (if (and (.exists file) (.isDirectory file)) 984 | (doto (File. file filename) (.createNewFile)) 985 | (doto file (.createNewFile)))] 986 | (scp-sink-file send recv file mode length options) 987 | (when times 988 | (.setLastModified file (first times)))) 989 | \T (scp-sink send recv file (scp-parse-times cmd) options) 990 | \D (let [[mode ^String filename] (scp-parse-copy cmd) 991 | dir (File. file filename)] 992 | (when (and (.exists dir) (not (.isDirectory dir))) 993 | (.delete dir)) 994 | (when (not (.exists dir)) 995 | (.mkdir dir)) 996 | (scp-sink send recv dir nil options)) 997 | \E nil))) 998 | 999 | 1000 | ;; http://blogs.sun.com/janp/entry/how_the_scp_protocol_works 1001 | ;; https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works 1002 | (defn scp-to 1003 | "Copy local path(s) to remote path via scp. 1004 | 1005 | Options are: 1006 | 1007 | :username username to use for authentication 1008 | :password password to use for authentication 1009 | :port port to use if no session specified 1010 | :mode mode, as a 4 digit octal number (default 0644) 1011 | :dir-mode directory mode, as a 4 digit octal number (default 0755) 1012 | :recursive flag for recursive operation 1013 | :preserve flag for preserving mode, mtime and atime. atime is not available 1014 | in java, so is set to mtime. mode is not readable in java." 1015 | [session local-paths remote-path 1016 | & {:keys [username password port mode dir-mode recursive preserve] :as opts}] 1017 | (let [local-paths (if (sequential? local-paths) local-paths [local-paths]) 1018 | files (scp-files local-paths recursive)] 1019 | (when (and session (not (connected? session))) 1020 | (connect session)) 1021 | (let [[^PipedInputStream in 1022 | ^PipedOutputStream send] (streams-for-in) 1023 | cmd (format "scp %s %s -t %s" (:remote-flags opts "") (if recursive "-r" "") remote-path) 1024 | _ (logging/tracef "scp-to: %s" cmd) 1025 | {:keys [^ChannelExec channel ^PipedInputStream out-stream]} 1026 | (ssh-exec session cmd in :stream opts) 1027 | exec channel 1028 | recv out-stream] 1029 | (logging/tracef 1030 | "scp-to %s %s" (string/join " " local-paths) remote-path) 1031 | (logging/trace "Receive initial ACK") 1032 | (scp-receive-ack recv) 1033 | (doseq [^File file files] 1034 | (logging/tracef "scp-to: from %s" (.getPath file)) 1035 | (if (.isDirectory file) 1036 | (scp-copy-dir send recv file opts) 1037 | (scp-copy-file send recv file opts))) 1038 | (logging/trace "Closing streams") 1039 | (.close send) 1040 | (.close recv) 1041 | (disconnect-channel exec) 1042 | nil))) 1043 | 1044 | (defn scp-from 1045 | "Copy remote path(s) to local path via scp. 1046 | 1047 | Options are: 1048 | 1049 | :username username to use for authentication 1050 | :password password to use for authentication 1051 | :port port to use if no session specified 1052 | :mode mode, as a 4 digit octal number (default 0644) 1053 | :dir-mode directory mode, as a 4 digit octal number (default 0755) 1054 | :recursive flag for recursive operation 1055 | :preserve flag for preserving mode, mtime and atime. atime is not available 1056 | in java, so is set to mtime. mode is not readable in java." 1057 | [session remote-paths ^String local-path 1058 | & {:keys [username password port mode dir-mode recursive preserve] :as opts}] 1059 | (let [remote-paths (if (sequential? remote-paths) remote-paths [remote-paths]) 1060 | file (File. local-path) 1061 | _ (when (and (.exists file) 1062 | (not (.isDirectory file)) 1063 | (> (count remote-paths) 1)) 1064 | (throw 1065 | (ex-info 1066 | (format "Copy of multiple files to file %s requested" local-path) 1067 | {:type :clj-ssh/scp-copy-multiple-files-to-file-requested})))] 1068 | (when (and session (not (connected? session))) 1069 | (connect session)) 1070 | (let [[^PipedInputStream in 1071 | ^PipedOutputStream send] (streams-for-in) 1072 | flags {:recursive "-r" :preserve "-p"} 1073 | cmd (format 1074 | "scp %s -f %s" 1075 | (:remote-flags 1076 | opts 1077 | (string/join 1078 | " " 1079 | (->> 1080 | (select-keys opts [:recursive :preserve]) 1081 | (filter val) 1082 | (map (comp flags key))))) 1083 | (string/join " " remote-paths)) 1084 | _ (logging/tracef "scp-from: %s" cmd) 1085 | {:keys [^ChannelExec channel 1086 | ^PipedInputStream out-stream]} 1087 | (ssh-exec session cmd in :stream opts) 1088 | exec channel 1089 | recv out-stream] 1090 | (logging/tracef 1091 | "scp-from %s %s" (string/join " " remote-paths) local-path) 1092 | (scp-send-ack send) 1093 | (logging/trace "Sent initial ACK") 1094 | (scp-sink send recv file nil opts) 1095 | (logging/trace "Closing streams") 1096 | (.close send) 1097 | (.close recv) 1098 | (disconnect-channel exec) 1099 | nil))) 1100 | 1101 | (def ^{:private true} key-types {:rsa KeyPair/RSA :dsa KeyPair/DSA}) 1102 | 1103 | (defn generate-keypair 1104 | "Generate a keypair, returned as [private public] byte arrays. 1105 | Valid types are :rsa and :dsa. key-size is in bytes. passphrase can be a 1106 | string or byte array. Optionally writes the keypair to the paths specified 1107 | using the :private-key-path and :public-key-path keys." 1108 | [agent key-type key-size passphrase 1109 | & {:keys [comment private-key-path public-key-path]}] 1110 | (let [keypair (KeyPair/genKeyPair agent (key-type key-types) key-size) 1111 | write-pub (if (nil? comment) 1112 | (fn [x] (.writePublicKey keypair x "")) 1113 | (fn [x] (.writePublicKey keypair x comment))) 1114 | write-pvt (if (nil? passphrase) 1115 | (fn [x] (.writePrivateKey keypair x)) 1116 | (fn [x] (.writePrivateKey keypair x 1117 | (if (= (type passphrase) String) 1118 | (.getBytes passphrase) 1119 | passphrase)))) 1120 | ] 1121 | (when public-key-path 1122 | (write-pub public-key-path)) 1123 | (when private-key-path 1124 | (write-pvt private-key-path)) 1125 | (let [pub-baos (ByteArrayOutputStream.) 1126 | pri-baos (ByteArrayOutputStream.)] 1127 | (write-pub pub-baos) 1128 | (write-pvt pri-baos) 1129 | [(.toByteArray pri-baos) (.toByteArray pub-baos)]))) 1130 | -------------------------------------------------------------------------------- /src/clj_ssh/ssh/protocols.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.ssh.protocols 2 | "Protocols for ssh") 3 | 4 | (defprotocol Session 5 | "Provides a protocol for a session." 6 | (connect [x] [x timeout] "Connect the session") 7 | (connected? [x] "Predicate for a connected session") 8 | (disconnect [x] "Disconnect the session") 9 | (session [x] "Return a Jsch Session for the session")) 10 | -------------------------------------------------------------------------------- /test/clj_ssh/cli_test.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.cli-test 2 | (:use 3 | clojure.test 4 | clj-ssh.cli 5 | clj-ssh.test-keys 6 | [clj-ssh.ssh 7 | :only [connect connected? disconnect ssh-agent ssh-agent? with-connection]] 8 | [clj-ssh.test-utils 9 | :only [quiet-ssh-logging sftp-monitor sftp-monitor-done home]]) 10 | (:require [clojure.java.io :as io]) 11 | (:import com.jcraft.jsch.JSch)) 12 | 13 | (use-fixtures :once quiet-ssh-logging) 14 | 15 | (deftest with-ssh-agent-test 16 | (testing "initialised" 17 | (is (ssh-agent? *ssh-agent*))) 18 | (testing "system ssh-agent" 19 | (with-ssh-agent (ssh-agent {}) 20 | (is (ssh-agent? *ssh-agent*)) 21 | (is (pos? (count (.getIdentityNames *ssh-agent*)))))) 22 | (testing "local ssh-agent" 23 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 24 | (is (ssh-agent? *ssh-agent*)) 25 | (is (zero? (count (.getIdentityNames *ssh-agent*)))))) 26 | (let [agent (ssh-agent {})] 27 | (with-ssh-agent agent 28 | (is (= *ssh-agent* agent))))) 29 | 30 | (deftest add-identity-test 31 | (let [key (private-key-path)] 32 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 33 | (add-identity :private-key-path key) 34 | (is (= 1 (count (.getIdentityNames *ssh-agent*)))) 35 | (add-identity :agent *ssh-agent* :private-key-path key))) 36 | (testing "passing byte arrays" 37 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 38 | (add-identity 39 | :name "name" 40 | :private-key (.getBytes (slurp (private-key-path))) 41 | :public-key (.getBytes (slurp (public-key-path)))) 42 | (is (= 1 (count (.getIdentityNames *ssh-agent*)))) 43 | (is (= "name" (first (.getIdentityNames *ssh-agent*))))))) 44 | 45 | (deftest has-identity?-test 46 | (let [key (private-key-path)] 47 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 48 | (is (not (has-identity? key))) 49 | (is (zero? (count (.getIdentityNames *ssh-agent*)))) 50 | (add-identity :private-key-path key) 51 | (is (= 1 (count (.getIdentityNames *ssh-agent*)))) 52 | (is (has-identity? key))))) 53 | 54 | (deftest session-test 55 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 56 | (let [session (session "localhost" :username (username) :port 22)] 57 | (is (instance? com.jcraft.jsch.Session session)) 58 | (is (not (connected? session)))))) 59 | 60 | (deftest session-connect-test 61 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 62 | (default-session-options 63 | {:username (username) :strict-host-key-checking :no}) 64 | (add-identity :private-key-path (private-key-path)) 65 | (let [session (session "localhost")] 66 | (is (instance? com.jcraft.jsch.Session session)) 67 | (is (not (connected? session))) 68 | (connect session) 69 | (is (connected? session)) 70 | (disconnect session) 71 | (is (not (connected? session)))) 72 | (let [session (session "localhost")] 73 | (with-connection session 74 | (is (connected? session))) 75 | (is (not (connected? session))))) 76 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 77 | (try (add-identity-with-keychain 78 | :private-key-path (encrypted-private-key-path) 79 | :passphrase "clj-ssh") 80 | (let [session (session "localhost")] 81 | (is (instance? com.jcraft.jsch.Session session)) 82 | (is (not (connected? session))) 83 | (connect session) 84 | (is (connected? session)) 85 | (disconnect session) 86 | (is (not (connected? session)))) 87 | (let [session (session "localhost")] 88 | (with-connection session 89 | (is (connected? session))) 90 | (is (not (connected? session)))) 91 | (catch Exception e 92 | (when-not (= :clj-ssh/no-passphrase-available (:type (ex-data e))) 93 | (throw e))))) 94 | (with-ssh-agent (ssh-agent {}) 95 | (let [session (session "localhost")] 96 | (is (instance? com.jcraft.jsch.Session session)) 97 | (is (not (connected? session))) 98 | (connect session) 99 | (is (connected? session)) 100 | (disconnect session) 101 | (is (not (connected? session)))) 102 | (let [session (session "localhost")] 103 | (with-connection session 104 | (is (connected? session))) 105 | (is (not (connected? session)))))) 106 | 107 | (deftest ssh-test 108 | (with-ssh-agent (ssh-agent {:use-system-ssh-agent false}) 109 | (add-identity :private-key-path (private-key-path)) 110 | (default-session-options 111 | {:username (username) :strict-host-key-checking :no}) 112 | (let [{:keys [exit out]} (ssh "localhost" :in "echo hello")] 113 | (is (zero? exit)) 114 | (is (.contains out "hello"))) 115 | (let [{:keys [exit out err]} (ssh "localhost" :cmd "/bin/bash -c 'ls /'")] 116 | (is (zero? exit)) 117 | (is (.contains out "bin")) 118 | (is (= "" err))) 119 | (let [{:keys [exit out err]} (ssh "localhost" "/bin/bash -c 'ls /'")] 120 | (is (zero? exit)) 121 | (is (.contains out "bin")) 122 | (is (= "" err))) 123 | (let [{:keys [exit out err]} (ssh "localhost" "/bin/bash" "-c" "'ls /'")] 124 | (is (zero? exit)) 125 | (is (.contains out "bin")) 126 | (is (= "" err))) 127 | (let [{:keys [exit out]} 128 | (ssh "localhost" :in "echo hello" :username (username))] 129 | (is (zero? exit)) 130 | (is (.contains out "hello"))) 131 | (let [{:keys [exit out err]} 132 | (ssh "localhost" :cmd "/bin/bash -c 'ls /'" :username (username))] 133 | (is (zero? exit)) 134 | (is (.contains out "bin")) 135 | (is (= "" err))) 136 | (let [{:keys [exit out]} 137 | (ssh "localhost" :in "tty -s" :pty true :username (username))] 138 | (is (zero? exit))) 139 | (let [{:keys [exit out]} 140 | (ssh "localhost" :in "tty -s" :pty false :username (username))] 141 | (is (= 1 exit))) 142 | (let [{:keys [exit out]} 143 | (ssh "localhost" :in "ssh-add -l" :agent-forwarding true 144 | :username (username))] 145 | (is (zero? exit))))) 146 | 147 | 148 | (deftest sftp-test 149 | (let [home (home) 150 | dir (sftp "localhost" :ls home)] 151 | (sftp "localhost" :cd "/") 152 | (is (= home (sftp "localhost" :pwd))) 153 | (let [tmpfile1 (java.io.File/createTempFile "clj-ssh" "test") 154 | tmpfile2 (java.io.File/createTempFile "clj-ssh" "test") 155 | file1 (.getPath tmpfile1) 156 | file2 (.getPath tmpfile2) 157 | content "content" 158 | content2 "content2"] 159 | (try 160 | (.setWritable tmpfile1 true false) 161 | (.setWritable tmpfile2 true false) 162 | (io/copy content tmpfile1) 163 | (sftp "localhost" :put file1 file2) 164 | (is (= content (slurp file2))) 165 | (io/copy content2 tmpfile2) 166 | (sftp "localhost" :get file2 file1) 167 | (is (= content2 (slurp file1))) 168 | (sftp 169 | "localhost" :put (java.io.ByteArrayInputStream. (.getBytes content)) file1) 170 | (is (= content (slurp file1))) 171 | (let [[monitor state] (sftp-monitor)] 172 | (sftp "localhost" :put (java.io.ByteArrayInputStream. (.getBytes content)) 173 | file2 :with-monitor monitor) 174 | (is (sftp-monitor-done state))) 175 | (is (= content (slurp file2))) 176 | (finally 177 | (.delete tmpfile1) 178 | (.delete tmpfile2)))))) 179 | 180 | 181 | (deftest out-stream-test 182 | (testing "exec" 183 | (testing ":out :string" 184 | (let [proc (ssh "localhost" "ls")] 185 | (is (zero? (:exit proc))) 186 | (is (pos? (count (:out proc))) "no options"))) 187 | (testing ":out :stream" 188 | (let [proc (ssh "localhost" "ls" :out :stream :pty true)] 189 | (is (clj-ssh.ssh/connected-channel? (:channel proc)) 190 | ":channel not connected") 191 | (is (> (count (slurp (:out-stream proc))) 1) ":out-stream") 192 | (Thread/sleep 100) 193 | (is (zero? (clj-ssh.ssh/exit-status (:channel proc))) 194 | "zero exit status") 195 | (clj-ssh.ssh/disconnect (:session proc))))) 196 | (testing "shell" 197 | (testing ":out :string" 198 | (let [proc (ssh "localhost" :in "ls")] 199 | (is (pos? (count (:out proc))) ":out has content") 200 | (is (zero? (:exit proc)) "zero exit status"))) 201 | (testing ":out stream" 202 | (let [proc (ssh "localhost" :in "ls" :out :stream)] 203 | (is (clj-ssh.ssh/connected-channel? (:channel proc)) 204 | ":channel connected") 205 | (is (> (count (slurp (:out-stream proc))) 1) ":out-stream") 206 | (Thread/sleep 100) 207 | (is (zero? (clj-ssh.ssh/exit-status (:channel proc))) 208 | "zero exit status") 209 | (clj-ssh.ssh/disconnect (:session proc)))))) 210 | -------------------------------------------------------------------------------- /test/clj_ssh/ssh_test.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.ssh-test 2 | (:use 3 | clojure.test 4 | clj-ssh.ssh 5 | clj-ssh.test-keys 6 | [clj-ssh.test-utils 7 | :only [quiet-ssh-logging sftp-monitor sftp-monitor-done]]) 8 | (:require [clojure.java.io :as io]) 9 | (:import com.jcraft.jsch.JSch)) 10 | 11 | (use-fixtures :once quiet-ssh-logging) 12 | 13 | (deftest file-path-test 14 | (is (= "abc" (#'clj-ssh.ssh/file-path "abc"))) 15 | (is (= "abc" (#'clj-ssh.ssh/file-path (java.io.File. "abc"))))) 16 | 17 | (deftest camelize-test 18 | (is (= "StrictHostKeyChecking" 19 | (#'clj-ssh.ssh/camelize "strict-host-key-checking")))) 20 | 21 | (deftest ssh-agent?-test 22 | (is (ssh-agent? (JSch.))) 23 | (is (not (ssh-agent? "i'm not an ssh-agent")))) 24 | 25 | (deftest create-ssh-agent-test 26 | (is (ssh-agent? (ssh-agent {}))) 27 | (is (ssh-agent? (ssh-agent {:use-system-ssh-agent false})))) 28 | 29 | (deftest make-identity-test 30 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 31 | (let [path (private-key-path) 32 | identity (make-identity agent path (str path ".pub"))] 33 | (is (instance? com.jcraft.jsch.Identity identity)) 34 | (is (not (.isEncrypted identity))) 35 | (add-identity agent {:identity identity}) 36 | (is (= 1 (count (.getIdentityNames agent))))) 37 | (let [path (encrypted-private-key-path) 38 | identity (make-identity agent path (str path ".pub"))] 39 | (is (instance? com.jcraft.jsch.Identity identity)) 40 | (is (.isEncrypted identity)) 41 | (add-identity agent {:identity identity :name "clj-ssh"}) 42 | (is (= 2 (count (.getIdentityNames agent))))))) 43 | 44 | (deftest add-identity-test 45 | (let [key (private-key-path)] 46 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 47 | (add-identity agent {:private-key-path key}) 48 | (is (= 1 (count (.getIdentityNames agent)))) 49 | (add-identity agent {:private-key-path key}))) 50 | (testing "passing byte arrays" 51 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 52 | (add-identity 53 | agent 54 | {:name "name" 55 | :private-key (.getBytes (slurp (private-key-path))) 56 | :public-key (.getBytes (slurp (public-key-path)))}) 57 | (is (= 1 (count (.getIdentityNames agent)))) 58 | (is (= "name" (first (.getIdentityNames agent)))))) 59 | (testing "ssh-agent" 60 | (let [agent (ssh-agent {})] 61 | (let [n (count (.getIdentityNames agent)) 62 | test-key-comment "key for test clj-ssh" 63 | names (vec (.getIdentityNames agent)) 64 | has-key (some #(= (private-key-path) %) names)] 65 | (add-identity 66 | agent 67 | {:private-key-path (private-key-path) 68 | :public-key-path (public-key-path)}) 69 | (let [names (.getIdentityNames agent)] 70 | (is (or has-key (= (inc n) (count names)))) 71 | (is (some #(= (private-key-path) %) names))))))) 72 | 73 | (deftest has-identity?-test 74 | (let [key (private-key-path) 75 | pub-key (public-key-path)] 76 | (testing "private-key-path only" 77 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 78 | (is (not (has-identity? agent key))) 79 | (is (zero? (count (.getIdentityNames agent)))) 80 | (add-identity agent {:private-key-path key}) 81 | (is (= 1 (count (.getIdentityNames agent)))) 82 | (is (has-identity? agent key)))) 83 | (testing "private-key-path and public-key-path" 84 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 85 | (is (not (has-identity? agent key))) 86 | (is (zero? (count (.getIdentityNames agent)))) 87 | (add-identity agent {:private-key-path key 88 | :public-key-path pub-key}) 89 | (is (= 1 (count (.getIdentityNames agent)))) 90 | (is (has-identity? agent key)))))) 91 | 92 | (deftest session-impl-test 93 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 94 | (let [session 95 | (#'clj-ssh.ssh/session-impl agent "somehost" (username) 22 nil {})] 96 | (is (instance? com.jcraft.jsch.Session session)) 97 | (is (not (connected? session)))) 98 | (let [session 99 | (#'clj-ssh.ssh/session-impl agent "localhost" (username) 22 nil 100 | {:strict-host-key-checking :no})] 101 | (is (instance? com.jcraft.jsch.Session session)) 102 | (is (not (connected? session))) 103 | (is (= "no" (.getConfig session "StrictHostKeyChecking")))))) 104 | 105 | (deftest session-test 106 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 107 | (let [session (session agent "localhost" {:username (username) :port 22})] 108 | (is (instance? com.jcraft.jsch.Session session)) 109 | (is (not (connected? session))))) 110 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 111 | (let [session (session agent "localhost" {:username (username)})] 112 | (is (instance? com.jcraft.jsch.Session session)) 113 | (is (not (connected? session))))) 114 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 115 | (let [session (session agent "localhost" {:username (username)})] 116 | (is (instance? com.jcraft.jsch.Session session)) 117 | (is (not (connected? session)))))) 118 | 119 | (deftest session-connect-test 120 | (testing "internal agent" 121 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 122 | (add-identity agent {:private-key-path (private-key-path)}) 123 | (let [session (session 124 | agent 125 | "localhost" 126 | {:username (username) :strict-host-key-checking :no})] 127 | (is (instance? com.jcraft.jsch.Session session)) 128 | (is (not (connected? session))) 129 | (connect session) 130 | (is (connected? session)) 131 | (disconnect session) 132 | (is (not (connected? session)))) 133 | (let [session (session 134 | agent 135 | "localhost" 136 | {:username (username) :strict-host-key-checking :no})] 137 | (with-connection session 138 | (is (connected? session))) 139 | (is (not (connected? session))))) 140 | (testing "key with passphrase" 141 | (try (let [agent (ssh-agent {:use-system-ssh-agent false})] 142 | (add-identity-with-keychain 143 | agent 144 | {:private-key-path (encrypted-private-key-path) 145 | :passphrase "clj-ssh"}) 146 | (let [session (session 147 | agent 148 | "localhost" 149 | {:username (username) 150 | :strict-host-key-checking :no})] 151 | (is (instance? com.jcraft.jsch.Session session)) 152 | (is (not (connected? session))) 153 | (connect session) 154 | (is (connected? session)) 155 | (disconnect session) 156 | (is (not (connected? session)))) 157 | (let [session (session 158 | agent 159 | "localhost" 160 | {:username (username) 161 | :strict-host-key-checking :no})] 162 | (with-connection session 163 | (is (connected? session))) 164 | (is (not (connected? session))))) 165 | (catch Exception e 166 | (when-not (= :clj-ssh/no-passphrase-available (:type (ex-data e))) 167 | (throw e)))))) 168 | (testing "system ssh-agent" 169 | (let [agent (ssh-agent {})] 170 | (let [session (session 171 | agent 172 | "localhost" 173 | {:username (username) 174 | :strict-host-key-checking :no})] 175 | (is (instance? com.jcraft.jsch.Session session)) 176 | (is (not (connected? session))) 177 | (connect session) 178 | (is (connected? session)) 179 | (disconnect session) 180 | (is (not (connected? session)))) 181 | (let [session (session 182 | agent 183 | "localhost" 184 | {:username (username) 185 | :strict-host-key-checking :no})] 186 | (with-connection session 187 | (is (connected? session))) 188 | (is (not (connected? session))))))) 189 | 190 | (deftest open-shell-channel-test 191 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 192 | (add-identity agent {:private-key-path (private-key-path)}) 193 | (let [session (session 194 | agent 195 | "localhost" 196 | {:username (username) :strict-host-key-checking :no})] 197 | (with-connection session 198 | (let [shell (open-channel session :shell) 199 | os (java.io.ByteArrayOutputStream.)] 200 | (.setInputStream 201 | shell 202 | (java.io.ByteArrayInputStream. (.getBytes "ls /;exit 0;\n")) 203 | false) 204 | (.setOutputStream shell os) 205 | (with-channel-connection shell 206 | (while (connected-channel? shell) 207 | (Thread/sleep 1000)) 208 | (is (.contains (str os) "bin")))))))) 209 | 210 | (deftest ssh-shell-test 211 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 212 | (add-identity agent {:private-key-path (private-key-path)}) 213 | (let [session (session 214 | agent 215 | "localhost" 216 | {:username (username) :strict-host-key-checking :no})] 217 | (with-connection session 218 | (let [{:keys [exit out]} (ssh-shell session "echo hello" "UTF-8" {})] 219 | (is (zero? exit)) 220 | (is (.contains out "hello"))) 221 | (let [{:keys [exit out]} (ssh-shell session "echo hello" :bytes {})] 222 | (is (zero? exit)) 223 | (is (.contains (String. out) "hello"))) 224 | (let [{:keys [exit out]} (ssh-shell 225 | session "echo hello;exit 1" "UTF-8" {})] 226 | (is (= 1 exit)) 227 | (is (.contains out "hello"))) 228 | (let [{:keys [channel out-stream]} (ssh-shell 229 | session "echo hello;exit 1" :stream {})] 230 | (while (connected-channel? channel) (Thread/sleep 100)) 231 | (is (= 1 (.getExitStatus channel))) 232 | (is (pos? (.available out-stream))) 233 | (let [bytes (byte-array 1024) 234 | n (.read out-stream bytes 0 1024)] 235 | (is (.contains (String. bytes 0 n) "hello")))) 236 | (let [{:keys [exit out]} (ssh-shell 237 | session "exit $(tty -s)" "UTF-8" {:pty true})] 238 | (is (zero? exit))) 239 | (let [{:keys [exit out]} (ssh-shell 240 | session "exit $(tty -s)" "UTF-8" {:pty nil})] 241 | (is (= 1 exit))) 242 | (let [{:keys [exit out]} (ssh-shell 243 | session "ssh-add -l" "UTF-8" {})] 244 | (is (pos? exit))) 245 | (let [{:keys [exit out]} (ssh-shell 246 | session "ssh-add -l" "UTF-8" 247 | {:agent-forwarding false})] 248 | (is (pos? exit))) 249 | (let [{:keys [exit out]} (ssh-shell 250 | session "ssh-add -l" "UTF-8" 251 | {:agent-forwarding true})] 252 | (is (re-find #"RSA" out)) 253 | (is (zero? exit))))))) 254 | 255 | (deftest ssh-exec-test 256 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 257 | (add-identity agent {:private-key-path (private-key-path)}) 258 | (let [session (session 259 | agent 260 | "localhost" 261 | {:username (username) 262 | :strict-host-key-checking :no})] 263 | (with-connection session 264 | (let [{:keys [exit out err]} 265 | (ssh-exec session "/bin/bash -c 'ls /'" nil "UTF-8" {})] 266 | (is (zero? exit)) 267 | (is (.contains out "bin")) 268 | (is (= "" err))) 269 | (let [{:keys [exit out err]} 270 | (ssh-exec session "/bin/bash -c 'lsxxxxx /'" nil "UTF-8" {})] 271 | (is (pos? exit)) 272 | (is (= "" out)) 273 | (is (.contains err "command not found"))) 274 | (let [{:keys [channel out-stream err-stream]} 275 | (ssh-exec 276 | session "/bin/bash -c 'ls / && lsxxxxx /'" nil :stream {})] 277 | (while (connected-channel? channel) (Thread/sleep 100)) 278 | (is (not= 0 (.getExitStatus channel))) 279 | (is (pos? (.available out-stream))) 280 | (is (pos? (.available err-stream))) 281 | (let [out-bytes (byte-array 1024) 282 | out-n (.read out-stream out-bytes 0 1024) 283 | err-bytes (byte-array 1024) 284 | err-n (.read err-stream err-bytes 0 1024)] 285 | (is (.contains (String. out-bytes 0 out-n) "bin")) 286 | (is (.contains 287 | (String. err-bytes 0 err-n) 288 | "command not found")))))))) 289 | 290 | (deftest ssh-test 291 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 292 | (add-identity agent {:private-key-path (private-key-path)}) 293 | (let [session (session 294 | agent 295 | "localhost" 296 | {:username (username) 297 | :strict-host-key-checking :no})] 298 | (with-connection session 299 | (let [{:keys [exit out]} (ssh session {:in "echo hello"})] 300 | (is (zero? exit)) 301 | (is (.contains out "hello"))) 302 | (let [{:keys [exit out err]} (ssh session {:cmd "/bin/bash -c 'ls /'"})] 303 | (is (zero? exit)) 304 | (is (.contains out "bin")) 305 | (is (= "" err))) 306 | (let [{:keys [exit out]} (ssh 307 | session 308 | {:in "echo hello" :username (username)})] 309 | (is (zero? exit)) 310 | (is (.contains out "hello"))) 311 | (let [{:keys [exit out err]} 312 | (ssh session {:cmd "/bin/bash -c 'ls /'" :username (username)})] 313 | (is (zero? exit)) 314 | (is (.contains out "bin")) 315 | (is (= "" err))) 316 | (let [{:keys [exit out]} 317 | (ssh session {:in "tty -s" :pty true :username (username)})] 318 | (is (zero? exit))) 319 | (let [{:keys [exit out]} 320 | (ssh session {:in "tty -s" :pty false :username (username)})] 321 | (is (= 1 exit))) 322 | (let [{:keys [exit out]} 323 | (ssh session {:in "ssh-add -l" :agent-forwarding true 324 | :username (username)})] 325 | (is (zero? exit)))))) 326 | (let [agent (ssh-agent {:use-system-ssh-agent :false})] 327 | (add-identity agent {:private-key-path (private-key-path)}) 328 | (let [session (session agent "localhost" {:username (username) 329 | :strict-host-key-checking :no})] 330 | (with-connection session 331 | (let [{:keys [exit out]} 332 | (ssh session {:in "echo hello" :username (username)})] 333 | (is (zero? exit)) 334 | (is (.contains out "hello"))) 335 | (let [{:keys [exit out]} 336 | (ssh session {:in "echo hello" :username (username)})] 337 | (is (zero? exit)) 338 | (is (.contains out "hello"))) 339 | (let [{:keys [exit out err]} 340 | (ssh session {:cmd "/bin/bash -c 'ls /'" :username (username)})] 341 | (is (zero? exit)) 342 | (is (.contains out "bin")) 343 | (is (= "" err))) 344 | (let [{:keys [exit out err]} 345 | (ssh session {:cmd "/bin/bash -c 'ls /'" :return-map true 346 | :username (username)})] 347 | (is (zero? exit)) 348 | (is (.contains out "bin")) 349 | (is (= "" err))))))) 350 | 351 | (deftest ssh-sftp-cmd-test 352 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 353 | (add-identity agent {:private-key-path (private-key-path)}) 354 | (let [session (session agent "localhost" {:username (username) 355 | :strict-host-key-checking :no})] 356 | (with-connection session 357 | (let [channel (ssh-sftp session) 358 | dir (ssh-sftp-cmd channel :ls ["/"] {})] 359 | (ssh-sftp-cmd channel :cd ["/"] {}) 360 | (is (= "/" (ssh-sftp-cmd channel :pwd [] {}))) 361 | ;; value equality comparison on lsentry is borked 362 | (is (= (map str dir) 363 | (map str (ssh-sftp-cmd channel :ls [] {}))))))))) 364 | 365 | (defn test-sftp-with [channel] 366 | (let [dir (sftp channel {} :ls "/")] 367 | (sftp channel {} :cd "/") 368 | (is (= "/" (sftp channel {} :pwd))) 369 | ;; value equality comparison on lsentry is borked 370 | (is (= (map str dir) 371 | (map str (sftp channel {} :ls)))) 372 | (let [tmpfile1 (java.io.File/createTempFile "clj-ssh" "test") 373 | tmpfile2 (java.io.File/createTempFile "clj-ssh" "test") 374 | file1 (.getPath tmpfile1) 375 | file2 (.getPath tmpfile2) 376 | content "content" 377 | content2 "content2"] 378 | (try 379 | (.setWritable tmpfile1 true false) 380 | (.setWritable tmpfile2 true false) 381 | (io/copy content tmpfile1) 382 | (sftp channel {} :put file1 file2) 383 | (is (= content (slurp file2))) 384 | (io/copy content2 tmpfile2) 385 | (sftp channel {} :get file2 file1) 386 | (is (= content2 (slurp file1))) 387 | (sftp 388 | channel {} 389 | :put (java.io.ByteArrayInputStream. (.getBytes content)) file1) 390 | (is (= content (slurp file1))) 391 | (let [[monitor state] (sftp-monitor)] 392 | (sftp channel {:with-monitor monitor} 393 | :put (java.io.ByteArrayInputStream. (.getBytes content)) file2) 394 | (is (sftp-monitor-done state))) 395 | (is (= content (slurp file2))) 396 | (finally 397 | (.delete tmpfile1) 398 | (.delete tmpfile2)))))) 399 | 400 | (defn test-sftp-transient-with [channel {:as options}] 401 | (let [dir (sftp channel options :ls "/")] 402 | (sftp channel options :cd "/") 403 | (is (not= "/" (sftp channel options :pwd))) 404 | (let [tmpfile1 (java.io.File/createTempFile "clj-ssh" "test") 405 | tmpfile2 (java.io.File/createTempFile "clj-ssh" "test") 406 | file1 (.getPath tmpfile1) 407 | file2 (.getPath tmpfile2) 408 | content "content" 409 | content2 "othercontent"] 410 | (try 411 | (.setWritable tmpfile1 true false) 412 | (.setWritable tmpfile2 true false) 413 | (io/copy content tmpfile1) 414 | (sftp channel options :put file1 file2) 415 | (is (= content (slurp file2))) 416 | (io/copy content2 tmpfile2) 417 | (sftp channel options :get file2 file1) 418 | (is (= content2 (slurp file1))) 419 | (sftp channel options 420 | :put (java.io.ByteArrayInputStream. (.getBytes content)) 421 | file1) 422 | (is (= content (slurp file1))) 423 | (let [[monitor state] (sftp-monitor)] 424 | (sftp channel (assoc options :with-monitor monitor) 425 | :put (java.io.ByteArrayInputStream. (.getBytes content)) 426 | file2) 427 | (is (sftp-monitor-done state))) 428 | (is (= content (slurp file2))) 429 | (finally 430 | (.delete tmpfile1) 431 | (.delete tmpfile2)))))) 432 | 433 | (deftest sftp-session-test 434 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 435 | (add-identity agent {:private-key-path (private-key-path)}) 436 | (let [session (session agent "localhost" {:username (username) 437 | :strict-host-key-checking :no})] 438 | (with-connection session 439 | (let [channel (ssh-sftp session)] 440 | (with-channel-connection channel 441 | (test-sftp-with channel))) 442 | (test-sftp-transient-with session {}))))) 443 | 444 | (defn test-scp-to-with 445 | [session] 446 | (let [tmpfile1 (java.io.File/createTempFile "clj-ssh" "test") 447 | tmpfile2 (java.io.File/createTempFile "clj-ssh" "test") 448 | file1 (.getPath tmpfile1) 449 | file2 (.getPath tmpfile2) 450 | content "content" 451 | content2 "content2"] 452 | (try 453 | (.setWritable tmpfile1 true false) 454 | (.setWritable tmpfile2 true false) 455 | (io/copy content tmpfile1) 456 | (scp-to session file1 file2) 457 | (is (= content (slurp file2)) "scp-to should copy content") 458 | (io/copy content2 tmpfile1) 459 | (scp-to session file1 file2) 460 | (is (= content2 (slurp file2)) 461 | "scp-to with implicit session should copy content") 462 | (finally 463 | (.delete tmpfile1) 464 | (.delete tmpfile2))))) 465 | 466 | (defn test-scp-from-with 467 | [session] 468 | (let [tmpfile1 (java.io.File/createTempFile "clj-ssh" "test") 469 | tmpfile2 (java.io.File/createTempFile "clj-ssh" "test") 470 | file1 (.getPath tmpfile1) 471 | file2 (.getPath tmpfile2) 472 | content "content" 473 | content2 "content2"] 474 | (try 475 | (.setWritable tmpfile1 true false) 476 | (.setWritable tmpfile2 true false) 477 | (io/copy content tmpfile1) 478 | (scp-from session file1 file2) 479 | (is (= content (slurp file2)) 480 | "scp-from should copy content") 481 | (io/copy content2 tmpfile1) 482 | (scp-from session file1 file2 483 | :cipher-none true 484 | :username (username) 485 | :strict-host-key-checking :no) 486 | (is (= content2 (slurp file2)) 487 | "scp-from with implicit session should copy content") 488 | (finally 489 | (.delete tmpfile1) 490 | (.delete tmpfile2))))) 491 | 492 | (deftest scp-test 493 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 494 | (add-identity agent {:private-key-path (private-key-path)}) 495 | (let [session (session 496 | agent 497 | "localhost" 498 | {:username (username) :strict-host-key-checking :no})] 499 | (with-connection session 500 | (test-scp-to-with session) 501 | (test-scp-from-with session))))) 502 | 503 | (deftest keypair-test 504 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 505 | (is (keypair agent {:private-key-path (private-key-path)})) 506 | (is (keypair agent {:private-key-path (private-key-path) 507 | :public-key-path (public-key-path)})))) 508 | 509 | (deftest generate-keypair-test 510 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 511 | (let [[priv pub] (generate-keypair agent :rsa 1024 "hello")] 512 | (add-identity agent {:name "name" 513 | :private-key priv 514 | :public-key pub 515 | :passphrase (.getBytes "hello")})))) 516 | 517 | (defn port-reachable? 518 | ([ip port timeout] 519 | (let [socket (doto (java.net.Socket.) 520 | (.setReuseAddress false) 521 | (.setSoLinger false 1) 522 | (.setSoTimeout timeout))] 523 | (try 524 | (.connect socket (java.net.InetSocketAddress. ip port)) 525 | true 526 | (catch java.io.IOException _) 527 | (finally 528 | (try (.close socket) (catch java.io.IOException _)))))) 529 | ([ip port] 530 | (port-reachable? ip port 2000)) 531 | ([port] 532 | (port-reachable? "localhost" port))) 533 | 534 | (deftest forward-local-port-test 535 | (testing "minimal test" 536 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 537 | (add-identity agent {:private-key-path (private-key-path)}) 538 | (let [session (session 539 | agent 540 | "localhost" 541 | {:username (username) 542 | :strict-host-key-checking :no})] 543 | (is (instance? com.jcraft.jsch.Session session)) 544 | (is (not (connected? session))) 545 | (is (not (port-reachable? 22222))) 546 | (connect session) 547 | (is (connected? session)) 548 | (forward-local-port session 22222 22) 549 | (is (port-reachable? 22222)) 550 | (unforward-local-port session 22222) 551 | (forward-local-port session 22223 22 "localhost") 552 | (is (port-reachable? 22223)) 553 | (unforward-local-port session 22223) 554 | (with-local-port-forward [session 22224 22] 555 | (is (port-reachable? 22224))) 556 | (with-local-port-forward [session 22225 22 "localhost"] 557 | (is (port-reachable? 22225))))))) 558 | 559 | (deftest forward-remote-port-test 560 | (testing "minimal test" 561 | (let [agent (ssh-agent {:use-system-ssh-agent false})] 562 | (is (zero? (count (.getIdentityNames agent)))) 563 | (add-identity agent {:private-key-path (private-key-path) 564 | :public-key-path (public-key-path)}) 565 | (is (pos? (count (.getIdentityNames agent)))) 566 | (let [session (session 567 | agent 568 | "localhost" 569 | {:username (username) 570 | :strict-host-key-checking :no})] 571 | (is (instance? com.jcraft.jsch.Session session)) 572 | (is (not (connected? session))) 573 | (is (not (port-reachable? 22222))) 574 | (connect session) 575 | (is (connected? session)) 576 | (forward-remote-port session 22222 22) 577 | (is (port-reachable? 22222)) 578 | (unforward-remote-port session 22222) 579 | (forward-remote-port session 22222 22 "localhost") 580 | (unforward-remote-port session 22222) 581 | (with-remote-port-forward [session 22222 22] 582 | (is (port-reachable? 22222))) 583 | (with-remote-port-forward [session 22222 22 "localhost"] 584 | (is (port-reachable? 22222))))))) 585 | 586 | (deftest jump-session-test 587 | (is (let [s (jump-session (ssh-agent {}) 588 | [{:hostname "localhost" 589 | :username (username) 590 | :strict-host-key-checking :no}] 591 | {})] 592 | (with-connection s 593 | (ssh-exec (the-session s) "ls" "" "" {}))) 594 | "one host") 595 | (is (let [s (jump-session (ssh-agent {}) 596 | [{:hostname "localhost" 597 | :username (username) 598 | :strict-host-key-checking :no} 599 | {:hostname "localhost" 600 | :username (username) 601 | :strict-host-key-checking :no}] 602 | {})] 603 | (with-connection s 604 | (ssh-exec (the-session s) "ls" "" "" {}))) 605 | "two hosts")) 606 | 607 | 608 | (deftest out-stream-test 609 | (let [s (session (ssh-agent {}) "localhost" {})] 610 | (with-connection s 611 | (testing "exec" 612 | (testing ":out :string" 613 | (let [proc (ssh s {:cmd "ls"})] 614 | (is (zero? (:exit proc))) 615 | (is (pos? (count (:out proc))) "no options"))) 616 | (testing ":out :stream" 617 | (let [proc (ssh s {:cmd "ls" :out :stream :pty true})] 618 | (is (> (count (slurp (:out-stream proc))) 1) ":out-stream") 619 | (while (connected-channel? (:channel proc)) 620 | (Thread/sleep 100)) 621 | (is (not (connected-channel? (:channel proc))) 622 | ":channel not connected") 623 | (is (zero? (exit-status (:channel proc))) 624 | "zero exit status")))) 625 | (testing "shell" 626 | (testing ":out :string" 627 | (let [proc (ssh s {:in "ls"})] 628 | (is (pos? (count (:out proc))) ":out has content") 629 | (is (zero? (:exit proc)) "zero exit status"))) 630 | (testing ":out stream" 631 | (let [proc (ssh s {:in "ls" :out :stream})] 632 | (is (> (count (slurp (:out-stream proc))) 1) ":out-stream") 633 | (while (connected-channel? (:channel proc)) 634 | (Thread/sleep 100)) 635 | (is (not (connected-channel? (:channel proc))) 636 | ":channel not connected") 637 | (is (zero? (exit-status (:channel proc))) 638 | "zero exit status"))))))) 639 | -------------------------------------------------------------------------------- /test/clj_ssh/test_keys.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.test-keys) 2 | 3 | ;; TEST SETUP 4 | ;; 5 | ;; The tests assume the following setup 6 | ;; 7 | ;; ssh-keygen -f ~/.ssh/clj_ssh -t rsa -C "key for test clj-ssh" -N "" 8 | ;; ssh-keygen -f ~/.ssh/clj_ssh_pp -t rsa -C "key for test clj-ssh" -N "clj-ssh" 9 | ;; cp ~/.ssh/authorized_keys ~/.ssh/authorized_keys.bak 10 | ;; echo "from=\"127.0.0.1,localhost,0.0.0.0\" $(cat ~/.ssh/clj_ssh.pub)" \ 11 | ;; >> ~/.ssh/authorized_keys 12 | ;; echo "from=\"127.0.0.1,localhost,0.0.0.0\" $(cat ~/.ssh/clj_ssh_pp.pub)" \ 13 | ;; >> ~/.ssh/authorized_keys 14 | ;; ssh-add -K ~/.ssh/clj_ssh_pp # add the key to the keychain 15 | 16 | (defn private-key-path 17 | [] (str (. System getProperty "user.home") "/.ssh/clj_ssh")) 18 | 19 | (defn public-key-path 20 | [] (str (. System getProperty "user.home") "/.ssh/clj_ssh.pub")) 21 | 22 | (defn encrypted-private-key-path 23 | [] (str (. System getProperty "user.home") "/.ssh/clj_ssh_pp")) 24 | 25 | (defn encrypted-public-key-path 26 | [] (str (. System getProperty "user.home") "/.ssh/clj_ssh_pp.pub")) 27 | 28 | (defn username 29 | [] (or (. System getProperty "ssh.username") 30 | (. System getProperty "user.name"))) 31 | -------------------------------------------------------------------------------- /test/clj_ssh/test_utils.clj: -------------------------------------------------------------------------------- 1 | (ns clj-ssh.test-utils 2 | (:use 3 | [clj-ssh.ssh :only [ssh-log-levels]]) 4 | (:import com.jcraft.jsch.Logger)) 5 | 6 | (def debug-log-levels 7 | {com.jcraft.jsch.Logger/DEBUG :debug 8 | com.jcraft.jsch.Logger/INFO :debug 9 | com.jcraft.jsch.Logger/WARN :debug 10 | com.jcraft.jsch.Logger/ERROR :error 11 | com.jcraft.jsch.Logger/FATAL :fatal}) 12 | 13 | (defn quiet-ssh-logging [f] 14 | (let [levels @ssh-log-levels] 15 | (try 16 | (reset! ssh-log-levels debug-log-levels) 17 | (f) 18 | (finally 19 | (reset! ssh-log-levels levels))))) 20 | 21 | (defn sftp-monitor 22 | "Create a SFTP progress monitor" 23 | [] 24 | (let [operation (atom nil) 25 | source (atom nil) 26 | destination (atom nil) 27 | number (atom nil) 28 | done (atom false) 29 | progress (atom 0) 30 | continue (atom true)] 31 | [ (proxy [com.jcraft.jsch.SftpProgressMonitor] [] 32 | (init [op src dest max] 33 | (do 34 | (reset! operation op) 35 | (reset! source src) 36 | (reset! destination dest) 37 | (reset! number max) 38 | (reset! done false))) 39 | (count [n] 40 | (reset! progress n) 41 | @continue) 42 | (end [] 43 | (reset! done true))) 44 | [operation source destination number done progress continue]])) 45 | 46 | (defn sftp-monitor-done [state] 47 | @(nth state 4)) 48 | 49 | (defn cwd 50 | [] (. System getProperty "user.dir")) 51 | 52 | (defn home 53 | [] (. System getProperty "user.home")) 54 | -------------------------------------------------------------------------------- /test/log4j.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | logs/ssh.log 10 | 11 | logs/old/ssh.%d{yyyy-MM-dd}.log 12 | 3 13 | 14 | 15 | %date %level [%thread] %logger{10} %msg%n 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | --------------------------------------------------------------------------------