diff options
| author | omnom62 <omnom62@outlook.com> | 2026-09-04 13:24:44 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-09-04 13:24:44 +1000 |
| commit | ecc84b8d9b74d5c45ee073c6c4cd603cfbf05406 (patch) | |
| tree | 04555e259bd52c8107b65019229ba51b9d398dae /plugins | |
| parent | 97c43a34bd953a06bfe6c4b010ab79f645bac061 (diff) | |
| download | rest.vyos-ecc84b8d9b74d5c45ee073c6c4cd603cfbf05406.tar.gz rest.vyos-ecc84b8d9b74d5c45ee073c6c4cd603cfbf05406.zip | |
T8989: new auth methods
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/httpapi/vyos.py | 313 |
1 files changed, 301 insertions, 12 deletions
diff --git a/plugins/httpapi/vyos.py b/plugins/httpapi/vyos.py index 07a60fe..ab49918 100644 --- a/plugins/httpapi/vyos.py +++ b/plugins/httpapi/vyos.py @@ -33,14 +33,135 @@ options: vars: - name: ansible_httpapi_api_key - name: ansible_vyos_api_key + auth_method: + type: str + description: + - Authentication method to use. + - C(key) sends the API key as a form field (default, backward-compatible). + - C(header) sends the API key as an C(X-API-Key) header. + - C(bearer) exchanges the API key for a short-lived JWT via C(POST /token) + and sends it as an Authorization Bearer header for subsequent requests. + - C(mtls) uses mutual TLS client certificate authentication. No API key + is sent. Requires C(ansible_httpapi_client_cert) and + C(ansible_httpapi_client_key) to be set at the connection level. + - C(oidc) fetches a Bearer token from an external identity provider using + the OAuth2 client credentials grant and sends it as an Authorization + Bearer header. Requires C(ansible_vyos_oidc_token_url), + C(ansible_vyos_oidc_client_id), and C(ansible_vyos_oidc_client_secret). + default: key + choices: + - key + - header + - bearer + - mtls + - oidc + vars: + - name: ansible_httpapi_vyos_auth_method + - name: ansible_vyos_auth_method + oidc_token_url: + type: str + description: + - Full URL of the OAuth2/OIDC token endpoint. + - E.g. C(https://keycloak.example.com/realms/vyos/protocol/openid-connect/token). + - Required when C(auth_method=oidc). + vars: + - name: ansible_vyos_oidc_token_url + oidc_client_id: + type: str + description: + - OAuth2 client ID for the client credentials grant. + - Required when C(auth_method=oidc). + vars: + - name: ansible_vyos_oidc_client_id + oidc_client_secret: + type: str + description: + - OAuth2 client secret for the client credentials grant. + - Required when C(auth_method=oidc). + vars: + - name: ansible_vyos_oidc_client_secret +notes: + - Bearer tokens are cached in memory for the duration of the connection + and refreshed automatically 30 seconds before expiry. + - Token expiry is controlled on the device via + C(set service https api rest authentication expiration <seconds>). + - For mTLS, set C(ansible_httpapi_client_cert) and C(ansible_httpapi_client_key) + at the connection level. The netcommon httpapi connection plugin handles + the TLS handshake automatically. + - OIDC tokens are cached and refreshed using the C(expires_in) value + returned by the identity provider. +examples: | + # inventory.yml — form-field API key (default, backward-compatible) + all: + hosts: + vyos01: + ansible_host: 192.168.1.1 + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + ansible_httpapi_use_ssl: true + ansible_httpapi_validate_certs: false + ansible_httpapi_api_key: mysecretkey + + # inventory.yml — X-API-Key header + all: + hosts: + vyos01: + ansible_host: 192.168.1.1 + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + ansible_httpapi_use_ssl: true + ansible_httpapi_validate_certs: false + ansible_httpapi_api_key: mysecretkey + ansible_vyos_auth_method: header + + # inventory.yml — Bearer token (JWT) + all: + hosts: + vyos01: + ansible_host: 192.168.1.1 + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + ansible_httpapi_use_ssl: true + ansible_httpapi_validate_certs: false + ansible_httpapi_api_key: mysecretkey + ansible_vyos_auth_method: bearer + + # inventory.yml — mTLS client certificate + all: + hosts: + vyos01: + ansible_host: 192.168.1.1 + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + ansible_httpapi_use_ssl: true + ansible_httpapi_validate_certs: false + ansible_vyos_auth_method: mtls + ansible_httpapi_client_cert: /etc/ansible/certs/client.pem + ansible_httpapi_client_key: /etc/ansible/certs/client.key + + # inventory.yml — OIDC (Keycloak client credentials) + all: + hosts: + vyos01: + ansible_host: 192.168.1.1 + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + ansible_httpapi_use_ssl: true + ansible_httpapi_validate_certs: false + ansible_vyos_auth_method: oidc + ansible_vyos_oidc_token_url: https://keycloak.example.com/realms/vyos/protocol/openid-connect/token + ansible_vyos_oidc_client_id: vyos-api + ansible_vyos_oidc_client_secret: mysecret """ import json +import time import traceback try: from urllib.parse import urlencode + from urllib.request import Request, urlopen except ImportError: from urllib import urlencode @@ -53,12 +174,22 @@ from ansible.plugins.httpapi import HttpApiBase class HttpApi(HttpApiBase): """HttpApi plugin for the VyOS HTTPS REST API.""" + def __init__(self, *args, **kwargs): + super(HttpApi, self).__init__(*args, **kwargs) + self._bearer_token = None + self._bearer_token_expiry = 0 + self._oidc_token = None + self._oidc_token_expiry = 0 + def login(self, username, password): - """VyOS uses a static API key — no login endpoint needed.""" + """VyOS uses a static API key or external auth — no login endpoint needed.""" pass def logout(self): - pass + self._bearer_token = None + self._bearer_token_expiry = 0 + self._oidc_token = None + self._oidc_token_expiry = 0 def update_auth(self, response, response_text): return None @@ -67,8 +198,8 @@ class HttpApi(HttpApiBase): if exc.code == 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.", + "Check your authentication configuration and that " + "'set service https api rest' is enabled on the device.", ) return exc @@ -89,6 +220,107 @@ class HttpApi(HttpApiBase): ) return key + def _get_auth_method(self): + try: + return self.get_option("auth_method") or "key" + except Exception: + return "key" + + def _get_bearer_token(self): + """Return a cached VyOS JWT, refreshing if expired.""" + if self._bearer_token and time.time() < self._bearer_token_expiry - 30: + return self._bearer_token + + api_key = self._get_api_key() + form_data = urlencode({"key": api_key}) + response, response_data = self.connection.send( + "/token", + data=form_data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + raw = to_text(response_data.getvalue()) + try: + result = json.loads(raw) + except ValueError: + raise ConnectionError( + "VyOS /token returned non-JSON: {0}".format(raw[:300]), + ) + if not result.get("success"): + raise ConnectionError( + "VyOS /token error: {0}".format( + result.get("error") or "success=false", + ), + ) + token_data = result.get("data", {}) + self._bearer_token = token_data.get("token") + expires_in = token_data.get("expires_in", 3600) + self._bearer_token_expiry = time.time() + expires_in + return self._bearer_token + + def _get_oidc_token(self): + """Fetch an OIDC token from the IdP using client credentials, cache it.""" + if self._oidc_token and time.time() < self._oidc_token_expiry - 30: + return self._oidc_token + + try: + token_url = self.get_option("oidc_token_url") + client_id = self.get_option("oidc_client_id") + client_secret = self.get_option("oidc_client_secret") + except Exception: + token_url = client_id = client_secret = None + + if not token_url: + raise ConnectionError( + "ansible_vyos_oidc_token_url is required for auth_method=oidc.", + ) + if not client_id: + raise ConnectionError( + "ansible_vyos_oidc_client_id is required for auth_method=oidc.", + ) + if not client_secret: + raise ConnectionError( + "ansible_vyos_oidc_client_secret is required for auth_method=oidc.", + ) + + body = urlencode( + { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + ).encode("utf-8") + + try: + req = Request( + token_url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urlopen(req, timeout=10) as resp: + raw = resp.read().decode("utf-8") + except Exception as exc: + raise ConnectionError( + "OIDC token fetch failed from {0}: {1}".format(token_url, exc), + ) + + try: + token_response = json.loads(raw) + except ValueError: + raise ConnectionError( + "OIDC token endpoint returned non-JSON: {0}".format(raw[:300]), + ) + + if "access_token" not in token_response: + raise ConnectionError( + "OIDC token response missing access_token: {0}".format(raw[:300]), + ) + + self._oidc_token = token_response["access_token"] + expires_in = token_response.get("expires_in", 3600) + self._oidc_token_expiry = time.time() + expires_in + return self._oidc_token + def send_request(self, data, **payload): # pylint: disable=arguments-renamed """POST to a VyOS REST endpoint. @@ -106,21 +338,78 @@ class HttpApi(HttpApiBase): ConnectionError: on HTTP error or VyOS success=false response. """ endpoint = data + auth_method = self._get_auth_method() 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"}, - ) + if auth_method == "header": + api_key = self._get_api_key() + form_data = urlencode({"data": body}) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "X-API-Key": api_key, + } + response, response_data = self.connection.send( + endpoint, + data=form_data, + method="POST", + headers=headers, + ) + + elif auth_method == "bearer": + token = self._get_bearer_token() + form_data = urlencode({"data": body}) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Bearer {0}".format(token), + } + response, response_data = self.connection.send( + endpoint, + data=form_data, + method="POST", + headers=headers, + ) + + elif auth_method == "mtls": + # No API key sent — authentication is via client certificate + # configured at the connection level via ansible_httpapi_client_cert + # and ansible_httpapi_client_key. + form_data = urlencode({"data": body}) + response, response_data = self.connection.send( + endpoint, + data=form_data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + elif auth_method == "oidc": + token = self._get_oidc_token() + form_data = urlencode({"data": body}) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Bearer {0}".format(token), + } + response, response_data = self.connection.send( + endpoint, + data=form_data, + method="POST", + headers=headers, + ) + + else: + # default: key in form body (backward-compatible) + api_key = self._get_api_key() + 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"}, + ) raw = to_text(response_data.getvalue()) |
