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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 86bf86dd54cabe94e072c6e3a2b64ffcdfbdf774a5f33f1a3a76012829dd71bc
|
4
|
+
data.tar.gz: c2e5d2fe1ecfa259f78c2d80f76d94eaa68afb817d584c57d02bfb4c46779924
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
-
-
|
13
|
-
-
|
14
|
-
-
|
15
|
-
|
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
|
20
|
-
-
|
55
|
+
- Add security enhancements and input validation
|
56
|
+
- V0.7.0 - enhanced security, YARD docs, and Windows support
|
21
57
|
|
22
|
-
###
|
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
|
-
-
|
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:
|
67
|
+
Families: bsd
|
68
68
|
Release: 21.4R3-S1.6
|
69
|
-
Arch:
|
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
|
-
#
|
81
|
-
$ inspec shell -t
|
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
|
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
|
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
|
-
#
|
98
|
+
# Different passwords via environment
|
94
99
|
export JUNIPER_BASTION_HOST=jump.example.com
|
95
|
-
export JUNIPER_BASTION_USER=
|
96
|
-
export
|
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 |
|
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 `
|
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
|
-
#
|
37
|
-
|
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
|
-
|
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
|
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
|
-
#
|
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
|