├── LICENSE ├── README.md ├── agent_unix.go ├── agent_windows.go ├── go.mod ├── go.sum └── simplessh.go /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Sam Freiberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simplessh 2 | [![GoDoc](https://godoc.org/github.com/sfreiberg/simplessh?status.png)](https://godoc.org/github.com/sfreiberg/simplessh) 3 | 4 | SimpleSSH is a simple wrapper around go ssh and sftp libraries. 5 | 6 | ## Features 7 | * Multiple authentication methods (password, private key and ssh-agent) 8 | * Sudo support 9 | * Simple file upload/download support 10 | 11 | ## Installation 12 | `go get github.com/sfreiberg/simplessh` 13 | 14 | ## Example 15 | 16 | ``` 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/sfreiberg/simplessh" 23 | ) 24 | 25 | func main() { 26 | /* 27 | Leave privKeyPath empty to use $HOME/.ssh/id_rsa. 28 | If username is blank simplessh will attempt to use the current user. 29 | */ 30 | client, err := simplessh.ConnectWithKeyFile("localhost:22", "root", "/home/user/.ssh/id_rsa") 31 | if err != nil { 32 | panic(err) 33 | } 34 | defer client.Close() 35 | 36 | output, err := client.Exec("uptime") 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | fmt.Printf("Uptime: %s\n", output) 42 | } 43 | 44 | ``` 45 | 46 | ## License 47 | SimpleSSH is licensed under the MIT license. -------------------------------------------------------------------------------- /agent_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package simplessh 4 | 5 | import ( 6 | "net" 7 | "os" 8 | "time" 9 | 10 | "golang.org/x/crypto/ssh" 11 | "golang.org/x/crypto/ssh/agent" 12 | ) 13 | 14 | // Connect with a ssh agent with a custom timeout. If username is empty simplessh will attempt to get the current user. 15 | func connectWithAgentTimeout(host, username string, timeout time.Duration) (*Client, error) { 16 | sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | authMethod := ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers) 22 | 23 | return connect(username, host, authMethod, timeout) 24 | } 25 | -------------------------------------------------------------------------------- /agent_windows.go: -------------------------------------------------------------------------------- 1 | package simplessh 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/davidmz/go-pageant" 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | // Connect with a ssh agent with a custom timeout. If username is empty simplessh will attempt to get the current user. 12 | func connectWithAgentTimeout(host, username string, timeout time.Duration) (*Client, error) { 13 | if !pageant.Available() { 14 | return nil, fmt.Errorf("Pageant is unavailable") 15 | } 16 | 17 | authMethod := ssh.PublicKeysCallback(pageant.New().Signers) 18 | return connect(username, host, authMethod, timeout) 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sfreiberg/simplessh 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/davidmz/go-pageant v1.0.2 7 | github.com/pkg/sftp v1.13.4 8 | golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 9 | ) 10 | 11 | require ( 12 | github.com/kr/fs v0.1.0 // indirect 13 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= 4 | github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= 5 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 6 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 7 | github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg= 8 | github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 12 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 13 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 14 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 15 | golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 16 | golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA= 17 | golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 18 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 19 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= 22 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= 24 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 25 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 26 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 29 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 30 | -------------------------------------------------------------------------------- /simplessh.go: -------------------------------------------------------------------------------- 1 | package simplessh 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net" 9 | "os" 10 | "os/user" 11 | "path/filepath" 12 | "sync" 13 | "time" 14 | 15 | "github.com/pkg/sftp" 16 | "golang.org/x/crypto/ssh" 17 | ) 18 | 19 | const DefaultTimeout = 30 * time.Second 20 | 21 | // This is the phrase that tells us sudo is looking for a password via stdin 22 | const sudoPwPrompt = "sudo_password" 23 | 24 | // Set a default HostKeyCallback variable. This may not be desireable for some 25 | // environments. 26 | var HostKeyCallback = ssh.InsecureIgnoreHostKey() 27 | 28 | // sudoWriter is used to both combine stdout and stderr as well as 29 | // look for a password request from sudo. 30 | type sudoWriter struct { 31 | b bytes.Buffer 32 | pw string // The password to pass to sudo (if requested) 33 | stdin io.Writer // The writer from the ssh session 34 | m sync.Mutex 35 | } 36 | 37 | func (w *sudoWriter) Write(p []byte) (int, error) { 38 | // If we get the sudo password prompt phrase send the password via stdin 39 | // and don't write it to the buffer. 40 | if string(p) == sudoPwPrompt { 41 | w.stdin.Write([]byte(w.pw + "\n")) 42 | w.pw = "" // We don't need the password anymore so reset the string 43 | return len(p), nil 44 | } 45 | 46 | w.m.Lock() 47 | defer w.m.Unlock() 48 | 49 | return w.b.Write(p) 50 | } 51 | 52 | type Client struct { 53 | SSHClient *ssh.Client 54 | } 55 | 56 | // Connect with a password. If username is empty simplessh will attempt to get the current user. 57 | func ConnectWithPassword(host, username, pass string) (*Client, error) { 58 | return ConnectWithPasswordTimeout(host, username, pass, DefaultTimeout) 59 | } 60 | 61 | // Same as ConnectWithPassword but allows a custom timeout. If username is empty simplessh will attempt to get the current user. 62 | func ConnectWithPasswordTimeout(host, username, pass string, timeout time.Duration) (*Client, error) { 63 | authMethod := ssh.Password(pass) 64 | 65 | return connect(username, host, authMethod, timeout) 66 | } 67 | 68 | // Connect with a private key. If privKeyPath is an empty string it will attempt 69 | // to use $HOME/.ssh/id_rsa. If username is empty simplessh will attempt to get the current user. 70 | func ConnectWithKeyFileTimeout(host, username, privKeyPath string, timeout time.Duration) (*Client, error) { 71 | if privKeyPath == "" { 72 | currentUser, err := user.Current() 73 | if err == nil { 74 | privKeyPath = filepath.Join(currentUser.HomeDir, ".ssh", "id_rsa") 75 | } 76 | } 77 | 78 | privKey, err := ioutil.ReadFile(privKeyPath) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return ConnectWithKeyTimeout(host, username, string(privKey), timeout) 84 | } 85 | 86 | // Connect with a private key with passphrase. If privKeyPath is an empty string it will attempt 87 | // to use $HOME/.ssh/id_rsa. If username is empty simplessh will attempt to get the current user. 88 | func ConnectWithKeyFilePassphraseTimeout(host, username, privKeyPath string, passPhrase string, timeout time.Duration) (*Client, error) { 89 | if privKeyPath == "" { 90 | currentUser, err := user.Current() 91 | if err == nil { 92 | privKeyPath = filepath.Join(currentUser.HomeDir, ".ssh", "id_rsa") 93 | } 94 | } 95 | pemKey, err := ioutil.ReadFile(privKeyPath) 96 | if err != nil { 97 | return nil, err 98 | } 99 | signer, err := ssh.ParsePrivateKeyWithPassphrase(pemKey, []byte(passPhrase)) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | return ConnectWithKeyPassphraseTimeout(host, username, signer, timeout) 105 | } 106 | 107 | // Same as ConnectWithKeyFile but allows a custom timeout. If username is empty simplessh will attempt to get the current user. 108 | func ConnectWithKeyFile(host, username, privKeyPath string) (*Client, error) { 109 | return ConnectWithKeyFileTimeout(host, username, privKeyPath, DefaultTimeout) 110 | } 111 | 112 | // KeyFile with a passphrase 113 | func ConnectWithKeyFilePassphrase(host, username, privKeyPath string, passPhrase string) (*Client, error) { 114 | return ConnectWithKeyFilePassphraseTimeout(host, username, privKeyPath, passPhrase, DefaultTimeout) 115 | } 116 | 117 | // Connect with a private key with a custom timeout. If username is empty simplessh will attempt to get the current user. 118 | func ConnectWithKeyTimeout(host, username, privKey string, timeout time.Duration) (*Client, error) { 119 | signer, err := ssh.ParsePrivateKey([]byte(privKey)) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | authMethod := ssh.PublicKeys(signer) 125 | 126 | return connect(username, host, authMethod, timeout) 127 | } 128 | 129 | // Connect with a private key with passphrase with a custom timeout. If username is empty simplessh will attempt to get the current user. 130 | func ConnectWithKeyPassphraseTimeout(host, username string, signer ssh.Signer, timeout time.Duration) (*Client, error) { 131 | authMethod := ssh.PublicKeys(signer) 132 | 133 | return connect(username, host, authMethod, timeout) 134 | } 135 | 136 | // Connect with a private key. If username is empty simplessh will attempt to get the current user. 137 | func ConnectWithKey(host, username, privKey string) (*Client, error) { 138 | return ConnectWithKeyTimeout(host, username, privKey, DefaultTimeout) 139 | } 140 | 141 | // Connect to an ssh agent with a custom timeout. If username is empty simplessh will attempt to get the current user. The windows implementation uses a different library which expects pageant to be running. 142 | func ConnectWithAgentTimeout(host, username string, timeout time.Duration) (*Client, error) { 143 | return connectWithAgentTimeout(host, username, timeout) 144 | } 145 | 146 | // Connect to an ssh agent. If username is empty simplessh will attempt to get the current user. The windows implementation uses a different library which expects pageant to be running. 147 | func ConnectWithAgent(host, username string) (*Client, error) { 148 | return ConnectWithAgentTimeout(host, username, DefaultTimeout) 149 | } 150 | 151 | func connect(username, host string, authMethod ssh.AuthMethod, timeout time.Duration) (*Client, error) { 152 | if username == "" { 153 | user, err := user.Current() 154 | if err != nil { 155 | return nil, fmt.Errorf("Username wasn't specified and couldn't get current user: %v", err) 156 | } 157 | 158 | username = user.Username 159 | } 160 | 161 | sshconf := ssh.Config{ 162 | Ciphers: []string{ 163 | "arcfour128", 164 | "arcfour256", 165 | "arcfour", 166 | "aes128-ctr", 167 | "aes192-ctr", 168 | "aes256-ctr", 169 | "aes128-cbc", 170 | "3des-cbc", 171 | "des-cbc", 172 | 173 | "aes128-gcm@openssh.com", 174 | "chacha20-poly1305@openssh.com", 175 | }, 176 | } 177 | 178 | config := &ssh.ClientConfig{ 179 | Config: sshconf, 180 | User: username, 181 | Auth: []ssh.AuthMethod{authMethod}, 182 | HostKeyCallback: HostKeyCallback, 183 | HostKeyAlgorithms: []string{ 184 | ssh.KeyAlgoRSA, 185 | ssh.KeyAlgoDSA, 186 | ssh.KeyAlgoECDSA256, 187 | ssh.KeyAlgoSKECDSA256, 188 | ssh.KeyAlgoECDSA384, 189 | ssh.KeyAlgoECDSA521, 190 | ssh.KeyAlgoED25519, 191 | ssh.KeyAlgoSKED25519, 192 | ssh.KeyAlgoRSASHA256, 193 | ssh.KeyAlgoRSASHA512, 194 | 195 | ssh.CertAlgoRSAv01, 196 | ssh.CertAlgoDSAv01, 197 | ssh.CertAlgoECDSA256v01, 198 | ssh.CertAlgoECDSA384v01, 199 | ssh.CertAlgoECDSA521v01, 200 | ssh.CertAlgoSKECDSA256v01, 201 | ssh.CertAlgoED25519v01, 202 | ssh.CertAlgoSKED25519v01, 203 | ssh.CertAlgoRSASHA256v01, 204 | ssh.CertAlgoRSASHA512v01, 205 | }, 206 | } 207 | 208 | host = addPortToHost(host) 209 | 210 | conn, err := net.DialTimeout("tcp", host, timeout) 211 | if err != nil { 212 | return nil, err 213 | } 214 | sshConn, chans, reqs, err := ssh.NewClientConn(conn, host, config) 215 | if err != nil { 216 | return nil, err 217 | } 218 | client := ssh.NewClient(sshConn, chans, reqs) 219 | 220 | c := &Client{SSHClient: client} 221 | return c, nil 222 | } 223 | 224 | // Execute cmd on the remote host and return stderr and stdout 225 | func (c *Client) Exec(cmd string) ([]byte, error) { 226 | session, err := c.SSHClient.NewSession() 227 | if err != nil { 228 | return nil, err 229 | } 230 | defer session.Close() 231 | 232 | return session.CombinedOutput(cmd) 233 | } 234 | 235 | // Execute cmd via sudo. Do not include the sudo command in 236 | // the cmd string. For example: Client.ExecSudo("uptime", "password"). 237 | // If you are using passwordless sudo you can use the regular Exec() 238 | // function. 239 | func (c *Client) ExecSudo(cmd, passwd string) ([]byte, error) { 240 | session, err := c.SSHClient.NewSession() 241 | if err != nil { 242 | return nil, err 243 | } 244 | defer session.Close() 245 | 246 | // -n run non interactively 247 | // -p specify the prompt. We do this to know that sudo is asking for a passwd 248 | // -S Writes the prompt to StdErr and reads the password from StdIn 249 | cmd = "sudo -p " + sudoPwPrompt + " -S " + cmd 250 | 251 | // Use the sudoRW struct to handle the interaction with sudo and capture the 252 | // output of the command 253 | w := &sudoWriter{ 254 | pw: passwd, 255 | } 256 | w.stdin, err = session.StdinPipe() 257 | if err != nil { 258 | return nil, err 259 | } 260 | 261 | // Combine stdout, stderr to the same writer which also looks for the sudo 262 | // password prompt 263 | session.Stdout = w 264 | session.Stderr = w 265 | 266 | err = session.Run(cmd) 267 | 268 | return w.b.Bytes(), err 269 | } 270 | 271 | // Download a file from the remote server 272 | func (c *Client) Download(remote, local string) error { 273 | client, err := sftp.NewClient(c.SSHClient) 274 | if err != nil { 275 | return err 276 | } 277 | defer client.Close() 278 | 279 | remoteFile, err := client.Open(remote) 280 | if err != nil { 281 | return err 282 | } 283 | defer remoteFile.Close() 284 | 285 | localFile, err := os.Create(local) 286 | if err != nil { 287 | return err 288 | } 289 | defer localFile.Close() 290 | 291 | _, err = io.Copy(localFile, remoteFile) 292 | return err 293 | } 294 | 295 | // Upload a file to the remote server 296 | func (c *Client) Upload(local, remote string) error { 297 | client, err := sftp.NewClient(c.SSHClient) 298 | if err != nil { 299 | return err 300 | } 301 | defer client.Close() 302 | 303 | localFile, err := os.Open(local) 304 | if err != nil { 305 | return err 306 | } 307 | defer localFile.Close() 308 | 309 | remoteFile, err := client.Create(remote) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | _, err = io.Copy(remoteFile, localFile) 315 | return err 316 | } 317 | 318 | // Remove a file from the remote server 319 | func (c *Client) Remove(path string) error { 320 | client, err := sftp.NewClient(c.SSHClient) 321 | if err != nil { 322 | return err 323 | } 324 | defer client.Close() 325 | 326 | return client.Remove(path) 327 | } 328 | 329 | // Remove a directory from the remote server 330 | func (c *Client) RemoveDirectory(path string) error { 331 | client, err := sftp.NewClient(c.SSHClient) 332 | if err != nil { 333 | return err 334 | } 335 | defer client.Close() 336 | 337 | return client.RemoveDirectory(path) 338 | } 339 | 340 | // Read a remote file and return the contents. 341 | func (c *Client) ReadAll(filepath string) ([]byte, error) { 342 | sftp, err := c.SFTPClient() 343 | if err != nil { 344 | panic(err) 345 | } 346 | defer sftp.Close() 347 | 348 | file, err := sftp.Open(filepath) 349 | if err != nil { 350 | return nil, err 351 | } 352 | defer file.Close() 353 | 354 | return ioutil.ReadAll(file) 355 | } 356 | 357 | // Close the underlying SSH connection 358 | func (c *Client) Close() error { 359 | return c.SSHClient.Close() 360 | } 361 | 362 | // Return an sftp client. The client needs to be closed when it's no 363 | // longer needed. 364 | func (c *Client) SFTPClient() (*sftp.Client, error) { 365 | return sftp.NewClient(c.SSHClient) 366 | } 367 | 368 | func addPortToHost(host string) string { 369 | _, _, err := net.SplitHostPort(host) 370 | 371 | // We got an error so blindly try to add a port number 372 | if err != nil { 373 | return net.JoinHostPort(host, "22") 374 | } 375 | 376 | return host 377 | } 378 | --------------------------------------------------------------------------------