uoregon-multissh 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.ruby-version +1 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +17 -0
- data/README.md +73 -0
- data/bin/multissh +7 -0
- data/lib/cli.rb +134 -0
- data/lib/credential.rb +210 -0
- data/lib/multissh.rb +43 -0
- data/lib/update.rb +31 -0
- data/lib/util.rb +86 -0
- data/lib/worker.rb +103 -0
- data/multissh.gemspec +14 -0
- metadata +57 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b6c803a8837c4f704bfd43e4ee2b92e03afb75d419c9353d60bc8b596171ac03
|
4
|
+
data.tar.gz: 56dab86e92624866237d5e4f1f986e83961ac8660509ea4ae91d0bab3a9431b9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 386e2b55b1bf09ed100083b108fd38d276f29dcf9b51858848eddafa002bf7a56463eca9ce2c6c4c02997024d9e3601cbb5344429b2a2c291113c4845ff3bb72
|
7
|
+
data.tar.gz: ce46bd0c1e12847e5440f1027f058b11c99be94c93e07760488091df6f4ec59c2318dd5dd277573643db0df44762f4e331cf7d1454c94e53e7aceefed524f612
|
data/.gitignore
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.5.3
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
data/README.md
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# MultiSSH
|
2
|
+
|
3
|
+
Do all the things everywhere at the same time
|
4
|
+
|
5
|
+
|
6
|
+
### Setup
|
7
|
+
|
8
|
+
Clone the repo and install the required gems with bundler
|
9
|
+
```
|
10
|
+
git clone https://github.com/lcrownover/multissh
|
11
|
+
cd multissh
|
12
|
+
bundle install
|
13
|
+
```
|
14
|
+
|
15
|
+
On the first run, you'll be prompted to generate a credential file.
|
16
|
+
This file is stored at *~/.ssh/multissh.yaml*, with the owner as the current user and mode of 600.
|
17
|
+
|
18
|
+
If you decline to generate this file, it will prompt for password if not provided via command line.
|
19
|
+
|
20
|
+
If you don't have ssh-agent configured with your keys, it will prompt for a private key password during credential file generation, or during run if you opted out of the credential file.
|
21
|
+
|
22
|
+
|
23
|
+
<br>
|
24
|
+
|
25
|
+
### Usage
|
26
|
+
|
27
|
+
```
|
28
|
+
Usage: multissh.rb --nodes "server1,server2" --command "echo 'hello'"
|
29
|
+
--nodes "NODES" REQUIRED: "server1,server2,server3" OR "@nodes.txt"
|
30
|
+
--command "COMMAND" REQUIRED: "echo 'hello'" OR @command.txt
|
31
|
+
--username "USERNAME" OPTIONAL: current user by default
|
32
|
+
--password "PASSWORD" OPTIONAL: will prompt if needed
|
33
|
+
--pkey_password "PASSWORD" OPTIONAL: will prompt if needed
|
34
|
+
--block OPTIONAL: block mode for command ouptut
|
35
|
+
--regenerate_config OPTIONAL: regenerate configuration file
|
36
|
+
--debug OPTIONAL: debug mode
|
37
|
+
```
|
38
|
+
|
39
|
+
<br><br>
|
40
|
+
|
41
|
+
### Examples
|
42
|
+
|
43
|
+
Run a command against a comma-separated list of nodes
|
44
|
+
```bash
|
45
|
+
ruby multissh.rb --nodes "NODE1,NODE2" --command "COMMAND"
|
46
|
+
```
|
47
|
+
|
48
|
+
<br>
|
49
|
+
|
50
|
+
Run a command against a file containing a newline-separated list of nodes
|
51
|
+
```
|
52
|
+
node1.example.org
|
53
|
+
node2.example.org
|
54
|
+
```
|
55
|
+
|
56
|
+
```bash
|
57
|
+
ruby multissh.rb --nodes @nodes.txt --command "COMMAND"
|
58
|
+
```
|
59
|
+
|
60
|
+
<br>
|
61
|
+
|
62
|
+
Run a list of newline-separated commands against a newline-separated list of nodes
|
63
|
+
```
|
64
|
+
echo $(hostname)
|
65
|
+
yum install ruby
|
66
|
+
ruby -v
|
67
|
+
```
|
68
|
+
|
69
|
+
```bash
|
70
|
+
ruby multissh.rb --nodes @nodes.txt --command @commands.txt
|
71
|
+
```
|
72
|
+
|
73
|
+
<br>
|
data/bin/multissh
ADDED
data/lib/cli.rb
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
require_relative 'credential'
|
2
|
+
|
3
|
+
class Cli
|
4
|
+
attr_accessor :username, :password, :key_password, :nodes, :command, :block, :debug, :credential
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
|
8
|
+
@options = {}
|
9
|
+
opt_parse = OptionParser.new do |opt|
|
10
|
+
opt.banner = 'Usage: multissh.rb --nodes "server1,server2" --command "echo \'hello\'"'
|
11
|
+
opt.on('--nodes "NODES"', 'REQUIRED: "server1,server2,server3" OR "@nodes.txt"') { |o| @options[:nodes] = o }
|
12
|
+
opt.on('--command "COMMAND"', 'REQUIRED: "echo \'hello\'" OR @command.txt') { |o| @options[:command] = o }
|
13
|
+
opt.on('--username "USERNAME"', 'OPTIONAL: current user by default') { |o| @options[:username] = o }
|
14
|
+
opt.on('--password "PASSWORD"', 'OPTIONAL: will prompt if needed') { |o| @options[:password] = o }
|
15
|
+
opt.on('--pkey_password "PASSWORD"', 'OPTIONAL: will prompt if needed') { |o| @options[:pkey_password] = o }
|
16
|
+
opt.on('--disable_sudo', 'OPTIONAL: disable_sudo requirement and run as current user') { @options[:disable_sudo] = true }
|
17
|
+
opt.on('--block', 'OPTIONAL: block mode for command ouptut') { @options[:block] = true }
|
18
|
+
opt.on('--regenerate_config', 'OPTIONAL: regenerate configuration file') { @options[:regenerate_config] = true }
|
19
|
+
opt.on('--debug', 'OPTIONAL: debug mode') { @options[:debug] = true }
|
20
|
+
end
|
21
|
+
opt_parse.parse!
|
22
|
+
|
23
|
+
begin
|
24
|
+
valid_options_set = false
|
25
|
+
valid_options_set = true if @options[:nodes] && @options[:command]
|
26
|
+
valid_options_set = true if @options[:regenerate_config]
|
27
|
+
|
28
|
+
raise OptionParser::MissingArgument unless valid_options_set
|
29
|
+
rescue
|
30
|
+
puts "\n"
|
31
|
+
abort(opt_parse.help)
|
32
|
+
end
|
33
|
+
|
34
|
+
@username = @options[:username]
|
35
|
+
@password = @options[:password]
|
36
|
+
@pkey_password = @options[:pkey_password]
|
37
|
+
|
38
|
+
@debug = true if @options[:debug]
|
39
|
+
@regenerate = true if @options[:regenerate_config]
|
40
|
+
@disable_sudo = true if @options[:disable_sudo]
|
41
|
+
|
42
|
+
credential = Credential.new(
|
43
|
+
username = @username,
|
44
|
+
password = @password,
|
45
|
+
pkey_password = @pkey_password,
|
46
|
+
regenerate = @regenerate,
|
47
|
+
debug = @debug
|
48
|
+
)
|
49
|
+
@username = credential.username
|
50
|
+
@password = credential.password
|
51
|
+
@pkey_password = if credential.pkey_password == "" then nil else credential.pkey_password end
|
52
|
+
|
53
|
+
abort() if @options[:regenerate_config]
|
54
|
+
|
55
|
+
@nodes = parse_nodes(@options[:nodes])
|
56
|
+
@command = parse_command(@options[:command])
|
57
|
+
@block = true if @options[:block]
|
58
|
+
|
59
|
+
rescue Interrupt
|
60
|
+
puts "\nCtrl+C Interrupt\n"
|
61
|
+
exit 1
|
62
|
+
|
63
|
+
end#initialize
|
64
|
+
|
65
|
+
|
66
|
+
|
67
|
+
def parse_nodes(nodes)
|
68
|
+
##
|
69
|
+
# If '@' is used, return a list of nodes from a file
|
70
|
+
# Otherwise return a list of nodes parsed from comma-separated input from cli
|
71
|
+
#
|
72
|
+
if nodes.start_with?('@')
|
73
|
+
node_list = []
|
74
|
+
file_path = nodes[1..-1]
|
75
|
+
expanded_file_path = File.expand_path(file_path)
|
76
|
+
if File.exists? expanded_file_path
|
77
|
+
File.open(expanded_file_path, 'r') do |f|
|
78
|
+
f.each_line do |line|
|
79
|
+
line.chomp!.strip!
|
80
|
+
unless line.start_with?('#') || line.empty?
|
81
|
+
node_list.append(line)
|
82
|
+
end#unless
|
83
|
+
end#f.each_line
|
84
|
+
end#File.open
|
85
|
+
end#File.exists?
|
86
|
+
node_list
|
87
|
+
else
|
88
|
+
nodes.split(',').map(&:chomp)
|
89
|
+
end#if
|
90
|
+
end#parse_nodes
|
91
|
+
|
92
|
+
|
93
|
+
def parse_command(command)
|
94
|
+
##
|
95
|
+
# If '@' is used, return a command string from a file
|
96
|
+
# Otherwise return specified command
|
97
|
+
#
|
98
|
+
if command.start_with?('@')
|
99
|
+
command_list = []
|
100
|
+
file_path = command[1..-1]
|
101
|
+
expanded_file_path = File.expand_path(file_path)
|
102
|
+
if File.exists? expanded_file_path
|
103
|
+
File.open(expanded_file_path, 'r') do |f|
|
104
|
+
f.each_line do |line|
|
105
|
+
line.chomp!.strip!
|
106
|
+
unless line.start_with?('#') || line.empty?
|
107
|
+
command_list.append(line)
|
108
|
+
end#unless
|
109
|
+
end#f.each_line
|
110
|
+
end#File.open
|
111
|
+
end#File.exists?
|
112
|
+
command_list.map! do |command|
|
113
|
+
command = format_command(command, @disable_sudo)
|
114
|
+
end
|
115
|
+
command = command_list.join('; ')
|
116
|
+
else
|
117
|
+
command = command.chomp
|
118
|
+
command = format_command(command, @disable_sudo)
|
119
|
+
end#if
|
120
|
+
end#parse_command
|
121
|
+
|
122
|
+
|
123
|
+
def format_command(command, disable_sudo=false)
|
124
|
+
pre_command = ". ~/.bash_profile; "\
|
125
|
+
"export PATH=$PATH:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin; "
|
126
|
+
unless command[0..3] == 'sudo'
|
127
|
+
unless disable_sudo
|
128
|
+
command = 'sudo ' + command
|
129
|
+
end
|
130
|
+
end
|
131
|
+
pre_command + command + ' 2>&1'
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
data/lib/credential.rb
ADDED
@@ -0,0 +1,210 @@
|
|
1
|
+
class Credential
|
2
|
+
attr_accessor :config_file_path, :username, :password, :pkey_password, :sudo_password, :snowflakes
|
3
|
+
|
4
|
+
def initialize(username, password, pkey_password, regenerate, debug)
|
5
|
+
@debug = debug
|
6
|
+
@util = Util.new(@debug)
|
7
|
+
|
8
|
+
@config_file_path = "#{%x{echo ~}.chomp}/.ssh/multissh.yaml"
|
9
|
+
@username = set_username(username)
|
10
|
+
@password = set_password(password)
|
11
|
+
@pkey_password = pkey_password
|
12
|
+
@sudo_password = nil
|
13
|
+
@snowflakes = nil
|
14
|
+
@regenerate = true if regenerate
|
15
|
+
|
16
|
+
generate_config if @regenerate
|
17
|
+
process_config
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
def process_config
|
22
|
+
if File.exist? @config_file_path
|
23
|
+
@util.dbg('configuration file exists')
|
24
|
+
else
|
25
|
+
@util.dbg('couldnt find configuration file')
|
26
|
+
generate_config
|
27
|
+
end
|
28
|
+
|
29
|
+
begin
|
30
|
+
@yaml = YAML.load_file(@config_file_path)
|
31
|
+
rescue
|
32
|
+
raise 'Configuration file detected but unable to properly load. Please regenerate using "multissh.rb --regenerate_config"'
|
33
|
+
end
|
34
|
+
|
35
|
+
if @yaml['enabled']
|
36
|
+
@util.dbg('credential enabled')
|
37
|
+
@util.dbg(@yaml)
|
38
|
+
|
39
|
+
unless @yaml['credentials'][@username]
|
40
|
+
printf "No saved credential for #{@username}, create one? [yes]: "
|
41
|
+
if @util.check_affirmative
|
42
|
+
credential = generate_credential_entry
|
43
|
+
@yaml['credentials'][@username] = credential
|
44
|
+
save_config
|
45
|
+
load_config
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
unless @password
|
50
|
+
@password = @util.decrypt(@yaml['credentials'][@username]['password'])
|
51
|
+
@util.dbg("password - #{@password}")
|
52
|
+
end
|
53
|
+
|
54
|
+
unless @pkey_password
|
55
|
+
@pkey_password = @util.decrypt(@yaml['credentials'][@username]['pkey_password'])
|
56
|
+
@util.dbg("pkey_password - #{@pkey_password}")
|
57
|
+
end
|
58
|
+
|
59
|
+
@sudo_password = @util.decrypt(@yaml['credentials'][@username]['sudo_password'])
|
60
|
+
@util.dbg("sudo_password - #{@sudo_password}")
|
61
|
+
|
62
|
+
else
|
63
|
+
@util.dbg('credential disabled')
|
64
|
+
unless @password
|
65
|
+
printf "Enter System Password: "
|
66
|
+
@password = STDIN.noecho(&:gets).chomp
|
67
|
+
puts "\n"
|
68
|
+
end
|
69
|
+
|
70
|
+
unless ssh_agent_loaded?
|
71
|
+
unless @pkey_password
|
72
|
+
puts "ssh-agent is not in use, falling back to manual entry"
|
73
|
+
printf "Enter Private Key Password: "
|
74
|
+
@pkey_password = STDIN.noecho(&:gets).chomp
|
75
|
+
puts "\n"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
def set_username(username)
|
83
|
+
unless username.nil? or username == ''
|
84
|
+
@username = username
|
85
|
+
else
|
86
|
+
@username = %x{whoami}.chomp.to_s
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def set_password(password)
|
91
|
+
if password == ""
|
92
|
+
printf "Enter System Password: "
|
93
|
+
password = STDIN.noecho(&:gets).chomp
|
94
|
+
password
|
95
|
+
else
|
96
|
+
password
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
def generate_config
|
102
|
+
@util.dbg('starting generation process')
|
103
|
+
unless @regenerate
|
104
|
+
puts "\n\n\n\n"
|
105
|
+
puts "* No existing configuration file found at path: ".red + "#{@config_file_path}".yellow
|
106
|
+
puts "*"
|
107
|
+
puts "* MultiSSH handles [sudo] prompts and private key failures by storing your credentials in its config file"
|
108
|
+
puts "* The owner of this config file will be set to #{@username.green} with a mode of #{'600'.green}"
|
109
|
+
puts "*"
|
110
|
+
puts "* If you opt out, you will be prompted for your password on every run"
|
111
|
+
puts "*"
|
112
|
+
printf "* Would you like MultiSSH to store your credentials? (y/n): "
|
113
|
+
ans = gets.chomp.downcase
|
114
|
+
@util.dbg(ans)
|
115
|
+
until ['y','n'].include? ans
|
116
|
+
printf "Please answer only with 'y' or 'n': "
|
117
|
+
ans = gets.chomp.downcase
|
118
|
+
end
|
119
|
+
generate = (ans == 'y') ? true : false
|
120
|
+
|
121
|
+
else
|
122
|
+
generate = true
|
123
|
+
puts 'MultiSSH called with "--regenerate_config", generating new configuration file'
|
124
|
+
end
|
125
|
+
|
126
|
+
if generate
|
127
|
+
credential = generate_credential_entry
|
128
|
+
@yaml = { "enabled" => true, "credentials" => { @username => credential } }
|
129
|
+
else
|
130
|
+
@yaml = { "enabled" => false }
|
131
|
+
end
|
132
|
+
|
133
|
+
save_config
|
134
|
+
set_secure_permissions
|
135
|
+
|
136
|
+
unless generate then puts "configuration file set to disabled" end
|
137
|
+
|
138
|
+
puts "Configuration file saved to #{@config_file_path}".yellow
|
139
|
+
@util.dbg('end generation process')
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
def ssh_agent_loaded?
|
144
|
+
begin
|
145
|
+
@util.dbg('ssh-agent begin check')
|
146
|
+
Net::SSH::Authentication::Agent.new.connect!
|
147
|
+
@util.dbg('ssh-agent loaded')
|
148
|
+
true
|
149
|
+
rescue Net::SSH::Authentication::AgentNotAvailable
|
150
|
+
@util.dbg('ssh-agent not loaded')
|
151
|
+
false
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def private_key_exist?
|
156
|
+
if !Dir.glob("#{%x{echo ~}.chomp}/.ssh/id_*").empty?
|
157
|
+
false
|
158
|
+
else
|
159
|
+
true
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def generate_credential_entry
|
164
|
+
@util.dbg("generating new entry for #{@username}")
|
165
|
+
print "#{@username} - Password: "
|
166
|
+
password = STDIN.noecho(&:gets).chomp
|
167
|
+
epassword = @util.encrypt(password)
|
168
|
+
puts "\n"
|
169
|
+
|
170
|
+
unless ssh_agent_loaded?
|
171
|
+
pkey_password = nil
|
172
|
+
if private_key_exist?
|
173
|
+
print "#{@username} - SSH Private Key Password: "
|
174
|
+
pkey_password = STDIN.noecho(&:gets).chomp
|
175
|
+
epkey_password = @util.encrypt(pkey_password)
|
176
|
+
puts "\n"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
credential = { "password" => epassword, "pkey_password" => epkey_password }
|
181
|
+
@util.dbg("credential:")
|
182
|
+
@util.dbg(credential)
|
183
|
+
return credential
|
184
|
+
end
|
185
|
+
|
186
|
+
def save_config
|
187
|
+
@util.dbg("saving config to '#{@config_file_path}'")
|
188
|
+
File.open(@config_file_path, 'w') { |f| f.write @yaml.to_yaml }
|
189
|
+
end
|
190
|
+
|
191
|
+
def load_config
|
192
|
+
@util.dbg("loading config from '#{@config_file_path}'")
|
193
|
+
@yaml = YAML.load_file(@config_file_path)
|
194
|
+
end
|
195
|
+
|
196
|
+
|
197
|
+
def set_secure_permissions
|
198
|
+
%x{chown #{@username} #{@config_file_path}; chmod 600 #{@config_file_path} }
|
199
|
+
target_uid = %x{id}.split[0].match('\d+').to_s
|
200
|
+
target_mode = '600'
|
201
|
+
file_uid = File.stat(@config_file_path).uid.to_s
|
202
|
+
file_mode = File.stat(@config_file_path).mode.to_s(8)[-3..-1]
|
203
|
+
@util.dbg("target_uid: #{target_uid}, target_mode: #{target_mode}")
|
204
|
+
@util.dbg("file_uid: #{file_uid}, file_mode: #{file_mode}")
|
205
|
+
unless target_uid == file_uid && target_mode == file_mode
|
206
|
+
raise "Failed to set permissions on #{@config_file_path}".red
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
end
|
data/lib/multissh.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
require 'parallel'
|
3
|
+
require 'optparse'
|
4
|
+
require 'io/console'
|
5
|
+
require 'colorize'
|
6
|
+
require 'yaml'
|
7
|
+
|
8
|
+
require_relative 'update'
|
9
|
+
require_relative 'cli'
|
10
|
+
require_relative 'worker'
|
11
|
+
require_relative 'util'
|
12
|
+
|
13
|
+
|
14
|
+
class Multissh < Cli
|
15
|
+
|
16
|
+
def run
|
17
|
+
tasks = []
|
18
|
+
@nodes.each do |node|
|
19
|
+
worker = Worker.new(
|
20
|
+
hostname=node.chomp,
|
21
|
+
username=@username,
|
22
|
+
password=@password,
|
23
|
+
pkey_password=@pkey_password,
|
24
|
+
sudo_password=@sudo_password,
|
25
|
+
command=@command,
|
26
|
+
block=@block,
|
27
|
+
debug=@debug,
|
28
|
+
)
|
29
|
+
tasks.append(worker)
|
30
|
+
end
|
31
|
+
|
32
|
+
results = Parallel.map(tasks) do |task|
|
33
|
+
task.go
|
34
|
+
end
|
35
|
+
|
36
|
+
rescue Interrupt
|
37
|
+
puts "\nCtrl+C Interrupt\n"
|
38
|
+
exit 1
|
39
|
+
|
40
|
+
end#run
|
41
|
+
|
42
|
+
end#class
|
43
|
+
|
data/lib/update.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
class Update
|
2
|
+
attr_reader :local_revision, :remote_revision
|
3
|
+
|
4
|
+
def initialize(called_command)
|
5
|
+
begin
|
6
|
+
%x{git fetch -q}
|
7
|
+
@local_revision = %x{git rev-parse HEAD}
|
8
|
+
@remote_revision = (%x{git ls-remote --heads --tags origin}).split.first
|
9
|
+
rescue
|
10
|
+
@local_revision = nil
|
11
|
+
@remote_revision = nil
|
12
|
+
end
|
13
|
+
|
14
|
+
if update.local_revision != update.remote_revision
|
15
|
+
printf "MultiSSH update available. Would you like to update? (y/n): "
|
16
|
+
if ['y', 'Y'].include? gets.chomp
|
17
|
+
%x{git reset --hard origin/master && git pull}
|
18
|
+
at_exit do
|
19
|
+
%x{#{called_command}}
|
20
|
+
end#at_exit
|
21
|
+
exit 0
|
22
|
+
end#if
|
23
|
+
end#if
|
24
|
+
end#initialize
|
25
|
+
|
26
|
+
def show
|
27
|
+
puts "local: #{@local_revision}"
|
28
|
+
puts "remote: #{@remote_revision}"
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
data/lib/util.rb
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
class Util
|
2
|
+
def initialize(debug)
|
3
|
+
@debug = debug
|
4
|
+
end
|
5
|
+
|
6
|
+
|
7
|
+
def debug_top(data)
|
8
|
+
"\n\n\n#{('-'*80).blue}\n#{'raw data:'.yellow}\n#{data.inspect.yellow}\n\n#{'formatted:'.green}\n"
|
9
|
+
end
|
10
|
+
|
11
|
+
|
12
|
+
def debug_bottom
|
13
|
+
"#{('-'*80).blue}"
|
14
|
+
end
|
15
|
+
|
16
|
+
def dbg(msg)
|
17
|
+
if @debug
|
18
|
+
puts "debug: #{msg}".blue
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def display_data(header, data)
|
23
|
+
if @debug then puts debug_top(data) end
|
24
|
+
|
25
|
+
data.split(/\r\n?/).each do |line|
|
26
|
+
puts format_line(header, line)
|
27
|
+
end
|
28
|
+
|
29
|
+
if @debug then puts debug_bottom end
|
30
|
+
end
|
31
|
+
|
32
|
+
def check_affirmative
|
33
|
+
ans = ['y', 'yes', ''].include?(gets.chomp) ? true : false
|
34
|
+
dbg("affirmative = #{ans}")
|
35
|
+
puts "\n"
|
36
|
+
return ans
|
37
|
+
end
|
38
|
+
|
39
|
+
def format_line(header, line)
|
40
|
+
if not line.chomp.empty?
|
41
|
+
"#{header.blue}#{line}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def display_error(error)
|
46
|
+
if @debug
|
47
|
+
puts error.backtrace
|
48
|
+
puts error
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def show_summary(worker)
|
54
|
+
if @debug
|
55
|
+
puts "\n\n#{worker.to_s.blue}\n"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def encrypt(s)
|
60
|
+
if s
|
61
|
+
ecarr = []
|
62
|
+
s.chomp.each_byte do |c|
|
63
|
+
(33..126).to_a.include?(c + 20) ? ecarr << (c + 20).chr : ecarr << (c + 20 - 94).chr
|
64
|
+
end
|
65
|
+
ecarr.join
|
66
|
+
else
|
67
|
+
''
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
|
73
|
+
def decrypt(s)
|
74
|
+
if s
|
75
|
+
carr = []
|
76
|
+
s.chomp.each_byte do |c|
|
77
|
+
(33..126).to_a.include?(c - 20) ? carr << (c - 20).chr : carr << (c - 20 + 94).chr
|
78
|
+
end
|
79
|
+
carr.join
|
80
|
+
else
|
81
|
+
''
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
end
|
data/lib/worker.rb
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
class Worker
|
2
|
+
def initialize(hostname, username, password, pkey_password, sudo_password, command, block, debug)
|
3
|
+
@hostname = hostname
|
4
|
+
@username = username
|
5
|
+
@password = password
|
6
|
+
@pkey_password = pkey_password
|
7
|
+
@sudo_password = sudo_password
|
8
|
+
@command = command
|
9
|
+
@block = block
|
10
|
+
|
11
|
+
@header = "#{hostname} -- "
|
12
|
+
@util = Util.new(debug)
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
def go
|
17
|
+
@util.show_summary(self)
|
18
|
+
|
19
|
+
result = ''
|
20
|
+
begin
|
21
|
+
Net::SSH.start(@hostname, @username, :password => @password, :passphrase => @pkey_password, :non_interactive => true) do |ssh|
|
22
|
+
channel = ssh.open_channel do |channel, success|
|
23
|
+
|
24
|
+
# request a pseudo TTY formatted to screen width
|
25
|
+
cols = %x{tput cols}.chomp.to_i - @header.length
|
26
|
+
channel.request_pty(opts={:term=>'xterm',:chars_wide => cols})
|
27
|
+
|
28
|
+
@util.dbg("sending command: #{@command}")
|
29
|
+
channel.exec(@command)
|
30
|
+
|
31
|
+
channel.on_data do |channel, data|
|
32
|
+
|
33
|
+
attempts = 0
|
34
|
+
|
35
|
+
if attempts >= 2
|
36
|
+
raise 'failed to connect -- too many attempts'
|
37
|
+
end
|
38
|
+
|
39
|
+
if data =~ /Sorry, try again/
|
40
|
+
raise 'failed to connect -- incorrect sudo password'
|
41
|
+
end
|
42
|
+
|
43
|
+
if data =~ /#{@username}@#{@hostname}'s password:/
|
44
|
+
raise 'failed to connect -- password failed'
|
45
|
+
end
|
46
|
+
|
47
|
+
if data =~ /^\[sudo\] password for / and attempts == 0
|
48
|
+
if @sudo_password
|
49
|
+
channel.send_data "#{@sudo_password}\n"
|
50
|
+
elsif @password
|
51
|
+
channel.send_data "#{@password}\n"
|
52
|
+
else
|
53
|
+
raise 'failed to connect -- no sudo_password or password defined'
|
54
|
+
end
|
55
|
+
attempts += 1
|
56
|
+
@util.dbg("attempts: #{attempts}")
|
57
|
+
elsif data =~ /^\[sudo\] password for / and attempts == 1
|
58
|
+
channel.send_data "#{@password}\n"
|
59
|
+
attempts += 1
|
60
|
+
@util.dbg("attempts: #{attempts}")
|
61
|
+
end
|
62
|
+
|
63
|
+
unless @block
|
64
|
+
@util.display_data(@header, data)
|
65
|
+
else
|
66
|
+
result += data.to_s
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
72
|
+
|
73
|
+
channel.wait
|
74
|
+
|
75
|
+
if @block
|
76
|
+
@util.display_data(@header, result)
|
77
|
+
puts "\n"
|
78
|
+
end
|
79
|
+
|
80
|
+
end#start
|
81
|
+
|
82
|
+
rescue SocketError => e
|
83
|
+
@util.display_error(e)
|
84
|
+
puts "Failed to connect to #{@hostname}\n".red
|
85
|
+
|
86
|
+
rescue RuntimeError => e
|
87
|
+
@util.display_error(e)
|
88
|
+
puts "#{@hostname} -- incorrect password, failed to connect".red
|
89
|
+
|
90
|
+
rescue => e
|
91
|
+
@util.display_error(e)
|
92
|
+
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
|
98
|
+
def to_s
|
99
|
+
"Worker: {hostname:'#{@hostname}',username:'#{@username}',password:'#{@password}',command:'#{@command}',block:'#{@block}'"
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
end
|
data/multissh.gemspec
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
Gem::Specification.new do |spec|
|
2
|
+
spec.name = 'uoregon-multissh'
|
3
|
+
spec.version = '0.2.2'
|
4
|
+
spec.date = '2019-05-14'
|
5
|
+
spec.summary = "Do all the things everywhere at the same time"
|
6
|
+
spec.description = "Quickly run multiple commands on many boxes at the same time"
|
7
|
+
spec.authors = ["Lucas Crownover"]
|
8
|
+
spec.email = 'lcrownover127@gmail.com'
|
9
|
+
spec.homepage = 'https://www.savethemanatee.org/manatees/facts/'
|
10
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
11
|
+
spec.executables = ["multissh"]
|
12
|
+
spec.require_paths = ["lib"]
|
13
|
+
spec.license = 'MIT'
|
14
|
+
end
|
metadata
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: uoregon-multissh
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Lucas Crownover
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-05-14 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Quickly run multiple commands on many boxes at the same time
|
14
|
+
email: lcrownover127@gmail.com
|
15
|
+
executables:
|
16
|
+
- multissh
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- ".gitignore"
|
21
|
+
- ".ruby-version"
|
22
|
+
- Gemfile
|
23
|
+
- Gemfile.lock
|
24
|
+
- README.md
|
25
|
+
- bin/multissh
|
26
|
+
- lib/cli.rb
|
27
|
+
- lib/credential.rb
|
28
|
+
- lib/multissh.rb
|
29
|
+
- lib/update.rb
|
30
|
+
- lib/util.rb
|
31
|
+
- lib/worker.rb
|
32
|
+
- multissh.gemspec
|
33
|
+
homepage: https://www.savethemanatee.org/manatees/facts/
|
34
|
+
licenses:
|
35
|
+
- MIT
|
36
|
+
metadata: {}
|
37
|
+
post_install_message:
|
38
|
+
rdoc_options: []
|
39
|
+
require_paths:
|
40
|
+
- lib
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
47
|
+
requirements:
|
48
|
+
- - ">="
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: '0'
|
51
|
+
requirements: []
|
52
|
+
rubyforge_project:
|
53
|
+
rubygems_version: 2.7.6
|
54
|
+
signing_key:
|
55
|
+
specification_version: 4
|
56
|
+
summary: Do all the things everywhere at the same time
|
57
|
+
test_files: []
|