trainsh 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6b809837d484a4a100b8d6b6d4718d623bb91990ea72d7181b5e70dbe265282a
4
+ data.tar.gz: 12a87fc192b92df5f3f0528c620d898c24dc5d7ec849b51081c7d996d57e51fa
5
+ SHA512:
6
+ metadata.gz: adef421ee124699ee90b841a57c0ef0653e7377a2d9cb6b3f6b0faa50948cc903e36fe38af4893ac8d60e8a9c94eb1adf3f8c66cd733c163700fac1480d148a4
7
+ data.tar.gz: 98947781c875c65f82563caeafba4637764c65758ebf8edf60fd16bf0765afe3a286032045957c20c0291248baf75f46dc165f25605492eb920b620a1d7737bc
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## Version 0.2.0
4
+
5
+ - Renamed to TrainSH
data/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # TrainSH
2
+
3
+ ## Summary
4
+
5
+ Interactive Shell for Remote Systems
6
+
7
+ Based on the Train ecosystem, provide a shell to manage systems via a multitude of transports.
8
+
9
+ Train default supports:
10
+
11
+ - Docker
12
+ - Local
13
+ - SSH (Unix, Windows, Cisco)
14
+ - WinRM (Windows)
15
+
16
+ 3rd party plugins support:
17
+
18
+ - AWS Systems Manager
19
+ - LXD
20
+ - Telnet
21
+ - Serial/USB interfaces
22
+ - VMware Guest Operations (VMware Tools)
23
+ - ...
24
+
25
+ ## Example uses
26
+
27
+ When specifying a password within an URL take care of special characters or the connection will fail.
28
+
29
+ ```shell
30
+ trainsh connect local://
31
+ ```
32
+
33
+ ```shell
34
+ trainsh connect winrm://Administrator:Passw0rd@10.20.30.40
35
+ ```
36
+
37
+ ```shell
38
+ trainsh connect awsssm://i-123456789abc0
39
+ ```
40
+
41
+ ```shell
42
+ trainsh connect vsphere-gom://example-vm
43
+ ```
44
+
45
+ If the transport has additional options like `key_files`, they need to be added as URL parameters (`ssh://10.20.30.40/?key_files=~/.ssh/id_rsa2`).
46
+
47
+ ## Shell commands
48
+
49
+ There are three separate categories of commands when in the Shell:
50
+
51
+ - remote commands
52
+ - local commands, which are prefixed with `.`
53
+ - built-in commands, wich are prefixed with `!`
54
+
55
+ Anything you type without a prefix gets executed 1:1 on the remote system. Please notice that Train is headless, so any interactive programs will not work but lock up your shell! If you need to edit remote files or use a pager (like `less`), please look into the Built-In Commands section.
56
+
57
+ Local commands get executed 1:1 on your local system, so you are able to check things locally or change paths, for example.
58
+
59
+ There is also the Sessionprefix `@`, which is described briefly in the "Prompt and Sessions" section.
60
+
61
+ ## Built-In Commands
62
+
63
+ `!!!`
64
+ Quit TrainSH. Aliases: `quit`, `exit`, `logout`, `disconnect`.
65
+
66
+ `!clear-history`
67
+ Clear your TrainSH history, for example to remove clutter or sensitive information
68
+
69
+ `!connect <uri>`
70
+ Connect to another system. The URI needs to match the format of the used Train transport, which is usually `transportname://host` but varies. See the Train transport's documentation for details.
71
+
72
+ `!detect`
73
+ Re-runs the OS detection which is running automatically on start. This will determine the OS, OS-family and general platform information via Train.
74
+
75
+ `!download <remote> <local>`
76
+ Download a file to the local system. You need to specify the target, if you want to keep the name just use `.` as local part.
77
+
78
+ `!edit <remotefile>`
79
+ Downloads the remote file as temporary file and opens the system default editor locally (`EDITOR` and `VISUAL` environment variables, with fallback to `vi`). Upon exiting the file, it will be uploaded again and overwrite the remote file.
80
+
81
+ `!env`
82
+ Prints the environment variables of your remote shell. This will be filled on first command invocation to save IO. **Currently unsupported for Windows remote systems**
83
+
84
+ `!history`
85
+ Output your TrainSH command history. As this uses the popular Readline library, you can also navigate your history with the Up/Down arrows or use Ctrl-R for reverse search. You can do auto completion for built-in commands.
86
+
87
+ `!host`
88
+ Outputs the remote hostname.
89
+
90
+ `!ping`
91
+ Executes a simple command to measure roundtrip/overhead time.
92
+
93
+ `!pwd`
94
+ Output the remote working directory. This will be filled on first command invocation to save IO.
95
+
96
+ `!read <remotefile>`
97
+ Download the specified file and display it in the system default pager (`PAGER` environment variable, with fallback to `less`).
98
+
99
+ `!reconnect`
100
+ Quits and reopens the current session.
101
+
102
+ `!sessions`
103
+ List all currently active sessions (See "Prompt and Sessions" section). Password information is redacted for security reasons.
104
+
105
+ `!session <session_id>`
106
+ Change to another session by passing its ID (=number)
107
+
108
+ `!upload <local> <remote>`
109
+ Upload files to the target machine
110
+
111
+ ## Prompt and Sessions
112
+
113
+ `OK trainsh(@0 local://trc4023)>`
114
+
115
+ The prompt consists roughly of three areas:
116
+
117
+ - Exit code of last command ("OK" or the exit code in format `Exx` with xx being the numeric code)
118
+ - Session indication
119
+ - Input area
120
+
121
+ The session indication includes the ID of the session, so `@0` means session 0, `@1` means session 1 and so on. The second part shows the Train URI for the remote system for easy identification.
122
+
123
+ Any TrainSH invocation has at minimum one active session but if you want, you can add more sessions with the `!connect <uri>` built-in. Every session has its own Train backend and storage of current working directory, environment variables etc. You can list active sessions with `!sessions` and switch between them using `!session <session_id>`.
124
+
125
+ If you want to execute commands on another session ad-hoc, you can prefix it with the session id: `@1 uname -a` will execute the `uname -a` command on session ID 1.
126
+
127
+ Depending on the transport and target, the time between sending a command and retrieving the result will vary widely. In TrainSH this is called the "ping", which measures the minimum overhead/latency for the session. It varies massively between Train plugins and is not only related to the network distance, but also to the way of command execution - which might involve a number of HTTPS requests or the time to invoke a remote shell (examples: AWS SSM and VSphere GOM transports).
128
+
129
+ ### Under the Hood
130
+
131
+ As Train is headless/stateless system, there will be various pre- and postfixed commands to make your life easier. This means that any state-related commands like changing the current directory or modification of environment variables would be unavailable for the next command - as it technically is a separate shell.
132
+
133
+ To make this easier, internal commands get attached to your input like this:
134
+
135
+ - Prefix: Get remote hostname (discovery task, just on first execution)
136
+ - Prefix: Set previous environment variables
137
+ - Prefix: Set previous path
138
+ - Command
139
+ - Postfix: Retrieve and save exit code of command
140
+ - Postfix: Retrieve and save new path
141
+ - Postfix: Retrieve and save new environment variables
142
+
143
+ Output of commands gets separated by outputting a highly random string between, which should not result in false positives. If a false positive occurs for some reason, TrainSH will fail and output an error.
data/lib/trainsh.rb ADDED
@@ -0,0 +1,5 @@
1
+ # require_relative 'trainsh/config'
2
+ require_relative 'trainsh/constants'
3
+ require_relative 'trainsh/errors'
4
+ # require_relative 'trainsh/log'
5
+ require_relative 'trainsh/version'
@@ -0,0 +1,261 @@
1
+ require_relative '../trainsh'
2
+ require_relative 'session'
3
+
4
+ require_relative 'mixin/builtin_commands'
5
+ require_relative 'mixin/file_helpers'
6
+ require_relative 'mixin/sessions'
7
+
8
+ # TODO
9
+ # require_relative 'detectors/target/env.rb'
10
+ # require_relative 'detectors/target/kitchen.rb'
11
+
12
+ require 'colored'
13
+ require 'fileutils'
14
+ require 'readline'
15
+ require 'train'
16
+ require 'thor'
17
+
18
+ module TrainSH
19
+ class Cli < Thor
20
+ include Thor::Actions
21
+ check_unknown_options!
22
+ # add_runtime_options!
23
+
24
+ def self.exit_on_failure?
25
+ true
26
+ end
27
+
28
+ map %w[version] => :__print_version
29
+ desc 'version', 'Display version'
30
+ def __print_version
31
+ say "#{TrainSH::PRODUCT} #{TrainSH::VERSION} (Ruby #{RUBY_VERSION}-#{RUBY_PLATFORM})"
32
+ end
33
+
34
+ EXIT_COMMANDS = %w[!!! exit quit logout disconnect].freeze
35
+ INTERACTIVE_COMMANDS = %w[more less vi vim nano].freeze
36
+
37
+ no_commands do
38
+ include TrainSH::Mixin::BuiltInCommands
39
+ include TrainSH::Mixin::FileHelpers
40
+ include TrainSH::Mixin::Sessions
41
+
42
+ def __disconnect
43
+ session.close
44
+
45
+ say 'Disconnected'
46
+ end
47
+
48
+ # TODO
49
+ # def __detect_url(url)
50
+ # url || kitchen_url || ENV['target']
51
+ # end
52
+
53
+ def __detect
54
+ raise 'No session in __detect' unless session
55
+
56
+ platform = session.platform
57
+
58
+ say format('Platform: %<title>s %<release>s (%<arch>s)',
59
+ title: platform.title,
60
+ release: platform.release,
61
+ arch: platform.arch)
62
+ say format('Hierarchy: %<hierarchy>s',
63
+ hierarchy: platform.family_hierarchy.reverse.join('/'))
64
+ say 'Measuring ping over connection...'
65
+
66
+ # Idle command will also trigger discovery commands on first run
67
+ session.run_idle
68
+
69
+ say format('Ping: %<ping>dms', ping: session.ping) if session.ping
70
+ end
71
+
72
+ def target_detectors
73
+ Dir[File.join(__dir__, 'lib', '*.rb')].sort.each { |file| require file }
74
+
75
+ TrainSH::Detectors::TargetDetector.descendants
76
+ end
77
+
78
+ def execute(input)
79
+ case input[0]
80
+ when '.'
81
+ execute_locally input[1..]
82
+ when '!'
83
+ execute_builtin input[1..]
84
+ when '@'
85
+ execute_via_session input[1..]
86
+ else
87
+ execute_via_train input
88
+ end
89
+ end
90
+
91
+ def execute_locally(input)
92
+ system(input)
93
+ end
94
+
95
+ def execute_via_train(input, session_id = current_session_id)
96
+ return if interactive_command? input
97
+
98
+ command_result = @sessions[session_id].run(input)
99
+
100
+ say command_result.stdout unless command_result.stdout && command_result.stdout.empty?
101
+ say command_result.stderr.red unless command_result.stderr && command_result.stderr.empty?
102
+ end
103
+
104
+ def execute_builtin(input)
105
+ cmd, *args = input.split
106
+
107
+ ruby_cmd = cmd.tr('-', '_')
108
+
109
+ if builtin_commands.include? ruby_cmd
110
+ send("#{BUILTIN_PREFIX}#{ruby_cmd}".to_sym, *args)
111
+ else
112
+ say format('Unknown built-in "%<cmd>s"', cmd: cmd)
113
+ end
114
+ end
115
+
116
+ def execute_via_session(input)
117
+ session_id, *data = input.split
118
+
119
+ session_id = validate_session_id(session_id)
120
+ if session_id.nil?
121
+ say 'Expecting valid session id, e.g. `!session 2`'.red
122
+ return
123
+ end
124
+
125
+ input = data.join(' ')
126
+ if input.empty?
127
+ say 'Specify command to execute, e.g. `@0 ls`'
128
+ return
129
+ end
130
+
131
+ execute_via_train(input, session_id)
132
+ end
133
+
134
+ def interactive_command?(cmd)
135
+ return unless INTERACTIVE_COMMANDS.any? { |banned| cmd.start_with? banned }
136
+
137
+ say 'Cannot execute interactive commands on non-tty sessions'.red
138
+ end
139
+
140
+ def exit_command?(cmd)
141
+ EXIT_COMMANDS.include? cmd
142
+ end
143
+
144
+ def prompt
145
+ exitcode = current_session.exitcode
146
+ exitcode_prefix = exitcode.zero? ? 'OK '.green : format('E%02d ', exitcode).red
147
+
148
+ format(::TrainSH::PROMPT,
149
+ exitcode: exitcode,
150
+ exitcode_prefix: exitcode_prefix,
151
+ session_id: current_session_id,
152
+ backend: current_session.backend || 'unknown',
153
+ host: current_session.host || 'unknown',
154
+ path: current_session.pwd || '?')
155
+ end
156
+
157
+ def auto_complete(partial)
158
+ choices = []
159
+
160
+ choices.concat(builtin_commands.map { |cmd| "!#{cmd.tr('_', '-')}" })
161
+ choices.concat(sessions.map { |session_id| "@#{session_id}" })
162
+ choices.concat %w[!!!]
163
+
164
+ choices.filter { |choice| choice.start_with? partial }
165
+ end
166
+
167
+ # def get_log_level(level)
168
+ # valid = %w{debug info warn error fatal}
169
+ #
170
+ # if valid.include?(level)
171
+ # l = level
172
+ # else
173
+ # l = "info"
174
+ # end
175
+ #
176
+ # Logger.const_get(l.upcase)
177
+ # end
178
+ end
179
+
180
+ # class_option :log_level, desc: "Log level", aliases: "-l", default: :info
181
+ class_option :messy, desc: 'Skip deletion of temporary files for speedup', default: false, type: :boolean
182
+
183
+ desc 'connect URL', 'Connect to a destination interactively'
184
+
185
+ def connect(url)
186
+ exit unless use_session(url)
187
+
188
+ say format('Connected to %<url>s', url: session.url).bold
189
+ say 'Running platform detection...'
190
+ __detect
191
+
192
+ # History persistence (TODO: Extract)
193
+ user_conf_dir = File.join(ENV['HOME'], TrainSH::USER_CONF_DIR)
194
+ history_file = File.join(user_conf_dir, 'history')
195
+ FileUtils.mkdir_p(user_conf_dir)
196
+ FileUtils.touch(history_file)
197
+ File.readlines(history_file).each { |line| Readline::HISTORY.push line.strip }
198
+ at_exit {
199
+ history_file = File.join(user_conf_dir, 'history')
200
+ File.open(history_file, 'w') { |f|
201
+ f.write Readline::HISTORY.to_a.join("\n")
202
+ }
203
+ }
204
+
205
+ # Catch Ctrl-C and exit cleanly
206
+ stty_save = `stty -g`.chomp
207
+ trap('INT') do
208
+ puts '^C'
209
+ system('stty', stty_save)
210
+ exit
211
+ end
212
+
213
+ # Autocompletion
214
+ Readline.completion_proc = method(:auto_complete).to_proc
215
+ Readline.completion_append_character = ' '
216
+
217
+ while (input = Readline.readline(prompt, true))
218
+ if input.empty?
219
+ Readline::HISTORY.pop
220
+ next
221
+ end
222
+
223
+ Readline::HISTORY.pop if input.start_with? '!history'
224
+
225
+ break if exit_command? input
226
+ next if interactive_command? input
227
+
228
+ execute input
229
+ end
230
+ end
231
+
232
+ # desc 'copy FILE/DIR|URL FILE/DIR|URL', 'Copy files or directories'
233
+ # def copy(url_or_file, url_or_file)
234
+ # # TODO?
235
+ # end
236
+
237
+ desc 'detect URL', 'Retrieve remote OS and platform information'
238
+ def detect(url)
239
+ exit unless use_session(url)
240
+ __detect
241
+ end
242
+
243
+ # desc 'exec URL -- (COMMAND)', 'Execute remote commands'
244
+ # method_option :file, aliases: '-f', desc: "command file to read"
245
+ # def exec
246
+ # # TODO
247
+ # # TODO: Also accept getting commands from STDIN and from a file
248
+ # end
249
+
250
+ desc 'list-transports', 'List available transports'
251
+ def list_transports
252
+ # TODO: Train only lazy-loads and does not have a full registry
253
+ # https://github.com/inspec/train/blob/be90ca53ea1c1e8aa7439c504fbee86f4b399d83/lib/train.rb#L38-L61
254
+
255
+ # TODO: Filter for only "OS" transports as well
256
+ transports = %w[local ssh winrm docker]
257
+
258
+ say "Available transports: #{transports.sort.join(', ')}"
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,15 @@
1
+ require 'mixlib/config'
2
+
3
+ module TrainSH
4
+ module Config
5
+ extend Mixlib::Config
6
+ config_strict_mode true
7
+
8
+ default :log_level, :info
9
+
10
+ default :pager, ENV['PAGER'] || 'less'
11
+ default :editor, ENV['EDITOR'] || ENV['VISUAL'] || 'vi'
12
+
13
+ default :user_config, "~/#{ENV['USER_CONF_DIR']}"
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ module TrainSH
2
+ PRODUCT = 'TrainSH'.freeze
3
+
4
+ # The executable for interactive use
5
+ EXEC = 'trainsh'.freeze
6
+
7
+ # Prefix for environment variables
8
+ ENV_PREFIX = 'TRAINSH_'.freeze
9
+
10
+ # The user's configuration directory
11
+ USER_CONF_DIR = '.trainsh'.freeze
12
+
13
+ # Minimum version for remote file manipulation
14
+ TRAIN_MUTABLE_VERSION = '3.5.0'.freeze
15
+
16
+ # Prompt (TODO: Make configuratble)
17
+ PROMPT = '%<exitcode_prefix>strainsh(@%<session_id>d %<backend>s://%<host>s)> '.freeze
18
+
19
+ # Variable to remotely persist exit code
20
+ EXITCODE_VAR = 'CMD_EXIT'.freeze
21
+ end
@@ -0,0 +1,17 @@
1
+ module TrainSH
2
+ module Detectors
3
+ class TargetDetector
4
+ def self.descendants
5
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
6
+ end
7
+
8
+ def to_s
9
+ self.class.to_s
10
+ end
11
+
12
+ def url
13
+ raise "Implement `url` for target detector #{self}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ require_relative '../target'
2
+
3
+ module TrainSH
4
+ module Detectors
5
+ class EnvTarget < TargetDetector
6
+ def url
7
+ ENV['target']
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,44 @@
1
+ require_relative '../target'
2
+
3
+ module TrainSH
4
+ module Detectors
5
+ class KitchenTarget < TargetDetector
6
+ def url
7
+ # return unless kitchen_available?
8
+ # return unless kitchen_instance
9
+
10
+ # kitchen_config['transport']
11
+ end
12
+
13
+ # private
14
+
15
+ # def kitchen_available?
16
+ # `kitchen`
17
+ # true
18
+ # rescue
19
+ # false
20
+ # end
21
+
22
+ # attr_writer :kitchen_instances
23
+ # def kitchen_instances
24
+ # return if @kitchen_instances
25
+
26
+ # @kitchen_instances ||= YAML.load(`KITCHEN_LOCAL_YAML=".kitchen.ec2.yaml" kitchen list --json`)
27
+ # end
28
+
29
+ # def kitchen_config
30
+ # return if @kitchen_config
31
+
32
+ # parsed_output = YAML.load(`KITCHEN_LOCAL_YAML=".kitchen.ec2.yaml" kitchen diagnose customize-amazon2`)
33
+ # @kitchen_config = kitchen_config['instances'].keys.first
34
+ # end
35
+
36
+ # def kitchen_instance_name
37
+ # kitchen_instances.select! { |instance| instance['last_action'] != nil }
38
+
39
+ # return nil if kitchen_instances.count != 1
40
+ # kitchen_instances.first['instance']
41
+ # end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,2 @@
1
+ module TrainSH
2
+ end
@@ -0,0 +1,7 @@
1
+ require 'mixlib/log'
2
+
3
+ module TrainSH
4
+ class Log
5
+ extend Mixlib::Log
6
+ end
7
+ end
@@ -0,0 +1,156 @@
1
+ require 'train/errors'
2
+
3
+ module TrainSH
4
+ module Mixin
5
+ module BuiltInCommands
6
+ BUILTIN_PREFIX = 'builtincmd_'.freeze
7
+
8
+ def builtin_commands
9
+ methods.sort.filter { |method| method.to_s.start_with? BUILTIN_PREFIX }.map { |method| method.to_s.delete_prefix BUILTIN_PREFIX }
10
+ end
11
+
12
+ def builtincmd_clear_history(_args = nil)
13
+ Readline::HISTORY.clear
14
+ end
15
+
16
+ def builtincmd_connect(url = nil)
17
+ if url.nil? || url.strip.empty?
18
+ say 'Expecting session url, e.g. `!connect docker://123456789abcdef0`'.red
19
+ return false
20
+ end
21
+
22
+ use_session(url)
23
+ end
24
+
25
+ # def builtincmd_copy(source = nil, destination = nil)
26
+ # # TODO: Copy files between sessions
27
+ # end
28
+
29
+ def builtincmd_detect(_args = nil)
30
+ __detect
31
+ end
32
+
33
+ def builtincmd_download(remote_path = nil, local_path = nil)
34
+ if remote_path.nil? || local_path.nil?
35
+ say 'Expecting remote path and local path, e.g. `!download /etc/passwd /home/ubuntu`'
36
+ return false
37
+ end
38
+
39
+ return unless train_mutable?
40
+
41
+ session.download(remote_path, local_path)
42
+ rescue ::Train::NotImplementedError
43
+ say 'Backend for session does not implement download operation'.red
44
+ end
45
+
46
+ def builtincmd_edit(path = nil)
47
+ if path.nil? || path.strip.empty?
48
+ say 'Expecting remote path, e.g. `!less /tmp/somefile.txt`'.red
49
+ return false
50
+ end
51
+
52
+ tempfile = read_file(path)
53
+
54
+ localeditor = ENV['EDITOR'] || ENV['VISUAL'] || 'vi' # TODO: configuration, Windows, ...
55
+ say format('Using local editor `%<editor>s` for %<tempfile>s', editor: localeditor, tempfile: tempfile.path)
56
+
57
+ system("#{localeditor} #{tempfile.path}")
58
+
59
+ new_content = File.read(tempfile.path)
60
+
61
+ write_file(path, new_content)
62
+ tempfile.unlink
63
+ rescue ::Train::NotImplementedError
64
+ say 'Backend for session does not implement file operations'.red
65
+ end
66
+
67
+ def builtincmd_env(_args = nil)
68
+ puts session.env
69
+ end
70
+
71
+ def builtincmd_read(path = nil)
72
+ if path.nil? || path.strip.empty?
73
+ say 'Expecting remote path, e.g. `!read /tmp/somefile.txt`'.red
74
+ return false
75
+ end
76
+
77
+ tempfile = read_file(path)
78
+ return false unless tempfile
79
+
80
+ localpager = ENV['PAGER'] || 'less' # TODO: configuration, Windows, ...
81
+ say format('Using local pager `%<pager>s` for %<tempfile>s', pager: localpager, tempfile: tempfile.path)
82
+ system("#{localpager} #{tempfile.path}")
83
+
84
+ tempfile.unlink
85
+ rescue ::NotImplementedError
86
+ say 'Backend for session does not implement file operations'.red
87
+ end
88
+
89
+ def builtincmd_history(_args = nil)
90
+ puts Readline::HISTORY.to_a
91
+ end
92
+
93
+ def builtincmd_host(_args = nil)
94
+ say session.host
95
+ end
96
+
97
+ def builtincmd_ping(_args = nil)
98
+ session.run_idle
99
+ say format('Ping: %<ping>dms', ping: session.ping)
100
+ end
101
+
102
+ def builtincmd_pwd(_args = nil)
103
+ say session.pwd
104
+ end
105
+
106
+ def builtincmd_reconnect(_args = nil)
107
+ session.reconnect
108
+ end
109
+
110
+ def builtincmd_sessions(_args = nil)
111
+ say 'Active sessions:'
112
+
113
+ @sessions.each_with_index do |session, idx|
114
+ say format('[%<idx>d] %<session>s', idx: idx, session: session.url)
115
+ end
116
+ end
117
+
118
+ def builtincmd_session(session_id = nil)
119
+ session_id = validate_session_id(session_id)
120
+
121
+ if session_id.nil?
122
+ say 'Expecting valid session id, e.g. `!session 2`'.red
123
+ return false
124
+ end
125
+
126
+ # TODO: Make this more pretty
127
+ session_url = @sessions[session_id].url
128
+
129
+ use_session(session_url)
130
+ end
131
+
132
+ def builtincmd_upload(local_path = nil, remote_path = nil)
133
+ if remote_path.nil? || local_path.nil?
134
+ say 'Expecting remote path and local path, e.g. `!download /home/ubuntu/passwd /etc`'
135
+ return false
136
+ end
137
+
138
+ return unless train_mutable?
139
+
140
+ session.upload(local_path, remote_path)
141
+ rescue ::Errno::ENOENT
142
+ say "Local file/directory '#{local_path}' does not exist".red
143
+ rescue ::NotImplementedError
144
+ say 'Backend for session does not implement upload operation'.red
145
+ end
146
+
147
+ private
148
+
149
+ def train_mutable?
150
+ return true if session.respond_to?(:upload)
151
+
152
+ say "Support for remote file modification needs at least Train #{::TrainSH::TRAIN_MUTABLE_VERSION} (is: #{::Train::VERSION})".red
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,23 @@
1
+ require 'tempfile' unless defined?(Tempfile)
2
+
3
+ module TrainSH
4
+ module Mixin
5
+ module FileHelpers
6
+ def read_file(path)
7
+ remotefile = session.file(path)
8
+ say format('Remote file %<filename>s does not exist', filename: path) unless remotefile.exist?
9
+
10
+ localfile = Tempfile.open
11
+ localfile.write(remotefile.content || '')
12
+ localfile.close
13
+
14
+ localfile
15
+ end
16
+
17
+ def write_file(path, content)
18
+ remotefile = session.file(path)
19
+ remotefile.content = content
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,54 @@
1
+ require_relative '../session'
2
+
3
+ module TrainSH
4
+ module Mixin
5
+ module Sessions
6
+ def use_session(url)
7
+ @sessions = [] if @sessions.nil?
8
+
9
+ existing_id = @sessions.index { |session| session.url == url }
10
+
11
+ if existing_id.nil?
12
+ @current_session_id = @sessions.count
13
+ @sessions << TrainSH::Session.new(url)
14
+ else
15
+ @current_session_id = existing_id
16
+ end
17
+ rescue Train::PluginLoadError
18
+ say format('No Train plugin found for url %<url>s', url: url).red
19
+ nil
20
+ end
21
+
22
+ def session(session_id = current_session_id)
23
+ @sessions[session_id]
24
+ end
25
+
26
+ # ?
27
+ def sessions
28
+ (0..@sessions.count - 1).to_a
29
+ end
30
+
31
+ def current_session_id
32
+ @current_session_id ||= 0
33
+ end
34
+
35
+ def current_session
36
+ @sessions[current_session_id]
37
+ end
38
+
39
+ def validate_session_id(session_id)
40
+ unless session_id.match?(/^[0-9]+$/)
41
+ say 'Expected session id to be numeric'.red
42
+ return
43
+ end
44
+
45
+ if @sessions[session_id.to_i].nil?
46
+ say format('No session id [%s] found', session_id).red
47
+ return
48
+ end
49
+
50
+ session_id.to_i
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,190 @@
1
+ require 'benchmark'
2
+ require 'forwardable'
3
+
4
+ require 'train'
5
+
6
+ module TrainSH
7
+ class Command
8
+ MAGIC_STRING = 'mVDK6afaqa6fb7kcMqTpR2aoUFbYsRt889G4eGoI'.freeze
9
+
10
+ attr_writer :connection
11
+
12
+ def initialize(command, connection)
13
+ @command = command
14
+ @connection = connection
15
+
16
+ @prefixes = []
17
+ @postfixes = []
18
+ end
19
+
20
+ def prefix(prefix_command, &block)
21
+ @prefixes << {
22
+ command: prefix_command,
23
+ block: block
24
+ }
25
+ end
26
+
27
+ def postfix(postfix_command, &block)
28
+ @postfixes << {
29
+ command: postfix_command,
30
+ block: block
31
+ }
32
+ end
33
+
34
+ def run
35
+ result = @connection.run_command aggregate_commands
36
+ stdouts = parse(result)
37
+
38
+ prefixes_stdout = stdouts.first(@prefixes.count).reverse
39
+ @prefixes.each_with_index do |prefix, idx|
40
+ next if prefix[:block].nil?
41
+
42
+ prefix[:block].call prefixes_stdout[idx]
43
+ end
44
+ @prefixes.count.times { stdouts.shift } unless @prefixes.empty?
45
+
46
+ postfixes_stdout = stdouts.last(@postfixes.count)
47
+ @postfixes.each_with_index do |postfix, idx|
48
+ next if postfix[:block].nil?
49
+
50
+ postfix[:block].call postfixes_stdout[idx]
51
+ end
52
+ @postfixes.count.times { stdouts.pop } unless @postfixes.empty?
53
+
54
+ raise 'Pre-/Postfix command processing ended up with more than one remaining stdout' if stdouts.count > 1
55
+
56
+ result.stdout = stdouts.first
57
+ # result.stderr = "" # TODO
58
+ result.exit_status = 0 # TODO
59
+ result
60
+ end
61
+
62
+ def parse(result)
63
+ result.stdout
64
+ .gsub(/\r\n/, "\n")
65
+ .gsub(/ *$/, '')
66
+ .split(MAGIC_STRING)
67
+ .map(&:strip)
68
+ end
69
+
70
+ def aggregate_commands
71
+ separator = "\necho #{MAGIC_STRING}\n"
72
+
73
+ commands = @prefixes.reverse.map { |ary| ary[:command] }
74
+ commands << "#{@command}\n#{save_exit_code}"
75
+ commands.concat(@postfixes.map { |ary| ary[:command] })
76
+
77
+ commands.join(separator)
78
+ end
79
+
80
+ def save_exit_code
81
+ @connection.platform.windows? ? "$#{TrainSH::EXITCODE_VAR}=$LastExitCode" : "export #{TrainSH::EXITCODE_VAR}=$?"
82
+ end
83
+ end
84
+
85
+ class Session
86
+ extend Forwardable
87
+ def_delegators :@connection, :platform, :run_command, :backend_type, :file, :upload, :download
88
+
89
+ attr_reader :connection, :backend, :host
90
+
91
+ attr_accessor :pwd, :env, :ping, :exitcode
92
+
93
+ def initialize(url = nil)
94
+ connect(url) unless url.nil?
95
+ end
96
+
97
+ def connect(url)
98
+ @url = url
99
+
100
+ data = Train.unpack_target_from_uri(url)
101
+
102
+ # TODO: Wire up with "messy" parameter
103
+ data[:cleanup] = false
104
+
105
+ backend = Train.create(data[:backend], data)
106
+ return false unless backend
107
+
108
+ @backend = data[:backend]
109
+ @host = data[:host]
110
+
111
+ @connection = backend.connection
112
+ connection.wait_until_ready
113
+
114
+ at_exit { disconnect }
115
+ end
116
+
117
+ def disconnect
118
+ puts "Closing session #{url}"
119
+
120
+ connection.close
121
+ end
122
+
123
+ def reconnect
124
+ disconnect
125
+
126
+ connect(url)
127
+ end
128
+
129
+ # Redact password information
130
+ def url
131
+ Addressable::URI.parse(@url).omit(:password).to_s
132
+ end
133
+
134
+ def run(command, skip_affixes: false)
135
+ command = Command.new(command, @connection)
136
+
137
+ # Save exit code
138
+ command.postfix(exitcode_get) { |output| @exitcode = output.to_i }
139
+
140
+ # Request UTF-8 instead of UTF-16
141
+ # command.prefix("$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'") if platform.windows?
142
+
143
+ unless skip_affixes
144
+ command.prefix(pwd_set)
145
+ command.postfix(pwd_get) { |output| @pwd = output }
146
+ command.prefix(env_set)
147
+ command.postfix(env_get) { |output| @env = output }
148
+ end
149
+
150
+ # Discovery tasks
151
+ command.prefix(host_get) { |output| @host = output } if host.nil? || host == 'unknown'
152
+
153
+ command.run
154
+ end
155
+
156
+ def run_idle
157
+ @ping = ::Benchmark.measure { run('#', skip_affixes: true) }.real * 1000
158
+ end
159
+
160
+ private
161
+
162
+ def exitcode_get
163
+ "echo $#{TrainSH::EXITCODE_VAR}"
164
+ end
165
+
166
+ def host_get
167
+ 'hostname'
168
+ end
169
+
170
+ def pwd_get
171
+ platform.windows? ? '(Get-Location).Path' : 'pwd'
172
+ end
173
+
174
+ def pwd_set(path = pwd)
175
+ return '' if path.nil?
176
+
177
+ platform.windows? ? "Set-Location #{path}" : "cd #{path}"
178
+ end
179
+
180
+ # TODO: Preserve Windows environment variables
181
+ def env_get
182
+ platform.windows? ? '' : 'export'
183
+ end
184
+
185
+ # TODO: Preserve Windows environment variables
186
+ def env_set
187
+ platform.windows? ? '' : env
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,3 @@
1
+ module TrainSH
2
+ VERSION = '0.2.0'.freeze
3
+ end
metadata ADDED
@@ -0,0 +1,214 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: trainsh
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Heinen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-02-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bump
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.9'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.9'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler-audit
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: mdl
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.9'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: overcommit
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.55'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.55'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '12.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '12.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.10'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.10'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.92'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.92'
111
+ - !ruby/object:Gem::Dependency
112
+ name: colored
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.2'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.2'
125
+ - !ruby/object:Gem::Dependency
126
+ name: readline
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.0'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: thor
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '1.1'
146
+ type: :runtime
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '1.1'
153
+ - !ruby/object:Gem::Dependency
154
+ name: train
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '3.5'
160
+ type: :runtime
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '3.5'
167
+ description: Based on the Train ecosystem, provide a shell to manage systems via a
168
+ multitude of transports.
169
+ email:
170
+ - theinen@tecracer.de
171
+ executables: []
172
+ extensions: []
173
+ extra_rdoc_files: []
174
+ files:
175
+ - CHANGELOG.md
176
+ - README.md
177
+ - lib/trainsh.rb
178
+ - lib/trainsh/cli.rb
179
+ - lib/trainsh/config.rb
180
+ - lib/trainsh/constants.rb
181
+ - lib/trainsh/detectors/target.rb
182
+ - lib/trainsh/detectors/target/env.rb
183
+ - lib/trainsh/detectors/target/kitchen.rb
184
+ - lib/trainsh/errors.rb
185
+ - lib/trainsh/log.rb
186
+ - lib/trainsh/mixin/builtin_commands.rb
187
+ - lib/trainsh/mixin/file_helpers.rb
188
+ - lib/trainsh/mixin/sessions.rb
189
+ - lib/trainsh/session.rb
190
+ - lib/trainsh/version.rb
191
+ homepage: https://github.com/tecracer-chef/trainsh
192
+ licenses:
193
+ - Nonstandard
194
+ metadata: {}
195
+ post_install_message:
196
+ rdoc_options: []
197
+ require_paths:
198
+ - lib
199
+ required_ruby_version: !ruby/object:Gem::Requirement
200
+ requirements:
201
+ - - ">="
202
+ - !ruby/object:Gem::Version
203
+ version: '2.6'
204
+ required_rubygems_version: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ requirements: []
210
+ rubygems_version: 3.0.3
211
+ signing_key:
212
+ specification_version: 4
213
+ summary: Interactive Shell for Remote Systems
214
+ test_files: []