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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4428bf34013845866814e344ce4f67460a6af1a70fc33f33641a9412fee9dccb
4
- data.tar.gz: 40f7a51485657e5616c744fa379283c8c0dfc1dbaa6d3a307ac4e9c909884d0d
3
+ metadata.gz: 86bf86dd54cabe94e072c6e3a2b64ffcdfbdf774a5f33f1a3a76012829dd71bc
4
+ data.tar.gz: c2e5d2fe1ecfa259f78c2d80f76d94eaa68afb817d584c57d02bfb4c46779924
5
5
  SHA512:
6
- metadata.gz: 31d2993f5e85a04ff1c1940644242df90e1c19066339eb8be395ec1378767fce53958278318f58e1fea7a51c0ea5f0923f5447f8f204f5b374e4e8ca6e4d91bd
7
- data.tar.gz: f0da0acd3ea9398723a62cbb5a5f73a9af010c440218a7a01f62ba2f4071eb33833d7d284b5e4b55d166f0e61c3b47988bd85cd3854dad6eb00f2155551b1dd0
6
+ metadata.gz: 168356b0c2a48f7b4817acb535571c2b651d12365dc7340c96c9b2b2d6db30981516683c29965bd295459d8cbb2b2625e020a35789a3bbed4d295ce29712264e
7
+ data.tar.gz: 8a209708671652728890ff96f75b3a2da922f37f93c61896881e99b387adc15ecc1e889d6e416637dc0eee011ed1f1b9b0fcd33b99c2bacfe15f2736daaa9da8
data/CHANGELOG.md CHANGED
@@ -5,23 +5,83 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.6.2] - 2025-06-18
8
+ ## [0.7.1] - 2025-06-23
9
+
10
+ ### Added
11
+
12
+ - Implement Priority 2 DRY improvements and boost coverage to 90.88%
13
+ - Add coverage analysis utility script
14
+ - Boost test coverage to 93.13% and enhance coverage analysis tool
15
+ - **coverage**: Integrate coverage reporting into release process and CI/CD
16
+ - **docs**: Enhance coverage report with Material for MkDocs styling
17
+
18
+ ### Documentation
19
+
20
+ - Add YARD documentation for inspect method and test organization guide
21
+ - Add YARD documentation for all constants
22
+ - **roadmap**: Modernize with v0.7.1 status and Material styling
9
23
 
10
24
  ### Fixed
11
25
 
