train-juniper 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.example +15 -24
- data/CHANGELOG.md +45 -0
- data/CONTRIBUTING.md +1 -1
- data/README.md +9 -8
- data/lib/train-juniper/connection/bastion_proxy.rb +49 -53
- data/lib/train-juniper/connection/command_executor.rb +2 -2
- data/lib/train-juniper/connection/ssh_askpass.rb +69 -0
- data/lib/train-juniper/connection/ssh_session.rb +3 -0
- data/lib/train-juniper/connection/windows_proxy.rb +48 -0
- data/lib/train-juniper/connection.rb +34 -2
- data/lib/train-juniper/constants.rb +10 -0
- data/lib/train-juniper/helpers/mock_responses.rb +46 -0
- data/lib/train-juniper/platform.rb +75 -48
- data/lib/train-juniper/version.rb +1 -1
- data/train-juniper.gemspec +1 -1
- metadata +10 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b7571220250289d5866fa3074789e166f5000cde3b93a0174889e24d1a8ac335
|
4
|
+
data.tar.gz: 276162fb0254fc498b4ad562ba3377a1d0d2ba84faccd630eb67f084185798b8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3007c4a588b1024c1553cfa22f9d606628d335578438c4d78c927fb3fa4fe99fad50dcac50da6a184998934a913c218d40a51d6b54fcaaf575dc6b3df7e8ffa8
|
7
|
+
data.tar.gz: 8c52911377b95ef3e51a2e36c4e7b0cc9eaaf3ee9d3877af16f3fdb869e5929904d65bda70344ba049037f6a1cbc138bfd119b18e0d2785a864fd0819a177e7d
|
data/.env.example
CHANGED
@@ -1,29 +1,20 @@
|
|
1
|
-
#
|
2
|
-
# Copy this file to .env and
|
3
|
-
# The plugin automatically detects these environment variables
|
1
|
+
# Example environment configuration for train-juniper
|
2
|
+
# Copy this file to .env and update with your values
|
4
3
|
|
5
|
-
# Juniper
|
6
|
-
JUNIPER_HOST=
|
7
|
-
JUNIPER_USER=your_username
|
8
|
-
JUNIPER_PASSWORD=your_password
|
4
|
+
# Direct connection to Juniper device
|
5
|
+
JUNIPER_HOST=192.168.1.1
|
9
6
|
JUNIPER_PORT=22
|
10
|
-
|
7
|
+
JUNIPER_USER=admin
|
8
|
+
JUNIPER_PASSWORD=your-password-here
|
11
9
|
|
12
|
-
#
|
13
|
-
|
14
|
-
JUNIPER_BASTION_USER=your_jump_username
|
15
|
-
JUNIPER_BASTION_PORT=22
|
16
|
-
JUNIPER_BASTION_PASSWORD=your_jump_password
|
10
|
+
# Optional: Connection timeout in seconds
|
11
|
+
CONNECTION_TIMEOUT=60
|
17
12
|
|
18
|
-
#
|
19
|
-
#
|
13
|
+
# Optional: Bastion/jump host configuration
|
14
|
+
# Uncomment and configure if you need to connect through a bastion host
|
15
|
+
# JUNIPER_BASTION_HOST=jump.example.com
|
16
|
+
# JUNIPER_BASTION_USER=admin
|
17
|
+
# JUNIPER_BASTION_PORT=22
|
18
|
+
# JUNIPER_BASTION_PASSWORD=bastion-password-here
|
20
19
|
|
21
|
-
#
|
22
|
-
# 1. Basic connection (auto-detects above variables):
|
23
|
-
# inspec detect -t juniper://
|
24
|
-
#
|
25
|
-
# 2. Override specific values:
|
26
|
-
# inspec detect -t juniper://different_user@different_host --password override_pass
|
27
|
-
#
|
28
|
-
# 3. Test connection:
|
29
|
-
# source .env && inspec detect -t juniper:// -l debug
|
20
|
+
# Note: If JUNIPER_BASTION_PASSWORD is not set, it will use JUNIPER_PASSWORD
|
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,51 @@ 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] - 2025-07-04
|
9
|
+
|
10
|
+
### Documentation
|
11
|
+
|
12
|
+
- Update roadmap with v0.7.4 accomplishments and clear prioritization
|
13
|
+
|
14
|
+
### Fixed
|
15
|
+
|
16
|
+
- Resolve UUID warning and migrate to XML parsing ([#4](https://github.com/mitre/train-juniper/issues/4))
|
17
|
+
|
18
|
+
### Refactor
|
19
|
+
|
20
|
+
- Improve code quality based on deep review
|
21
|
+
|
22
|
+
### Testing
|
23
|
+
|
24
|
+
- Achieve 100% line coverage with cross-platform Windows plink test
|
25
|
+
|
26
|
+
## [0.7.4] - 2025-06-24
|
27
|
+
|
28
|
+
### Added
|
29
|
+
|
30
|
+
- Add Windows plink.exe support for bastion authentication
|
31
|
+
|
32
|
+
### Documentation
|
33
|
+
|
34
|
+
- Add Windows bastion setup guide and improve navigation
|
35
|
+
- Fix MkDocs link warnings
|
36
|
+
- Add host key acceptance instructions and InSpec testing examples
|
37
|
+
- Improve Windows documentation and standardize on inspec shell
|
38
|
+
|
39
|
+
### Fixed
|
40
|
+
|
41
|
+
- Clean up ENV in Windows plink test to prevent CI failures
|
42
|
+
- Remove trailing whitespace to pass linting
|
43
|
+
|
44
|
+
### Miscellaneous Tasks
|
45
|
+
|
46
|
+
- Fix linting issues in Windows test script
|
47
|
+
|
48
|
+
### Testing
|
49
|
+
|
50
|
+
- Add Windows testing scripts and guide
|
51
|
+
- Add .env file support to Windows test scripts
|
52
|
+
|
8
53
|
## [0.7.3] - 2025-06-23
|
9
54
|
|
10
55
|
### Documentation
|
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)
|
149
|
+
- Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
|
150
150
|
- Be respectful and collaborative
|
151
151
|
- Help others learn and contribute
|
152
152
|
|
data/README.md
CHANGED
@@ -409,7 +409,7 @@ This gem supports a wide range of platforms to ensure maximum compatibility:
|
|
409
409
|
| `x86_64-linux-musl` | Alpine Linux | Docker containers |
|
410
410
|
| `x86_64-darwin` | Intel macOS | Older Mac workstations |
|
411
411
|
| `arm64-darwin-*` | Apple Silicon macOS | Modern Mac workstations |
|
412
|
-
| `x64-mingw-ucrt` | Windows (UCRT) | Windows 10/11 with modern Ruby |
|
412
|
+
| `x64-mingw-ucrt` | Windows (UCRT) | Windows 10/11 with modern Ruby ([bastion setup](windows-bastion-setup.md)) |
|
413
413
|
| `x86_64-freebsd` | FreeBSD | Network appliances (JunOS heritage) |
|
414
414
|
| `x86_64-solaris` | Solaris/illumos | Enterprise environments |
|
415
415
|
|
@@ -418,10 +418,11 @@ This gem supports a wide range of platforms to ensure maximum compatibility:
|
|
418
418
|
|
419
419
|
### Documentation
|
420
420
|
|
421
|
-
- **[Installation Guide](installation)** - Complete installation instructions
|
422
|
-
- **[Basic Usage](basic-usage)** - Getting started with the plugin
|
423
|
-
- **[
|
424
|
-
- **[
|
421
|
+
- **[Installation Guide](installation.md)** - Complete installation instructions
|
422
|
+
- **[Basic Usage](basic-usage.md)** - Getting started with the plugin
|
423
|
+
- **[Windows Bastion Setup](windows-bastion-setup.md)** - Windows bastion/jump host authentication guide
|
424
|
+
- **[Release Process](RELEASE_PROCESS.md)** - How to cut releases and publish gems
|
425
|
+
- **[Project Roadmap](ROADMAP.md)** - Future development plans and contribution opportunities
|
425
426
|
|
426
427
|
### Plugin Development Resources
|
427
428
|
|
@@ -438,7 +439,7 @@ We welcome contributions! Here's how to get started:
|
|
438
439
|
4. Run `bundle exec rake test` to ensure tests pass
|
439
440
|
5. Submit a pull request
|
440
441
|
|
441
|
-
Please see our [Contributing Guide](CONTRIBUTING) for more details.
|
442
|
+
Please see our [Contributing Guide](CONTRIBUTING.md) for more details.
|
442
443
|
|
443
444
|
## Support and Contact
|
444
445
|
|
@@ -472,12 +473,12 @@ Special thanks to the Train and InSpec communities for their excellent documenta
|
|
472
473
|
|
473
474
|
Licensed under the Apache-2.0 license, except as noted below.
|
474
475
|
|
475
|
-
See [LICENSE](LICENSE) for full details.
|
476
|
+
See [LICENSE](LICENSE.md) for full details.
|
476
477
|
|
477
478
|
### Notice
|
478
479
|
|
479
480
|
This software was produced for the U.S. Government under contract and is subject to Federal Acquisition Regulation Clause 52.227-14.
|
480
481
|
|
481
|
-
See [NOTICE](NOTICE) for full details.
|
482
|
+
See [NOTICE](NOTICE.md) for full details.
|
482
483
|
|
483
484
|
© 2025 The MITRE Corporation.
|
@@ -1,26 +1,41 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'train-juniper/constants'
|
4
|
+
require 'train-juniper/connection/windows_proxy'
|
5
|
+
require 'train-juniper/connection/ssh_askpass'
|
4
6
|
|
5
7
|
module TrainPlugins
|
6
8
|
module Juniper
|
7
9
|
# Handles bastion host proxy configuration and authentication
|
8
10
|
module BastionProxy
|
11
|
+
include WindowsProxy
|
12
|
+
include SshAskpass
|
13
|
+
|
9
14
|
# Configure bastion proxy for SSH connection
|
10
15
|
# @param ssh_options [Hash] SSH options to modify
|
11
16
|
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
17
|
bastion_user = @options[:bastion_user] || @options[:user]
|
16
18
|
bastion_port = @options[:bastion_port]
|
19
|
+
bastion_password = @options[:bastion_password] || @options[:password]
|
20
|
+
|
21
|
+
# On Windows with password auth, use plink.exe if available
|
22
|
+
if Gem.win_platform? && bastion_password && plink_available?
|
23
|
+
configure_plink_proxy(ssh_options, bastion_user, bastion_port, bastion_password)
|
24
|
+
else
|
25
|
+
configure_standard_proxy(ssh_options, bastion_user, bastion_port)
|
26
|
+
end
|
27
|
+
end
|
17
28
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
29
|
+
private
|
30
|
+
|
31
|
+
# Configure standard SSH proxy using Net::SSH::Proxy::Jump
|
32
|
+
# @param ssh_options [Hash] SSH options to modify
|
33
|
+
# @param bastion_user [String] Username for bastion
|
34
|
+
# @param bastion_port [Integer] Port for bastion
|
35
|
+
def configure_standard_proxy(ssh_options, bastion_user, bastion_port)
|
36
|
+
require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
|
23
37
|
|
38
|
+
proxy_jump = build_proxy_jump_string(bastion_user, bastion_port)
|
24
39
|
@logger.debug("Using bastion host: #{proxy_jump}")
|
25
40
|
|
26
41
|
# Set up automated password authentication via SSH_ASKPASS
|
@@ -29,49 +44,34 @@ module TrainPlugins
|
|
29
44
|
ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
|
30
45
|
end
|
31
46
|
|
32
|
-
#
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
47
|
+
# Configure plink.exe proxy for Windows password authentication
|
48
|
+
# @param ssh_options [Hash] SSH options to modify
|
49
|
+
# @param bastion_user [String] Username for bastion
|
50
|
+
# @param bastion_port [Integer] Port for bastion
|
51
|
+
# @param bastion_password [String] Password for bastion
|
52
|
+
def configure_plink_proxy(ssh_options, bastion_user, bastion_port, bastion_password)
|
53
|
+
require 'net/ssh/proxy/command' unless defined?(Net::SSH::Proxy::Command)
|
54
|
+
|
55
|
+
proxy_cmd = build_plink_proxy_command(
|
56
|
+
@options[:bastion_host],
|
57
|
+
bastion_user,
|
58
|
+
bastion_port,
|
59
|
+
bastion_password
|
60
|
+
)
|
61
|
+
|
62
|
+
@logger.debug('Using plink.exe for bastion proxy')
|
63
|
+
ssh_options[:proxy] = Net::SSH::Proxy::Command.new(proxy_cmd)
|
41
64
|
end
|
42
65
|
|
43
|
-
#
|
44
|
-
# @param
|
45
|
-
# @
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
+
# Build proxy jump string from bastion options
|
67
|
+
# @param bastion_user [String] Username for bastion
|
68
|
+
# @param bastion_port [Integer] Port for bastion
|
69
|
+
# @return [String] Proxy jump string
|
70
|
+
def build_proxy_jump_string(bastion_user, bastion_port)
|
71
|
+
if bastion_port == Constants::DEFAULT_SSH_PORT
|
72
|
+
"#{bastion_user}@#{@options[:bastion_host]}"
|
66
73
|
else
|
67
|
-
#
|
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
|
74
|
+
"#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
|
75
75
|
end
|
76
76
|
end
|
77
77
|
|
@@ -88,11 +88,7 @@ module TrainPlugins
|
|
88
88
|
end
|
89
89
|
|
90
90
|
# Use ProxyJump (-J) which handles password authentication properly
|
91
|
-
jump_host =
|
92
|
-
"#{bastion_user}@#{@options[:bastion_host]}"
|
93
|
-
else
|
94
|
-
"#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
|
95
|
-
end
|
91
|
+
jump_host = build_proxy_jump_string(bastion_user, bastion_port)
|
96
92
|
args += ['-J', jump_host]
|
97
93
|
|
98
94
|
# Add SSH keys if specified
|
@@ -40,9 +40,9 @@ module TrainPlugins
|
|
40
40
|
# Format JunOS result
|
41
41
|
format_junos_result(output, safe_cmd)
|
42
42
|
rescue StandardError => e
|
43
|
-
log_error(e,
|
43
|
+
log_error(e, "Command execution failed for: #{safe_cmd}")
|
44
44
|
# Handle connection errors gracefully
|
45
|
-
error_result(e.message)
|
45
|
+
error_result("#{e.message} (command: #{safe_cmd})")
|
46
46
|
# :nocov:
|
47
47
|
end
|
48
48
|
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tempfile'
|
4
|
+
|
5
|
+
module TrainPlugins
|
6
|
+
module Juniper
|
7
|
+
# SSH_ASKPASS script management for automated password authentication
|
8
|
+
module SshAskpass
|
9
|
+
# Set up SSH_ASKPASS for bastion password authentication
|
10
|
+
def setup_bastion_password_auth
|
11
|
+
bastion_password = @options[:bastion_password] || @options[:password]
|
12
|
+
return unless bastion_password
|
13
|
+
|
14
|
+
@ssh_askpass_script = create_ssh_askpass_script(bastion_password)
|
15
|
+
ENV['SSH_ASKPASS'] = @ssh_askpass_script
|
16
|
+
ENV['SSH_ASKPASS_REQUIRE'] = 'force'
|
17
|
+
@logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
|
18
|
+
end
|
19
|
+
|
20
|
+
# Create temporary SSH_ASKPASS script for automated password authentication
|
21
|
+
# @param password [String] The password to use
|
22
|
+
# @return [String] Path to the created script
|
23
|
+
def create_ssh_askpass_script(password)
|
24
|
+
if Gem.win_platform?
|
25
|
+
create_windows_askpass_script(password)
|
26
|
+
else
|
27
|
+
create_unix_askpass_script(password)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
# Create Windows PowerShell script for SSH_ASKPASS
|
34
|
+
# @param password [String] The password to use
|
35
|
+
# @return [String] Path to the wrapper batch file
|
36
|
+
def create_windows_askpass_script(password)
|
37
|
+
# :nocov:
|
38
|
+
# Create Windows PowerShell script
|
39
|
+
script = Tempfile.new(['ssh_askpass', '.ps1'])
|
40
|
+
# PowerShell handles escaping better, just escape quotes
|
41
|
+
escaped_password = password.gsub("'", "''")
|
42
|
+
script.write("Write-Output '#{escaped_password}'\r\n")
|
43
|
+
script.close
|
44
|
+
|
45
|
+
# Create a wrapper batch file to execute PowerShell with bypass policy
|
46
|
+
wrapper = Tempfile.new(['ssh_askpass_wrapper', '.bat'])
|
47
|
+
wrapper.write("@echo off\r\npowershell.exe -ExecutionPolicy Bypass -File \"#{script.path}\"\r\n")
|
48
|
+
wrapper.close
|
49
|
+
|
50
|
+
@logger.debug("Created SSH_ASKPASS PowerShell script at #{script.path} with wrapper at #{wrapper.path}")
|
51
|
+
wrapper.path
|
52
|
+
# :nocov:
|
53
|
+
end
|
54
|
+
|
55
|
+
# Create Unix shell script for SSH_ASKPASS
|
56
|
+
# @param password [String] The password to use
|
57
|
+
# @return [String] Path to the created script
|
58
|
+
def create_unix_askpass_script(password)
|
59
|
+
script = Tempfile.new(['ssh_askpass', '.sh'])
|
60
|
+
script.write("#!/bin/bash\necho '#{password}'\n")
|
61
|
+
script.close
|
62
|
+
File.chmod(0o755, script.path)
|
63
|
+
|
64
|
+
@logger.debug("Created SSH_ASKPASS script at #{script.path}")
|
65
|
+
script.path
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -17,6 +17,9 @@ module TrainPlugins
|
|
17
17
|
|
18
18
|
# Default SSH options for Juniper connections
|
19
19
|
# @note verify_host_key is set to :never for network device compatibility
|
20
|
+
# Rationale: Network devices often regenerate SSH keys after firmware updates
|
21
|
+
# and operate in controlled environments where MITM attacks are mitigated by
|
22
|
+
# network segmentation. This matches standard network automation practices.
|
20
23
|
SSH_DEFAULTS = {
|
21
24
|
verify_host_key: :never
|
22
25
|
}.freeze
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'shellwords'
|
4
|
+
|
5
|
+
module TrainPlugins
|
6
|
+
module Juniper
|
7
|
+
# Windows-specific proxy handling using plink.exe
|
8
|
+
# This pattern is used by various Ruby projects for Windows SSH support,
|
9
|
+
# including hglib.rb (Mercurial) and follows Net::SSH::Proxy::Command patterns
|
10
|
+
module WindowsProxy
|
11
|
+
# Check if plink.exe is available on Windows
|
12
|
+
# @return [Boolean] true if plink.exe is found in PATH
|
13
|
+
def plink_available?
|
14
|
+
return false unless Gem.win_platform?
|
15
|
+
|
16
|
+
ENV['PATH'].split(File::PATH_SEPARATOR).any? do |path|
|
17
|
+
File.exist?(File.join(path, 'plink.exe'))
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Build plink.exe proxy command for Windows bastion authentication
|
22
|
+
# @param bastion_host [String] Bastion hostname
|
23
|
+
# @param user [String] Username for bastion
|
24
|
+
# @param port [Integer] Port for bastion
|
25
|
+
# @param password [String] Password for bastion
|
26
|
+
# @return [String] Complete plink command string
|
27
|
+
def build_plink_proxy_command(bastion_host, user, port, password)
|
28
|
+
parts = []
|
29
|
+
parts << 'plink.exe'
|
30
|
+
parts << '-batch' # Non-interactive mode
|
31
|
+
parts << '-ssh' # Force SSH protocol (not telnet)
|
32
|
+
parts << '-pw'
|
33
|
+
parts << Shellwords.escape(password)
|
34
|
+
|
35
|
+
if port && port != 22
|
36
|
+
parts << '-P'
|
37
|
+
parts << port.to_s
|
38
|
+
end
|
39
|
+
|
40
|
+
parts << "#{user}@#{bastion_host}"
|
41
|
+
parts << '-nc'
|
42
|
+
parts << '%h:%p' # Netcat mode for proxying
|
43
|
+
|
44
|
+
parts.join(' ')
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -102,10 +102,10 @@ module TrainPlugins
|
|
102
102
|
end
|
103
103
|
|
104
104
|
@options[:keepalive] = true
|
105
|
-
@options[:keepalive_interval] =
|
105
|
+
@options[:keepalive_interval] = Constants::SSH_KEEPALIVE_INTERVAL
|
106
106
|
|
107
107
|
# Setup logger
|
108
|
-
@logger = @options[:logger] || Logger.new(STDOUT, level:
|
108
|
+
@logger = @options[:logger] || Logger.new(STDOUT, level: Constants::DEFAULT_LOG_LEVEL)
|
109
109
|
|
110
110
|
# JunOS CLI prompt patterns
|
111
111
|
@cli_prompt = /[%>$#]\s*$/
|
@@ -187,6 +187,38 @@ module TrainPlugins
|
|
187
187
|
false
|
188
188
|
end
|
189
189
|
|
190
|
+
# Required by Train framework for node identification
|
191
|
+
# @return [String] URI that uniquely identifies this connection
|
192
|
+
# @example Direct connection
|
193
|
+
# "juniper://admin@device.example.com:22"
|
194
|
+
# @example Bastion connection
|
195
|
+
# "juniper://admin@device.example.com:22?via=jumpuser@bastion.example.com:2222"
|
196
|
+
def uri
|
197
|
+
base_uri = "juniper://#{@options[:user]}@#{@options[:host]}:#{@options[:port]}"
|
198
|
+
|
199
|
+
# Include bastion information if connecting through a jump host
|
200
|
+
if @options[:bastion_host]
|
201
|
+
bastion_user = @options[:bastion_user] || @options[:user]
|
202
|
+
bastion_port = @options[:bastion_port] || 22
|
203
|
+
bastion_info = "via=#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
|
204
|
+
"#{base_uri}?#{bastion_info}"
|
205
|
+
else
|
206
|
+
base_uri
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
# Optional method for better UUID generation using device-specific identifiers
|
211
|
+
# @return [String] Unique identifier for this device/connection
|
212
|
+
# @note Tries to get Juniper device serial number, falls back to hostname
|
213
|
+
def unique_identifier
|
214
|
+
# Don't attempt device detection in mock mode
|
215
|
+
return @options[:host] if @options[:mock]
|
216
|
+
|
217
|
+
# Use the platform module's serial detection which follows DRY principle
|
218
|
+
serial = detect_junos_serial
|
219
|
+
serial || @options[:host]
|
220
|
+
end
|
221
|
+
|
190
222
|
# List of sensitive option keys to redact in logs
|
191
223
|
SENSITIVE_OPTIONS = %i[password bastion_password key_files proxy_command].freeze
|
192
224
|
private_constant :SENSITIVE_OPTIONS
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'logger'
|
4
|
+
|
3
5
|
module TrainPlugins
|
4
6
|
module Juniper
|
5
7
|
# Common constants used across the plugin
|
@@ -9,6 +11,14 @@ module TrainPlugins
|
|
9
11
|
DEFAULT_SSH_PORT = 22
|
10
12
|
# @return [Range] Valid port range for SSH connections
|
11
13
|
PORT_RANGE = (1..65_535)
|
14
|
+
# @return [Integer] SSH keepalive interval in seconds
|
15
|
+
SSH_KEEPALIVE_INTERVAL = 60
|
16
|
+
# @return [Integer] Maximum keepalive count before disconnect
|
17
|
+
SSH_KEEPALIVE_MAX_COUNT = 3
|
18
|
+
|
19
|
+
# Logging Configuration
|
20
|
+
# @return [Integer] Default log level
|
21
|
+
DEFAULT_LOG_LEVEL = Logger::WARN
|
12
22
|
|
13
23
|
# Standard SSH Options for network devices
|
14
24
|
STANDARD_SSH_OPTIONS = {
|
@@ -39,10 +39,56 @@ module TrainPlugins
|
|
39
39
|
OUTPUT
|
40
40
|
end
|
41
41
|
|
42
|
+
# Mock chassis hardware XML output
|
43
|
+
# @return [String] mock XML output for 'show chassis hardware | display xml' command
|
44
|
+
def self.mock_chassis_xml_output
|
45
|
+
<<~OUTPUT
|
46
|
+
<rpc-reply xmlns:junos="http://xml.juniper.net/junos/12.1X47/junos">
|
47
|
+
<chassis-inventory xmlns="http://xml.juniper.net/junos/12.1X47/junos-chassis">
|
48
|
+
<chassis junos:style="inventory">
|
49
|
+
<name>Chassis</name>
|
50
|
+
<serial-number>JN123456</serial-number>
|
51
|
+
<description>SRX240H2</description>
|
52
|
+
</chassis>
|
53
|
+
</chassis-inventory>
|
54
|
+
</rpc-reply>
|
55
|
+
OUTPUT
|
56
|
+
end
|
57
|
+
|
58
|
+
# Mock version XML output
|
59
|
+
# @return [String] mock XML output for 'show version | display xml' command
|
60
|
+
def self.mock_version_xml_output
|
61
|
+
<<~OUTPUT
|
62
|
+
<rpc-reply xmlns:junos="http://xml.juniper.net/junos/12.1X47/junos">
|
63
|
+
<software-information>
|
64
|
+
<host-name>lab-srx</host-name>
|
65
|
+
<product-model>SRX240H2</product-model>
|
66
|
+
<product-name>srx240h2</product-name>
|
67
|
+
<junos-version>12.1X47-D15.4</junos-version>
|
68
|
+
</software-information>
|
69
|
+
</rpc-reply>
|
70
|
+
OUTPUT
|
71
|
+
end
|
72
|
+
|
42
73
|
# Get mock response for a command
|
43
74
|
# @param cmd [String] the command to get response for
|
44
75
|
# @return [Array<String, Integer>] tuple of [output, exit_status]
|
45
76
|
def self.response_for(cmd)
|
77
|
+
# Check if command includes display modifiers
|
78
|
+
if cmd.include?('| display xml')
|
79
|
+
# Handle XML output requests
|
80
|
+
# Use simple string split to avoid ReDoS vulnerability
|
81
|
+
base_cmd = cmd.split('|').first.strip
|
82
|
+
|
83
|
+
case base_cmd
|
84
|
+
when 'show chassis hardware'
|
85
|
+
return [mock_chassis_xml_output, 0]
|
86
|
+
when 'show version'
|
87
|
+
return [mock_version_xml_output, 0]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Standard text response handling
|
46
92
|
response = RESPONSES.find { |pattern, _| cmd.match?(/#{pattern}/) }
|
47
93
|
|
48
94
|
if response
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'rexml/document'
|
4
|
+
|
3
5
|
# Platform definition file for Juniper network devices.
|
4
6
|
# This defines the "juniper" platform within Train's platform detection system.
|
5
7
|
|
@@ -51,6 +53,33 @@ module TrainPlugins::Juniper
|
|
51
53
|
|
52
54
|
private
|
53
55
|
|
56
|
+
# Generic XML extraction helper
|
57
|
+
# @param output [String] XML output from command
|
58
|
+
# @param xpath_patterns [Array<String>] XPath patterns to try in order
|
59
|
+
# @param command_desc [String] Description of command for error messages
|
60
|
+
# @yield [REXML::Element] Optional block to process the found element
|
61
|
+
# @return [String, nil] Extracted text or result of block processing
|
62
|
+
def extract_from_xml(output, xpath_patterns, command_desc)
|
63
|
+
return nil if output.nil? || output.empty?
|
64
|
+
|
65
|
+
doc = REXML::Document.new(output)
|
66
|
+
|
67
|
+
# Try each XPath pattern until we find an element
|
68
|
+
element = nil
|
69
|
+
xpath_patterns.each do |xpath|
|
70
|
+
element = doc.elements[xpath]
|
71
|
+
break if element
|
72
|
+
end
|
73
|
+
|
74
|
+
return nil unless element
|
75
|
+
|
76
|
+
# If block given, let it process the element, otherwise return text
|
77
|
+
block_given? ? yield(element) : element.text&.strip
|
78
|
+
rescue StandardError => e
|
79
|
+
logger&.warn("Failed to parse XML output from '#{command_desc}': #{e.message}")
|
80
|
+
nil
|
81
|
+
end
|
82
|
+
|
54
83
|
# Generic detection helper for version and architecture
|
55
84
|
# @param attribute_name [String] Name of the attribute to detect
|
56
85
|
# @param command [String] Command to run (default: 'show version')
|
@@ -95,79 +124,77 @@ module TrainPlugins::Juniper
|
|
95
124
|
# @return [String, nil] JunOS version string or nil if not detected
|
96
125
|
# @note This runs safely after the connection is established
|
97
126
|
def detect_junos_version
|
98
|
-
detect_attribute('junos_version') { |output|
|
127
|
+
detect_attribute('junos_version', 'show version | display xml') { |output| extract_version_from_xml(output) }
|
99
128
|
end
|
100
129
|
|
101
|
-
# Extract version string from JunOS show version output
|
102
|
-
# @param output [String]
|
130
|
+
# Extract version string from JunOS show version XML output
|
131
|
+
# @param output [String] XML output from 'show version | display xml' command
|
103
132
|
# @return [String, nil] Extracted version string or nil
|
104
|
-
def
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
/Junos:\s+([\w\d.-]+)/, # "Junos: 12.1X47-D15.4"
|
110
|
-
/JUNOS Software Release \[([\w\d.-]+)\]/, # "JUNOS Software Release [12.1X47-D15.4]"
|
111
|
-
/junos version ([\w\d.-]+)/i, # "junos version 21.4R3"
|
112
|
-
/Model: \S+, JUNOS Base OS boot \[([\w\d.-]+)\]/, # Some hardware variants
|
113
|
-
/([\d]+\.[\d]+[\w.-]*)/ # Generic version pattern
|
133
|
+
def extract_version_from_xml(output)
|
134
|
+
xpath_patterns = [
|
135
|
+
'//junos-version',
|
136
|
+
'//package-information/name[text()="junos"]/following-sibling::comment',
|
137
|
+
'//software-information/version'
|
114
138
|
]
|
115
139
|
|
116
|
-
|
117
|
-
match = output.match(pattern)
|
118
|
-
return match[1] if match
|
119
|
-
end
|
120
|
-
|
121
|
-
nil
|
140
|
+
extract_from_xml(output, xpath_patterns, 'show version | display xml')
|
122
141
|
end
|
123
142
|
|
124
143
|
# Detect JunOS architecture from device output
|
125
144
|
# @return [String, nil] Architecture string or nil if not detected
|
126
145
|
# @note This runs safely after the connection is established
|
127
146
|
def detect_junos_architecture
|
128
|
-
detect_attribute('junos_architecture') { |output|
|
147
|
+
detect_attribute('junos_architecture', 'show version | display xml') { |output| extract_architecture_from_xml(output) }
|
129
148
|
end
|
130
149
|
|
131
|
-
# Extract architecture string from JunOS show version output
|
132
|
-
# @param output [String]
|
150
|
+
# Extract architecture string from JunOS show version XML output
|
151
|
+
# @param output [String] XML output from 'show version | display xml' command
|
133
152
|
# @return [String, nil] Architecture string (x86_64, arm64, etc.) or nil
|
134
|
-
def
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
/Model:\s+(\S+)/, # "Model: SRX240H2" -> extract model as arch indicator
|
140
|
-
/Junos:\s+[\w\d.-]+\s+built\s+[\d-]+\s+[\d:]+\s+by\s+builder\s+on\s+(\S+)/, # Build architecture
|
141
|
-
/JUNOS.*\[([\w-]+)\]/, # JUNOS package architecture
|
142
|
-
/Architecture:\s+(\S+)/i, # Direct architecture line
|
143
|
-
/Platform:\s+(\S+)/i, # Platform designation
|
144
|
-
/Processor.*:\s*(\S+)/i # Processor type
|
153
|
+
def extract_architecture_from_xml(output)
|
154
|
+
xpath_patterns = [
|
155
|
+
'//product-model',
|
156
|
+
'//software-information/product-model',
|
157
|
+
'//chassis-inventory/chassis/description'
|
145
158
|
]
|
146
159
|
|
147
|
-
|
148
|
-
|
149
|
-
next unless match
|
160
|
+
extract_from_xml(output, xpath_patterns, 'show version | display xml') do |element|
|
161
|
+
model = element.text.strip
|
150
162
|
|
151
|
-
|
152
|
-
|
153
|
-
case arch_value
|
163
|
+
# Map model names to architecture
|
164
|
+
case model
|
154
165
|
when /SRX\d+/i
|
155
|
-
|
166
|
+
'x86_64' # Most SRX models are x86_64
|
156
167
|
when /MX\d+/i
|
157
|
-
|
168
|
+
'x86_64' # MX routers are typically x86_64
|
158
169
|
when /EX\d+/i
|
159
|
-
|
170
|
+
'arm64' # Many EX switches use ARM
|
160
171
|
when /QFX\d+/i
|
161
|
-
|
162
|
-
when /^(x86_64|amd64|i386|arm64|aarch64|sparc|mips)$/i
|
163
|
-
return arch_value.downcase
|
172
|
+
'x86_64' # QFX switches typically x86_64
|
164
173
|
else
|
165
|
-
#
|
166
|
-
|
174
|
+
# Default to x86_64 for unknown models
|
175
|
+
'x86_64'
|
167
176
|
end
|
168
177
|
end
|
178
|
+
end
|
169
179
|
|
170
|
-
|
180
|
+
# Detect JunOS serial number from device output
|
181
|
+
# @return [String, nil] Serial number string or nil if not detected
|
182
|
+
# @note This runs safely after the connection is established
|
183
|
+
def detect_junos_serial
|
184
|
+
detect_attribute('junos_serial', 'show chassis hardware | display xml') { |output| extract_serial_from_xml(output) }
|
185
|
+
end
|
186
|
+
|
187
|
+
# Extract serial number from JunOS chassis hardware XML output
|
188
|
+
# @param output [String] XML output from 'show chassis hardware | display xml' command
|
189
|
+
# @return [String, nil] Serial number string or nil
|
190
|
+
def extract_serial_from_xml(output)
|
191
|
+
xpath_patterns = [
|
192
|
+
'//chassis/serial-number',
|
193
|
+
'//chassis-sub-module/serial-number',
|
194
|
+
'//module/serial-number[1]'
|
195
|
+
]
|
196
|
+
|
197
|
+
extract_from_xml(output, xpath_patterns, 'show chassis hardware | display xml')
|
171
198
|
end
|
172
199
|
end
|
173
200
|
end
|
data/train-juniper.gemspec
CHANGED
@@ -62,7 +62,7 @@ Gem::Specification.new do |spec|
|
|
62
62
|
|
63
63
|
# Community plugins typically use train-core for smaller footprint
|
64
64
|
# train-core provides core functionality without cloud dependencies
|
65
|
-
spec.add_dependency 'train-core', '~> 3.12.13'
|
65
|
+
spec.add_dependency 'train-core', '~> 3.12', '>= 3.12.13'
|
66
66
|
|
67
67
|
# SSH connectivity dependencies - match train-core's exact version range
|
68
68
|
spec.add_dependency 'net-ssh', '>= 2.9', '< 8.0'
|
metadata
CHANGED
@@ -1,20 +1,23 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: train-juniper
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- MITRE Corporation
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-07-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: train-core
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.12'
|
20
|
+
- - ">="
|
18
21
|
- !ruby/object:Gem::Version
|
19
22
|
version: 3.12.13
|
20
23
|
type: :runtime
|
@@ -22,6 +25,9 @@ dependencies:
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
23
26
|
requirements:
|
24
27
|
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '3.12'
|
30
|
+
- - ">="
|
25
31
|
- !ruby/object:Gem::Version
|
26
32
|
version: 3.12.13
|
27
33
|
- !ruby/object:Gem::Dependency
|
@@ -227,8 +233,10 @@ files:
|
|
227
233
|
- lib/train-juniper/connection/bastion_proxy.rb
|
228
234
|
- lib/train-juniper/connection/command_executor.rb
|
229
235
|
- lib/train-juniper/connection/error_handling.rb
|
236
|
+
- lib/train-juniper/connection/ssh_askpass.rb
|
230
237
|
- lib/train-juniper/connection/ssh_session.rb
|
231
238
|
- lib/train-juniper/connection/validation.rb
|
239
|
+
- lib/train-juniper/connection/windows_proxy.rb
|
232
240
|
- lib/train-juniper/constants.rb
|
233
241
|
- lib/train-juniper/file_abstraction/juniper_file.rb
|
234
242
|
- lib/train-juniper/helpers/environment.rb
|