diff options
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/httpapi/vyos.py | 412 |
1 files changed, 289 insertions, 123 deletions
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 |