12
- - Fix Windows installation issue by relaxing FFI dependency to match InSpec 7 (>= 1.15.5, < 1.17.0) (#2)
13
- - Fix mock mode platform detection to correctly show JunOS version instead of gem version
14
- - Update release workflow to use trusted publishing with OIDC authentication
15
- - Update workflows to use Ruby 3.3 for improved trusted publishing support
26
+ - Add v0.7.0 to mkdocs and automate nav updates
27
+ - **coverage**: Properly handle SimpleCov :nocov: markers in analysis
28
+ - **docs**: Move Security Policy to About section in navigation
29
+
30
+ ### Miscellaneous Tasks
31
+
32
+ - Fix all RuboCop violations and prepare for v0.7.1 release
33
+ - Remove .rubocop_todo.yml after fixing all violations
34
+
35
+ ### Refactor
36
+
37
+ - Streamline release process for GitHub Actions
38
+ - Phase 1 modularization - extract JuniperFile, EnvironmentHelpers, and Validation
39
+ - Reorganize directory structure to follow Train plugin conventions
40
+ - Phase 2 modularization - extract CommandExecutor and ErrorHandling
41
+ - Phase 3 modularization - extract SSHSession and BastionProxy
42
+ - DRY improvements for v0.7.1
43
+ - Fix all RuboCop complexity issues without using todos
44
+ - **docs**: Reorganize navigation for better user experience
45
+
46
+ ### Testing
47
+
48
+ - Fix platform edge case test and boost coverage to 99.75%
49
+ - Achieve 100% code coverage 🎯
50
+
51
+ ## [0.7.0] - 2025-06-23
16
52
 
17
53
  ### Added
18
54
 
19
- - Add `mock?` method to properly support mock mode in platform detection
20
- - Add comprehensive mock mode documentation to README
55
+ - Add security enhancements and input validation
56
+ - V0.7.0 - enhanced security, YARD docs, and Windows support
21
57
 
22
- ### Changed
58
+ ### Documentation
59
+
60
+ - Update roadmap and fix documentation issues
61
+
62
+ ### Fixed
63
+
64
+ - Empty environment variables no longer override CLI flags
65
+ - Remove Brakeman from security tasks
66
+
67
+ ### Miscellaneous Tasks
68
+
69
+ - Update .gitignore for untracked files
70
+
71
+ ### Refactor
72
+
73
+ - Apply DRY principles throughout codebase
74
+ - Extract common version detection pattern
75
+
76
+ ### Styling
77
+
78
+ - Fix RuboCop offenses in connection files
79
+
80
+ ## [0.6.2] - 2025-06-19
81
+
82
+ ### Fixed
23
83
 
24
- - Update release process documentation to reflect trusted publishing setup
84
+ - Windows FFI compatibility and mock mode platform detection
25
85
 
26
86
  ## [0.6.1] - 2025-06-18
27
87
 
data/README.md CHANGED
@@ -64,9 +64,9 @@ $ inspec detect -t juniper://admin@192.168.1.1 --password yourpassword
64
64
 
65
65
  == Platform Details
66
66
  Name: juniper
67
- Families: network
67
+ Families: bsd
68
68
  Release: 21.4R3-S1.6
69
- Arch: network
69
+ Arch: x86_64
70
70
 
71
71
  # Interactive shell
72
72
  $ inspec shell -t juniper://admin@192.168.1.1 --password yourpassword
@@ -77,23 +77,29 @@ inspec> command('show version').stdout
77
77
  ### With Bastion Host (Jump Host)
78
78
 
79
79
  ```bash
80
- # Using Train standard bastion host options
81
- $ inspec shell -t "juniper://admin@10.1.1.1?bastion_host=jump.example.com&bastion_user=netadmin&bastion_password=jumppass"
80
+ # Simplified: Same username/password for bastion and device (most common)
81
+ $ inspec shell -t juniper://admin@10.1.1.1 --password yourpassword \
82
+ --bastion-host jump.example.com
83
+
84
+ # Different credentials for bastion and device
85
+ $ inspec shell -t juniper://admin@10.1.1.1 --password device_password \
86
+ --bastion-host jump.example.com --bastion-user netadmin \
87
+ --bastion-password jump_password
82
88
 
83
89
  # With custom port
84
- $ inspec shell -t "juniper://admin@10.1.1.1?bastion_host=jump.example.com&bastion_user=netadmin&bastion_port=2222&bastion_password=jumppass"
90
+ $ inspec shell -t juniper://admin@10.1.1.1 --password yourpassword \
91
+ --bastion-host jump.example.com --bastion-port 2222
85
92
 
86
93
  # Using environment variables (recommended for automation)
87
94
  export JUNIPER_BASTION_HOST=jump.example.com
88
- export JUNIPER_BASTION_USER=netadmin
89
- export JUNIPER_BASTION_PASSWORD=jump_password
90
- export JUNIPER_PASSWORD=device_password
95
+ export JUNIPER_PASSWORD=shared_password # Used for both bastion and device
91
96
  $ inspec shell -t juniper://admin@10.1.1.1
92
97
 
93
- # Same password for both bastion and device (common scenario)
98
+ # Different passwords via environment
94
99
  export JUNIPER_BASTION_HOST=jump.example.com
95
- export JUNIPER_BASTION_USER=admin
96
- export JUNIPER_PASSWORD=shared_password # Used for both when bastion_password not set
100
+ export JUNIPER_BASTION_USER=netadmin
101
+ export JUNIPER_BASTION_PASSWORD=jump_password
102
+ export JUNIPER_PASSWORD=device_password
97
103
  $ inspec shell -t juniper://admin@10.1.1.1
98
104
  ```
99
105
 
@@ -135,6 +141,16 @@ inspec detect -t juniper:// # Reads from .env automatically
135
141
 
136
142
  ## Configuration Options
137
143
 
144
+ ### Option Priority
145
+
146
+ The plugin uses the following priority order for configuration values:
147
+
148
+ 1. **Command-line flags** (highest priority) - e.g., `--bastion-user`
149
+ 2. **Environment variables** - e.g., `JUNIPER_BASTION_USER`
150
+ 3. **Defaults/Fallbacks** (lowest priority) - e.g., bastion_user falls back to main user
151
+
152
+ This allows maximum flexibility while providing sensible defaults for common scenarios.
153
+
138
154
  ### Connection Options
139
155
 
140
156
  | Option | Description | Default | Environment Variable |
@@ -152,7 +168,7 @@ inspec detect -t juniper:// # Reads from .env automatically
152
168
  | Option | Description | Default | Environment Variable |
153
169
  |--------|-------------|---------|---------------------|
154
170
  | `bastion_host` | SSH bastion/jump host | - | `JUNIPER_BASTION_HOST` |
155
- | `bastion_user` | SSH bastion username | root | `JUNIPER_BASTION_USER` |
171
+ | `bastion_user` | SSH bastion username | Falls back to main `user` | `JUNIPER_BASTION_USER` |
156
172
  | `bastion_port` | SSH bastion port | 22 | `JUNIPER_BASTION_PORT` |
157
173
  | `bastion_password` | Password for bastion authentication | - | `JUNIPER_BASTION_PASSWORD` |
158
174
  | `proxy_command` | Custom SSH ProxyCommand | - | `JUNIPER_PROXY_COMMAND` |
@@ -161,7 +177,8 @@ inspec detect -t juniper:// # Reads from .env automatically
161
177
 
162
178
  **Notes**:
163
179
  - Cannot specify both `bastion_host` and `proxy_command` simultaneously
164
- - If `bastion_password` not provided, falls back to using `password` for bastion authentication
180
+ - If `bastion_user` not provided, falls back to using main `user` for bastion authentication
181
+ - If `bastion_password` not provided, falls back to using main `password` for bastion authentication
165
182
  - Supports automated password authentication via SSH_ASKPASS mechanism
166
183
 
167
184
  ### InSpec Configuration File
data/Rakefile CHANGED
@@ -33,14 +33,8 @@ end
33
33
  require 'rubocop/rake_task'
34
34
 
35
35
  RuboCop::RakeTask.new(:lint) do |t|
36
- # Choices of rubocop rules to enforce are deeply personal.
37
- # Here, we set things up so that your plugin will use the Bundler-installed
38
- # train gem's copy of the Train project's rubocop.yml file (which
39
- # is indeed packaged with the train gem).
40
- require 'train/globals'
41
- train_rubocop_yml = File.join(Train.src_root, '.rubocop.yml')
42
-
43
- t.options = ['--display-cop-names', '--config', train_rubocop_yml]
36
+ # Use our local .rubocop.yml configuration
37
+ t.options = ['--display-cop-names', '--config', '.rubocop.yml']
44
38
  end
45
39
 
46
40
  #------------------------------------------------------------------#
@@ -70,11 +64,7 @@ task 'security:secrets' do
70
64
  end
71
65
  end
72
66
 
73
- desc 'Run Brakeman security vulnerability scan'
74
- task 'security:brakeman' do
75
- puts 'Running Brakeman security scan...'
76
- system('bundle exec brakeman --exit-on-warn --quiet --force') or abort('Security vulnerabilities found')
77
- end
67
+ # Brakeman removed - it's for Rails apps, not Ruby gems
78
68
 
79
69
  desc 'Run comprehensive security scan'
80
70
  task 'security:scan' do
@@ -83,7 +73,7 @@ task 'security:scan' do
83
73
  end
84
74
 
85
75
  desc 'Run all security checks'
86
- task security: %w[security:dependencies security:brakeman test:security]
76
+ task security: %w[security:dependencies test:security]
87
77
 
88
78
  desc 'Run all tests including security'
89
79
  task 'test:all' => %w[test security]
@@ -93,8 +83,25 @@ task 'test:all' => %w[test security]
93
83
  #------------------------------------------------------------------#
94
84
  Dir['tasks/*.rake'].each { |f| load f }
95
85
 
86
+ #------------------------------------------------------------------#
87
+ # Documentation Tasks
88
+ #------------------------------------------------------------------#
89
+ begin
90
+ require 'yard'
91
+ YARD::Rake::YardocTask.new do |t|
92
+ t.files = ['lib/**/*.rb']
93
+ t.options = ['--no-private']
94
+ t.stats_options = ['--list-undoc']
95
+ end
96
+ rescue LoadError
97
+ desc 'YARD documentation task'
98
+ task :yard do
99
+ puts 'YARD is not available. Run `bundle install` to install it.'
100
+ end
101
+ end
102
+
96
103
  #------------------------------------------------------------------#
97
104
  # Bundler Gem Tasks
98
105
  #------------------------------------------------------------------#
99
- # This provides the standard 'rake release' task that rubygems/release-gem expects
100
- require 'bundler/gem_tasks'
106
+ # Bundler gem tasks disabled - we use GitHub Actions for gem publication
107
+ # require 'bundler/gem_tasks'
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'train-juniper/constants'
4
+
5
+ module TrainPlugins
6
+ module Juniper
7
+ # Handles bastion host proxy configuration and authentication
8
+ module BastionProxy
9
+ # Configure bastion proxy for SSH connection
10
+ # @param ssh_options [Hash] SSH options to modify
11
+ def configure_bastion_proxy(ssh_options)
12
+ require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
13
+
14
+ # Build proxy jump string from bastion options
15
+ bastion_user = @options[:bastion_user] || @options[:user]
16
+ bastion_port = @options[:bastion_port]
17
+
18
+ proxy_jump = if bastion_port == Constants::DEFAULT_SSH_PORT
19
+ "#{bastion_user}@#{@options[:bastion_host]}"
20
+ else
21
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
22
+ end
23
+
24
+ @logger.debug("Using bastion host: #{proxy_jump}")
25
+
26
+ # Set up automated password authentication via SSH_ASKPASS
27
+ setup_bastion_password_auth
28
+
29
+ ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
30
+ end
31
+
32
+ # Set up SSH_ASKPASS for bastion password authentication
33
+ def setup_bastion_password_auth
34
+ bastion_password = @options[:bastion_password] || @options[:password]
35
+ return unless bastion_password
36
+
37
+ @ssh_askpass_script = create_ssh_askpass_script(bastion_password)
38
+ ENV['SSH_ASKPASS'] = @ssh_askpass_script
39
+ ENV['SSH_ASKPASS_REQUIRE'] = 'force'
40
+ @logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
41
+ end
42
+
43
+ # Create temporary SSH_ASKPASS script for automated password authentication
44
+ # @param password [String] The password to use
45
+ # @return [String] Path to the created script
46
+ def create_ssh_askpass_script(password)
47
+ require 'tempfile'
48
+
49
+ script = Tempfile.new(['ssh_askpass', '.sh'])
50
+ script.write("#!/bin/bash\necho '#{password}'\n")
51
+ script.close
52
+ File.chmod(0o755, script.path)
53
+
54
+ @logger.debug("Created SSH_ASKPASS script at #{script.path}")
55
+ script.path
56
+ end
57
+
58
+ # Generate SSH proxy command for bastion host using ProxyJump (-J)
59
+ # @param bastion_user [String] Username for bastion
60
+ # @param bastion_port [Integer] Port for bastion
61
+ # @return [String] SSH command string
62
+ def generate_bastion_proxy_command(bastion_user, bastion_port)
63
+ args = ['ssh']
64
+
65
+ # SSH options for connection
66
+ Constants::STANDARD_SSH_OPTIONS.each do |key, value|
67
+ args += ['-o', "#{key}=#{value}"]
68
+ end
69
+
70
+ # Use ProxyJump (-J) which handles password authentication properly
71
+ jump_host = if bastion_port == Constants::DEFAULT_SSH_PORT
72
+ "#{bastion_user}@#{@options[:bastion_host]}"
73
+ else
74
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
75
+ end
76
+ args += ['-J', jump_host]
77
+
78
+ # Add SSH keys if specified
79
+ if @options[:key_files]
80
+ Array(@options[:key_files]).each do |key_file|
81
+ args += ['-i', key_file]
82
+ end
83
+ end
84
+
85
+ # Target connection - %h and %p will be replaced by Net::SSH
86
+ args += ['%h', '-p', '%p']
87
+
88
+ args.join(' ')
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Handles command execution, sanitization, and output formatting
6
+ module CommandExecutor
7
+ # Command sanitization patterns
8
+ # Note: Pipe (|) is allowed as it's commonly used in JunOS commands
9
+ DANGEROUS_COMMAND_PATTERNS = [
10
+ /[;&<>$`]/, # Shell metacharacters (excluding pipe)
11
+ /\n|\r/, # Newlines that could inject commands
12
+ /\\(?![nrt])/ # Escape sequences (except valid ones like \n, \r, \t)
13
+ ].freeze
14
+
15
+ # Execute commands on Juniper device via SSH
16
+ # @param cmd [String] The JunOS command to execute
17
+ # @return [CommandResult] Result object with stdout, stderr, and exit status
18
+ # @raise [Train::ClientError] If command contains dangerous characters
19
+ # @example
20
+ # result = connection.run_command('show version')
21
+ # puts result.stdout
22
+ def run_command_via_connection(cmd)
23
+ # Sanitize command to prevent injection
24
+ safe_cmd = sanitize_command(cmd)
25
+
26
+ return mock_command_result(safe_cmd) if @options[:mock]
27
+
28
+ begin
29
+ # :nocov: Real SSH execution cannot be tested without actual devices
30
+ # Ensure we're connected
31
+ connect unless connected?
32
+
33
+ log_command(safe_cmd)
34
+
35
+ # Execute command via SSH session
36
+ output = @ssh_session.exec!(safe_cmd)
37
+
38
+ @logger.debug("Command output: #{output}")
39
+
40
+ # Format JunOS result
41
+ format_junos_result(output, safe_cmd)
42
+ rescue StandardError => e
43
+ log_error(e, 'Command execution failed')
44
+ # Handle connection errors gracefully
45
+ error_result(e.message)
46
+ # :nocov:
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Sanitize command to prevent injection attacks
53
+ def sanitize_command(cmd)
54
+ cmd_str = cmd.to_s.strip
55
+
56
+ if DANGEROUS_COMMAND_PATTERNS.any? { |pattern| cmd_str.match?(pattern) }
57
+ raise Train::ClientError, "Invalid characters in command: #{cmd_str.inspect}"
58
+ end
59
+
60
+ cmd_str
61
+ end
62
+
63
+ # Format JunOS command results
64
+ def format_junos_result(output, cmd)
65
+ # Parse JunOS-specific error patterns
66
+ if junos_error?(output)
67
+ error_result(output)
68
+ else
69
+ success_result(output, cmd)
70
+ end
71
+ end
72
+
73
+ # Clean command output
74
+ def clean_output(output, cmd)
75
+ # Handle nil output gracefully
76
+ return '' if output.nil?
77
+
78
+ # Remove command echo and prompts
79
+ lines = output.to_s.split("\n")
80
+ lines.reject! { |line| line.strip == cmd.strip }
81
+
82
+ # Remove JunOS prompt patterns from the end
83
+ lines.pop while lines.last&.strip&.match?(/^[%>$#]+\s*$/)
84
+
85
+ lines.join("\n")
86
+ end
87
+
88
+ # Mock command execution for testing
89
+ def mock_command_result(cmd)
90
+ output, exit_status = MockResponses.response_for(cmd)
91
+ # For mock mode, network devices return errors as stdout
92
+ Train::Extras::CommandResult.new(output, '', exit_status)
93
+ end
94
+
95
+ # Factory method for successful command results
96
+ # @param output [String] Command output
97
+ # @param cmd [String, nil] Original command for cleaning output
98
+ # @return [Train::Extras::CommandResult] Successful result object
99
+ def success_result(output, cmd = nil)
100
+ output = clean_output(output, cmd) if cmd
101
+ Train::Extras::CommandResult.new(output, '', 0)
102
+ end
103
+
104
+ # Factory method for error command results
105
+ # @param message [String] Error message
106
+ # @return [Train::Extras::CommandResult] Error result object
107
+ def error_result(message)
108
+ Train::Extras::CommandResult.new('', message, 1)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Handles error detection, classification, and messaging
6
+ module ErrorHandling
7
+ # JunOS error patterns organized by type
8
+ JUNOS_ERRORS = {
9
+ configuration: [/^error:/i, /configuration database locked/i],
10
+ syntax: [/syntax error/i],
11
+ command: [/invalid command/i, /unknown command/i],
12
+ argument: [/missing argument/i]
13
+ }.freeze
14
+
15
+ # Flattened error patterns for quick matching
16
+ JUNOS_ERROR_PATTERNS = JUNOS_ERRORS.values.flatten.freeze
17
+
18
+ # Check for JunOS error patterns
19
+ # @param output [String] Command output to check
20
+ # @return [Boolean] true if output contains error patterns
21
+ def junos_error?(output)
22
+ return false if output.nil? || output.empty?
23
+
24
+ JUNOS_ERROR_PATTERNS.any? { |pattern| output.match?(pattern) }
25
+ end
26
+
27
+ # Handle connection errors with helpful messages
28
+ # @param error [StandardError] The error that occurred
29
+ # @raise [Train::TransportError] Always raises with formatted message
30
+ def handle_connection_error(error)
31
+ @logger.error("SSH connection failed: #{error.message}")
32
+
33
+ if bastion_auth_error?(error)
34
+ raise Train::TransportError, bastion_error_message(error)
35
+ else
36
+ raise Train::TransportError, "Failed to connect to Juniper device #{@options[:host]}: #{error.message}"
37
+ end
38
+ end
39
+
40
+ # Check if error is bastion authentication related
41
+ # @param error [StandardError] The error to check
42
+ # @return [Boolean] true if error is bastion-related
43
+ def bastion_auth_error?(error)
44
+ @options[:bastion_host] &&
45
+ (error.message.include?('Permission denied') || error.message.include?('command failed'))
46
+ end
47
+
48
+ # Build helpful bastion error message
49
+ # @param error [StandardError] The original error
50
+ # @return [String] Detailed error message with troubleshooting steps
51
+ def bastion_error_message(error)
52
+ <<~ERROR
53
+ Failed to connect to Juniper device #{@options[:host]} via bastion #{@options[:bastion_host]}: #{error.message}
54
+
55
+ Possible causes:
56
+ 1. Incorrect bastion credentials (user: #{@options[:bastion_user] || @options[:user]})
57
+ 2. Network connectivity issues to bastion host
58
+ 3. Bastion host SSH service not available on port #{@options[:bastion_port]}
59
+ 4. Target device not reachable from bastion
60
+
61
+ Authentication options:
62
+ - Password: Use --bastion-password (or JUNIPER_BASTION_PASSWORD env var)
63
+ - SSH Key: Use --key-files option to specify SSH private key files
64
+ - SSH Agent: Ensure your SSH agent has the required keys loaded
65
+
66
+ For more details, see: https://mitre.github.io/train-juniper/troubleshooting/#bastion-authentication
67
+ ERROR
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Handles SSH session management and configuration
6
+ module SSHSession
7
+ # SSH option mapping configuration
8
+ SSH_OPTION_MAPPING = {
9
+ port: :port,
10
+ password: :password,
11
+ timeout: :timeout,
12
+ keepalive: :keepalive,
13
+ keepalive_interval: :keepalive_interval,
14
+ keys: ->(opts) { Array(opts[:key_files]) if opts[:key_files] },
15
+ keys_only: ->(opts) { opts[:keys_only] if opts[:key_files] }
16
+ }.freeze
17
+
18
+ # Default SSH options for Juniper connections
19
+ # @note verify_host_key is set to :never for network device compatibility
20
+ SSH_DEFAULTS = {
21
+ verify_host_key: :never
22
+ }.freeze
23
+
24
+ # Establish SSH connection to Juniper device
25
+ def connect
26
+ return if connected?
27
+
28
+ # :nocov: Real SSH connections cannot be tested without actual devices
29
+ begin
30
+ # Use direct SSH connection (network device pattern)
31
+ # Defensive loading - only require if not fully loaded
32
+ require 'net/ssh' unless defined?(Net::SSH) && Net::SSH.respond_to?(:start)
33
+
34
+ @logger.debug('Establishing SSH connection to Juniper device')
35
+
36
+ ssh_options = build_ssh_options
37
+
38
+ # Add bastion host support if configured
39
+ if @options[:bastion_host]
40
+ log_bastion_connection(@options[:bastion_host])
41
+ configure_bastion_proxy(ssh_options)
42
+ end
43
+
44
+ log_connection_attempt(@options[:host], @options[:port])
45
+ log_ssh_options(ssh_options)
46
+
47
+ # Direct SSH connection
48
+ @ssh_session = Net::SSH.start(@options[:host], @options[:user], ssh_options)
49
+ log_connection_success(@options[:host])
50
+
51
+ # Configure JunOS session for automation
52
+ test_and_configure_session
53
+ rescue StandardError => e
54
+ handle_connection_error(e)
55
+ end
56
+ # :nocov:
57
+ end
58
+
59
+ # Check if SSH connection is active
60
+ # @return [Boolean] true if connected, false otherwise
61
+ def connected?
62
+ return true if @options[:mock]
63
+
64
+ !@ssh_session.nil?
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ # Check if running in mock mode
70
+ # @return [Boolean] true if in mock mode
71
+ def mock?
72
+ @options[:mock] == true
73
+ end
74
+
75
+ # Test connection and configure JunOS session
76
+ def test_and_configure_session
77
+ @logger.debug('Testing SSH connection and configuring JunOS session')
78
+
79
+ # Test connection first
80
+ @ssh_session.exec!('echo "connection test"')
81
+ @logger.debug('SSH connection test successful')
82
+
83
+ # Optimize CLI for automation
84
+ @ssh_session.exec!('set cli screen-length 0')
85
+ @ssh_session.exec!('set cli screen-width 0')
86
+ @ssh_session.exec!('set cli complete-on-space off') if @options[:disable_complete_on_space]
87
+
88
+ @logger.debug('JunOS session configured successfully')
89
+ rescue StandardError => e
90
+ @logger.warn("Failed to configure JunOS session: #{e.message}")
91
+ end
92
+
93
+ private
94
+
95
+ # Build SSH connection options from @options
96
+ def build_ssh_options
97
+ SSH_DEFAULTS.merge(
98
+ SSH_OPTION_MAPPING.each_with_object({}) do |(ssh_key, option_key), opts|
99
+ value = option_key.is_a?(Proc) ? option_key.call(@options) : @options[option_key]
100
+ opts[ssh_key] = value unless value.nil?
101
+ end
102
+ )
103
+ end
104
+ end
105
+ end
106
+ end