diff options
| author | omnom62 <75066712+omnom62@users.noreply.github.com> | 2026-10-26 22:46:53 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-10-26 14:46:53 +0200 |
| commit | ee810434422fa47c403dfe3fed5af9ca1c738128 (patch) | |
| tree | d28d9fc9fa207400f0a11ce1dcf0b4f54e692851 | |
| parent | 41781e7a16e73fdca1f26c677ed3645ca2a8a3d0 (diff) | |
| download | rest.vyos-ee810434422fa47c403dfe3fed5af9ca1c738128.tar.gz rest.vyos-ee810434422fa47c403dfe3fed5af9ca1c738128.zip | |
* https: T8989: auth_methods AI comments and conflicts fixed
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | changelogs/fragments/t8989_auth_methods.yml | 19 | ||||
| -rw-r--r-- | docs/vyos.rest.vyos_httpapi.rst | 180 | ||||
| -rw-r--r-- | plugins/httpapi/vyos.py | 412 | ||||
| -rw-r--r-- | tests/unit/test_httpapi_vyos.py | 471 |
5 files changed, 953 insertions, 131 deletions
@@ -71,7 +71,7 @@ ansible-galaxy collection install vyos.rest/ --force ### Httpapi plugins Name | Description --- | --- -[vyos.rest.vyos](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_httpapi.rst)|HttpApi plugin for VyOS REST API +[vyos.rest.vyos](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_httpapi.rst)|VyOS REST API ### Modules Name | Description diff --git a/changelogs/fragments/t8989_auth_methods.yml b/changelogs/fragments/t8989_auth_methods.yml new file mode 100644 index 0000000..9044d19 --- /dev/null +++ b/changelogs/fragments/t8989_auth_methods.yml @@ -0,0 +1,19 @@ +minor_changes: + - vyos httpapi plugin - add support for four additional authentication + methods against the VyOS REST API - ``header`` (API key sent as an + ``X-API-Key`` header), ``bearer`` (a VyOS-issued bearer token, fetched + once and cached until it expires), ``mtls`` (mutual TLS, with no + application-level credential sent at all), and ``oidc`` (a bearer + token obtained from an external OpenID Connect provider via the + client_credentials grant, likewise fetched and cached) - selectable + via the new ``auth_method`` option. The existing API-key-in-the-request-body + behaviour remains the default, so no change is required for existing + inventories. + - vyos httpapi plugin - the OIDC token endpoint must use ``https``, because + the request carries the client secret and the response carries a bearer + token. A plain ``http`` endpoint (for example a lab identity provider) is + accepted only with the new ``oidc_allow_insecure_http`` option, which logs + a warning, and other URL schemes are never accepted. Certificate + validation of the token endpoint can be turned off for a self-signed lab + provider with ``oidc_validate_certs``, and the request is bounded by + ``oidc_timeout``. diff --git a/docs/vyos.rest.vyos_httpapi.rst b/docs/vyos.rest.vyos_httpapi.rst index e493ebc..b91c229 100644 --- a/docs/vyos.rest.vyos_httpapi.rst +++ b/docs/vyos.rest.vyos_httpapi.rst @@ -5,10 +5,9 @@ vyos.rest.vyos ************** -**HttpApi plugin for VyOS REST API** +**VyOS REST API** -Version added: 1.0.0 .. contents:: :local: @@ -17,9 +16,8 @@ Version added: 1.0.0 Synopsis -------- -- This HttpApi plugin provides methods to connect to VyOS devices via their HTTPS REST API. -- Use with ``ansible_connection=ansible.netcommon.httpapi`` and ``ansible_network_os=vyos.rest.vyos``. -- The VyOS REST API must be enabled with ``set service https api keys id ansible key YOUR_KEY``, ``set service https api rest``, then ``commit && save``. +- HTTPAPI plugin for interacting with VyOS REST API. +- Supports multiple authentication methods against the VyOS REST API -- ``key`` (API key in the request body), ``header`` (API key as an ``X-API-Key`` header), ``bearer`` (a VyOS-issued bearer token, fetched and cached), ``mtls`` (mutual TLS, no application-level credential), and ``oidc`` (a bearer token obtained from an external OpenID Connect provider via the client_credentials grant, fetched and cached). @@ -48,13 +46,181 @@ Parameters <td> </td> <td> + <div> ini entries: + <p>[httpapi]<br>api_key = VALUE</p> + </div> + <div>env:ANSIBLE_HTTPAPI_API_KEY</div> <div>env:VYOS_API_KEY</div> <div>var: ansible_httpapi_api_key</div> <div>var: ansible_vyos_api_key</div> </td> <td> - <div>The API key configured on the VyOS device.</div> - <div>Set <code>ansible_httpapi_api_key</code> in inventory or the <code>VYOS_API_KEY</code> environment variable.</div> + <div>VyOS API key. Required for auth_method <code>key</code>, <code>header</code>, and <code>bearer</code>.</div> + </td> + </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>auth_method</b> + <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> + <div style="font-size: small"> + <span style="color: purple">string</span> + </div> + </td> + <td> + <ul style="margin: 0; padding: 0"><b>Choices:</b> + <li><div style="color: blue"><b>key</b> ←</div></li> + <li>header</li> + <li>bearer</li> + <li>mtls</li> + <li>oidc</li> + </ul> + </td> + <td> + <div> ini entries: + <p>[httpapi]<br>auth_method = key</p> + </div> + <div>var: ansible_httpapi_auth_method</div> + </td> + <td> + <div>Authentication method to use against the VyOS REST API.</div> + </td> + </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>oidc_allow_insecure_http</b> + <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> + <div style="font-size: small"> + <span style="color: purple">boolean</span> + </div> + </td> + <td> + <ul style="margin: 0; padding: 0"><b>Choices:</b> + <li><div style="color: blue"><b>no</b> ←</div></li> + <li>yes</li> + </ul> + </td> + <td> + <div> ini entries: + <p>[httpapi]<br>oidc_allow_insecure_http = no</p> + </div> + <div>var: ansible_httpapi_oidc_allow_insecure_http</div> + </td> + <td> + <div>By default the OIDC token endpoint must use <code>https</code>, because the request carries the client secret and the response carries a bearer token. Set this to <code>true</code> to allow a plain <code>http</code> endpoint.</div> + <div>Intended for isolated lab setups only. With this enabled the client secret and the access token travel in cleartext and can be captured and replayed by anyone on the network path.</div> + </td> + </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>oidc_client_id</b> + <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> + <div style="font-size: small"> + <span style="color: purple">string</span> + </div> + </td> + <td> + </td> + <td> + <div> ini entries: + <p>[httpapi]<br>oidc_client_id = VALUE</p> + </div> + <div>var: ansible_httpapi_oidc_client_id</div> + </td> + <td> + <div>OIDC client ID for the client_credentials grant.</div> + </td> + </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>oidc_client_secret</b> + <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> + <div style="font-size: small"> + <span style="color: purple">string</span> + </div> + </td> + <td> + </td> + <td> + <div> ini entries: + <p>[httpapi]<br>oidc_client_secret = VALUE</p> + </div> + <div>var: ansible_httpapi_oidc_client_secret</div> + </td> + <td> + <div>OIDC client secret for the client_credentials grant.</div> + </td> + </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>oidc_timeout</b> + <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> + <div style="font-size: small"> + <span style="color: purple">integer</span> + </div> + </td> + <td> + <b>Default:</b><br/><div style="color: blue">10</div> + </td> + <td> + <div> ini entries: + <p>[httpapi]<br>oidc_timeout = 10</p> + </div> + <div>var: ansible_httpapi_oidc_timeout</div> + </td> + <td> + <div>Timeout, in seconds, for the token request made to the OIDC provider. An unavailable or stalled identity provider would otherwise hang the Ansible task indefinitely.</div> + </td> + </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>oidc_token_url</b> + <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> + <div style="font-size: small"> + <span style="color: purple">string</span> + </div> + </td> + <td> + </td> + <td> + <div> ini entries: + <p>[httpapi]<br>oidc_token_url = VALUE</p> + </div> + <div>var: ansible_httpapi_oidc_token_url</div> + </td> + <td> + <div>Token endpoint URL of the OIDC provider. Required for auth_method <code>oidc</code>.</div> + </td> + </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>oidc_validate_certs</b> + <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> + <div style="font-size: small"> + <span style="color: purple">boolean</span> + </div> + </td> + <td> + <ul style="margin: 0; padding: 0"><b>Choices:</b> + <li>no</li> + <li><div style="color: blue"><b>yes</b> ←</div></li> + </ul> + </td> + <td> + <div> ini entries: + <p>[httpapi]<br>oidc_validate_certs = yes</p> + </div> + <div>var: ansible_httpapi_oidc_validate_certs</div> + </td> + <td> + <div>Validate the TLS certificate of the OIDC token endpoint.</div> + <div>Set to <code>false</code> only for a lab identity provider that uses a self-signed certificate.</div> </td> </tr> </table> diff --git a/plugins/httpapi/vyos.py b/plugins/httpapi/vyos.py index 07a60fe..b13a807 100644 --- a/plugins/httpapi/vyos.py +++ b/plugins/httpapi/vyos.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) - from __future__ import absolute_import, division, print_function @@ -9,170 +8,337 @@ __metaclass__ = type DOCUMENTATION = r""" --- name: vyos -short_description: HttpApi plugin for VyOS REST API +short_description: VyOS REST API description: - - This HttpApi plugin provides methods to connect to VyOS devices via their - HTTPS REST API. - - Use with C(ansible_connection=ansible.netcommon.httpapi) and - C(ansible_network_os=vyos.rest.vyos). - - The VyOS REST API must be enabled with - C(set service https api keys id ansible key YOUR_KEY), - C(set service https api rest), then C(commit && save). -version_added: "1.0.0" -author: - - VyOS Community (@vyos) + - HTTPAPI plugin for interacting with VyOS REST API. + - >- + Supports multiple authentication methods against the VyOS REST API -- + C(key) (API key in the request body), C(header) (API key as an + C(X-API-Key) header), C(bearer) (a VyOS-issued bearer token, fetched + and cached), C(mtls) (mutual TLS, no application-level credential), + and C(oidc) (a bearer token obtained from an external OpenID Connect + provider via the client_credentials grant, fetched and cached). +author: VyOS Community (@vyos) options: api_key: + description: VyOS API key. Required for auth_method C(key), C(header), and C(bearer). type: str - description: - - The API key configured on the VyOS device. - - Set C(ansible_httpapi_api_key) in inventory or the C(VYOS_API_KEY) - environment variable. - env: - - name: VYOS_API_KEY vars: - name: ansible_httpapi_api_key - name: ansible_vyos_api_key + env: + - name: ANSIBLE_HTTPAPI_API_KEY + - name: VYOS_API_KEY + ini: + - section: httpapi + key: api_key + auth_method: + description: Authentication method to use against the VyOS REST API. + type: str + choices: [key, header, bearer, mtls, oidc] + default: key + vars: + - name: ansible_httpapi_auth_method + ini: + - section: httpapi + key: auth_method + oidc_token_url: + description: Token endpoint URL of the OIDC provider. Required for auth_method C(oidc). + type: str + vars: + - name: ansible_httpapi_oidc_token_url + ini: + - section: httpapi + key: oidc_token_url + oidc_client_id: + description: OIDC client ID for the client_credentials grant. + type: str + vars: + - name: ansible_httpapi_oidc_client_id + ini: + - section: httpapi + key: oidc_client_id + oidc_client_secret: + description: OIDC client secret for the client_credentials grant. + type: str + vars: + - name: ansible_httpapi_oidc_client_secret + ini: + - section: httpapi + key: oidc_client_secret + oidc_timeout: + description: >- + Timeout, in seconds, for the token request made to the OIDC + provider. An unavailable or stalled identity provider would + otherwise hang the Ansible task indefinitely. + type: int + default: 10 + vars: + - name: ansible_httpapi_oidc_timeout + ini: + - section: httpapi + key: oidc_timeout + oidc_allow_insecure_http: + description: + - >- + By default the OIDC token endpoint must use C(https), because the + request carries the client secret and the response carries a bearer + token. Set this to C(true) to allow a plain C(http) endpoint. + - >- + Intended for isolated lab setups only. With this enabled the client + secret and the access token travel in cleartext and can be captured + and replayed by anyone on the network path. + type: bool + default: false + vars: + - name: ansible_httpapi_oidc_allow_insecure_http + ini: + - section: httpapi + key: oidc_allow_insecure_http + oidc_validate_certs: + description: + - Validate the TLS certificate of the OIDC token endpoint. + - >- + Set to C(false) only for a lab identity provider that uses a + self-signed certificate. + type: bool + default: true + vars: + - name: ansible_httpapi_oidc_validate_certs + ini: + - section: httpapi + key: oidc_validate_certs """ import json -import traceback - +import os +import time -try: - from urllib.parse import urlencode -except ImportError: - from urllib import urlencode +from urllib.parse import urlencode, urlparse from ansible.errors import AnsibleConnectionFailure -from ansible.module_utils._text import to_text from ansible.module_utils.connection import ConnectionError +from ansible.module_utils.urls import open_url from ansible.plugins.httpapi import HttpApiBase +from ansible.utils.display import Display + + +display = Display() class HttpApi(HttpApiBase): - """HttpApi plugin for the VyOS HTTPS REST API.""" - def login(self, username, password): - """VyOS uses a static API key — no login endpoint needed.""" - pass + def __init__(self, connection): + super().__init__(connection) + self._bearer_token = None + self._bearer_token_expiry = 0 + self._oidc_token = None + self._oidc_token_expiry = 0 def logout(self): - pass - - def update_auth(self, response, response_text): - return None + self._bearer_token = None + self._bearer_token_expiry = 0 + self._oidc_token = None + self._oidc_token_expiry = 0 def handle_httperror(self, exc): - if exc.code == 401: + if getattr(exc, "code", None) == 401: raise AnsibleConnectionFailure( - "VyOS API returned HTTP 401 Unauthorized. " - "Check ansible_httpapi_api_key is correct and that " - "'set service https api rest' is configured on the device.", + "Authentication to the VyOS REST API failed: {0}".format(exc), ) return exc + # ----------------------------------------------------------------- + # API key resolution -- shared by the key, header, and bearer + # (token-fetch) auth methods. + # ----------------------------------------------------------------- + def _get_api_key(self): - """Read the API key — option, env var, or fail clearly.""" - try: - key = self.get_option("api_key") - except Exception: - key = None - if not key: - import os - - key = os.environ.get("VYOS_API_KEY", "") - if not key: + api_key = self.get_option("api_key") + if not api_key: + api_key = os.environ.get("VYOS_API_KEY") + if not api_key: raise ConnectionError( - "No VyOS API key found. Set ansible_httpapi_api_key in " - "inventory or export VYOS_API_KEY=<key>.", + "No VyOS API key available: set api_key (or ANSIBLE_HTTPAPI_API_KEY / " + "VYOS_API_KEY) to authenticate.", ) - return key + return api_key - def send_request(self, data, **payload): # pylint: disable=arguments-renamed - """POST to a VyOS REST endpoint. + @staticmethod + def _parse_response(response): + if hasattr(response, "read"): + response = response.read() + if isinstance(response, bytes): + response = response.decode("utf-8") + if isinstance(response, str): + response = json.loads(response) + return response - Args: - data (str): API path, e.g. '/configure' or '/retrieve'. - Named 'data' to match the HttpApiBase signature. - Internally referred to as endpoint to avoid collision - with the VyOS payload field also called 'data'. - **payload: VyOS API fields: op, path, value, url, file, etc. + # ----------------------------------------------------------------- + # Bearer token: fetched from the VyOS device itself (/token), + # authenticated the same way the "key" method authenticates an + # ordinary request, then cached until it expires. + # ----------------------------------------------------------------- - Returns: - dict: Parsed JSON response from VyOS. + def _fetch_bearer_token(self): + api_key = self._get_api_key() + body = urlencode({"key": api_key}) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + _status, raw_response = self.connection.send( + "/token", + data=body, + method="POST", + headers=headers, + ) + response = self._parse_response(raw_response) + if not response.get("success"): + raise ConnectionError(response.get("error") or "VyOS token request failed") + token_data = response.get("data") or {} + token = token_data.get("token") + expires_in = token_data.get("expires_in", 3600) + self._bearer_token = token + self._bearer_token_expiry = time.time() + expires_in + return token - Raises: - ConnectionError: on HTTP error or VyOS success=false response. - """ - endpoint = data + def _get_bearer_token(self): + if self._bearer_token and self._bearer_token_expiry > time.time(): + return self._bearer_token + return self._fetch_bearer_token() - try: - api_key = self._get_api_key() - if "_raw_list" in payload: - body = json.dumps(payload["_raw_list"]) - else: - body = json.dumps(payload) - - form_data = urlencode({"data": body, "key": api_key}) - response, response_data = self.connection.send( - endpoint, - data=form_data, - method="POST", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + # ----------------------------------------------------------------- + # OIDC token: fetched from an external IdP via the + # client_credentials grant, cached the same way as the bearer + # token. Uses ansible.module_utils.urls.open_url rather than + # urllib.request.urlopen directly, per Ansible's own sanity + # requirement (open_url adds proxy support and consistent TLS + # validation across the whole ecosystem). + # ----------------------------------------------------------------- - raw = to_text(response_data.getvalue()) - - try: - result = json.loads(raw) - except ValueError: - raise ConnectionError( - "VyOS API at {ep} returned non-JSON ({code}): {raw}".format( - ep=endpoint, - code=getattr(response, "status", "?"), - raw=raw[:300], - ), - ) - - if not result.get("success"): - raise ConnectionError( - "VyOS API error [{ep}]: {err}".format( - ep=endpoint, - err=result.get("error") or "success=false", - ), + def _check_oidc_scheme(self, token_url): + """The token request carries client_secret and the response + carries a bearer token, so fail closed unless the endpoint is + https. Plain http is allowed only through the explicit + oidc_allow_insecure_http opt-in (lab use), with a warning. Any + other scheme (file:, ftp:, ...) is never accepted: open_url + would otherwise follow it. + """ + scheme = urlparse(token_url).scheme + if scheme == "https": + return + if scheme == "http": + if self.get_option("oidc_allow_insecure_http"): + display.warning( + "oidc_token_url uses plain http: the OIDC client secret and the " + "access token are sent in cleartext. Use https outside a lab.", ) - - return result - - except (ConnectionError, AnsibleConnectionFailure): - raise - except Exception as exc: + return raise ConnectionError( - "{exc_type} in send_request({ep}): {exc}\n{tb}".format( - exc_type=type(exc).__name__, - ep=endpoint, - exc=to_text(exc), - tb=traceback.format_exc(), - ), + "oidc_token_url must use https because it carries the client secret " + "and the access token. For a lab identity provider set " + "oidc_allow_insecure_http=true.", ) + raise ConnectionError( + "oidc_token_url must be an https URL (got scheme '{0}').".format(scheme), + ) - def get_info(self): - """GET /info — the one unauthenticated endpoint.""" + def _fetch_oidc_token(self): + token_url = self.get_option("oidc_token_url") + if not token_url: + raise ConnectionError( + "oidc_token_url is required when auth_method=oidc.", + ) + self._check_oidc_scheme(token_url) + body = urlencode( + { + "grant_type": "client_credentials", + "client_id": self.get_option("oidc_client_id"), + "client_secret": self.get_option("oidc_client_secret"), + }, + ) + timeout = self.get_option("oidc_timeout") + if timeout is None: + timeout = 10 + validate_certs = self.get_option("oidc_validate_certs") + if validate_certs is None: + validate_certs = True try: - response, response_data = self.connection.send( - "/info", - data=None, - method="GET", + response = open_url( + token_url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + timeout=timeout, + validate_certs=validate_certs, ) - return json.loads(to_text(response_data.getvalue())) - except (ConnectionError, AnsibleConnectionFailure): - raise + payload = json.loads(response.read()) except Exception as exc: + raise ConnectionError("OIDC token fetch failed: {0}".format(exc)) + + access_token = payload.get("access_token") + if not access_token: raise ConnectionError( - "{exc_type} in get_info(): {exc}\n{tb}".format( - exc_type=type(exc).__name__, - exc=to_text(exc), - tb=traceback.format_exc(), - ), + "OIDC token response did not contain an access_token.", ) + expires_in = payload.get("expires_in", 3600) + self._oidc_token = access_token + self._oidc_token_expiry = time.time() + expires_in + return access_token + + def _get_oidc_token(self): + if self._oidc_token and self._oidc_token_expiry > time.time(): + return self._oidc_token + return self._fetch_oidc_token() + + # ----------------------------------------------------------------- + # send_request: shared by every auth method. Only the auth + # material attached to the request (body field vs. header, and + # which header) differs by method; the request/response envelope + # itself is identical. + # + # The first parameter is named "data" (not e.g. "url_path") to + # match HttpApiBase.send_request's own signature -- pylint's + # arguments-renamed check flags an override that renames a base- + # class parameter, since that silently breaks any caller using the + # keyword form. It still carries a URL path string in this + # plugin's own usage; the **op_kwargs that follow are this + # resource's own operation details (op, path, value, ...), kept + # separate so they don't collide with the "data" name. + # ----------------------------------------------------------------- + + def send_request(self, data, **op_kwargs): + url_path = data + auth_method = self.get_option("auth_method") or "key" + + form_data = {} + if op_kwargs: + form_data["data"] = json.dumps(op_kwargs) + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + if auth_method == "key": + form_data["key"] = self._get_api_key() + elif auth_method == "header": + headers["X-API-Key"] = self._get_api_key() + elif auth_method == "bearer": + headers["Authorization"] = "Bearer {0}".format(self._get_bearer_token()) + elif auth_method == "oidc": + headers["Authorization"] = "Bearer {0}".format(self._get_oidc_token()) + elif auth_method == "mtls": + pass + else: + raise ConnectionError("Unsupported auth_method: {0}".format(auth_method)) + + body = urlencode(form_data) + + _status, raw_response = self.connection.send( + url_path, + data=body, + method="POST", + headers=headers, + ) + response = self._parse_response(raw_response) + + if not response.get("success"): + raise ConnectionError(response.get("error") or "VyOS API request failed") + + return response diff --git a/tests/unit/test_httpapi_vyos.py b/tests/unit/test_httpapi_vyos.py new file mode 100644 index 0000000..2ef1098 --- /dev/null +++ b/tests/unit/test_httpapi_vyos.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- +"""Unit tests for plugins/httpapi/vyos.py + +Tests cover all five auth methods (key, header, bearer, mTLS, and OIDC) +and token caching behaviour. The Ansible connection layer is mocked so no +real device is needed. +""" +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import json +import time +import unittest + +from io import BytesIO +from unittest.mock import MagicMock, patch + +from ansible.errors import AnsibleConnectionFailure +from ansible.module_utils.connection import ConnectionError +from ansible_collections.vyos.rest.plugins.httpapi.vyos import HttpApi + + +def _make_response(payload, status=200): + """Return a (response, BytesIO) pair like connection.send() does.""" + resp = MagicMock() + resp.status = status + return resp, BytesIO(json.dumps(payload).encode()) + + +class TestHttpApiInit(unittest.TestCase): + def _plugin(self, auth_method="key", api_key="testkey"): + conn = MagicMock() + plugin = HttpApi(conn) + plugin.get_option = { + "api_key": api_key, + "auth_method": auth_method, + }.get + return plugin + + def test_bearer_token_initially_none(self): + plugin = self._plugin() + self.assertIsNone(plugin._bearer_token) + self.assertEqual(plugin._bearer_token_expiry, 0) + + def test_logout_clears_token(self): + plugin = self._plugin() + plugin._bearer_token = "sometoken" + plugin._bearer_token_expiry = 9999999999 + plugin.logout() + self.assertIsNone(plugin._bearer_token) + self.assertEqual(plugin._bearer_token_expiry, 0) + + +class TestGetApiKey(unittest.TestCase): + def _plugin(self, key=None, env_key=None): + conn = MagicMock() + plugin = HttpApi(conn) + plugin.get_option = lambda opt: key if opt == "api_key" else "key" + if env_key is not None: + import os + + os.environ["VYOS_API_KEY"] = env_key + return plugin + + def tearDown(self): + import os + + os.environ.pop("VYOS_API_KEY", None) + + def test_returns_option_key(self): + plugin = self._plugin(key="mykey") + self.assertEqual(plugin._get_api_key(), "mykey") + + def test_falls_back_to_env_var(self): + plugin = self._plugin(key=None, env_key="envkey") + self.assertEqual(plugin._get_api_key(), "envkey") + + def test_raises_when_no_key(self): + plugin = self._plugin(key=None) + with self.assertRaises(ConnectionError): + plugin._get_api_key() + + +class TestSendRequestKeyMethod(unittest.TestCase): + def _plugin(self, auth_method="key", api_key="testkey"): + conn = MagicMock() + plugin = HttpApi(conn) + plugin.get_option = { + "api_key": api_key, + "auth_method": auth_method, + }.get + return plugin + + def test_key_method_sends_form_field(self): + plugin = self._plugin(auth_method="key") + plugin.connection.send.return_value = _make_response( + {"success": True, "data": {"host-name": "vyos"}, "error": None}, + ) + result = plugin.send_request("/retrieve", op="showConfig", path=["system"]) + self.assertTrue(result["success"]) + call_kwargs = plugin.connection.send.call_args + self.assertIn("key=testkey", call_kwargs[1]["data"]) + self.assertNotIn("X-API-Key", call_kwargs[1].get("headers", {})) + + def test_key_method_raises_on_success_false(self): + plugin = self._plugin(auth_method="key") + plugin.connection.send.return_value = _make_response( + {"success": False, "error": "Invalid key", "data": None}, + ) + with self.assertRaises(ConnectionError) as ctx: + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertIn("Invalid key", str(ctx.exception)) + + +class TestSendRequestHeaderMethod(unittest.TestCase): + def _plugin(self, api_key="testkey"): + conn = MagicMock() + plugin = HttpApi(conn) + plugin.get_option = { + "api_key": api_key, + "auth_method": "header", + }.get + return plugin + + def test_header_method_sends_x_api_key_header(self): + plugin = self._plugin() + plugin.connection.send.return_value = _make_response( + {"success": True, "data": {}, "error": None}, + ) + plugin.send_request("/retrieve", op="showConfig", path=[]) + call_kwargs = plugin.connection.send.call_args[1] + self.assertEqual(call_kwargs["headers"]["X-API-Key"], "testkey") + self.assertNotIn("key=", call_kwargs["data"]) + + def test_header_method_no_key_in_body(self): + plugin = self._plugin() + plugin.connection.send.return_value = _make_response( + {"success": True, "data": {}, "error": None}, + ) + plugin.send_request("/retrieve", op="showConfig", path=[]) + call_kwargs = plugin.connection.send.call_args[1] + self.assertNotIn("key=testkey", call_kwargs["data"]) + + +class TestSendRequestBearerMethod(unittest.TestCase): + def _plugin(self, api_key="testkey"): + conn = MagicMock() + plugin = HttpApi(conn) + plugin.get_option = { + "api_key": api_key, + "auth_method": "bearer", + }.get + return plugin + + def _token_response(self, token="jwt123", expires_in=3600): + return _make_response( + { + "success": True, + "data": {"token": token, "expires_in": expires_in}, + "error": None, + }, + ) + + def _retrieve_response(self): + return _make_response( + {"success": True, "data": {"host-name": "vyos"}, "error": None}, + ) + + def test_bearer_fetches_token_then_sends_auth_header(self): + plugin = self._plugin() + plugin.connection.send.side_effect = [ + self._token_response(), + self._retrieve_response(), + ] + result = plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertTrue(result["success"]) + # First call should be to /token + first_call = plugin.connection.send.call_args_list[0] + self.assertEqual(first_call[0][0], "/token") + # Second call should have Authorization header + second_call = plugin.connection.send.call_args_list[1] + self.assertEqual( + second_call[1]["headers"]["Authorization"], + "Bearer jwt123", + ) + + def test_bearer_caches_token(self): + plugin = self._plugin() + plugin.connection.send.side_effect = [ + self._token_response(), + self._retrieve_response(), + self._retrieve_response(), + ] + plugin.send_request("/retrieve", op="showConfig", path=[]) + plugin.send_request("/retrieve", op="showConfig", path=[]) + # /token should only be called once + token_calls = [c for c in plugin.connection.send.call_args_list if c[0][0] == "/token"] + self.assertEqual(len(token_calls), 1) + + def test_bearer_refreshes_expired_token(self): + plugin = self._plugin() + # Set an already-expired token + plugin._bearer_token = "oldtoken" + plugin._bearer_token_expiry = time.time() - 100 + plugin.connection.send.side_effect = [ + self._token_response(token="newtoken"), + self._retrieve_response(), + ] + plugin.send_request("/retrieve", op="showConfig", path=[]) + token_calls = [c for c in plugin.connection.send.call_args_list if c[0][0] == "/token"] + self.assertEqual(len(token_calls), 1) + self.assertEqual(plugin._bearer_token, "newtoken") + + def test_bearer_raises_on_token_failure(self): + plugin = self._plugin() + plugin.connection.send.return_value = _make_response( + {"success": False, "error": "Invalid key", "data": None}, + ) + with self.assertRaises(ConnectionError) as ctx: + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertIn("Invalid key", str(ctx.exception)) + + +class TestHandleHttpError(unittest.TestCase): + def test_401_raises_connection_failure(self): + conn = MagicMock() + plugin = HttpApi(conn) + exc = MagicMock() + exc.code = 401 + with self.assertRaises(AnsibleConnectionFailure): + plugin.handle_httperror(exc) + + def test_other_errors_returned(self): + conn = MagicMock() + plugin = HttpApi(conn) + exc = MagicMock() + exc.code = 500 + result = plugin.handle_httperror(exc) + self.assertEqual(result, exc) + + +if __name__ == "__main__": + unittest.main() + + +class TestSendRequestMtlsMethod(unittest.TestCase): + def _plugin(self): + conn = MagicMock() + plugin = HttpApi(conn) + plugin.get_option = { + "auth_method": "mtls", + }.get + return plugin + + def test_mtls_sends_no_api_key(self): + plugin = self._plugin() + plugin.connection.send.return_value = ( + MagicMock(status=200), + BytesIO( + json.dumps( + {"success": True, "data": {}, "error": None}, + ).encode(), + ), + ) + plugin.send_request("/retrieve", op="showConfig", path=[]) + call_kwargs = plugin.connection.send.call_args[1] + self.assertNotIn("key=", call_kwargs["data"]) + self.assertNotIn("X-API-Key", call_kwargs.get("headers", {})) + self.assertNotIn("Authorization", call_kwargs.get("headers", {})) + + def test_mtls_sends_no_authorization_header(self): + plugin = self._plugin() + plugin.connection.send.return_value = ( + MagicMock(status=200), + BytesIO( + json.dumps( + {"success": True, "data": {}, "error": None}, + ).encode(), + ), + ) + plugin.send_request("/retrieve", op="showConfig", path=[]) + headers = plugin.connection.send.call_args[1].get("headers", {}) + self.assertNotIn("Authorization", headers) + + +class TestSendRequestOidcMethod(unittest.TestCase): + def _plugin( + self, + token_url="https://idp/token", + client_id="vyos-api", + client_secret="secret", + **extra, + ): + conn = MagicMock() + plugin = HttpApi(conn) + options = { + "auth_method": "oidc", + "oidc_token_url": token_url, + "oidc_client_id": client_id, + "oidc_client_secret": client_secret, + } + options.update(extra) + plugin.get_option = options.get + return plugin + + def _idp_response(self, token="oidctoken123", expires_in=3600): + return json.dumps( + { + "access_token": token, + "expires_in": expires_in, + "token_type": "Bearer", + }, + ).encode() + + def _retrieve_response(self): + return ( + MagicMock(status=200), + BytesIO( + json.dumps( + {"success": True, "data": {"host-name": "vyos"}, "error": None}, + ).encode(), + ), + ) + + def test_oidc_fetches_token_from_idp(self): + plugin = self._plugin() + with patch("ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url") as mock_open_url: + mock_resp = MagicMock() + mock_resp.read.return_value = self._idp_response() + mock_open_url.return_value = mock_resp + plugin.connection.send.return_value = self._retrieve_response() + plugin.send_request("/retrieve", op="showConfig", path=[]) + + call_kwargs = plugin.connection.send.call_args[1] + self.assertEqual( + call_kwargs["headers"]["Authorization"], + "Bearer oidctoken123", + ) + + def test_oidc_caches_token(self): + plugin = self._plugin() + with patch("ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url") as mock_open_url: + mock_resp = MagicMock() + mock_resp.read.return_value = self._idp_response() + mock_open_url.return_value = mock_resp + plugin.connection.send.return_value = self._retrieve_response() + plugin.send_request("/retrieve", op="showConfig", path=[]) + plugin.connection.send.return_value = self._retrieve_response() + plugin.send_request("/retrieve", op="showConfig", path=[]) + # open_url should only be called once + self.assertEqual(mock_open_url.call_count, 1) + + def test_oidc_refreshes_expired_token(self): + plugin = self._plugin() + plugin._oidc_token = "oldtoken" + plugin._oidc_token_expiry = time.time() - 100 + with patch("ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url") as mock_open_url: + mock_resp = MagicMock() + mock_resp.read.return_value = self._idp_response(token="newtoken") + mock_open_url.return_value = mock_resp + plugin.connection.send.return_value = self._retrieve_response() + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertEqual(plugin._oidc_token, "newtoken") + + def test_oidc_raises_when_token_url_missing(self): + plugin = self._plugin(token_url=None) + with self.assertRaises(ConnectionError) as ctx: + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertIn("oidc_token_url", str(ctx.exception)) + + def test_oidc_raises_when_idp_unreachable(self): + plugin = self._plugin() + with patch("ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url") as mock_open_url: + mock_open_url.side_effect = Exception("Connection refused") + with self.assertRaises(ConnectionError) as ctx: + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertIn("OIDC token fetch failed", str(ctx.exception)) + + def test_oidc_passes_configured_timeout(self): + """An unavailable or stalled IdP must not hang the task + indefinitely -- the timeout is passed through explicitly + rather than relying on open_url's own default.""" + plugin = self._plugin() + plugin.get_option = { + "auth_method": "oidc", + "oidc_token_url": "https://idp/token", + "oidc_client_id": "vyos-api", + "oidc_client_secret": "secret", + "oidc_timeout": 5, + }.get + with patch("ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url") as mock_open_url: + mock_resp = MagicMock() + mock_resp.read.return_value = self._idp_response() + mock_open_url.return_value = mock_resp + plugin.connection.send.return_value = self._retrieve_response() + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertEqual(mock_open_url.call_args[1]["timeout"], 5) + + def _run_token_fetch(self, plugin): + """Run one request with open_url and the plugin's display + mocked; returns (mock_open_url, mock_display).""" + target = "ansible_collections.vyos.rest.plugins.httpapi.vyos" + with patch(target + ".open_url") as mock_open_url, patch( + target + ".display", + ) as mock_display: + mock_resp = MagicMock() + mock_resp.read.return_value = self._idp_response() + mock_open_url.return_value = mock_resp + plugin.connection.send.return_value = self._retrieve_response() + plugin.send_request("/retrieve", op="showConfig", path=[]) + return mock_open_url, mock_display + + def test_oidc_https_endpoint_accepted_without_warning(self): + plugin = self._plugin(token_url="https://idp/token") + mock_open_url, mock_display = self._run_token_fetch(plugin) + self.assertEqual(mock_open_url.call_count, 1) + mock_display.warning.assert_not_called() + + def test_oidc_rejects_plain_http_by_default(self): + """The request carries client_secret and the response a bearer + token, so a plain http endpoint must fail closed -- before any + request is made.""" + plugin = self._plugin(token_url="http://idp/token") + target = "ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url" + with patch(target) as mock_open_url: + with self.assertRaises(ConnectionError) as ctx: + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertIn("https", str(ctx.exception)) + self.assertIn("oidc_allow_insecure_http", str(ctx.exception)) + mock_open_url.assert_not_called() + + def test_oidc_plain_http_allowed_with_opt_in_and_warns(self): + plugin = self._plugin(token_url="http://idp/token", oidc_allow_insecure_http=True) + mock_open_url, mock_display = self._run_token_fetch(plugin) + self.assertEqual(mock_open_url.call_count, 1) + self.assertEqual(mock_display.warning.call_count, 1) + self.assertIn("cleartext", mock_display.warning.call_args[0][0]) + + def test_oidc_rejects_other_schemes_even_with_opt_in(self): + """The opt-in only relaxes http. file:, ftp: and a missing scheme + are never followed.""" + for url in ("file:///etc/passwd", "ftp://idp/token", "idp/token"): + plugin = self._plugin(token_url=url, oidc_allow_insecure_http=True) + target = "ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url" + with patch(target) as mock_open_url: + with self.assertRaises(ConnectionError) as ctx: + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertIn("https", str(ctx.exception), url) + mock_open_url.assert_not_called() + + def test_oidc_validates_certs_by_default(self): + plugin = self._plugin() + mock_open_url, _display = self._run_token_fetch(plugin) + self.assertIs(mock_open_url.call_args[1]["validate_certs"], True) + + def test_oidc_cert_validation_can_be_disabled_for_lab(self): + plugin = self._plugin(oidc_validate_certs=False) + mock_open_url, _display = self._run_token_fetch(plugin) + self.assertIs(mock_open_url.call_args[1]["validate_certs"], False) + + def test_oidc_raises_when_access_token_missing(self): + plugin = self._plugin() + with patch("ansible_collections.vyos.rest.plugins.httpapi.vyos.open_url") as mock_open_url: + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps({"error": "invalid_client"}).encode() + mock_open_url.return_value = mock_resp + with self.assertRaises(ConnectionError) as ctx: + plugin.send_request("/retrieve", op="showConfig", path=[]) + self.assertIn("access_token", str(ctx.exception)) |
