├── LICENSE ├── README.md ├── client.go ├── client_unix.go ├── client_windows.go └── key.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nanobox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Origin README see [here](https://github.com/glinton/ssh/blob/master/README.md) 2 | 3 | This ssh package contains helpers for working with ssh in go. The `client.go` file 4 | is a modified version of `docker/machine/libmachine/ssh/client.go` that only 5 | uses golang's native ssh client. It has also been improved to resize the tty as 6 | needed. The key functions are meant to be used by either client or server 7 | and will generate/store keys if not found. 8 | 9 | ## Usage: 10 | 11 | ```go 12 | package main 13 | 14 | import ( 15 | "fmt" 16 | 17 | "github.com/nanobox-io/golang-ssh" 18 | ) 19 | 20 | func main() { 21 | err := connect() 22 | if err != nil { 23 | fmt.Printf("Failed to connect - %s\n", err) 24 | } 25 | } 26 | 27 | func connect() error { 28 | nanPass := ssh.Auth{Passwords: []string{"pass"}} 29 | client, err := ssh.NewNativeClient("user", "localhost", "SSH-2.0-MyCustomClient-1.0", 2222, &nanPass, nil) 30 | if err != nil { 31 | return fmt.Errorf("Failed to create new client - %s", err) 32 | } 33 | 34 | err = client.Shell() 35 | if err != nil && err.Error() != "exit status 255" { 36 | return fmt.Errorf("Failed to request shell - %s", err) 37 | } 38 | 39 | return nil 40 | } 41 | ``` 42 | 43 | ## Compile for Windows: 44 | 45 | If you get this error: 46 | 47 | > go: github.com/Sirupsen/logrus@v1.2.0: parsing go.mod: unexpected module path "github.com/sirupsen/logrus" 48 | 49 | when compile for Windows with `go mod`, see [this issue](https://github.com/golang/go/issues/26208) 50 | for some hints and there was a walkaround: 51 | 52 | go mod init 53 | go get github.com/docker/docker@v0.0.0-20180422163414-57142e89befe 54 | GOOS=windows GOARCH=amd64 go build 55 | 56 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | // Package ssh is a helper for working with ssh in go. The client implementation 14 | // is a modified version of `docker/machine/libmachine/ssh/client.go` and only 15 | // uses golang's native ssh client. It has also been improved to resize the tty 16 | // accordingly. The key functions are meant to be used by either client or server 17 | // and will generate/store keys if not found. 18 | package ssh 19 | 20 | import ( 21 | "bytes" 22 | "encoding/binary" 23 | "fmt" 24 | "io" 25 | "io/ioutil" 26 | "os" 27 | "strconv" 28 | "strings" 29 | "time" 30 | 31 | "github.com/docker/docker/pkg/term" 32 | "github.com/docker/machine/libmachine/log" 33 | "github.com/docker/machine/libmachine/mcnutils" 34 | "golang.org/x/crypto/ssh" 35 | "golang.org/x/crypto/ssh/terminal" 36 | ) 37 | 38 | // ExitError is a conveniance wrapper for (crypto/ssh).ExitError type. 39 | type ExitError struct { 40 | Err error 41 | ExitCode int 42 | } 43 | 44 | // Error implements error interface. 45 | func (err *ExitError) Error() string { 46 | return err.Err.Error() 47 | } 48 | 49 | // Cause implements errors.Causer interface. 50 | func (err *ExitError) Cause() error { 51 | return err.Err 52 | } 53 | 54 | func wrapError(err error) error { 55 | switch err := err.(type) { 56 | case *ssh.ExitError: 57 | e, s := &ExitError{Err: err, ExitCode: -1}, strings.TrimSpace(err.Error()) 58 | // Best-effort attempt to parse exit code from os/exec error string, 59 | // like "Process exited with status 127". 60 | if i := strings.LastIndex(s, " "); i != -1 { 61 | if n, err := strconv.Atoi(s[i+1:]); err == nil { 62 | e.ExitCode = n 63 | } 64 | } 65 | return e 66 | default: 67 | return err 68 | } 69 | } 70 | 71 | // Client is a relic interface that both native and external client matched 72 | type Client interface { 73 | // Output returns the output of the command run on the remote host. 74 | Output(command string) (string, error) 75 | 76 | // Shell requests a shell from the remote. If an arg is passed, it tries to 77 | // exec them on the server. 78 | Shell(args ...string) error 79 | 80 | // Start starts the specified command without waiting for it to finish. You 81 | // have to call the Wait function for that. 82 | // 83 | // The first two io.ReadCloser are the standard output and the standard 84 | // error of the executing command respectively. The returned error follows 85 | // the same logic as in the exec.Cmd.Start function. 86 | Start(command string) (io.ReadCloser, io.ReadCloser, error) 87 | 88 | // Wait waits for the command started by the Start function to exit. The 89 | // returned error follows the same logic as in the exec.Cmd.Wait function. 90 | Wait() error 91 | } 92 | 93 | // NativeClient is the structure for native client use 94 | type NativeClient struct { 95 | Config ssh.ClientConfig // Config defines the golang ssh client config 96 | Hostname string // Hostname is the host to connect to 97 | Port int // Port is the port to connect to 98 | ClientVersion string // ClientVersion is the version string to send to the server when identifying 99 | openSession *ssh.Session 100 | } 101 | 102 | // Auth contains auth info 103 | type Auth struct { 104 | Passwords []string // Passwords is a slice of passwords to submit to the server 105 | Keys []string // Keys is a slice of filenames of keys to try 106 | RawKeys [][]byte // RawKeys is a slice of private keys to try 107 | } 108 | 109 | // Config is used to create new client. 110 | type Config struct { 111 | User string // username to connect as, required 112 | Host string // hostname to connect to, required 113 | Version string // ssh client version, "SSH-2.0-Go" by default 114 | Port int // port to connect to, 22 by default 115 | Auth *Auth // authentication methods to use 116 | Timeout time.Duration // connect timeout, 30s by default 117 | HostKey ssh.HostKeyCallback // callback for verifying server keys, ssh.InsecureIgnoreHostKey by default 118 | } 119 | 120 | func (cfg *Config) version() string { 121 | if cfg.Version != "" { 122 | return cfg.Version 123 | } 124 | return "SSH-2.0-Go" 125 | } 126 | 127 | func (cfg *Config) port() int { 128 | if cfg.Port != 0 { 129 | return cfg.Port 130 | } 131 | return 22 132 | } 133 | 134 | func (cfg *Config) timeout() time.Duration { 135 | if cfg.Timeout != 0 { 136 | return cfg.Timeout 137 | } 138 | return 30 * time.Second 139 | } 140 | 141 | func (cfg *Config) hostKey() ssh.HostKeyCallback { 142 | if cfg.HostKey != nil { 143 | return cfg.HostKey 144 | } 145 | return ssh.InsecureIgnoreHostKey() 146 | } 147 | 148 | // NewClient creates a new Client using the golang ssh library. 149 | func NewClient(cfg *Config) (Client, error) { 150 | config, err := NewNativeConfig(cfg.User, cfg.version(), cfg.Auth, cfg.hostKey()) 151 | if err != nil { 152 | return nil, fmt.Errorf("Error getting config for native Go SSH: %s", err) 153 | } 154 | config.Timeout = cfg.timeout() 155 | 156 | return &NativeClient{ 157 | Config: config, 158 | Hostname: cfg.Host, 159 | Port: cfg.port(), 160 | ClientVersion: cfg.version(), 161 | }, nil 162 | } 163 | 164 | // NewNativeClient creates a new Client using the golang ssh library 165 | func NewNativeClient(user, host, clientVersion string, port int, auth *Auth, hostKeyCallback ssh.HostKeyCallback) (Client, error) { 166 | if clientVersion == "" { 167 | clientVersion = "SSH-2.0-Go" 168 | } 169 | 170 | config, err := NewNativeConfig(user, clientVersion, auth, hostKeyCallback) 171 | if err != nil { 172 | return nil, fmt.Errorf("Error getting config for native Go SSH: %s", err) 173 | } 174 | 175 | return &NativeClient{ 176 | Config: config, 177 | Hostname: host, 178 | Port: port, 179 | ClientVersion: clientVersion, 180 | }, nil 181 | } 182 | 183 | // NewNativeConfig returns a golang ssh client config struct for use by the NativeClient 184 | func NewNativeConfig(user, clientVersion string, auth *Auth, hostKeyCallback ssh.HostKeyCallback) (ssh.ClientConfig, error) { 185 | var ( 186 | authMethods []ssh.AuthMethod 187 | ) 188 | 189 | if auth != nil { 190 | rawKeys := auth.RawKeys 191 | for _, k := range auth.Keys { 192 | key, err := ioutil.ReadFile(k) 193 | if err != nil { 194 | return ssh.ClientConfig{}, err 195 | } 196 | 197 | rawKeys = append(rawKeys, key) 198 | } 199 | 200 | for _, key := range rawKeys { 201 | privateKey, err := ssh.ParsePrivateKey(key) 202 | if err != nil { 203 | return ssh.ClientConfig{}, err 204 | } 205 | 206 | authMethods = append(authMethods, ssh.PublicKeys(privateKey)) 207 | } 208 | 209 | for _, p := range auth.Passwords { 210 | authMethods = append(authMethods, ssh.Password(p)) 211 | } 212 | } 213 | 214 | if hostKeyCallback == nil { 215 | hostKeyCallback = ssh.InsecureIgnoreHostKey() 216 | } 217 | 218 | return ssh.ClientConfig{ 219 | User: user, 220 | Auth: authMethods, 221 | ClientVersion: clientVersion, 222 | HostKeyCallback: hostKeyCallback, 223 | }, nil 224 | } 225 | 226 | func (client *NativeClient) dialSuccess() bool { 227 | if _, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), &client.Config); err != nil { 228 | log.Debugf("Error dialing TCP: %s", err) 229 | return false 230 | } 231 | return true 232 | } 233 | 234 | func (client *NativeClient) session(command string) (*ssh.Session, error) { 235 | if err := mcnutils.WaitFor(client.dialSuccess); err != nil { 236 | return nil, fmt.Errorf("Error attempting SSH client dial: %s", err) 237 | } 238 | 239 | conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), &client.Config) 240 | if err != nil { 241 | return nil, fmt.Errorf("Mysterious error dialing TCP for SSH (we already succeeded at least once) : %s", err) 242 | } 243 | 244 | return conn.NewSession() 245 | } 246 | 247 | // Output returns the output of the command run on the remote host. 248 | func (client *NativeClient) Output(command string) (string, error) { 249 | session, err := client.session(command) 250 | if err != nil { 251 | return "", err 252 | } 253 | 254 | output, err := session.CombinedOutput(command) 255 | defer session.Close() 256 | 257 | return string(bytes.TrimSpace(output)), wrapError(err) 258 | } 259 | 260 | // Output returns the output of the command run on the remote host as well as a pty. 261 | func (client *NativeClient) OutputWithPty(command string) (string, error) { 262 | session, err := client.session(command) 263 | if err != nil { 264 | return "", nil 265 | } 266 | 267 | fd := int(os.Stdin.Fd()) 268 | 269 | termWidth, termHeight, err := terminal.GetSize(fd) 270 | if err != nil { 271 | return "", err 272 | } 273 | 274 | modes := ssh.TerminalModes{ 275 | ssh.ECHO: 0, 276 | ssh.TTY_OP_ISPEED: 14400, 277 | ssh.TTY_OP_OSPEED: 14400, 278 | } 279 | 280 | // request tty -- fixes error with hosts that use 281 | // "Defaults requiretty" in /etc/sudoers - I'm looking at you RedHat 282 | if err := session.RequestPty("xterm", termHeight, termWidth, modes); err != nil { 283 | return "", err 284 | } 285 | 286 | output, err := session.CombinedOutput(command) 287 | defer session.Close() 288 | 289 | return string(bytes.TrimSpace(output)), wrapError(err) 290 | } 291 | 292 | // Start starts the specified command without waiting for it to finish. You 293 | // have to call the Wait function for that. 294 | func (client *NativeClient) Start(command string) (io.ReadCloser, io.ReadCloser, error) { 295 | session, err := client.session(command) 296 | if err != nil { 297 | return nil, nil, err 298 | } 299 | 300 | stdout, err := session.StdoutPipe() 301 | if err != nil { 302 | return nil, nil, err 303 | } 304 | stderr, err := session.StderrPipe() 305 | if err != nil { 306 | return nil, nil, err 307 | } 308 | if err := session.Start(command); err != nil { 309 | return nil, nil, err 310 | } 311 | 312 | client.openSession = session 313 | return ioutil.NopCloser(stdout), ioutil.NopCloser(stderr), nil 314 | } 315 | 316 | // Wait waits for the command started by the Start function to exit. The 317 | // returned error follows the same logic as in the exec.Cmd.Wait function. 318 | func (client *NativeClient) Wait() error { 319 | err := client.openSession.Wait() 320 | _ = client.openSession.Close() 321 | client.openSession = nil 322 | return err 323 | } 324 | 325 | // Shell requests a shell from the remote. If an arg is passed, it tries to 326 | // exec them on the server. 327 | func (client *NativeClient) Shell(args ...string) error { 328 | var ( 329 | termWidth, termHeight = 80, 24 330 | ) 331 | conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), &client.Config) 332 | if err != nil { 333 | return err 334 | } 335 | 336 | session, err := conn.NewSession() 337 | if err != nil { 338 | return err 339 | } 340 | 341 | defer session.Close() 342 | 343 | session.Stdout = os.Stdout 344 | session.Stderr = os.Stderr 345 | session.Stdin = os.Stdin 346 | 347 | modes := ssh.TerminalModes{ 348 | ssh.ECHO: 1, 349 | } 350 | 351 | fd := os.Stdin.Fd() 352 | 353 | if term.IsTerminal(fd) { 354 | oldState, err := term.MakeRaw(fd) 355 | if err != nil { 356 | return err 357 | } 358 | 359 | defer term.RestoreTerminal(fd, oldState) 360 | 361 | winsize, err := term.GetWinsize(fd) 362 | if err == nil { 363 | termWidth = int(winsize.Width) 364 | termHeight = int(winsize.Height) 365 | } 366 | } 367 | 368 | if err := session.RequestPty("xterm", termHeight, termWidth, modes); err != nil { 369 | return err 370 | } 371 | 372 | if len(args) == 0 { 373 | if err := session.Shell(); err != nil { 374 | return err 375 | } 376 | 377 | // monitor for sigwinch 378 | go monWinCh(session, os.Stdout.Fd()) 379 | 380 | session.Wait() 381 | } else { 382 | session.Run(strings.Join(args, " ")) 383 | } 384 | 385 | return nil 386 | } 387 | 388 | // termSize gets the current window size and returns it in a window-change friendly 389 | // format. 390 | func termSize(fd uintptr) []byte { 391 | size := make([]byte, 16) 392 | 393 | winsize, err := term.GetWinsize(fd) 394 | if err != nil { 395 | binary.BigEndian.PutUint32(size, uint32(80)) 396 | binary.BigEndian.PutUint32(size[4:], uint32(24)) 397 | return size 398 | } 399 | 400 | binary.BigEndian.PutUint32(size, uint32(winsize.Width)) 401 | binary.BigEndian.PutUint32(size[4:], uint32(winsize.Height)) 402 | 403 | return size 404 | } 405 | -------------------------------------------------------------------------------- /client_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package ssh 4 | 5 | import ( 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | // monWinCh watches for the system to signal a window resize and requests 14 | // a window-change from the server. 15 | func monWinCh(session *ssh.Session, fd uintptr) { 16 | sigs := make(chan os.Signal, 1) 17 | 18 | signal.Notify(sigs, syscall.SIGWINCH) 19 | defer signal.Stop(sigs) 20 | 21 | // resize the tty if any signals received 22 | for range sigs { 23 | session.SendRequest("window-change", false, termSize(fd)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package ssh 4 | 5 | import ( 6 | "golang.org/x/crypto/ssh" 7 | ) 8 | 9 | // monWinCh does nothing for now on windows 10 | func monWinCh(session *ssh.Session, fd uintptr) { 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /key.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "fmt" 10 | "io/ioutil" 11 | "os" 12 | 13 | "github.com/jcelliott/lumber" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | // GetKeyPair will attempt to get the keypair from a file and will fail back 18 | // to generating a new set and saving it to the file. Returns pub, priv, err 19 | func GetKeyPair(file string) (string, string, error) { 20 | // read keys from file 21 | _, err := os.Stat(file) 22 | if err == nil { 23 | priv, err := ioutil.ReadFile(file) 24 | if err != nil { 25 | lumber.Debug("Failed to read file - %s", err) 26 | goto genKeys 27 | } 28 | pub, err := ioutil.ReadFile(file + ".pub") 29 | if err != nil { 30 | lumber.Debug("Failed to read pub file - %s", err) 31 | goto genKeys 32 | } 33 | return string(pub), string(priv), nil 34 | } 35 | 36 | // generate keys and save to file 37 | genKeys: 38 | pub, priv, err := GenKeyPair() 39 | err = ioutil.WriteFile(file, []byte(priv), 0600) 40 | if err != nil { 41 | return "", "", fmt.Errorf("Failed to write file - %s", err) 42 | } 43 | err = ioutil.WriteFile(file+".pub", []byte(pub), 0644) 44 | if err != nil { 45 | return "", "", fmt.Errorf("Failed to write pub file - %s", err) 46 | } 47 | 48 | return pub, priv, nil 49 | } 50 | 51 | // GenKeyPair make a pair of public and private keys for SSH access. 52 | // Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file. 53 | // Private Key generated is PEM encoded 54 | func GenKeyPair() (string, string, error) { 55 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 56 | if err != nil { 57 | return "", "", err 58 | } 59 | 60 | privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} 61 | var private bytes.Buffer 62 | if err := pem.Encode(&private, privateKeyPEM); err != nil { 63 | return "", "", err 64 | } 65 | 66 | // generate public key 67 | pub, err := ssh.NewPublicKey(&privateKey.PublicKey) 68 | if err != nil { 69 | return "", "", err 70 | } 71 | 72 | public := ssh.MarshalAuthorizedKey(pub) 73 | return string(public), private.String(), nil 74 | } 75 | --------------------------------------------------------------------------------