├── lib ├── autocomplete.js ├── grep.js ├── creds.js └── hosts.js ├── .travis.yml ├── remtail.png ├── .gitignore ├── test ├── ssh_config.txt ├── remtail.json ├── gaurav_ssh_config.txt ├── privateKey.txt ├── lib │ └── test-creds.js └── test.js ├── .jshintrc ├── package.json ├── LICENSE ├── README.md └── remtail.js /lib/autocomplete.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.10' -------------------------------------------------------------------------------- /remtail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NickCarneiro/remtail/HEAD/remtail.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.iml 10 | .idea/ 11 | pids 12 | logs 13 | results 14 | 15 | npm-debug.log 16 | node_modules 17 | -------------------------------------------------------------------------------- /test/ssh_config.txt: -------------------------------------------------------------------------------- 1 | Host trillworks 2 | HostName trillworks.com 3 | User nickc 4 | Host globcong 5 | HostName globcong.com 6 | User maurice 7 | IdentityFile test/privateKey.txt -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "curly": true, 3 | "eqeqeq": true, 4 | "immed": true, 5 | "latedef": true, 6 | "newcap": true, 7 | "noarg": true, 8 | "sub": true, 9 | "undef": true, 10 | "unused": true, 11 | "boss": true, 12 | "eqnull": true, 13 | "node": true, 14 | "loopfunc": true 15 | } 16 | -------------------------------------------------------------------------------- /lib/grep.js: -------------------------------------------------------------------------------- 1 | var colors = require('colors'); 2 | 3 | function highlightString(logLine, re) { 4 | logLine = logLine.replace(re, function(match) { 5 | return colors.red(match) 6 | }); 7 | return logLine; 8 | } 9 | 10 | module.exports = { 11 | highlightString: highlightString 12 | }; 13 | -------------------------------------------------------------------------------- /test/remtail.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "hostname": "trillworks.com", 4 | "port": 22, 5 | "user": "bigtex", 6 | "password": "hunter2" 7 | }, 8 | { 9 | "hostname": "globcong.com", 10 | "user": "peter", 11 | "password": "blah" 12 | }, 13 | { 14 | "hostname": "yahoo.com", 15 | "user": "ganley", 16 | "privateKey": "test/privateKey.txt" 17 | } 18 | ] -------------------------------------------------------------------------------- /test/gaurav_ssh_config.txt: -------------------------------------------------------------------------------- 1 | Host ggmathur 2 | User gaurav 3 | HostName ggmathur.ausoff.globcong.net 4 | IdentityFile test/privateKey.txt 5 | Host tst-user1 6 | User gaurav 7 | HostName tst-user1.globcong.net 8 | IdentityFile test/privateKey.txt 9 | Host tst-user2 10 | User gaurav 11 | HostName tst-user2.globcong.net 12 | IdentityFile test/privateKey.txt 13 | Host tst-svc1 14 | User gaurav 15 | HostName tst-svc1.globcong.net 16 | IdentityFile test/privateKey.txt 17 | Host tst-svc2 18 | User gaurav 19 | HostName tst-svc2.globcong.net 20 | IdentityFile test/privateKey.txt 21 | Host ausoff-releng1.ausoff.globcong.net 22 | ForwardAgent yes -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remtail", 3 | "version": "0.0.16", 4 | "description": "tail out log files from multiple remote hosts", 5 | "main": "remtail.js", 6 | "scripts": { 7 | "test": "node test/test.js", 8 | "jshint": "jshint *.js lib/*.js" 9 | }, 10 | "keywords": [ 11 | "tail" 12 | ], 13 | "author": "Nick Carneiro", 14 | "contributors": [ 15 | "Jeremy Pitzeruse" 16 | ], 17 | "license": "MIT", 18 | "dependencies": { 19 | "colors": "^1.0.3", 20 | "commander": "^2.6.0", 21 | "osenv": "^0.1.0", 22 | "readline-sync": "^0.5.6", 23 | "ssh-config": "^0.1.0", 24 | "ssh2": "^0.4.4", 25 | "winston": "^1.0.0" 26 | }, 27 | "devDependencies": { 28 | "jshint": "^2.6.3", 29 | "tape": "^3.5.0" 30 | }, 31 | "bin": { 32 | "remtail": "remtail.js" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Nick Carneiro 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 | -------------------------------------------------------------------------------- /test/privateKey.txt: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-128-CBC,0DEA59BB8C7F897774DA1AF47997A1B9 4 | 5 | gFmrxYU0sM0mG2b9MVhWZnPcLJUNDBz43WFamgl6YyyzOF8GxOXpp0ozyCSD2ZWB 6 | Q6MeKX4Z3uxQCfTVB7dbTYGojQTBQbeByF0Bztup1mk3ZRXknWjdt88+Ptjxaxvr 7 | cxV5YlR7NZUeufRqESzeoISRiJP6G2y0hhla4RqR88V7Q2Mju23S47rxL7xmpc4N 8 | x6cuPN5b2saSqZzlhXTjICQclLpxhUpO3PNlbCp2KUDlyEVtN9bDPsd87DHSOTK2 9 | y4FJyUBL3FqM5Dz8Krq0KU0Wy2Z+zoljrg3CbPFFIfMrz0SutzhxasPz5I73nNR2 10 | vOLvbFnQhDk6UnZZowQhr/rj/B50zC0D50NqlGFkdLTYOXVzL3DwBZmTcbDESdtD 11 | SJ6dE/q/0loKDWx1grUUJkmYyHJmf8Gm59Mb8H1g4tNv+yxXpZt0aWknzc0xIy93 12 | eMdHsDeEJZamYNq5yrFt6QOI2S3gREvzGPedZAKQEZiQTDMEN6fo6enkRHPaE79Y 13 | swkiaO7LG08JKfFeAN4RxcnJ3upe+BQQIt+RTXgv2t0wxQKo0Pxi33IP2CuzwD5z 14 | 9aaTNR3Es7p1gjt5L+dsU2rjTk9aowIIznoEIehIu/0r1da1DSBr821fkY3OY/+q 15 | toLoGTduTwag65RnXknoM4wggxorp0PQWGiShytnQ1haZHfUzXWnQxFQaU4doJTp 16 | E8qO/zIyGGXFb1wMtCXNvGTqODHDLw9P5Lw+mojgJjGsvHQaD2ctWAnuqKoYvWA1 17 | oELMw2DnRsradG4t2y7OkiOaHio58Skt4aFW/DnoLO1EJQYexc9r15+KIw5E44Qn 18 | nhq5DYMbs+RzU3WcoLPAQzzC8l65Jh3k4TC2mG2uINzf0rExEmVU8OXbw+yalKJX 19 | zxMGsnDyNggvC8hrblf2Ywab9Mv479o3h8ILZet1g9I6GPElVpXJH9HO6on9z7xL 20 | VXx0sJ6/7bCiSTLL9ju1cP/W6kxUe++CTcoXi/Lgrh9rYn/5RoAEw2klErn5EBjD 21 | j0vxHGbbMQs0pzZayGw5mmMJAxcNzwkiWuiHf0e7iwEFOrZ3u00JtjsciRBpLhzD 22 | H3Btwqm9OroRvvRdRh6ubPZQ9uUvF+6vkqfLSVLrEIgJeVleYvHW8Xdw++YzFRvl 23 | yjWxyw0GqeQx5yiZgFb/X02EvdaSSianb3bkqUm9rqGT/Bodm3vc8qR0IALv8soF 24 | Bxmgdhbl5I633jCUzWTs5bnau/Qlgw3uyGSdm5bTeaUlgnv9SPa71DRIdiEcuW6R 25 | egBXEkn17qqAZgfxXqFn0b4keyU7+vSSjsUrXWOBrfonCYiWJe+At+tFMy/JP9W0 26 | MZbIBa9Nkx0VzAdzlGe6AQuIyn5Smz8wpLUvniQYUXJZ6QeSjmRcyKOFbDc5Fv3X 27 | ilxeNX3C8qva2XB/Qwmgn9Nwbl5/h/Bb/RIwFsp3dtTpjaGOY8VPmXaj0nd5KIcf 28 | /DBaUoap0Y0JEyJXONiXMTq1raDzjJ2O215uiy+7fC4DbuhUqSHHCRvl4JfohW4S 29 | 6o8FkHfMFG/e5gbiGv9bWdpbG1loYt3VnCZ9JcihVQ8aREjlpib+mBLJz3GsyJ2B 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remtail 2 | 3 | tail out log files from multiple remote hosts 4 | 5 | ![Image of example command](/remtail.png) 6 | 7 | # [![NPM version][npm-image]][npm-url][![Build Status](https://travis-ci.org/NickCarneiro/remtail.svg)](https://travis-ci.org/NickCarneiro/remtail) 8 | 9 | # install! 10 | 11 | 12 | ``` 13 | npm install -g remtail 14 | ``` 15 | 16 | # basic usage 17 | 18 | Connect to as many hosts as you want. 19 | 20 | ``` 21 | remtail trillworks.com:/var/log/nginx/access.log okpedro.com:/var/log/apache2/other_vhosts_access.log 22 | ``` 23 | 24 | Specify multiple files on the same host by repeating the hostname. 25 | 26 | ``` 27 | remtail trillworks.com:/var/log/nginx/access.log trillworks.com:/var/log/nginx/error.log 28 | ``` 29 | 30 | grep-like functionality with highlighting. 31 | 32 | Only print lines containing "Comment", with "Comment" shown in red. 33 | ``` 34 | remtail --grep "Comment" trillworks.com:/var/log/apache2/other_vhosts_access.log 35 | ``` 36 | For case insensitive search, use --grepi. 37 | ``` 38 | remtail --grepi "Comment" trillworks.com:/var/log/apache2/other_vhosts_access.log 39 | ``` 40 | 41 | You have full support for JavaScript regular expressions, so you can search for multiple strings like this: 42 | ``` 43 | remtail --grepi "GET|POST" trillworks.com:/var/log/apache2/other_vhosts_access.log 44 | ``` 45 | 46 | 47 | # advanced usage 48 | 49 | To avoid typing in passwords for every host, 50 | [copy your public key](http://askubuntu.com/questions/4830/easiest-way-to-copy-ssh-keys-to-another-machine) 51 | to the remote servers. Then add entries in your ssh config. (~/.ssh/config). Here is the format: 52 | 53 | 54 | Host trillworks 55 | HostName trillworks.com 56 | User burt 57 | IdentityFile ~/.ssh/id_rsa 58 | 59 | Specify an alternate ssh config with -s. 60 | 61 | 62 | If you want to live dangerously, you can throw your passwords in a json credentials file (~/.remtail.json). 63 | 64 | *WARNING: This is deprecated and will be removed in version 1.0.* 65 | 66 | 67 | [ 68 | { 69 | "hostname": "trillworks.com", 70 | "port": 22, 71 | "user": "buzz", 72 | "password": "hunter2" 73 | }, 74 | { 75 | "hostname": "globcong.com", 76 | "user": "woody", 77 | "privateKey": "/Users/woody/.ssh/id_rsa" 78 | } 79 | ] 80 | 81 | 82 | Specify an alternate credentials file path with -c. 83 | 84 | # development 85 | 86 | ## unit tests 87 | 88 | ```node test/test.js``` 89 | 90 | ## linter 91 | 92 | ```npm run-script jshint``` 93 | 94 | # license 95 | 96 | MIT 97 | 98 | 99 | [npm-url]: https://npmjs.org/package/remtail 100 | [npm-image]: https://badge.fury.io/js/remtail.svg 101 | -------------------------------------------------------------------------------- /lib/creds.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var osenv = require('osenv'); 3 | var logger = require('winston'); 4 | var sshConfig = require('ssh-config'); 5 | 6 | /** 7 | * Standardize reading of private key files 8 | * 9 | * @param {string} identityFile The path to the private key 10 | * @returns {string} The contents of the file if it exists 11 | */ 12 | var readPrivateKey = function(identityFile) { 13 | var filePath = identityFile.replace('~', osenv.home()); 14 | try { 15 | return fs.readFileSync(filePath, 'ascii'); 16 | } catch (e) { 17 | logger.error('Failed to read private key [' + filePath + ']'); 18 | return ''; 19 | } 20 | }; 21 | 22 | /** 23 | * @param {object} masterCredentialsMap - a mapping of hostname to credentials object 24 | * @param {Array} credentialList - an array of credential objects 25 | * @returns {*} 26 | */ 27 | var addFileCredentials = function(masterCredentialsMap, credentialList) { 28 | credentialList.forEach(function (credential) { 29 | var credentialMap = { 30 | user: credential['user'] 31 | }; 32 | 33 | if (credential['password']) { 34 | credentialMap['password'] = credential['password']; 35 | } 36 | 37 | if (credential['port']) { 38 | credentialMap['port'] = credential['port']; 39 | } 40 | 41 | if (credential['privateKey']) { 42 | credentialMap['privateKey'] = readPrivateKey(credential['privateKey']); 43 | } 44 | 45 | // if there is no credentialMap entry for this hostname, put credential in the map, if an entry already, exists 46 | // overwrite existing properties 47 | if (credential['hostname'] in masterCredentialsMap) { 48 | for (var property in credentialMap) { 49 | masterCredentialsMap[credential['hostname']][property] = credentialMap[property]; 50 | } 51 | } else { 52 | masterCredentialsMap[credential['hostname']] = credentialMap; 53 | } 54 | }); 55 | return masterCredentialsMap; 56 | }; 57 | 58 | 59 | /** 60 | * @param {object} credentialsMap - an empty object 61 | * @param {Array} sshConfigCredentials - credentials extracted by ssh-config-parser 62 | */ 63 | var buildSshConfigCredentialsMap = function(credentialsMap, sshConfigCredentials) { 64 | sshConfigCredentials.forEach(function(credential) { 65 | var standardizedCredential = {}; 66 | var hostname; 67 | 68 | if ('HostName' in credential) { 69 | hostname = credential['HostName']; 70 | } else { 71 | console.log('Missing HostName in ssh config for ' + credential['Host']); 72 | return; 73 | } 74 | 75 | if ('IdentityFile' in credential) { 76 | standardizedCredential.privateKey = readPrivateKey(credential['IdentityFile']); 77 | } 78 | 79 | if ('Port' in credential) { 80 | standardizedCredential.port = credential['Port']; 81 | } 82 | 83 | if ('User' in credential) { 84 | standardizedCredential.user = credential['User']; 85 | } 86 | credentialsMap[hostname] = standardizedCredential; 87 | }); 88 | return credentialsMap; 89 | }; 90 | 91 | 92 | // 93 | function objectToArray(obj) { 94 | var arr = []; 95 | for (var key in obj) { 96 | // Remove the length property set by the ssh-config package. 97 | if (key !== 'length') { 98 | arr.push(obj[key]); 99 | } 100 | } 101 | return arr; 102 | } 103 | 104 | 105 | function parseSshConfig(sshConfigFileContents) { 106 | // Previously we were using a library that parsed ssh config files into arrays. 107 | // Mimic that behavior here so the rest of the code doesn't need to change. 108 | return objectToArray(sshConfig.parse(sshConfigFileContents)); 109 | } 110 | 111 | 112 | module.exports = { 113 | addFileCredentials: addFileCredentials, 114 | buildSshConfigCredentialsMap: buildSshConfigCredentialsMap, 115 | parseSshConfig: parseSshConfig 116 | }; 117 | 118 | 119 | -------------------------------------------------------------------------------- /test/lib/test-creds.js: -------------------------------------------------------------------------------- 1 | var credentialUtil = require('../../lib/creds'); 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | var test = require('tape'); 5 | 6 | var SSH_CONFIG_FILE = path.join(__dirname, '..', 'ssh_config.txt'); 7 | var GAURAV_SSH_CONFIG_FILE = path.join(__dirname, '..', 'gaurav_ssh_config.txt'); 8 | var REMTAIL_JSON_FILE = path.join(__dirname, '..', 'remtail.json'); 9 | var PRIVATE_KEY_FILE = path.join(__dirname, '..', 'privateKey.txt'); 10 | 11 | var SSH_CONFIG_CONTENTS = fs.readFileSync(SSH_CONFIG_FILE, 'UTF-8'); 12 | var GAURAV_SSH_CONFIG_CONTENTS = fs.readFileSync(GAURAV_SSH_CONFIG_FILE, 'UTF-8'); 13 | var REMTAIL_JSON_CONTENTS = fs.readFileSync(REMTAIL_JSON_FILE, 'UTF-8'); 14 | var PRIVATE_KEY_CONTENTS = fs.readFileSync(PRIVATE_KEY_FILE, 'UTF-8'); 15 | 16 | var SSH_CONFIG = credentialUtil.parseSshConfig(SSH_CONFIG_CONTENTS); 17 | var REMTAIL_JSON = JSON.parse(REMTAIL_JSON_CONTENTS); 18 | 19 | test('ssh config parsing', function(t) { 20 | var expectedSshConfig = [ 21 | { 22 | Host: 'trillworks', 23 | HostName: 'trillworks.com', 24 | User: 'nickc' 25 | }, { 26 | Host: 'globcong', 27 | HostName: 'globcong.com', 28 | User: 'maurice', 29 | IdentityFile: 'test/privateKey.txt' 30 | } 31 | ]; 32 | 33 | var sshConfig = credentialUtil.parseSshConfig(SSH_CONFIG_CONTENTS); 34 | 35 | t.deepEquals(sshConfig, expectedSshConfig); 36 | t.end(); 37 | }); 38 | 39 | 40 | test('basic ssh credentials map', function(t) { 41 | var expectedCredentialsMap = { 42 | 'trillworks.com': { 43 | user: 'nickc' 44 | }, 45 | 'globcong.com': { 46 | user: 'maurice', 47 | privateKey: PRIVATE_KEY_CONTENTS 48 | } 49 | }; 50 | 51 | var credentialsMap = credentialUtil.buildSshConfigCredentialsMap({}, SSH_CONFIG); 52 | 53 | t.deepEquals(credentialsMap, expectedCredentialsMap); 54 | t.end(); 55 | }); 56 | 57 | 58 | test('basic remtail credentials map', function (t) { 59 | var expectedCredentialsMap = { 60 | 'trillworks.com': { 61 | port: 22, 62 | user: 'bigtex', 63 | password: 'hunter2' 64 | }, 65 | 'globcong.com': { 66 | user: 'peter', 67 | password: 'blah' 68 | }, 69 | "yahoo.com": { 70 | user: 'ganley', 71 | privateKey: PRIVATE_KEY_CONTENTS 72 | } 73 | }; 74 | 75 | var credentialsMap = credentialUtil.addFileCredentials({}, REMTAIL_JSON); 76 | 77 | t.deepEquals(credentialsMap, expectedCredentialsMap); 78 | t.end(); 79 | }); 80 | 81 | 82 | test('merging credentials maps', function(t) { 83 | var expectedCredentialsMap = { 84 | 'trillworks.com': { 85 | port: 22, 86 | user: 'bigtex', 87 | password: 'hunter2' 88 | }, 89 | 'globcong.com': { 90 | user: 'peter', 91 | password: 'blah', 92 | privateKey: PRIVATE_KEY_CONTENTS 93 | }, 94 | "yahoo.com": { 95 | user: 'ganley', 96 | privateKey: PRIVATE_KEY_CONTENTS 97 | } 98 | }; 99 | 100 | var credentialsMap = {}; 101 | credentialUtil.buildSshConfigCredentialsMap(credentialsMap, SSH_CONFIG); 102 | credentialUtil.addFileCredentials(credentialsMap, REMTAIL_JSON); 103 | 104 | t.deepEquals(credentialsMap, expectedCredentialsMap); 105 | t.end(); 106 | }); 107 | 108 | 109 | test('gaurav ssh config with ForwardAgent entry', function(t) { 110 | var expectedCredentialsMap = { 111 | 'ggmathur.ausoff.globcong.net': { 112 | user: 'gaurav', 113 | privateKey: PRIVATE_KEY_CONTENTS 114 | }, 115 | 'tst-user1.globcong.net': { 116 | user: 'gaurav', 117 | privateKey: PRIVATE_KEY_CONTENTS 118 | }, 119 | 'tst-user2.globcong.net': { 120 | user: 'gaurav', 121 | privateKey: PRIVATE_KEY_CONTENTS 122 | }, 123 | 'tst-svc1.globcong.net': { 124 | user: 'gaurav', 125 | privateKey: PRIVATE_KEY_CONTENTS 126 | }, 127 | 'tst-svc2.globcong.net': { 128 | user: 'gaurav', 129 | privateKey: PRIVATE_KEY_CONTENTS 130 | } 131 | }; 132 | 133 | 134 | var gauravSshConfig = credentialUtil.parseSshConfig(GAURAV_SSH_CONFIG_CONTENTS); 135 | var credentialsMap = credentialUtil.buildSshConfigCredentialsMap({}, gauravSshConfig); 136 | 137 | t.deepEquals(credentialsMap, expectedCredentialsMap); 138 | t.end(); 139 | }); -------------------------------------------------------------------------------- /lib/hosts.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | function buildDisplayPaths(hosts) { 4 | for (var hostName in hosts) { 5 | var host = hosts[hostName]; 6 | host.displayPaths = {}; 7 | var pathname; 8 | if (host.paths.length > 1) { 9 | pathname = host.paths[0]; 10 | var ptr = 0, 11 | startIndex = 0, 12 | endIndex = 0; 13 | var matchString; 14 | var matched; 15 | while (ptr > -1) { 16 | matchString = pathname.substr(0, ptr); 17 | 18 | matched = host.paths.every(function(path) { 19 | return path.indexOf(matchString) === 0; 20 | }); 21 | 22 | if (matched) { 23 | startIndex = ptr + 1; 24 | } else { 25 | break; 26 | } 27 | 28 | ptr = pathname.indexOf('/', ptr + 1); 29 | } 30 | 31 | while (ptr > -1) { 32 | matchString = pathname.substr(ptr); 33 | console.log(matchString); 34 | 35 | matched = host.paths.every(function(path) { 36 | return path.indexOf(matchString) === path.length - matchString.length; 37 | }); 38 | 39 | if (matched) { 40 | endIndex = pathname.length - ptr; 41 | break; 42 | } 43 | 44 | ptr = pathname.indexOf('/', ptr + 1); 45 | } 46 | 47 | host.paths.forEach(function(path) { 48 | var length = path.length - endIndex; 49 | var displayPath = path.substr(startIndex, length - startIndex); 50 | host.displayPaths[path] = displayPath; 51 | }); 52 | } else { 53 | pathname = host.paths[0]; 54 | hosts[hostName].displayPaths[pathname] = path.basename(pathname); 55 | } 56 | } 57 | } 58 | 59 | 60 | /** 61 | * 62 | * @param {string[]} hostPathPairs - an array of pairs like ['nickc@tst-web1:/var/log/httpd.log'] 63 | * @returns {{object}} a map of host names to host objects like {password: 'blah', paths: []} 64 | */ 65 | function buildHostMap(hostPathPairs) { 66 | var hosts = {}; 67 | var colorFunctions = ['red', 'yellow', 'green', 'blue', 'magenta', 'cyan', 'white', 'gray']; 68 | var colorIndex = 0; 69 | hostPathPairs.forEach(function(pair) { 70 | var hostAndPair = pair.split(':'); 71 | if (hostAndPair.length !== 2) { 72 | console.log('Failed to parse host ' + pair); 73 | console.log('Expected a host/path pair like this:'); 74 | console.log('trillworks.com:/var/log/logfile.log'); 75 | process.exit(1); 76 | } 77 | var host = hostAndPair[0]; 78 | var path = hostAndPair[1]; 79 | if (host in hosts) { 80 | hosts[host].paths.push(path); 81 | } else { 82 | hosts[host] = { 83 | paths: [path], 84 | color: colorFunctions[colorIndex] 85 | }; 86 | colorIndex++; 87 | if (colorIndex === colorFunctions.length) { 88 | colorIndex = 0; 89 | } 90 | } 91 | }); 92 | 93 | buildDisplayPaths(hosts); 94 | 95 | return hosts; 96 | } 97 | 98 | 99 | /** 100 | * Add login credentials for each host. This mutates the hosts map param. 101 | * 102 | * @param {object} hosts - an object of hosts generated by buildHostMap 103 | * @param {object} credentialMap - hostname to password, port, privateKey 104 | */ 105 | function addCredentials(hosts, credentialMap) { 106 | if (!credentialMap) { 107 | return; 108 | } 109 | for (var hostName in hosts) { 110 | var host = hosts[hostName]; 111 | if (hostName in credentialMap) { 112 | var credentials = credentialMap[hostName]; 113 | host['user'] = credentials['user']; 114 | 115 | if (credentials['password']) { 116 | host['password'] = credentials['password']; 117 | } else if (credentials['privateKey']) { 118 | host['privateKey'] = credentials['privateKey']; 119 | } 120 | 121 | if (credentials['port']) { 122 | host['port'] = credentials['port']; 123 | } else { 124 | host['port'] = 22; 125 | } 126 | } 127 | } 128 | } 129 | 130 | 131 | module.exports = { 132 | buildDisplayPaths: buildDisplayPaths, 133 | buildHostMap: buildHostMap, 134 | addCredentials: addCredentials 135 | }; -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var test = require('tape'); 2 | var fs = require('fs'); 3 | var hostUtils = require('../lib/hosts'); 4 | var credentialUtils = require('../lib/creds'); 5 | 6 | require('./lib/test-creds'); 7 | 8 | var hostPathPairs = [ 9 | 'trillworks.com:/var/log/blah.log', 10 | 'trillworks.com:/var/log/wow.log', 11 | 'globcong.com:/var/www/django.log', 12 | 'yahoo.com:/var/log/whoa.log', 13 | 'github.com:/var/logs/app1/logs/application.log', 14 | 'github.com:/var/logs/test-app2/logs/application.log' 15 | ]; 16 | var hostMap = hostUtils.buildHostMap(hostPathPairs); 17 | 18 | var credentialsFileString = fs.readFileSync(__dirname + '/remtail.json'); 19 | var credentialList = JSON.parse(credentialsFileString); 20 | var credentialsMap = {}; 21 | credentialUtils.addFileCredentials(credentialsMap, credentialList); 22 | 23 | var expectedPrivateKey = fs.readFileSync(__dirname + '/privateKey.txt', 'utf-8'); 24 | 25 | test('build map of hosts from command line args', function (t) { 26 | 27 | var expectedHostMap = { 28 | 'trillworks.com': { 29 | color: 'red', 30 | paths: ['/var/log/blah.log', '/var/log/wow.log'], 31 | displayPaths: { 32 | '/var/log/blah.log': 'blah.log', 33 | '/var/log/wow.log': 'wow.log' 34 | } 35 | }, 36 | 'globcong.com': { 37 | color: 'yellow', 38 | paths: ['/var/www/django.log'], 39 | displayPaths: { 40 | '/var/www/django.log': 'django.log' 41 | } 42 | }, 43 | 'yahoo.com': { 44 | color: 'green', 45 | paths: ['/var/log/whoa.log'], 46 | displayPaths: { 47 | '/var/log/whoa.log': 'whoa.log' 48 | } 49 | }, 50 | 'github.com': { 51 | color: 'blue', 52 | paths: ['/var/logs/app1/logs/application.log', '/var/logs/test-app2/logs/application.log'], 53 | displayPaths: { 54 | '/var/logs/app1/logs/application.log': 'app1', 55 | '/var/logs/test-app2/logs/application.log': 'test-app2' 56 | } 57 | } 58 | }; 59 | t.deepEquals(hostMap, expectedHostMap); 60 | t.end(); 61 | }); 62 | 63 | 64 | test('build credentials map from properties file', function (t) { 65 | 66 | var expectedCredentialsMap = { 67 | 'trillworks.com': { 68 | port: 22, 69 | user: 'bigtex', 70 | password: 'hunter2' 71 | }, 72 | 'globcong.com': { 73 | user: 'peter', 74 | password: 'blah' 75 | }, 76 | "yahoo.com": { 77 | user: 'ganley', 78 | privateKey: expectedPrivateKey 79 | } 80 | }; 81 | t.deepEquals(credentialsMap, expectedCredentialsMap); 82 | t.end(); 83 | }); 84 | 85 | 86 | test('add credentials to hosts map', function (t) { 87 | hostUtils.addCredentials(hostMap, credentialsMap); 88 | 89 | var expectedHostMap = { 90 | 'trillworks.com': { 91 | color: 'red', 92 | paths: ['/var/log/blah.log', '/var/log/wow.log'], 93 | user: 'bigtex', 94 | password: 'hunter2', 95 | port: 22, 96 | displayPaths: { 97 | '/var/log/blah.log': 'blah.log', 98 | '/var/log/wow.log': 'wow.log' 99 | } 100 | }, 101 | 'globcong.com': { 102 | color: 'yellow', 103 | paths: ['/var/www/django.log'] , 104 | user: 'peter', 105 | password: 'blah', 106 | port: 22, 107 | displayPaths: { 108 | '/var/www/django.log': 'django.log' 109 | } 110 | }, 111 | 'yahoo.com': { 112 | color: 'green', 113 | paths: ['/var/log/whoa.log'], 114 | privateKey: expectedPrivateKey, 115 | port: 22, 116 | user: 'ganley', 117 | displayPaths: { 118 | '/var/log/whoa.log': 'whoa.log' 119 | } 120 | }, 121 | 'github.com': { 122 | color: 'blue', 123 | paths: ['/var/logs/app1/logs/application.log', '/var/logs/test-app2/logs/application.log'], 124 | displayPaths: { 125 | '/var/logs/app1/logs/application.log': 'app1', 126 | '/var/logs/test-app2/logs/application.log': 'test-app2' 127 | } 128 | } 129 | }; 130 | t.deepEquals(hostMap, expectedHostMap); 131 | t.end(); 132 | }); 133 | 134 | 135 | test('test colors wraparound', function (t) { 136 | var hostPathPairs = [ 137 | 'trillworks.com:/var/log/blah.log', 138 | 'yahoo.com:/var/log/wow.log', 139 | 'google.com:/var/www/django.log', 140 | 'monster.com:/var/www/django.log', 141 | 'globcong.com:/var/www/django.log', 142 | 'linkedin.com:/var/www/django.log', 143 | 'alexa.com:/var/www/django.log', 144 | 'bing.com:/var/www/django.log', 145 | 'microsoft.com:/var/www/django.log', 146 | 'gatorade.com:/var/www/django.log' 147 | ]; 148 | var hostMap = hostUtils.buildHostMap(hostPathPairs); 149 | t.equals(hostMap['gatorade.com']['color'], 'yellow'); 150 | t.end(); 151 | }); 152 | 153 | 154 | test('build credentials map from ssh_config', function (t) { 155 | var sshConfigFile = fs.readFileSync(__dirname + '/ssh_config.txt', 'utf-8'); 156 | var sshConfig = credentialUtils.parseSshConfig(sshConfigFile); 157 | var credentialsMap = credentialUtils.buildSshConfigCredentialsMap({}, sshConfig); 158 | var expectedCredentialsMap = { 159 | 'trillworks.com': { 160 | user: 'nickc' 161 | }, 162 | 'globcong.com': { 163 | user: 'maurice', 164 | privateKey: expectedPrivateKey 165 | } 166 | }; 167 | t.deepEquals(credentialsMap, expectedCredentialsMap); 168 | t.end(); 169 | }); 170 | 171 | 172 | test('add ssh config files to hostmap', function (t) { 173 | var sshConfigFile = fs.readFileSync(__dirname + '/ssh_config.txt', 'utf-8'); 174 | var sshConfig = credentialUtils.parseSshConfig(sshConfigFile); 175 | var credentialsMap = credentialUtils.buildSshConfigCredentialsMap({}, sshConfig); 176 | var expectedHostMap = { 177 | 'trillworks.com': { 178 | color: 'red', 179 | paths: ['/var/log/blah.log', '/var/log/wow.log'], 180 | displayPaths: { 181 | '/var/log/blah.log': 'blah.log', 182 | '/var/log/wow.log': 'wow.log' 183 | }, 184 | user: 'nickc', 185 | port: 22 186 | }, 187 | 'globcong.com': { 188 | color: 'yellow', 189 | paths: ['/var/www/django.log'], 190 | displayPaths: { 191 | '/var/www/django.log': 'django.log' 192 | }, 193 | privateKey: expectedPrivateKey, 194 | user: 'maurice', 195 | port: 22 196 | }, 197 | 'yahoo.com': { 198 | color: 'green', 199 | paths: ['/var/log/whoa.log'], 200 | displayPaths: { 201 | '/var/log/whoa.log': 'whoa.log' 202 | } 203 | }, 204 | 'github.com': { 205 | color: 'blue', 206 | paths: ['/var/logs/app1/logs/application.log', '/var/logs/test-app2/logs/application.log'], 207 | displayPaths: { 208 | '/var/logs/app1/logs/application.log': 'app1', 209 | '/var/logs/test-app2/logs/application.log': 'test-app2' 210 | } 211 | } 212 | }; 213 | var hostMap = hostUtils.buildHostMap(hostPathPairs); 214 | hostUtils.addCredentials(hostMap, credentialsMap); 215 | t.deepEquals(hostMap, expectedHostMap); 216 | t.end(); 217 | }); -------------------------------------------------------------------------------- /remtail.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * High level explanation: 5 | * Get credentials from credentials file and arguments and create a credentials map 6 | * Get hosts information from arguments and build a hosts map 7 | * Add the credentials map data to the hosts map 8 | * Connect to every host in the hosts map 9 | */ 10 | var SshClient = require('ssh2').Client; 11 | var hostUtils = require('./lib/hosts'); 12 | var credentialUtils = require('./lib/creds'); 13 | var grepUtils = require('./lib/grep'); 14 | var colors = require('colors'); 15 | var readlineSync = require('readline-sync'); 16 | var path = require('path'); 17 | var fs = require('fs'); 18 | var osenv = require('osenv'); 19 | var program = require('commander'); 20 | var packageJson = require('./package.json'); 21 | 22 | var logger = require('winston'); 23 | logger.cli(); 24 | 25 | var DEFAULT_CREDENTIALS_LOCATION = path.join(osenv.home(), '.remtail.json'); 26 | var DEFAULT_SSH_CONFIG = path.join(osenv.home(), '.ssh', 'config'); 27 | 28 | 29 | function main() { 30 | program 31 | .version(packageJson.version) 32 | .usage('[options] : :') 33 | .option('-c, --credentials [path]', 'Path to credentials file [DEPRECATED]') 34 | .option('-s, --sshconfig [path]', 'Path to ssh config file') 35 | .option('-g, --grep [regex]', 'Regular expression to filter output on') 36 | .option('-i, --grepi [regex]', 'Case-insensitive regular expression to filter output on') 37 | .option('-v, --verbose', 'Be more verbose when running the setup') 38 | .parse(process.argv); 39 | 40 | if (program.args.length === 0) { 41 | program.outputHelp(); 42 | process.exit(1); 43 | } 44 | 45 | if (program.verbose) { 46 | logger.level = 'debug'; 47 | } 48 | 49 | var credentialsMap = {}; 50 | 51 | var sshConfigFilePath = program.sshconfig || DEFAULT_SSH_CONFIG; 52 | logger.debug('Attempting with ssh config file [%s]', sshConfigFilePath); 53 | if (fs.existsSync(sshConfigFilePath)) { 54 | try { 55 | var sshConfig = fs.readFileSync(sshConfigFilePath, 'utf-8'); 56 | var sshConfigCredentials = credentialUtils.parseSshConfig(sshConfig); 57 | credentialsMap = credentialUtils.buildSshConfigCredentialsMap(credentialsMap, sshConfigCredentials); 58 | } catch (e) { 59 | logger.error('Could not parse ssh config file [%s]', sshConfigFilePath, e); 60 | } 61 | } else { 62 | logger.debug('Failed to locate ssh config file [%s]', sshConfigFilePath); 63 | } 64 | 65 | var credentialsFilePath = program.credentials || DEFAULT_CREDENTIALS_LOCATION; 66 | logger.debug('Attempting with credentials file [%s]', credentialsFilePath); 67 | if (fs.existsSync(credentialsFilePath)) { 68 | try { 69 | var credentialsFileString = fs.readFileSync(credentialsFilePath, 'utf-8'); 70 | var credentialList = JSON.parse(credentialsFileString); 71 | credentialsMap = credentialUtils.addFileCredentials(credentialsMap, credentialList); 72 | } catch (e) { 73 | logger.error('Could not parse credentials file [%s]', credentialsFilePath, e); 74 | } 75 | } else { 76 | logger.debug('Failed to locate credentials file [%s]', credentialsFilePath); 77 | } 78 | 79 | var hosts = hostUtils.buildHostMap(program.args); 80 | logger.debug('Credentials'); 81 | logger.debug(JSON.stringify(credentialsMap, null, 2)); 82 | logger.debug('Hosts'); 83 | logger.debug(JSON.stringify(hosts, null, 2)); 84 | hostUtils.addCredentials(hosts, credentialsMap); 85 | 86 | // open an ssh connection to every host and run the tail commands 87 | var connectionMap = {}; 88 | var hostsSize = 0; 89 | for (var hostName in hosts) { 90 | hostsSize++; 91 | var host = hosts[hostName]; 92 | 93 | for (var i in host.paths) { 94 | var file = host.paths[i]; 95 | var tailCommand = "tail -F " + file; 96 | var displayPath = host.displayPaths[file]; 97 | 98 | console.log('hostname: ' + hostName); 99 | console.log('command: ' + tailCommand); 100 | 101 | var conn = connectionMap[hostName] || new SshClient(); 102 | 103 | // use bind to build a function that takes copies of local vars 104 | // from this particular iteration of the for loop 105 | var readyCallback = function(conn, tailCommand, hostName, displayPath) { 106 | var host = hosts[hostName]; 107 | conn.exec(tailCommand, function(err, stream) { 108 | if (err) { 109 | throw err; 110 | } 111 | 112 | stream.on('close', function() { 113 | console.log('Connected closed to: ' + hostName); 114 | conn.end(); 115 | }).on('data', function(data) { 116 | var dataString = data.toString('utf-8'); 117 | var lines = dataString.split('\n'); 118 | lines.forEach(function(line) { 119 | if (line) { 120 | if (program.grep || program.grepi) { 121 | var re; 122 | if (program.grepi) { 123 | re = new RegExp(program.grepi, 'i'); 124 | } else { 125 | re = new RegExp(program.grep); 126 | } 127 | if (line.match(re)) { 128 | var highlightedLine = grepUtils.highlightString(line, re); 129 | var coloredLine = colors[host.color](hostName + ' ' + displayPath) + ' ' + 130 | highlightedLine; 131 | console.log(coloredLine); 132 | } 133 | } else { 134 | console.log(colors[host.color](hostName + ' ' + displayPath) + ' ' + line); 135 | } 136 | } 137 | }); 138 | 139 | }).stderr.on('data', function(data) { 140 | var dataString = data.toString('utf-8'); 141 | var lines = dataString.split('\n'); 142 | lines.forEach(function(line) { 143 | // stderr is ungreppable for now 144 | console.log(colors[host.color](hostName + ' ' + displayPath) + ' ' + line); 145 | }); 146 | }); 147 | }); 148 | }.bind(this, conn, tailCommand, hostName, displayPath); 149 | conn.on('ready', readyCallback); 150 | 151 | if (!connectionMap[hostName]) { 152 | var connectionParams = { 153 | host: hostName, 154 | port: host.port, 155 | username: host.user 156 | }; 157 | 158 | if (!host.user) { 159 | connectionParams.username = host.user = 160 | readlineSync.question('Username for ' + hostName + ':\n'); 161 | } 162 | 163 | // Authentication Method 164 | if (host.password) { 165 | connectionParams.password = host.password; 166 | } else if (host.privateKey) { 167 | connectionParams.privateKey = host.privateKey; 168 | if (host.passphrase) { 169 | connectionParams.passphrase = host.passphrase; 170 | } else if (host.privateKey.indexOf('ENCRYPTED') !== -1) { 171 | connectionParams.passphrase = host.passphrase = 172 | readlineSync.question('ssh key passphrase for ' + hostName + ':\n', {noEchoBack: true}); 173 | } 174 | } else { 175 | var identifier = connectionParams.username + '@' + hostName; 176 | connectionParams.password = host.password = 177 | readlineSync.question('Password for ' + identifier + ':\n', {noEchoBack: true}); 178 | } 179 | 180 | conn.on('error', function(connectionParams, error) { 181 | if (error.level === 'client-socket') { 182 | console.log('Could not connect to host ' + connectionParams.host); 183 | process.exit(1); 184 | 185 | } else if (error.level === 'client-authentication') { 186 | console.log('Could not authenticate ' + connectionParams.username + '@' + connectionParams.host); 187 | } 188 | }.bind(this, connectionParams)); 189 | try { 190 | conn.connect(connectionParams); 191 | } catch (e) { 192 | console.log('Could not connect to ' + connectionParams.host); 193 | if (e.toString().indexOf('Malformed private key') !== -1) { 194 | console.log('Incorrect passphrase for private key.'); 195 | } else { 196 | console.log(e.toString()); 197 | } 198 | process.exit(1); 199 | } 200 | connectionMap[hostName] = conn; 201 | } 202 | } 203 | } 204 | } 205 | 206 | 207 | if (require.main === module) { 208 | main(); 209 | } 210 | --------------------------------------------------------------------------------