├── LICENSE ├── README.md ├── loom.go └── loom_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Mark Fletcher 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | loom 2 | ============ 3 | 4 | A pure Go implementation of several SSH commands, inspired by [Python's Fabric](http://www.fabfile.org/). 5 | 6 | With loom, you can run commands as well as put and get files from remote servers, over SSH. 7 | 8 | For documentation, check [godoc](http://godoc.org/github.com/wingedpig/loom). 9 | 10 | For support, visit the [Loom Email Group](https://groups.io/org/groupsio/loom). 11 | 12 | ## TODOs 13 | * Examples 14 | * In Run(), use pipes instead of CombinedOutput so that we can show the output of commands more interactively, instead of now, which is after they're completely done executing. 15 | * Handle wildcards in Get() 16 | * Better error checking in Get() 17 | -------------------------------------------------------------------------------- /loom.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package loom implements a set of functions to interact with remote servers using SSH. 3 | It is based on the Python fabric library. 4 | */ 5 | package loom 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "io/ioutil" 13 | "log" 14 | "os" 15 | "os/exec" 16 | "os/user" 17 | "path/filepath" 18 | "strconv" 19 | "strings" 20 | "sync" 21 | 22 | "golang.org/x/crypto/ssh" 23 | ) 24 | 25 | // Config contains ssh and other configuration data needed for all the public functions in loom. 26 | type Config struct { 27 | // The user name used in SSH connections. If not specified, the current user is assumed. 28 | User string 29 | 30 | // Password for SSH connections. This is optional. If the user has an ~/.ssh/id_rsa keyfile, 31 | // that will also be tried. In addition, other key files can be specified. 32 | Password string 33 | 34 | // The machine:port to connect to. 35 | Host string 36 | 37 | // The file names of additional key files to use for authentication (~/.ssh/id_rsa is defaulted). 38 | // RSA (PKCS#1), DSA (OpenSSL), and ECDSA private keys are supported. 39 | KeyFiles []string 40 | 41 | // If true, send command output to stdout. 42 | DisplayOutput bool 43 | 44 | // If true, errors are fatal and will abort immediately. 45 | AbortOnError bool 46 | } 47 | 48 | // parsekey is a private function that reads in a keyfile containing a private key and parses it. 49 | func parsekey(file string) (ssh.Signer, error) { 50 | var private ssh.Signer 51 | privateBytes, err := ioutil.ReadFile(file) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | private, err = ssh.ParsePrivateKey(privateBytes) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return private, nil 61 | } 62 | 63 | // connect is a private function to set up the ssh connection. It is called at the beginning of every public 64 | // function. 65 | func (config *Config) connect() (*ssh.Session, error) { 66 | 67 | sshconfig := &ssh.ClientConfig{ 68 | User: config.User, 69 | } 70 | 71 | if config.User == "" { 72 | u, err := user.Current() 73 | if err != nil { 74 | return nil, err 75 | } 76 | sshconfig.User = u.Username 77 | } 78 | 79 | if config.Password != "" { 80 | sshconfig.Auth = append(sshconfig.Auth, ssh.Password(config.Password)) 81 | } 82 | 83 | var pkey ssh.Signer 84 | var keyfile string 85 | var err error 86 | 87 | // Include additional key files 88 | for _, keyfile = range config.KeyFiles { 89 | pkey, err = parsekey(keyfile) 90 | if err != nil { 91 | if config.AbortOnError == true { 92 | log.Fatalf("%s", err) 93 | } 94 | return nil, err 95 | } 96 | sshconfig.Auth = append(sshconfig.Auth, ssh.PublicKeys(pkey)) 97 | } 98 | 99 | // By default, we try to include ~/.ssh/id_rsa. It is not an error if this file 100 | // doesn't exist. 101 | keyfile = os.Getenv("HOME") + "/.ssh/id_rsa" 102 | pkey, err = parsekey(keyfile) 103 | if err == nil { 104 | fmt.Println("Adding default rsa key") 105 | sshconfig.Auth = append(sshconfig.Auth, ssh.PublicKeys(pkey)) 106 | } 107 | 108 | host := config.Host 109 | if strings.Contains(host, ":") == false { 110 | host = host + ":22" 111 | } 112 | client, err := ssh.Dial("tcp", host, sshconfig) 113 | if err != nil { 114 | if config.AbortOnError == true { 115 | log.Fatalf("%s", err) 116 | } 117 | return nil, err 118 | } 119 | 120 | session, err := client.NewSession() 121 | if err != nil { 122 | if config.AbortOnError == true { 123 | log.Fatalf("%s", err) 124 | } 125 | return nil, err 126 | } 127 | return session, err 128 | } 129 | 130 | // doRun is called by both Run() and Sudo() to execute a command. 131 | func (config *Config) doRun(cmd string, sudo bool) (string, error) { 132 | 133 | session, err := config.connect() 134 | if err != nil { 135 | if config.AbortOnError == true { 136 | log.Fatalf("%s", err) 137 | } 138 | return "", err 139 | } 140 | defer session.Close() 141 | 142 | // Set up terminal modes 143 | modes := ssh.TerminalModes{ 144 | ssh.ECHO: 0, // disable echoing 145 | ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud 146 | ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud 147 | } 148 | // Request pseudo terminal 149 | if err := session.RequestPty("xterm", 80, 40, modes); err != nil { 150 | if config.AbortOnError == true { 151 | log.Fatalf("%s", err) 152 | } 153 | return "", err 154 | } 155 | 156 | if sudo == true { 157 | cmd = fmt.Sprintf("/usr/bin/sudo bash < 0 { 166 | fmt.Printf("%s", soutput) 167 | } 168 | if config.AbortOnError == true { 169 | log.Fatalf("%s", err) 170 | } 171 | return "", err 172 | } 173 | session.SendRequest("close", false, nil) 174 | if config.DisplayOutput == true { 175 | fmt.Printf("%s", soutput) 176 | } 177 | return soutput, nil 178 | } 179 | 180 | type singleWriterReader struct { 181 | b *bytes.Buffer 182 | b2 *bytes.Buffer 183 | teeReader io.Reader 184 | mu sync.Mutex 185 | usingRead bool 186 | } 187 | 188 | func newSingleWriterReader() singleWriterReader { 189 | b := bytes.NewBuffer([]byte{}) 190 | b2 := bytes.NewBuffer([]byte{}) 191 | return singleWriterReader{b, b2, io.TeeReader(b, b2), sync.Mutex{}, false} 192 | } 193 | 194 | func (w *singleWriterReader) Write(p []byte) (int, error) { 195 | w.mu.Lock() 196 | defer w.mu.Unlock() 197 | return w.b.Write(p) 198 | } 199 | 200 | func (w *singleWriterReader) Read(p []byte) (int, error) { 201 | w.usingRead = true 202 | w.mu.Lock() 203 | defer w.mu.Unlock() 204 | n, err := w.teeReader.Read(p) 205 | return n, err 206 | } 207 | 208 | // Note: This should only be read once at the end, after everything has 209 | // be written or read. 210 | func (w *singleWriterReader) Bytes() []byte { 211 | w.mu.Lock() 212 | defer w.mu.Unlock() 213 | 214 | return append(w.b2.Bytes(), w.b.Bytes()...) 215 | } 216 | 217 | func (config *Config) executeCommand(s *ssh.Session, cmd string, sudo bool) ([]byte, error) { 218 | if s.Stdout != nil { 219 | return nil, errors.New("ssh: Stdout already set") 220 | } 221 | if s.Stderr != nil { 222 | return nil, errors.New("ssh: Stderr already set") 223 | } 224 | 225 | b := newSingleWriterReader() 226 | s.Stdout = &b 227 | s.Stderr = &b 228 | done := make(chan bool) 229 | 230 | if sudo { 231 | stdInWriter, err := s.StdinPipe() 232 | if err != nil { 233 | if config.AbortOnError == true { 234 | log.Fatalf("%s", err) 235 | } 236 | return nil, err 237 | } 238 | 239 | go config.injectSudoPasswordIfNecessary(done, &b, stdInWriter) 240 | } 241 | 242 | err := s.Run(cmd) 243 | close(done) 244 | return b.Bytes(), err 245 | } 246 | 247 | func (config *Config) injectSudoPasswordIfNecessary(done <-chan bool, stdOutReader io.Reader, stdInWriter io.Writer) error { 248 | matchFound := false 249 | sudoMatcher := newSudoMatcher(config.User) 250 | for matchFound == false { 251 | select { 252 | case <-done: 253 | default: 254 | bytesRead := make([]byte, sudoMatcher.totalMatchLength) 255 | _, err := stdOutReader.Read(bytesRead) 256 | if err == io.EOF { 257 | continue 258 | } 259 | 260 | if err != nil { 261 | return err 262 | } 263 | matchFound = sudoMatcher.Match(bytesRead) 264 | if matchFound { 265 | stdInWriter.Write([]byte(config.Password + "\n")) 266 | break 267 | } 268 | } 269 | } 270 | 271 | return nil 272 | } 273 | 274 | type sudoMatcher struct { 275 | currentIndexMatch int 276 | currentPrompt string 277 | stringToFind string 278 | totalMatchLength int 279 | } 280 | 281 | func newSudoMatcher(user string) sudoMatcher { 282 | stringToFind := fmt.Sprintf("[sudo] password for %s:", user) 283 | totalMatchLength := len([]byte(stringToFind)) 284 | return sudoMatcher{0, "", stringToFind, totalMatchLength} 285 | } 286 | 287 | func (m *sudoMatcher) Match(additionalBytes []byte) bool { 288 | readString := string(additionalBytes) 289 | for _, runeVal := range readString { 290 | if runeVal == rune(m.stringToFind[m.currentIndexMatch]) { 291 | m.currentPrompt = m.currentPrompt + string(runeVal) 292 | m.currentIndexMatch++ 293 | } else { 294 | m.currentPrompt = "" 295 | m.currentIndexMatch = 0 296 | } 297 | 298 | if len(m.currentPrompt) == m.totalMatchLength { 299 | return true 300 | } 301 | } 302 | return false 303 | } 304 | 305 | // Run takes a command and runs it on the remote host, using ssh. 306 | func (config *Config) Run(cmd string) (string, error) { 307 | if config.DisplayOutput == true { 308 | fmt.Printf("run: %s\n", cmd) 309 | } 310 | return config.doRun(cmd, false) 311 | } 312 | 313 | // Sudo takes a command and runs it as root on the remote host, using sudo over ssh. 314 | func (config *Config) Sudo(cmd string) (string, error) { 315 | if config.DisplayOutput == true { 316 | fmt.Printf("sudo: %s\n", cmd) 317 | } 318 | return config.doRun(cmd, true) 319 | } 320 | 321 | // Put copies one or more local files to the remote host, using scp. localfiles can 322 | // contain wildcards, and remotefile can be either a directory or a file. 323 | func (config *Config) Put(localfiles string, remotefile string) error { 324 | 325 | files, err := filepath.Glob(localfiles) 326 | if err != nil { 327 | if config.AbortOnError == true { 328 | log.Fatalf("%s", err) 329 | } 330 | return err 331 | } 332 | if len(files) == 0 { 333 | err = fmt.Errorf("No files match %s", localfiles) 334 | if config.AbortOnError == true { 335 | log.Fatalf("%s", err) 336 | } 337 | return err 338 | } 339 | for _, localfile := range files { 340 | 341 | fmt.Printf("put: %s %s\n", localfile, remotefile) 342 | contents, err := ioutil.ReadFile(localfile) 343 | if err != nil { 344 | if config.AbortOnError == true { 345 | log.Fatalf("%s", err) 346 | } 347 | return err 348 | } 349 | 350 | // get the local file mode bits 351 | fi, err := os.Stat(localfile) 352 | if err != nil { 353 | if config.AbortOnError == true { 354 | log.Fatalf("%s", err) 355 | } 356 | return err 357 | } 358 | // the file mode bits are the 9 least significant bits of Mode() 359 | mode := fi.Mode() & 1023 360 | 361 | session, err := config.connect() 362 | if err != nil { 363 | if config.AbortOnError == true { 364 | log.Fatalf("%s", err) 365 | } 366 | return err 367 | } 368 | var stdoutBuf bytes.Buffer 369 | var stderrBuf bytes.Buffer 370 | session.Stdout = &stdoutBuf 371 | session.Stderr = &stderrBuf 372 | 373 | w, _ := session.StdinPipe() 374 | 375 | _, lfile := filepath.Split(localfile) 376 | err = session.Start("/usr/bin/scp -qrt " + remotefile) 377 | if err != nil { 378 | w.Close() 379 | session.Close() 380 | if config.AbortOnError == true { 381 | log.Fatalf("%s", err) 382 | } 383 | return err 384 | } 385 | fmt.Fprintf(w, "C%04o %d %s\n", mode, len(contents), lfile /*remotefile*/) 386 | w.Write(contents) 387 | fmt.Fprint(w, "\x00") 388 | w.Close() 389 | 390 | err = session.Wait() 391 | if err != nil { 392 | if config.AbortOnError == true { 393 | log.Fatalf("%s", err) 394 | } 395 | session.Close() 396 | return err 397 | } 398 | 399 | if config.DisplayOutput == true { 400 | stdout := stdoutBuf.String() 401 | stderr := stderrBuf.String() 402 | fmt.Printf("%s%s", stderr, stdout) 403 | } 404 | session.Close() 405 | 406 | } 407 | 408 | return nil 409 | } 410 | 411 | // PutString generates a new file on the remote host containing data. The file is created with mode 0644. 412 | func (config *Config) PutString(data string, remotefile string) error { 413 | 414 | if config.DisplayOutput == true { 415 | fmt.Printf("putstring: %s\n", remotefile) 416 | } 417 | session, err := config.connect() 418 | if err != nil { 419 | if config.AbortOnError == true { 420 | log.Fatalf("%s", err) 421 | } 422 | return err 423 | } 424 | var stdoutBuf bytes.Buffer 425 | var stderrBuf bytes.Buffer 426 | session.Stdout = &stdoutBuf 427 | session.Stderr = &stderrBuf 428 | 429 | w, _ := session.StdinPipe() 430 | 431 | _, rfile := filepath.Split(remotefile) 432 | err = session.Start("/usr/bin/scp -qrt " + remotefile) 433 | if err != nil { 434 | w.Close() 435 | session.Close() 436 | if config.AbortOnError == true { 437 | log.Fatalf("%s", err) 438 | } 439 | return err 440 | } 441 | fmt.Fprintf(w, "C0644 %d %s\n", len(data), rfile) 442 | w.Write([]byte(data)) 443 | fmt.Fprint(w, "\x00") 444 | w.Close() 445 | 446 | err = session.Wait() 447 | if err != nil { 448 | if config.AbortOnError == true { 449 | log.Fatalf("%s", err) 450 | } 451 | session.Close() 452 | return err 453 | } 454 | 455 | if config.DisplayOutput == true { 456 | stdout := stdoutBuf.String() 457 | stderr := stderrBuf.String() 458 | fmt.Printf("%s%s", stderr, stdout) 459 | } 460 | session.Close() 461 | 462 | return nil 463 | } 464 | 465 | // Get copies the file from the remote host to the local host, using scp. Wildcards are not currently supported. 466 | func (config *Config) Get(remotefile string, localfile string) error { 467 | 468 | if config.DisplayOutput == true { 469 | fmt.Printf("get: %s %s\n", remotefile, localfile) 470 | } 471 | 472 | session, err := config.connect() 473 | if err != nil { 474 | if config.AbortOnError == true { 475 | log.Fatalf("%s", err) 476 | } 477 | return err 478 | } 479 | defer session.Close() 480 | 481 | // TODO: Handle wildcards in remotefile 482 | 483 | var stdoutBuf bytes.Buffer 484 | var stderrBuf bytes.Buffer 485 | session.Stdout = &stdoutBuf 486 | session.Stderr = &stderrBuf 487 | w, _ := session.StdinPipe() 488 | 489 | err = session.Start("/usr/bin/scp -qrf " + remotefile) 490 | if err != nil { 491 | w.Close() 492 | if config.AbortOnError == true { 493 | log.Fatalf("%s", err) 494 | } 495 | return err 496 | } 497 | // TODO: better error checking than just firing and forgetting these nulls. 498 | fmt.Fprintf(w, "\x00") 499 | fmt.Fprintf(w, "\x00") 500 | fmt.Fprintf(w, "\x00") 501 | fmt.Fprintf(w, "\x00") 502 | fmt.Fprintf(w, "\x00") 503 | fmt.Fprintf(w, "\x00") 504 | 505 | err = session.Wait() 506 | if err != nil { 507 | if config.AbortOnError == true { 508 | log.Fatalf("%s", err) 509 | } 510 | return err 511 | } 512 | 513 | stdout := stdoutBuf.String() 514 | //stderr := stderrBuf.String() 515 | 516 | // first line of stdout contains file information 517 | fields := strings.SplitN(stdout, "\n", 2) 518 | mode, _ := strconv.ParseInt(fields[0][1:5], 8, 32) 519 | 520 | // need to generate final local file name 521 | // localfile could be a directory or a filename 522 | // if it's a directory, we need to append the remotefile filename 523 | // if it doesn't exist, we assume file 524 | var lfile string 525 | _, rfile := filepath.Split(remotefile) 526 | l := len(localfile) 527 | if localfile[l-1] == '/' { 528 | localfile = localfile[:l-1] 529 | } 530 | fi, err := os.Stat(localfile) 531 | if err != nil || fi.IsDir() == false { 532 | lfile = localfile 533 | } else if fi.IsDir() == true { 534 | lfile = localfile + "/" + rfile 535 | } 536 | // there's a trailing 0 in the file that we need to nuke 537 | l = len(fields[1]) 538 | err = ioutil.WriteFile(lfile, []byte(fields[1][:l-1]), os.FileMode(mode)) 539 | if err != nil { 540 | if config.AbortOnError == true { 541 | log.Fatalf("%s", err) 542 | } 543 | return err 544 | } 545 | return nil 546 | } 547 | 548 | // Local executes a command on the local host. 549 | func (config *Config) Local(cmd string) (string, error) { 550 | if config.DisplayOutput == true { 551 | fmt.Printf("local: %s\n", cmd) 552 | } 553 | var command *exec.Cmd 554 | command = exec.Command("/bin/sh", "-c", cmd) 555 | var out bytes.Buffer 556 | command.Stdout = &out 557 | err := command.Run() 558 | if err != nil { 559 | if config.AbortOnError == true { 560 | log.Fatalf("%s", err) 561 | } 562 | return "", err 563 | } 564 | if config.DisplayOutput == true { 565 | fmt.Printf("%s", out.String()) 566 | } 567 | return out.String(), nil 568 | } 569 | -------------------------------------------------------------------------------- /loom_test.go: -------------------------------------------------------------------------------- 1 | package loom 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func Test_Run(t *testing.T) { 11 | 12 | // These tests assume you can ssh log into localhost. Adjust config.User and such accordingly. 13 | var config Config 14 | config.Host = "127.0.0.1:22" 15 | localout, err := config.Local("/bin/ls -l /var/tmp") 16 | if err != nil { 17 | t.Errorf("Local error, %s", err) 18 | } 19 | remoteout, err := config.Run("/bin/ls -l /var/tmp") 20 | if err != nil { 21 | t.Errorf("Run error, %s", err) 22 | } 23 | remoteout = strings.Replace(remoteout, "\r", "", -1) 24 | if localout != remoteout { 25 | t.Errorf("Local and remote differ") 26 | } 27 | dir, err := ioutil.TempDir("/var/tmp", "loom") 28 | if err != nil { 29 | t.Errorf("Cannot create temp directory, %s", err) 30 | } 31 | defer os.RemoveAll(dir) 32 | 33 | file1 := dir + "/a" 34 | file2 := dir + "/b" 35 | testText := `Lorem ipsum Minim voluptate aliquip commodo.` 36 | 37 | err = config.PutString(testText, file1) 38 | if err != nil { 39 | t.Errorf("PutString error, %s", err) 40 | } 41 | err = config.Get(file1, file2) 42 | if err != nil { 43 | t.Errorf("Get error, %s", err) 44 | } 45 | // read file2, verify that it matches testText 46 | verifyText, err := ioutil.ReadFile(file2) 47 | if err != nil { 48 | t.Errorf("Get failed, can't read %s, %s", file2, err) 49 | } 50 | if string(verifyText) != testText { 51 | t.Errorf("Text mismatch, expected '%s', got '%s'", testText, verifyText) 52 | } 53 | } 54 | 55 | func TestInjectSudoPasswordIfNecessary(t *testing.T) { 56 | config := Config{ 57 | User: "user", 58 | Password: "user", 59 | Host: "127.0.0.1:22", 60 | DisplayOutput: true, 61 | AbortOnError: false, 62 | } 63 | 64 | val, err := config.Sudo("ls -l") 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | t.Log(val) 70 | } 71 | 72 | func TestEnsureSudoMatcherShouldMatch(t *testing.T) { 73 | totalPayload := `Command 1 74 | [sudo] password for user: 75 | Command2` 76 | 77 | sm := newSudoMatcher("user") 78 | 79 | match := sm.Match([]byte(totalPayload)) 80 | if !match { 81 | t.Errorf("Expected a match") 82 | } 83 | } 84 | 85 | func TestEnsureSudoMatcherShouldNotMatch(t *testing.T) { 86 | totalPayload := `some othercommand 87 | foo 88 | bar` 89 | 90 | sm := newSudoMatcher("user") 91 | 92 | match := sm.Match([]byte(totalPayload)) 93 | if match { 94 | t.Errorf("No match was expected") 95 | } 96 | } 97 | 98 | func TestEnsureSudoMatcherShouldMatchAfterMultipleMatchTries(t *testing.T) { 99 | sm := newSudoMatcher("user") 100 | 101 | match := sm.Match([]byte(`Command 1 102 | [sudo] password`)) 103 | if match { 104 | t.Errorf("Expected no match") 105 | } 106 | 107 | match = sm.Match([]byte(` for user:`)) 108 | if !match { 109 | t.Errorf("Expected a match") 110 | } 111 | } 112 | --------------------------------------------------------------------------------