train-juniper 0.6.2 → 0.7.1
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 +4 -4
- data/CHANGELOG.md +69 -9
- data/README.md +30 -13
- data/Rakefile +23 -16
- data/lib/train-juniper/connection/bastion_proxy.rb +92 -0
- data/lib/train-juniper/connection/command_executor.rb +112 -0
- data/lib/train-juniper/connection/error_handling.rb +71 -0
- data/lib/train-juniper/connection/ssh_session.rb +106 -0
- data/lib/train-juniper/connection/validation.rb +63 -0
- data/lib/train-juniper/connection.rb +120 -331
- data/lib/train-juniper/constants.rb +40 -0
- data/lib/train-juniper/file_abstraction/juniper_file.rb +69 -0
- data/lib/train-juniper/helpers/environment.rb +30 -0
- data/lib/train-juniper/helpers/logging.rb +77 -0
- data/lib/train-juniper/helpers/mock_responses.rb +57 -0
- data/lib/train-juniper/platform.rb +53 -82
- data/lib/train-juniper/transport.rb +13 -1
- data/lib/train-juniper/version.rb +2 -1
- data/train-juniper.gemspec +16 -0
- metadata +152 -2
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TrainPlugins
|
4
|
+
module Juniper
|
5
|
+
# Validation methods for connection options
|
6
|
+
module Validation
|
7
|
+
# Validate all connection options
|
8
|
+
def validate_connection_options!
|
9
|
+
validate_required_options!
|
10
|
+
validate_option_types!
|
11
|
+
validate_proxy_options!
|
12
|
+
end
|
13
|
+
|
14
|
+
# Validate required options are present
|
15
|
+
def validate_required_options!
|
16
|
+
raise Train::ClientError, 'Host is required' unless @options[:host]
|
17
|
+
raise Train::ClientError, 'User is required' unless @options[:user]
|
18
|
+
end
|
19
|
+
|
20
|
+
# Validate option types and ranges
|
21
|
+
def validate_option_types!
|
22
|
+
validate_port! if @options[:port]
|
23
|
+
validate_timeout! if @options[:timeout]
|
24
|
+
validate_bastion_port! if @options[:bastion_port]
|
25
|
+
end
|
26
|
+
|
27
|
+
# Validate port is in valid range
|
28
|
+
def validate_port!
|
29
|
+
validate_port_value!(:port)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Validate timeout is positive number
|
33
|
+
def validate_timeout!
|
34
|
+
timeout = @options[:timeout]
|
35
|
+
raise Train::ClientError, "Invalid timeout: #{timeout} (must be positive number)" unless timeout.is_a?(Numeric) && timeout.positive?
|
36
|
+
end
|
37
|
+
|
38
|
+
# Validate bastion port is in valid range
|
39
|
+
def validate_bastion_port!
|
40
|
+
validate_port_value!(:bastion_port)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# DRY method for validating port values
|
46
|
+
# @param port_key [Symbol] The options key containing the port value
|
47
|
+
# @param port_name [String] The name to use in error messages (defaults to port_key)
|
48
|
+
def validate_port_value!(port_key, port_name = nil)
|
49
|
+
port_name ||= port_key.to_s.tr('_', ' ')
|
50
|
+
port = @options[port_key].to_i
|
51
|
+
raise Train::ClientError, "Invalid #{port_name}: #{@options[port_key]} (must be 1-65535)" unless port.between?(1, 65_535)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Validate proxy configuration options (Train standard)
|
55
|
+
def validate_proxy_options!
|
56
|
+
# Cannot use both bastion_host and proxy_command simultaneously
|
57
|
+
if @options[:bastion_host] && @options[:proxy_command]
|
58
|
+
raise Train::ClientError, 'Cannot specify both bastion_host and proxy_command'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -14,8 +14,18 @@
|
|
14
14
|
require 'train'
|
15
15
|
require 'logger'
|
16
16
|
|
17
|
-
# Juniper-specific
|
17
|
+
# Juniper-specific modules
|
18
|
+
require 'train-juniper/constants'
|
18
19
|
require 'train-juniper/platform'
|
20
|
+
require 'train-juniper/connection/validation'
|
21
|
+
require 'train-juniper/connection/command_executor'
|
22
|
+
require 'train-juniper/connection/error_handling'
|
23
|
+
require 'train-juniper/connection/ssh_session'
|
24
|
+
require 'train-juniper/connection/bastion_proxy'
|
25
|
+
require 'train-juniper/helpers/environment'
|
26
|
+
require 'train-juniper/helpers/logging'
|
27
|
+
require 'train-juniper/helpers/mock_responses'
|
28
|
+
require 'train-juniper/file_abstraction/juniper_file'
|
19
29
|
|
20
30
|
# Using Train's SSH transport for connectivity
|
21
31
|
|
@@ -25,25 +35,71 @@ module TrainPlugins
|
|
25
35
|
class Connection < Train::Plugins::Transport::BaseConnection
|
26
36
|
# Include Juniper-specific platform detection
|
27
37
|
include TrainPlugins::Juniper::Platform
|
38
|
+
# Include environment variable helpers
|
39
|
+
include TrainPlugins::Juniper::Environment
|
40
|
+
# Include validation methods
|
41
|
+
include TrainPlugins::Juniper::Validation
|
42
|
+
# Include command execution methods
|
43
|
+
include TrainPlugins::Juniper::CommandExecutor
|
44
|
+
# Include error handling methods
|
45
|
+
include TrainPlugins::Juniper::ErrorHandling
|
46
|
+
# Include SSH session management
|
47
|
+
include TrainPlugins::Juniper::SSHSession
|
48
|
+
# Include bastion proxy support
|
49
|
+
include TrainPlugins::Juniper::BastionProxy
|
50
|
+
# Include logging helpers
|
51
|
+
include TrainPlugins::Juniper::Logging
|
52
|
+
|
53
|
+
# Alias for Train CommandResult for backward compatibility
|
54
|
+
CommandResult = Train::Extras::CommandResult
|
28
55
|
|
29
56
|
attr_reader :ssh_session
|
30
57
|
|
58
|
+
# Configuration mapping for environment variables
|
59
|
+
ENV_CONFIG = {
|
60
|
+
host: { env: 'JUNIPER_HOST' },
|
61
|
+
user: { env: 'JUNIPER_USER' },
|
62
|
+
password: { env: 'JUNIPER_PASSWORD' },
|
63
|
+
port: { env: 'JUNIPER_PORT', type: :int, default: Constants::DEFAULT_SSH_PORT },
|
64
|
+
timeout: { env: 'JUNIPER_TIMEOUT', type: :int, default: 30 },
|
65
|
+
bastion_host: { env: 'JUNIPER_BASTION_HOST' },
|
66
|
+
bastion_user: { env: 'JUNIPER_BASTION_USER' },
|
67
|
+
bastion_port: { env: 'JUNIPER_BASTION_PORT', type: :int, default: Constants::DEFAULT_SSH_PORT },
|
68
|
+
bastion_password: { env: 'JUNIPER_BASTION_PASSWORD' },
|
69
|
+
proxy_command: { env: 'JUNIPER_PROXY_COMMAND' }
|
70
|
+
}.freeze
|
71
|
+
|
72
|
+
# Initialize a new Juniper connection
|
73
|
+
# @param options [Hash] Connection options
|
74
|
+
# @option options [String] :host The hostname or IP address of the Juniper device
|
75
|
+
# @option options [String] :user The username for authentication
|
76
|
+
# @option options [String] :password The password for authentication (optional if using key_files)
|
77
|
+
# @option options [Integer] :port The SSH port (default: 22)
|
78
|
+
# @option options [Integer] :timeout Connection timeout in seconds (default: 30)
|
79
|
+
# @option options [String] :bastion_host Jump/bastion host for connection
|
80
|
+
# @option options [String] :proxy_command SSH proxy command
|
81
|
+
# @option options [Logger] :logger Custom logger instance
|
82
|
+
# @option options [Boolean] :mock Enable mock mode for testing
|
31
83
|
def initialize(options)
|
32
84
|
# Configure SSH connection options for Juniper devices
|
33
85
|
# Support environment variables for authentication (following train-vsphere pattern)
|
34
86
|
@options = options.dup
|
35
|
-
@options[:host] ||= ENV.fetch('JUNIPER_HOST', nil)
|
36
|
-
@options[:user] ||= ENV.fetch('JUNIPER_USER', nil)
|
37
|
-
@options[:password] ||= ENV.fetch('JUNIPER_PASSWORD', nil)
|
38
|
-
@options[:port] ||= ENV['JUNIPER_PORT']&.to_i || 22
|
39
|
-
@options[:timeout] ||= ENV['JUNIPER_TIMEOUT']&.to_i || 30
|
40
87
|
|
41
|
-
#
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
88
|
+
# Apply environment variable configuration using DRY approach
|
89
|
+
ENV_CONFIG.each do |key, config|
|
90
|
+
# Skip if option already has a value from command line
|
91
|
+
next if @options[key]
|
92
|
+
|
93
|
+
# Get value from environment
|
94
|
+
env_val = config[:type] == :int ? env_int(config[:env]) : env_value(config[:env])
|
95
|
+
|
96
|
+
# Only apply env value if it exists, otherwise use default (but not for nil CLI values)
|
97
|
+
if env_val
|
98
|
+
@options[key] = env_val
|
99
|
+
elsif !@options.key?(key) && config[:default]
|
100
|
+
@options[key] = config[:default]
|
101
|
+
end
|
102
|
+
end
|
47
103
|
|
48
104
|
@options[:keepalive] = true
|
49
105
|
@options[:keepalive_interval] = 60
|
@@ -55,20 +111,21 @@ module TrainPlugins
|
|
55
111
|
@cli_prompt = /[%>$#]\s*$/
|
56
112
|
@config_prompt = /[%#]\s*$/
|
57
113
|
|
58
|
-
# Log connection info
|
59
|
-
|
60
|
-
@logger.debug("Juniper connection initialized with options: #{safe_options.inspect}")
|
61
|
-
@logger.debug("Environment: JUNIPER_BASTION_USER=#{ENV.fetch('JUNIPER_BASTION_USER',
|
62
|
-
nil)} -> bastion_user=#{@options[:bastion_user]}")
|
114
|
+
# Log connection info safely
|
115
|
+
log_connection_info
|
63
116
|
|
64
|
-
# Validate
|
65
|
-
|
117
|
+
# Validate all connection options
|
118
|
+
validate_connection_options!
|
66
119
|
|
67
120
|
super(@options)
|
68
121
|
|
69
122
|
# Establish SSH connection to Juniper device (unless in mock mode or skip_connect)
|
70
|
-
@
|
71
|
-
|
123
|
+
if @options[:mock]
|
124
|
+
log_mock_mode
|
125
|
+
elsif !@options[:skip_connect]
|
126
|
+
@logger.debug('Attempting to connect to Juniper device...')
|
127
|
+
connect
|
128
|
+
end
|
72
129
|
end
|
73
130
|
|
74
131
|
# Secure string representation (never expose credentials)
|
@@ -76,339 +133,71 @@ module TrainPlugins
|
|
76
133
|
"#<#{self.class.name}:0x#{object_id.to_s(16)} @host=#{@options[:host]} @user=#{@options[:user]}>"
|
77
134
|
end
|
78
135
|
|
136
|
+
# Secure inspect method that uses to_s
|
137
|
+
# @return [String] Secure string representation
|
79
138
|
def inspect
|
80
139
|
to_s
|
81
140
|
end
|
82
141
|
|
83
|
-
#
|
84
|
-
#
|
142
|
+
# Access Juniper configuration and operational data as pseudo-files
|
143
|
+
# @param path [String] The pseudo-file path to access
|
144
|
+
# @return [JuniperFile] A file-like object for accessing Juniper data
|
145
|
+
# @example Access interface configuration
|
146
|
+
# file = connection.file('/config/interfaces')
|
147
|
+
# puts file.content
|
148
|
+
# @example Access operational data
|
149
|
+
# file = connection.file('/operational/interfaces')
|
150
|
+
# puts file.content
|
85
151
|
def file_via_connection(path)
|
86
152
|
# For Juniper devices, "files" are typically configuration sections
|
87
153
|
# or operational command outputs rather than traditional filesystem paths
|
88
154
|
JuniperFile.new(self, path)
|
89
155
|
end
|
90
156
|
|
91
|
-
#
|
92
|
-
#
|
93
|
-
#
|
157
|
+
# Upload files to Juniper device (not supported)
|
158
|
+
# @param locals [String, Array<String>] Local file path(s)
|
159
|
+
# @param remote [String] Remote destination path
|
160
|
+
# @raise [NotImplementedError] Always raises as uploads are not supported
|
161
|
+
# @note Network devices use command-based configuration instead of file uploads
|
94
162
|
def upload(locals, remote)
|
95
|
-
raise NotImplementedError,
|
163
|
+
raise NotImplementedError, Constants::UPLOAD_NOT_SUPPORTED
|
96
164
|
end
|
97
165
|
|
166
|
+
# Download files from Juniper device (not supported)
|
167
|
+
# @param remotes [String, Array<String>] Remote file path(s)
|
168
|
+
# @param local [String] Local destination path
|
169
|
+
# @raise [NotImplementedError] Always raises as downloads are not supported
|
170
|
+
# @note Use run_command() to retrieve configuration data instead
|
98
171
|
def download(remotes, local)
|
99
|
-
raise NotImplementedError,
|
100
|
-
end
|
101
|
-
|
102
|
-
# Execute commands on Juniper device via SSH
|
103
|
-
def run_command_via_connection(cmd)
|
104
|
-
return mock_command_result(cmd) if @options[:mock]
|
105
|
-
|
106
|
-
begin
|
107
|
-
# Ensure we're connected
|
108
|
-
connect unless connected?
|
109
|
-
|
110
|
-
@logger.debug("Executing command: #{cmd}")
|
111
|
-
|
112
|
-
# Execute command via SSH session
|
113
|
-
output = @ssh_session.exec!(cmd)
|
114
|
-
|
115
|
-
@logger.debug("Command output: #{output}")
|
116
|
-
|
117
|
-
# Format JunOS result
|
118
|
-
format_junos_result(output, cmd)
|
119
|
-
rescue StandardError => e
|
120
|
-
@logger.error("Command execution failed: #{e.message}")
|
121
|
-
# Handle connection errors gracefully
|
122
|
-
CommandResult.new('', e.message, 1)
|
123
|
-
end
|
124
|
-
end
|
125
|
-
|
126
|
-
# JunOS error patterns from implementation plan
|
127
|
-
JUNOS_ERROR_PATTERNS = [
|
128
|
-
/^error:/i,
|
129
|
-
/syntax error/i,
|
130
|
-
/invalid command/i,
|
131
|
-
/unknown command/i,
|
132
|
-
/missing argument/i
|
133
|
-
].freeze
|
134
|
-
|
135
|
-
private
|
136
|
-
|
137
|
-
# Establish SSH connection to Juniper device
|
138
|
-
def connect
|
139
|
-
return if connected?
|
140
|
-
|
141
|
-
begin
|
142
|
-
# Use direct SSH connection (network device pattern)
|
143
|
-
# Defensive loading - only require if not fully loaded
|
144
|
-
require 'net/ssh' unless defined?(Net::SSH) && Net::SSH.respond_to?(:start)
|
145
|
-
|
146
|
-
@logger.debug('Establishing SSH connection to Juniper device')
|
147
|
-
|
148
|
-
ssh_options = {
|
149
|
-
port: @options[:port] || 22,
|
150
|
-
password: @options[:password],
|
151
|
-
timeout: @options[:timeout] || 30,
|
152
|
-
verify_host_key: :never,
|
153
|
-
keepalive: @options[:keepalive],
|
154
|
-
keepalive_interval: @options[:keepalive_interval]
|
155
|
-
}
|
156
|
-
|
157
|
-
# Add SSH key authentication if specified
|
158
|
-
if @options[:key_files]
|
159
|
-
ssh_options[:keys] = Array(@options[:key_files])
|
160
|
-
ssh_options[:keys_only] = @options[:keys_only]
|
161
|
-
end
|
162
|
-
|
163
|
-
# Add bastion host support if configured
|
164
|
-
if @options[:bastion_host]
|
165
|
-
require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
|
166
|
-
|
167
|
-
# Build proxy jump string from bastion options
|
168
|
-
bastion_user = @options[:bastion_user] || 'root'
|
169
|
-
bastion_port = @options[:bastion_port] || 22
|
170
|
-
|
171
|
-
proxy_jump = if bastion_port == 22
|
172
|
-
"#{bastion_user}@#{@options[:bastion_host]}"
|
173
|
-
else
|
174
|
-
"#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
|
175
|
-
end
|
176
|
-
|
177
|
-
@logger.debug("Using bastion host: #{proxy_jump}")
|
178
|
-
|
179
|
-
# Set up automated password authentication via SSH_ASKPASS
|
180
|
-
bastion_password = @options[:bastion_password] || @options[:password] # Use explicit bastion password or fallback
|
181
|
-
if bastion_password
|
182
|
-
@ssh_askpass_script = create_ssh_askpass_script(bastion_password)
|
183
|
-
ENV['SSH_ASKPASS'] = @ssh_askpass_script
|
184
|
-
ENV['SSH_ASKPASS_REQUIRE'] = 'force' # Force use of SSH_ASKPASS even with terminal
|
185
|
-
@logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
|
186
|
-
end
|
187
|
-
|
188
|
-
ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
|
189
|
-
end
|
190
|
-
|
191
|
-
@logger.debug("Connecting to #{@options[:host]}:#{@options[:port]} as #{@options[:user]}")
|
192
|
-
|
193
|
-
# Direct SSH connection
|
194
|
-
@ssh_session = Net::SSH.start(@options[:host], @options[:user], ssh_options)
|
195
|
-
@logger.debug('SSH connection established successfully')
|
196
|
-
|
197
|
-
# Configure JunOS session for automation
|
198
|
-
test_and_configure_session
|
199
|
-
rescue StandardError => e
|
200
|
-
@logger.error("SSH connection failed: #{e.message}")
|
201
|
-
|
202
|
-
# Provide helpful error messages for common authentication issues
|
203
|
-
if (e.message.include?('Permission denied') || e.message.include?('command failed')) && @options[:bastion_host]
|
204
|
-
raise Train::TransportError, <<~ERROR
|
205
|
-
Failed to connect to Juniper device #{@options[:host]} via bastion #{@options[:bastion_host]}: #{e.message}
|
206
|
-
|
207
|
-
SSH bastion authentication with passwords is not supported due to ProxyCommand limitations.
|
208
|
-
Please use one of these alternatives:
|
209
|
-
|
210
|
-
1. SSH Key Authentication (Recommended):
|
211
|
-
Use --key-files option to specify SSH private key files
|
212
|
-
#{' '}
|
213
|
-
2. SSH Agent:
|
214
|
-
Ensure your SSH agent has the required keys loaded
|
215
|
-
#{' '}
|
216
|
-
3. Direct Connection:
|
217
|
-
Connect directly to the device if network allows (remove bastion options)
|
218
|
-
|
219
|
-
For more details, see: https://mitre.github.io/train-juniper/troubleshooting/#bastion-authentication
|
220
|
-
ERROR
|
221
|
-
else
|
222
|
-
raise Train::TransportError, "Failed to connect to Juniper device #{@options[:host]}: #{e.message}"
|
223
|
-
end
|
224
|
-
end
|
172
|
+
raise NotImplementedError, Constants::DOWNLOAD_NOT_SUPPORTED
|
225
173
|
end
|
226
174
|
|
227
|
-
# Check
|
228
|
-
|
229
|
-
|
175
|
+
# Check connection health
|
176
|
+
# @return [Boolean] true if connection is healthy, false otherwise
|
177
|
+
# @example
|
178
|
+
# if connection.healthy?
|
179
|
+
# puts "Connection is healthy"
|
180
|
+
# end
|
181
|
+
def healthy?
|
182
|
+
return false unless connected?
|
230
183
|
|
231
|
-
|
184
|
+
result = run_command_via_connection('show version')
|
185
|
+
result.exit_status.zero?
|
232
186
|
rescue StandardError
|
233
187
|
false
|
234
188
|
end
|
235
189
|
|
236
|
-
#
|
237
|
-
|
238
|
-
|
239
|
-
end
|
240
|
-
|
241
|
-
# Test connection and configure JunOS session
|
242
|
-
def test_and_configure_session
|
243
|
-
@logger.debug('Testing SSH connection and configuring JunOS session')
|
244
|
-
|
245
|
-
# Test connection first
|
246
|
-
@ssh_session.exec!('echo "connection test"')
|
247
|
-
@logger.debug('SSH connection test successful')
|
248
|
-
|
249
|
-
# Optimize CLI for automation
|
250
|
-
@ssh_session.exec!('set cli screen-length 0')
|
251
|
-
@ssh_session.exec!('set cli screen-width 0')
|
252
|
-
@ssh_session.exec!('set cli complete-on-space off') if @options[:disable_complete_on_space]
|
253
|
-
|
254
|
-
@logger.debug('JunOS session configured successfully')
|
255
|
-
rescue StandardError => e
|
256
|
-
@logger.warn("Failed to configure JunOS session: #{e.message}")
|
257
|
-
end
|
258
|
-
|
259
|
-
# Format JunOS command results (from implementation plan)
|
260
|
-
def format_junos_result(output, cmd)
|
261
|
-
# Parse JunOS-specific error patterns
|
262
|
-
if junos_error?(output)
|
263
|
-
CommandResult.new('', output, 1)
|
264
|
-
else
|
265
|
-
CommandResult.new(clean_output(output, cmd), '', 0)
|
266
|
-
end
|
267
|
-
end
|
268
|
-
|
269
|
-
# Check for JunOS error patterns (from implementation plan)
|
270
|
-
def junos_error?(output)
|
271
|
-
JUNOS_ERROR_PATTERNS.any? { |pattern| output.match?(pattern) }
|
272
|
-
end
|
273
|
-
|
274
|
-
# Clean command output
|
275
|
-
def clean_output(output, cmd)
|
276
|
-
# Handle nil output gracefully
|
277
|
-
return '' if output.nil?
|
278
|
-
|
279
|
-
# Remove command echo and prompts
|
280
|
-
lines = output.to_s.split("\n")
|
281
|
-
lines.reject! { |line| line.strip == cmd.strip }
|
282
|
-
|
283
|
-
# Remove JunOS prompt patterns from the end
|
284
|
-
lines.pop while lines.last && lines.last.strip.match?(/^[%>$#]+\s*$/)
|
285
|
-
|
286
|
-
lines.join("\n")
|
287
|
-
end
|
288
|
-
|
289
|
-
# Validate proxy configuration options (Train standard)
|
290
|
-
def validate_proxy_options
|
291
|
-
# Cannot use both bastion_host and proxy_command simultaneously
|
292
|
-
if @options[:bastion_host] && @options[:proxy_command]
|
293
|
-
raise Train::ClientError, 'Cannot specify both bastion_host and proxy_command'
|
294
|
-
end
|
295
|
-
end
|
296
|
-
|
297
|
-
# Create temporary SSH_ASKPASS script for automated password authentication
|
298
|
-
def create_ssh_askpass_script(password)
|
299
|
-
require 'tempfile'
|
300
|
-
|
301
|
-
script = Tempfile.new(['ssh_askpass', '.sh'])
|
302
|
-
script.write("#!/bin/bash\necho '#{password}'\n")
|
303
|
-
script.close
|
304
|
-
File.chmod(0o755, script.path)
|
305
|
-
|
306
|
-
@logger.debug("Created SSH_ASKPASS script at #{script.path}")
|
307
|
-
script.path
|
308
|
-
end
|
309
|
-
|
310
|
-
# Generate SSH proxy command for bastion host using ProxyJump (-J)
|
311
|
-
def generate_bastion_proxy_command(bastion_user, bastion_port)
|
312
|
-
args = ['ssh']
|
313
|
-
|
314
|
-
# SSH options for connection
|
315
|
-
args += ['-o', 'UserKnownHostsFile=/dev/null']
|
316
|
-
args += ['-o', 'StrictHostKeyChecking=no']
|
317
|
-
args += ['-o', 'LogLevel=ERROR']
|
318
|
-
args += ['-o', 'ForwardAgent=no']
|
319
|
-
|
320
|
-
# Use ProxyJump (-J) which handles password authentication properly
|
321
|
-
jump_host = if bastion_port == 22
|
322
|
-
"#{bastion_user}@#{@options[:bastion_host]}"
|
323
|
-
else
|
324
|
-
"#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
|
325
|
-
end
|
326
|
-
args += ['-J', jump_host]
|
327
|
-
|
328
|
-
# Add SSH keys if specified
|
329
|
-
if @options[:key_files]
|
330
|
-
Array(@options[:key_files]).each do |key_file|
|
331
|
-
args += ['-i', key_file]
|
332
|
-
end
|
333
|
-
end
|
334
|
-
|
335
|
-
# Target connection - %h and %p will be replaced by Net::SSH
|
336
|
-
args += ['%h', '-p', '%p']
|
337
|
-
|
338
|
-
args.join(' ')
|
339
|
-
end
|
340
|
-
|
341
|
-
# Mock command execution for testing
|
342
|
-
def mock_command_result(cmd)
|
343
|
-
case cmd
|
344
|
-
when /show version/
|
345
|
-
CommandResult.new(mock_show_version_output, '', 0)
|
346
|
-
when /show chassis hardware/
|
347
|
-
CommandResult.new(mock_chassis_output, '', 0)
|
348
|
-
when /show configuration/
|
349
|
-
CommandResult.new("interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}", '', 0)
|
350
|
-
when /show route/
|
351
|
-
CommandResult.new("inet.0: 5 destinations, 5 routes\n0.0.0.0/0 *[Static/5] 00:00:01\n", '', 0)
|
352
|
-
when /show system information/
|
353
|
-
CommandResult.new("Hardware: SRX240H2\nOS: JUNOS 12.1X47-D15.4\n", '', 0)
|
354
|
-
when /show interfaces/
|
355
|
-
CommandResult.new("Physical interface: ge-0/0/0, Enabled, Physical link is Up\n", '', 0)
|
356
|
-
else
|
357
|
-
CommandResult.new("% Unknown command: #{cmd}", '', 1)
|
358
|
-
end
|
359
|
-
end
|
360
|
-
|
361
|
-
# Mock JunOS version output for testing
|
362
|
-
def mock_show_version_output
|
363
|
-
<<~OUTPUT
|
364
|
-
Hostname: lab-srx
|
365
|
-
Model: SRX240H2
|
366
|
-
Junos: 12.1X47-D15.4
|
367
|
-
JUNOS Software Release [12.1X47-D15.4]
|
368
|
-
OUTPUT
|
369
|
-
end
|
370
|
-
|
371
|
-
# Mock chassis output for testing
|
372
|
-
def mock_chassis_output
|
373
|
-
<<~OUTPUT
|
374
|
-
Hardware inventory:
|
375
|
-
Item Version Part number Serial number Description
|
376
|
-
Chassis JN123456 SRX240H2
|
377
|
-
OUTPUT
|
378
|
-
end
|
379
|
-
end
|
380
|
-
|
381
|
-
# File abstraction for Juniper configuration and operational data
|
382
|
-
class JuniperFile
|
383
|
-
def initialize(connection, path)
|
384
|
-
@connection = connection
|
385
|
-
@path = path
|
386
|
-
end
|
190
|
+
# List of sensitive option keys to redact in logs
|
191
|
+
SENSITIVE_OPTIONS = %i[password bastion_password key_files proxy_command].freeze
|
192
|
+
private_constant :SENSITIVE_OPTIONS
|
387
193
|
|
388
|
-
|
389
|
-
# For Juniper devices, translate file paths to appropriate commands
|
390
|
-
case @path
|
391
|
-
when %r{/config/(.*)}
|
392
|
-
# Configuration sections: /config/interfaces -> show configuration interfaces
|
393
|
-
section = ::Regexp.last_match(1)
|
394
|
-
result = @connection.run_command("show configuration #{section}")
|
395
|
-
result.stdout
|
396
|
-
when %r{/operational/(.*)}
|
397
|
-
# Operational data: /operational/interfaces -> show interfaces
|
398
|
-
section = ::Regexp.last_match(1)
|
399
|
-
result = @connection.run_command("show #{section}")
|
400
|
-
result.stdout
|
401
|
-
else
|
402
|
-
# Default to treating path as a show command
|
403
|
-
result = @connection.run_command("show #{@path}")
|
404
|
-
result.stdout
|
405
|
-
end
|
406
|
-
end
|
194
|
+
private
|
407
195
|
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
196
|
+
# Log connection info without exposing sensitive data
|
197
|
+
def log_connection_info
|
198
|
+
safe_options = @options.except(*SENSITIVE_OPTIONS)
|
199
|
+
@logger.debug("Juniper connection initialized with options: #{safe_options.inspect}")
|
200
|
+
@logger.debug("Environment: JUNIPER_BASTION_USER=#{env_value('JUNIPER_BASTION_USER')} -> bastion_user=#{@options[:bastion_user]}")
|
412
201
|
end
|
413
202
|
end
|
414
203
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TrainPlugins
|
4
|
+
module Juniper
|
5
|
+
# Common constants used across the plugin
|
6
|
+
module Constants
|
7
|
+
# SSH Configuration
|
8
|
+
# @return [Integer] Default SSH port
|
9
|
+
DEFAULT_SSH_PORT = 22
|
10
|
+
# @return [Range] Valid port range for SSH connections
|
11
|
+
PORT_RANGE = (1..65_535)
|
12
|
+
|
13
|
+
# Standard SSH Options for network devices
|
14
|
+
STANDARD_SSH_OPTIONS = {
|
15
|
+
'UserKnownHostsFile' => '/dev/null',
|
16
|
+
'StrictHostKeyChecking' => 'no',
|
17
|
+
'LogLevel' => 'ERROR',
|
18
|
+
'ForwardAgent' => 'no'
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
# JunOS CLI Prompt Patterns
|
22
|
+
# @return [Regexp] Pattern matching JunOS CLI prompts
|
23
|
+
CLI_PROMPT = /[%>$#]\s*$/
|
24
|
+
# @return [Regexp] Pattern matching JunOS configuration mode prompts
|
25
|
+
CONFIG_PROMPT = /[%#]\s*$/
|
26
|
+
|
27
|
+
# File Path Patterns
|
28
|
+
# @return [Regexp] Pattern for configuration file paths
|
29
|
+
CONFIG_PATH_PATTERN = %r{/config/(.*)}
|
30
|
+
# @return [Regexp] Pattern for operational data paths
|
31
|
+
OPERATIONAL_PATH_PATTERN = %r{/operational/(.*)}
|
32
|
+
|
33
|
+
# Error Messages
|
34
|
+
# @return [String] Error message for unsupported upload operations
|
35
|
+
UPLOAD_NOT_SUPPORTED = 'File operations not supported for Juniper devices - use command-based configuration'
|
36
|
+
# @return [String] Error message for unsupported download operations
|
37
|
+
DOWNLOAD_NOT_SUPPORTED = 'File operations not supported for Juniper devices - use run_command() to retrieve data'
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'train-juniper/constants'
|
4
|
+
|
5
|
+
module TrainPlugins
|
6
|
+
module Juniper
|
7
|
+
# File abstraction for Juniper configuration and operational data
|
8
|
+
class JuniperFile
|
9
|
+
# Initialize a new JuniperFile
|
10
|
+
# @param connection [Connection] The Juniper connection instance
|
11
|
+
# @param path [String] The virtual file path
|
12
|
+
def initialize(connection, path)
|
13
|
+
@connection = connection
|
14
|
+
@path = path
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get the content of the virtual file
|
18
|
+
# @return [String] The command output based on the path
|
19
|
+
# @example
|
20
|
+
# file = connection.file('/config/interfaces')
|
21
|
+
# file.content # Returns output of 'show configuration interfaces'
|
22
|
+
def content
|
23
|
+
# For Juniper devices, translate file paths to appropriate commands
|
24
|
+
case @path
|
25
|
+
when Constants::CONFIG_PATH_PATTERN
|
26
|
+
# Configuration sections: /config/interfaces -> show configuration interfaces
|
27
|
+
section = ::Regexp.last_match(1)
|
28
|
+
result = @connection.run_command("show configuration #{section}")
|
29
|
+
result.stdout
|
30
|
+
when Constants::OPERATIONAL_PATH_PATTERN
|
31
|
+
# Operational data: /operational/interfaces -> show interfaces
|
32
|
+
section = ::Regexp.last_match(1)
|
33
|
+
result = @connection.run_command("show #{section}")
|
34
|
+
result.stdout
|
35
|
+
else
|
36
|
+
# Default to treating path as a show command
|
37
|
+
result = @connection.run_command("show #{@path}")
|
38
|
+
result.stdout
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Check if the file exists (has content)
|
43
|
+
# @return [Boolean] true if the file has content, false otherwise
|
44
|
+
def exist?
|
45
|
+
!content.empty?
|
46
|
+
rescue StandardError
|
47
|
+
false
|
48
|
+
end
|
49
|
+
|
50
|
+
# Return string representation of file path
|
51
|
+
# @return [String] the file path
|
52
|
+
def to_s
|
53
|
+
@path
|
54
|
+
end
|
55
|
+
|
56
|
+
# File upload not supported for network devices
|
57
|
+
# @raise [NotImplementedError] always raises as upload is not supported
|
58
|
+
def upload(_content)
|
59
|
+
raise NotImplementedError, Constants::UPLOAD_NOT_SUPPORTED
|
60
|
+
end
|
61
|
+
|
62
|
+
# File download not supported for network devices
|
63
|
+
# @raise [NotImplementedError] always raises as download is not supported
|
64
|
+
def download(_local_path)
|
65
|
+
raise NotImplementedError, Constants::DOWNLOAD_NOT_SUPPORTED
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|