train-juniper 0.7.0 → 0.7.3

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: abb9487cdbf492d9b94f9b45699e2a434095509e128d5928863e5fdff3c73241
4
- data.tar.gz: 0d9d6746cb16f185eef54b199aaf02b4509e66cc9292af3475913bae89f3e36c
3
+ metadata.gz: c7074cafad165e2055edc5be8495a250025f4fc06ad658e740b77248e3d2273c
4
+ data.tar.gz: febffacb8ad71ffc912a76b3be460f598e48e20b0c500de48f10c3cefe11c549
5
5
  SHA512:
6
- metadata.gz: 8c7c246e820daa1d436c03d4f95e7acab6bef59c600c9f18d99fcc94b63989a0adf00a483661fc362db634871446b469aa0bed753a6852b40d2da768709882d8
7
- data.tar.gz: db80d5fdefcb80294734f162b67f48f21219859ebed3ee294bcb1449876be0a7c7758e4ed613c8fc3e40a9d7d67c25df12316cc56e31352dc648eda2c8184c59
6
+ metadata.gz: 4c47e243ab6d5922904bd7898b4c1da8dff8ac4986f3b60629e4662c566e79ace9bbec8a0dfda2ee400e51ec7f060f2c871b11be07d84130ce1f1ac1d23b4d76
7
+ data.tar.gz: af8b001261139a75f38da8be9711c3c9f14c8a618bd8195234c750262ef40e4a772cd9a081a59deb2880e1c78ac5ad44846ba7c902cc360420a4d4a4b5092be6
data/CHANGELOG.md CHANGED
@@ -5,6 +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.7.3] - 2025-06-23
9
+
10
+ ### Documentation
11
+
12
+ - Improve README structure for better MkDocs rendering
13
+ - Add platform support section to README
14
+
15
+ ### Fixed
16
+
17
+ - Follow standard RubyGems conventions for gem packaging
18
+ - Remove .md extensions from internal MkDocs links
19
+ - **docs**: Improve Support section formatting with subsections
20
+ - **windows**: Use PowerShell for SSH_ASKPASS on Windows and add cross-platform CI/CD
21
+ - **ci**: Add comprehensive platform support for cross-platform compatibility
22
+ - Update ffi dependency to support Ruby 3.3 on Windows
23
+ - Handle Windows PowerShell script paths in bastion proxy tests
24
+ - Use direct gem push instead of rubygems/release-gem action
25
+ - Complete release workflow implementation
26
+
27
+ ### Miscellaneous Tasks
28
+
29
+ - Add session.md to .gitignore
30
+
31
+ ### Styling
32
+
33
+ - Fix trailing whitespace in bastion proxy files
34
+
35
+ ### Testing
36
+
37
+ - Add nocov markers for Windows-specific PowerShell code
38
+
39
+ ## [0.7.1] - 2025-06-23
40
+
41
+ ### Added
42
+
43
+ - Implement Priority 2 DRY improvements and boost coverage to 90.88%
44
+ - Add coverage analysis utility script
45
+ - Boost test coverage to 93.13% and enhance coverage analysis tool
46
+ - **coverage**: Integrate coverage reporting into release process and CI/CD
47
+ - **docs**: Enhance coverage report with Material for MkDocs styling
48
+
49
+ ### Documentation
50
+
51
+ - Add YARD documentation for inspect method and test organization guide
52
+ - Add YARD documentation for all constants
53
+ - **roadmap**: Modernize with v0.7.1 status and Material styling
54
+
55
+ ### Fixed
56
+
57
+ - Add v0.7.0 to mkdocs and automate nav updates
58
+ - **coverage**: Properly handle SimpleCov :nocov: markers in analysis
59
+ - **docs**: Move Security Policy to About section in navigation
60
+ - Resolve RuboCop violations for CI/CD compliance
61
+ - Update release task to handle GitHub Actions gem publishing
62
+ - Resolve final RuboCop issues in release task
63
+
64
+ ### Miscellaneous Tasks
65
+
66
+ - Fix all RuboCop violations and prepare for v0.7.1 release
67
+ - Remove .rubocop_todo.yml after fixing all violations
68
+
69
+ ### Refactor
70
+
71
+ - Streamline release process for GitHub Actions
72
+ - Phase 1 modularization - extract JuniperFile, EnvironmentHelpers, and Validation
73
+ - Reorganize directory structure to follow Train plugin conventions
74
+ - Phase 2 modularization - extract CommandExecutor and ErrorHandling
75
+ - Phase 3 modularization - extract SSHSession and BastionProxy
76
+ - DRY improvements for v0.7.1
77
+ - Fix all RuboCop complexity issues without using todos
78
+ - **docs**: Reorganize navigation for better user experience
79
+
80
+ ### Testing
81
+
82
+ - Fix platform edge case test and boost coverage to 99.75%
83
+ - Achieve 100% code coverage 🎯
84
+
8
85
  ## [0.7.0] - 2025-06-23
