Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"console": ["console"],
"mode": ["mode"],
"timeout": ["timeout", "ansible_timeout"],
"proxy_command": ["proxy_command", "ansible_pyez_proxy_command"],
}


Expand Down
32 changes: 31 additions & 1 deletion ansible_collections/juniper/device/plugins/connection/pyez.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,23 @@
vars:
- name: ansible_pyez_ssh_config
- name: ssh_config

pyez_proxy_command:
description:
- The SSH ProxyCommand used to connect to the Junos device through
a bastion/jump host.
- Format must match SSH option style used,
e.g. C(-o ProxyCommand="ssh -W %h:%p -q bastion01").
- The C(-o ProxyCommand=) prefix is stripped automatically before
passing the plain command string to PyEZ Device().
vars:
- name: proxy_command
- name: ansible_pyez_proxy_command
env:
- name: PROXY_COMMAND
"""
import json
import logging
import re

from ansible.errors import AnsibleError
from ansible.module_utils._text import to_bytes
Expand Down Expand Up @@ -362,6 +375,23 @@ def open(self):
"""
# Move all of the connection arguments into connect_args
connect_args = {}
# Read proxy_command from connection option
# Supports format:
# '-o ProxyCommand="ssh -W %h:%p -q bastion01"'
proxy_raw = self.get_option("pyez_proxy_command")
if proxy_raw:
proxy_raw = proxy_raw.strip()
match = re.match(
r'-o\s+ProxyCommand=(?:"([^"]+)"|\'([^\']+)\'|(\S+.*))',
proxy_raw,
re.IGNORECASE,
)
if match:
connect_args["proxy_command"] = (
match.group(1) or match.group(2) or match.group(3)
)
else:
connect_args["proxy_command"] = proxy_raw

# check for mode
if self.get_option("port") is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import json
import logging
import os
import re

# Standard library imports
from argparse import ArgumentParser
Expand Down Expand Up @@ -298,6 +299,20 @@ class ModuleDocFragment(object):
required: false
type: bool
default: false
proxy_command:
description:
- The SSH ProxyCommand used to connect to the Junos device through
a bastion/jump host.
- Accepts the format.
C(-o ProxyCommand="ssh -W %h:%p -q bastion01").
- The C(-o ProxyCommand=) prefix is automatically stripped before
passing the plain command string to PyEZ Device().
- The tokens C(%h) and C(%p) are expanded to the target device
hostname and port by PyEZ before executing the proxy.
- Cannot be combined with I(console) or a non-default I(mode).
required: false
default: none
type: str
"""

LOGGING_DOCUMENTATION = """
Expand Down Expand Up @@ -488,6 +503,7 @@ class ModuleDocFragment(object):
),
"timeout": dict(type="int", required=False, default=30),
"huge_tree": dict(type="bool", required=False, default=False),
"proxy_command": dict(type="str", required=False, default=None),
}

# Connection arguments which are mutually exclusive.
Expand All @@ -498,6 +514,8 @@ class ModuleDocFragment(object):
["attempts", "console"],
["cs_user", "console"],
["cs_passwd", "console"],
["proxy_command", "console"],
["proxy_command", "mode"],
]

# Specify the logging spec.
Expand Down Expand Up @@ -1174,6 +1192,27 @@ def open(self):
if self.params.get(key) is not None:
connect_args[key] = self.params.get(key)

# Parse proxy_command from compatible format:
# '-o ProxyCommand="ssh -W %h:%p -q bastion01"'
# Strip the '-o ProxyCommand=' wrapper and extract the plain command
# string before passing to PyEZ Device().
# Also accepts plain format: 'ssh -W %h:%p -q bastion01'
if connect_args.get("proxy_command"):
proxy_raw = connect_args["proxy_command"].strip()
match = re.match(
r'-o\s+ProxyCommand=(?:"([^"]+)"|\'([^\']+)\'|(\S+.*))',
proxy_raw,
re.IGNORECASE,
)
if match:
# Extract from double-quoted, single-quoted, or unquoted form
connect_args["proxy_command"] = (
match.group(1) or match.group(2) or match.group(3)
)
# else: already plain format e.g. "ssh -W %h:%p -q bastion01"
else:
connect_args["proxy_command"] = proxy_raw

try:
# self.close()
log_connect_args = dict(connect_args)
Expand Down
5 changes: 3 additions & 2 deletions tests/inventory
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
[junos]
local_connection_testcases ansible_host=x.x.x.x ansible_user=xxxx ansible_pass=xxxx ansible_port=22 ansible_connection=local ansible_command_timeout=300
local_connection_testcases ansible_host=x.x.x.x ansible_user=xxxx ansible_pass=xxxx ansible_port=22 ansible_connection=local ansib
le_private_key_file=~/.ssh/id_rsa ansible_command_timeout=300

pyez_connection_testcases ansible_host=x.x.x.x ansible_user=xxx ansible_ssh_pass=xxxx ansible_port=22 ansible_connection=juniper.device.pyez ansible_command_timeout=300
pyez_connection_testcases ansible_host=x.x.x.x ansible_user=xxx ansible_ssh_pass=xxxx ansible_port=22 ansible_connection=juniper.device.pyez ansible_private_key_file=~/.ssh/id_rsa ansible_command_timeout=300

[all:vars]
ansible_python_interpreter=<python interpreter path>
47 changes: 47 additions & 0 deletions tests/pb.juniper_junos_proxy_command.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
# Play 1: Positive — connect via bastion using proxy_command
- name: Test proxy_command via juniper.device — positive
hosts: all
gather_facts: false

vars:
proxy_command: '-o ProxyCommand="ssh -W %h:%p -q regress@bastion01"'

tasks:
- name: Get facts via bastion using proxy_command
juniper.device.facts:
register: result

- name: Print hostname fact
ansible.builtin.debug:
msg: "Connected to: {{ result.facts.hostname }}"

- name: Assert connection succeeded
ansible.builtin.assert:
that:
- result.failed == false
- result.facts.hostname is defined
success_msg: "PASS: Connected via bastion proxy_command"
fail_msg: "FAIL: Could not connect via bastion"

# Play 2: Negative — direct access must be blocked when proxy_command is unset
- name: Test proxy_command via juniper.device — negative
hosts: all
gather_facts: false

vars:
proxy_command: "" # override inventory value — disables proxy

tasks:
- name: Attempt facts without proxy (expect failure)
juniper.device.facts:
register: result
ignore_errors: true

- name: Assert connection was refused (direct access blocked)
ansible.builtin.assert:
that:
- result.failed == true
- "'ConnectRefusedError' in result.msg or 'ConnectError' in result.msg or 'Unable to make' in result.msg"
success_msg: "PASS: Direct access correctly blocked — proxy_command required"
fail_msg: "FAIL: Direct access succeeded but should have been blocked"
Loading