diff --git a/ansible_collections/juniper/device/plugins/action/extract_data.py b/ansible_collections/juniper/device/plugins/action/extract_data.py index 8790ea70..ae320b68 100644 --- a/ansible_collections/juniper/device/plugins/action/extract_data.py +++ b/ansible_collections/juniper/device/plugins/action/extract_data.py @@ -61,6 +61,7 @@ "console": ["console"], "mode": ["mode"], "timeout": ["timeout", "ansible_timeout"], + "proxy_command": ["proxy_command", "ansible_pyez_proxy_command"], } diff --git a/ansible_collections/juniper/device/plugins/connection/pyez.py b/ansible_collections/juniper/device/plugins/connection/pyez.py index bd8cd0d7..46ac0bcc 100644 --- a/ansible_collections/juniper/device/plugins/connection/pyez.py +++ b/ansible_collections/juniper/device/plugins/connection/pyez.py @@ -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 @@ -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: diff --git a/ansible_collections/juniper/device/plugins/module_utils/juniper_junos_common.py b/ansible_collections/juniper/device/plugins/module_utils/juniper_junos_common.py index 464591d2..ff7ab11b 100644 --- a/ansible_collections/juniper/device/plugins/module_utils/juniper_junos_common.py +++ b/ansible_collections/juniper/device/plugins/module_utils/juniper_junos_common.py @@ -40,6 +40,7 @@ import json import logging import os +import re # Standard library imports from argparse import ArgumentParser @@ -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 = """ @@ -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. @@ -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. @@ -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) diff --git a/tests/inventory b/tests/inventory index eb53d4ed..ee18ec1c 100644 --- a/tests/inventory +++ b/tests/inventory @@ -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= diff --git a/tests/pb.juniper_junos_proxy_command.yml b/tests/pb.juniper_junos_proxy_command.yml new file mode 100644 index 00000000..851449df --- /dev/null +++ b/tests/pb.juniper_junos_proxy_command.yml @@ -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"