9
86
 
10
87
  ### Added
data/CONTRIBUTING.md CHANGED
@@ -146,7 +146,7 @@ Releases are managed by project maintainers:
146
146
 
147
147
  ## Community
148
148
 
149
- - Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
149
+ - Follow our [Code of Conduct](CODE_OF_CONDUCT)
150
150
  - Be respectful and collaborative
151
151
  - Help others learn and contribute
152
152
 
data/README.md CHANGED
@@ -175,11 +175,11 @@ This allows maximum flexibility while providing sensible defaults for common sce
175
175
  | `key_files` | SSH private key files | - | - |
176
176
  | `keys_only` | Use only specified keys | false | - |
177
177
 
178
- **Notes**:
179
- - Cannot specify both `bastion_host` and `proxy_command` simultaneously
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
182
- - Supports automated password authentication via SSH_ASKPASS mechanism
178
+ !!! note "Important Configuration Notes"
179
+ - Cannot specify both `bastion_host` and `proxy_command` simultaneously
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
182
+ - Supports automated password authentication via SSH_ASKPASS mechanism
183
183
 
184
184
  ### InSpec Configuration File
185
185
 
@@ -280,7 +280,8 @@ Train.create('juniper', {
280
280
 
281
281
  ### Common Authentication Issues
282
282
 
283
- #### ❌ **Error**: "No bastion password specified"
283
+ #### ❌ Error: "No bastion password specified"
284
+
284
285
  **Solution**: Train doesn't have `--bastion-password`. Use one of these patterns:
285
286
  ```bash
286
287
  # Same password for both (most common)
@@ -293,7 +294,8 @@ inspec detect -t "juniper://user@device?bastion_host=jump" --key-files ~/.ssh/id
293
294
  inspec detect -t "juniper://user@device?proxy_command=sshpass%20-p%20jumppass%20ssh%20jumpuser@jump%20-W%20%h:%p" --password "device_pass"
294
295
  ```
295
296
 
296
- #### ❌ **Error**: "Authentication failed"
297
+ #### ❌ Error: "Authentication failed"
298
+
297
299
  **Solutions**:
298
300
  ```bash
299
301
  # Verify bastion connection first
@@ -309,7 +311,8 @@ inspec detect -t "juniper://user@device?bastion_host=jump&proxy_command=ssh%20-v
309
311
  inspec detect -t "juniper://user@device?bastion_host=jump" --password "pass" -l debug
310
312
  ```
311
313
 
312
- #### ❌ **Error**: "Connection timeout"
314
+ #### ❌ Error: "Connection timeout"
315
+
313
316
  **Solutions**:
314
317
  ```bash
315
318
  # Increase timeouts
@@ -349,6 +352,7 @@ result = connection.run_command('show version')
349
352
  ```
350
353
 
351
354
  Mock mode provides:
355
+
352
356
  - ✅ Realistic JunOS command outputs
353
357
  - ✅ Platform detection (JunOS 12.1X47-D15.4)
354
358
  - ✅ Error simulation for negative testing
@@ -393,12 +397,31 @@ This plugin implements the Train Plugin V1 API with:
393
397
  - **Platform** (`lib/train-juniper/platform.rb`) - JunOS platform detection
394
398
  - **Version** (`lib/train-juniper/version.rb`) - Plugin version management
395
399
 
400
+ ### Platform Support
401
+
402
+ This gem supports a wide range of platforms to ensure maximum compatibility:
403
+
404
+ | Platform | Description | Use Case |
405
+ |----------|-------------|----------|
406
+ | `ruby` | Platform-independent | Pure Ruby installations |
407
+ | `x86_64-linux` | Standard Linux | Most Linux servers and CI/CD |
408
+ | `aarch64-linux` | ARM64 Linux | AWS Graviton, Raspberry Pi |
409
+ | `x86_64-linux-musl` | Alpine Linux | Docker containers |
410
+ | `x86_64-darwin` | Intel macOS | Older Mac workstations |
411
+ | `arm64-darwin-*` | Apple Silicon macOS | Modern Mac workstations |
412
+ | `x64-mingw-ucrt` | Windows (UCRT) | Windows 10/11 with modern Ruby |
413
+ | `x86_64-freebsd` | FreeBSD | Network appliances (JunOS heritage) |
414
+ | `x86_64-solaris` | Solaris/illumos | Enterprise environments |
415
+
416
+ !!! note "Platform Compatibility"
417
+ This comprehensive platform support ensures the plugin works wherever InSpec runs, from developer workstations to CI/CD pipelines to production jump hosts. The FreeBSD support is particularly relevant given that JunOS is based on FreeBSD.
418
+
396
419
  ### Documentation
397
420
 
398
- - **[Installation Guide](installation.md)** - Complete installation instructions
399
- - **[Basic Usage](basic-usage.md)** - Getting started with the plugin
400
- - **[Release Process](RELEASE_PROCESS.md)** - How to cut releases and publish gems
401
- - **[Project Roadmap](ROADMAP.md)** - Future development plans and contribution opportunities
421
+ - **[Installation Guide](installation)** - Complete installation instructions
422
+ - **[Basic Usage](basic-usage)** - Getting started with the plugin
423
+ - **[Release Process](RELEASE_PROCESS)** - How to cut releases and publish gems
424
+ - **[Project Roadmap](ROADMAP)** - Future development plans and contribution opportunities
402
425
 
403
426
  ### Plugin Development Resources
404
427
 
@@ -407,19 +430,29 @@ This plugin implements the Train Plugin V1 API with:
407
430
 
408
431
  ## Contributing
409
432
 
433
+ We welcome contributions! Here's how to get started:
434
+
410
435
  1. Fork the repository
411
- 2. Create a feature branch
436
+ 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
412
437
  3. Make your changes with tests
413
- 4. Run `bundle exec rake test`
438
+ 4. Run `bundle exec rake test` to ensure tests pass
414
439
  5. Submit a pull request
415
440
 
441
+ Please see our [Contributing Guide](CONTRIBUTING) for more details.
442
+
416
443
  ## Support and Contact
417
444
 
445
+ ### General Support
446
+
418
447
  For questions, feature requests, or general support:
448
+
419
449
  - Email: [saf@mitre.org](mailto:saf@mitre.org)
420
450
  - GitHub Issues: [https://github.com/mitre/train-juniper/issues](https://github.com/mitre/train-juniper/issues)
421
451
 
452
+ ### Security Issues
453
+
422
454
  For security issues or vulnerabilities:
455
+
423
456
  - Email: [saf-security@mitre.org](mailto:saf-security@mitre.org)
424
457
  - GitHub Security: [https://github.com/mitre/train-juniper/security](https://github.com/mitre/train-juniper/security)
425
458
 
@@ -439,12 +472,12 @@ Special thanks to the Train and InSpec communities for their excellent documenta
439
472
 
440
473
  Licensed under the Apache-2.0 license, except as noted below.
441
474
 
442
- See [LICENSE](LICENSE.md) for full details.
475
+ See [LICENSE](LICENSE) for full details.
443
476
 
444
477
  ### Notice
445
478
 
446
479
  This software was produced for the U.S. Government under contract and is subject to Federal Acquisition Regulation Clause 52.227-14.
447
480
 
448
- See [NOTICE](NOTICE.md) for full details.
481
+ See [NOTICE](NOTICE) for full details.
449
482
 
450
483
  © 2025 The MITRE Corporation.
data/Rakefile CHANGED
@@ -103,5 +103,5 @@ end
103
103
  #------------------------------------------------------------------#
104
104
  # Bundler Gem Tasks
105
105
  #------------------------------------------------------------------#
106
- # This provides the standard 'rake release' task that rubygems/release-gem expects
107
- require 'bundler/gem_tasks'
106
+ # Bundler gem tasks disabled - we use GitHub Actions for gem publication
107
+ # require 'bundler/gem_tasks'
@@ -0,0 +1,112 @@
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
+ if Gem.win_platform?
50
+ # :nocov:
51
+ # Create Windows PowerShell script
52
+ script = Tempfile.new(['ssh_askpass', '.ps1'])
53
+ # PowerShell handles escaping better, just escape quotes
54
+ escaped_password = password.gsub("'", "''")
55
+ script.write("Write-Output '#{escaped_password}'\r\n")
56
+ script.close
57
+
58
+ # Create a wrapper batch file to execute PowerShell with bypass policy
59
+ wrapper = Tempfile.new(['ssh_askpass_wrapper', '.bat'])
60
+ wrapper.write("@echo off\r\npowershell.exe -ExecutionPolicy Bypass -File \"#{script.path}\"\r\n")
61
+ wrapper.close
62
+
63
+ @logger.debug("Created SSH_ASKPASS PowerShell script at #{script.path} with wrapper at #{wrapper.path}")
64
+ wrapper.path
65
+ # :nocov:
66
+ else
67
+ # Create Unix shell script
68
+ script = Tempfile.new(['ssh_askpass', '.sh'])
69
+ script.write("#!/bin/bash\necho '#{password}'\n")
70
+ script.close
71
+ File.chmod(0o755, script.path)
72
+
73
+ @logger.debug("Created SSH_ASKPASS script at #{script.path}")
74
+ script.path
75
+ end
76
+ end
77
+
78
+ # Generate SSH proxy command for bastion host using ProxyJump (-J)
79
+ # @param bastion_user [String] Username for bastion
80
+ # @param bastion_port [Integer] Port for bastion
81
+ # @return [String] SSH command string
82
+ def generate_bastion_proxy_command(bastion_user, bastion_port)
83
+ args = ['ssh']
84
+
85
+ # SSH options for connection
86
+ Constants::STANDARD_SSH_OPTIONS.each do |key, value|
87
+ args += ['-o', "#{key}=#{value}"]
88
+ end
89
+
90
+ # Use ProxyJump (-J) which handles password authentication properly
91
+ jump_host = if bastion_port == Constants::DEFAULT_SSH_PORT
92
+ "#{bastion_user}@#{@options[:bastion_host]}"
93
+ else
94
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
95
+ end
96
+ args += ['-J', jump_host]
97
+
98
+ # Add SSH keys if specified
99
+ if @options[:key_files]
100
+ Array(@options[:key_files]).each do |key_file|
101
+ args += ['-i', key_file]
102
+ end
103
+ end
104
+
105
+ # Target connection - %h and %p will be replaced by Net::SSH
106
+ args += ['%h', '-p', '%p']
107
+
108
+ args.join(' ')
109
+ end
110
+ end
111
+ end
112
+ 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