uoregon-multissh 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|