trainsh 0.2.0

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 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: []