summaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-09-04 13:24:44 +1000
committeromnom62 <omnom62@outlook.com>2026-09-04 13:24:44 +1000
commitecc84b8d9b74d5c45ee073c6c4cd603cfbf05406 (patch)
tree04555e259bd52c8107b65019229ba51b9d398dae /plugins
parent97c43a34bd953a06bfe6c4b010ab79f645bac061 (diff)
downloadrest.vyos-ecc84b8d9b74d5c45ee073c6c4cd603cfbf05406.tar.gz
rest.vyos-ecc84b8d9b74d5c45ee073c6c4cd603cfbf05406.zip
T8989: new auth methods
Diffstat (limited to 'plugins')
-rw-r--r--plugins/httpapi/vyos.py313
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())