summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/certification.yml53
-rw-r--r--.github/workflows/tests.yml5
-rw-r--r--README.md113
-rw-r--r--changelogs/fragments/t8989_aap_prereq.yml3
-rw-r--r--changelogs/fragments/t8989_auth_methods.yml19
-rw-r--r--docs/vyos.rest.vyos_httpapi.rst180
-rw-r--r--galaxy.yml2
-rw-r--r--meta/runtime.yml14
-rw-r--r--plugins/httpapi/vyos.py412
-rw-r--r--requirements.txt6
-rw-r--r--tests/unit/test_httpapi_vyos.py471
-rw-r--r--tox-ansible.ini1
12 files changed, 1086 insertions, 193 deletions
diff --git a/.github/workflows/certification.yml b/.github/workflows/certification.yml
new file mode 100644
index 0000000..2fb58dc
--- /dev/null
+++ b/.github/workflows/certification.yml
@@ -0,0 +1,53 @@
+---
+# This workflow calls a pinned version of the
+# reusable workflow. Copy this file into your
+# repository to check against pinned versions
+# of Automation Hub tests.
+# The version is pinned to an exact release tag
+# (e.g. @v1.2.0) for supply chain security.
+# Use Dependabot to receive automatic PRs when
+# new versions are released.
+name: Run collection certification checks
+
+permissions:
+ contents: read
+
+on:
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+ schedule:
+ - cron: "0 6 * * *"
+
+concurrency:
+ # Keyed on the PR number, not head_ref: pull requests from different forks can use the
+ # same branch name, which would make their runs cancel each other.
+ group: cert-ver-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: true
+
+# Files that are not related to the core functionality
+# of your collection can cause Ansible Lint to fail.
+# If this happens, add an .ansible-lint file that includes
+# those files and directories to the root of your
+# repository; for example:
+# https://github.com/ansible-collections/partner-certification-checker/blob/main/.ansible-lint
+
+# If there are sanity test failures that cannot be fixed and are allowed to ignore
+# https://docs.ansible.com/projects/lint/rules/sanity/, create a sanity ignore file
+# https://docs.ansible.com/projects/ansible/devel/dev_guide/testing/sanity/ignores.html#ignore-file-location
+# for each affected version of ansible-core (for example, `tests/sanity/ignore-2.18.txt`) and add corresponding entries.
+jobs:
+ call:
+ uses: ansible-collections/partner-certification-checker/.github/workflows/certification-reusable.yml@v6.0.0 # zizmor: ignore[unpinned-uses]
+ # For details on tested ansible-core branches and Python versions,
+ # see: https://github.com/ansible-collections/partner-certification-checker#tested-ansible-core-branches-and-python-versions
+ with:
+ # ansible-core-version: '2.18.0'
+ # skip-sanity-versions: 'stable-2.16'
+ # collection-root: 'ansible_collections/junipernetworks/junos'
+ collection-deps: 'ansible.netcommon'
+ # automation-hub: true
+ # Uncomment the lines below if you have 'automation-hub: true' and need to install collections from Red Hat Ansible Automation Hub.
+ # This requires setting a repository secret called 'AH_TOKEN' which value contains a Red Hat Ansible Automation Hub token.
+ # secrets:
+ # AH_TOKEN: ${{ secrets.AH_TOKEN }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index dedbc9e..6086077 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -46,7 +46,8 @@ jobs:
set([
'${{ needs.changelog.result }}',
'${{ needs.sanity.result }}',
- '${{ needs.unit-galaxy.result }}'
- '${{ needs.ansible-lint.result }}'
+ '${{ needs.unit-galaxy.result }}',
+ '${{ needs.ansible-lint.result }}',
+ '${{ needs.build-import.result }}',
'${{ needs.unit-source.result }}'
])"
diff --git a/README.md b/README.md
index d0c88d2..1b25f56 100644
--- a/README.md
+++ b/README.md
@@ -5,10 +5,12 @@ as an alternative to the SSH/CLI-based `vyos.vyos` collection.
## Requirements
-- VyOS 1.3 or later
-- Ansible >= 2.15.0
-- `ansible.netcommon` >= 2.5.1
-- `ansible.utils`
+- ansible-core >=2.16.0, see [Ansible version compatibility](#ansible-version-compatibility).
+- Python >=3.10 on the Ansible controller, as required by ansible-core 2.16.
+- [ansible.netcommon](https://galaxy.ansible.com/ui/repo/published/ansible/netcommon/) >=2.5.1, installed automatically as a dependency of this collection.
+- `ansible.utils`, installed automatically as a dependency of `ansible.netcommon`.
+- VyOS 1.3 or later with the REST API enabled and reachable over HTTPS with the `ansible.netcommon.httpapi` connection.
+- No additional Python packages are required by the collection's code.
## Configuring VyOS for REST API access
@@ -56,64 +58,77 @@ all:
## Installation
-```bash
-ansible-galaxy collection install vyos.rest
-```
+You can install the collection with the Ansible Galaxy CLI:
+
+ ansible-galaxy collection install vyos.rest
+
+This collection is distributed through Ansible Galaxy and is not yet available from Red Hat Ansible Automation Hub.
+Red Hat does not support installing collections from GitHub.
+<!-- TODO: once the collection is published on Automation Hub, add its installation link and the "Configuring Ansible Automation Hub" reference -->
+
+To install a specific version, for example 1.0.0:
+
+ ansible-galaxy collection install vyos.rest:==1.0.0
+
+To upgrade to the latest version:
-Or from source:
+ ansible-galaxy collection install vyos.rest --upgrade
-```bash
-git clone https://github.com/vyos/vyos.rest.git
-ansible-galaxy collection install vyos.rest/ --force
+You can also include it in a `requirements.yml` file and install it with `ansible-galaxy collection install -r requirements.yml`, using the format:
+
+```yaml
+---
+collections:
+ - name: vyos.rest
```
<!--start collection content-->
### 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/rest.vyos/blob/main/docs/vyos.rest.vyos_httpapi.rst)|HttpApi plugin for VyOS REST API
### Modules
Name | Description
--- | ---
-[vyos.rest.vyos_banner](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_banner_module.rst)|Manage multiline banners on VyOS devices via REST API.
-[vyos.rest.vyos_bgp_address_family](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_bgp_address_family_module.rst)|Manage BGP address-family configuration on VyOS devices using REST API
-[vyos.rest.vyos_bgp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_bgp_global_module.rst)|Manage BGP global configuration on VyOS devices using REST API
-[vyos.rest.vyos_command](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_command_module.rst)|Run show commands on VyOS devices using REST API
-[vyos.rest.vyos_config](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_config_module.rst)|Manage VyOS configuration using REST API
-[vyos.rest.vyos_configure](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_configure_module.rst)|Send raw set/delete commands to a VyOS device via REST API.
-[vyos.rest.vyos_facts](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_facts_module.rst)|Get facts about VyOS devices using REST API
-[vyos.rest.vyos_firewall_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_firewall_global_module.rst)|Manage global firewall configuration on VyOS devices using REST API
-[vyos.rest.vyos_firewall_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_firewall_interfaces_module.rst)|Manage firewall hook filters on VyOS devices using REST API
-[vyos.rest.vyos_firewall_rules](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_firewall_rules_module.rst)|Manage firewall rule sets on VyOS devices using REST API
-[vyos.rest.vyos_ha](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ha_module.rst)|Manage VRRP and load balancer configuration on VyOS via REST API
-[vyos.rest.vyos_hostname](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_hostname_module.rst)|Manage the system hostname on a VyOS device via the REST API.
-[vyos.rest.vyos_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_interfaces_module.rst)|Manage interface configuration on VyOS devices via REST API.
-[vyos.rest.vyos_l3_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_l3_interfaces_module.rst)|Manage L3 interface configuration on VyOS devices via REST API.
-[vyos.rest.vyos_lag_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lag_interfaces_module.rst)|Manage LAG interface configuration on VyOS devices via REST API.
-[vyos.rest.vyos_lldp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_global_module.rst)|Manage LLDP global configuration on VyOS via REST API.
-[vyos.rest.vyos_lldp_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_interfaces_module.rst)|Manage LLDP interface configuration on VyOS devices via REST API.
-[vyos.rest.vyos_logging_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API
-[vyos.rest.vyos_nat](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_nat_module.rst)|Manage NAT configuration on VyOS devices using REST API
-[vyos.rest.vyos_ntp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API
-[vyos.rest.vyos_ospf_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ospf_interfaces_module.rst)|Manage OSPF interface configuration on VyOS devices using REST API
-[vyos.rest.vyos_ospfv2](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ospfv2_module.rst)|Manage OSPFv2 configuration on VyOS devices using REST API
-[vyos.rest.vyos_ospfv3](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ospfv3_module.rst)|Manage OSPFv3 configuration on VyOS devices using REST API
-[vyos.rest.vyos_prefix_lists](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_prefix_lists_module.rst)|Manage prefix-list configuration on VyOS devices using REST API
-[vyos.rest.vyos_route_maps](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_route_maps_module.rst)|Manage route-map configuration on VyOS devices using REST API
-[vyos.rest.vyos_snmp_server](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API
-[vyos.rest.vyos_static_routes](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_static_routes_module.rst)|Manage static routes on VyOS devices via REST API.
-[vyos.rest.vyos_system](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_system_module.rst)|Manage system settings on VyOS devices using REST API
-[vyos.rest.vyos_user](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_user_module.rst)|Manage user accounts on VyOS devices using REST API
-[vyos.rest.vyos_vlan](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_vlan_module.rst)|Manage VLAN (vif) configuration on VyOS devices using REST API
-[vyos.rest.vyos_vrf](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_vrf_module.rst)|Manage VRF configuration on VyOS devices using REST API
+[vyos.rest.vyos_banner](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_banner_module.rst)|Manage multiline banners on VyOS devices via REST API.
+[vyos.rest.vyos_bgp_address_family](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_bgp_address_family_module.rst)|Manage BGP address-family configuration on VyOS devices using REST API
+[vyos.rest.vyos_bgp_global](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_bgp_global_module.rst)|Manage BGP global configuration on VyOS devices using REST API
+[vyos.rest.vyos_command](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_command_module.rst)|Run show commands on VyOS devices using REST API
+[vyos.rest.vyos_config](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_config_module.rst)|Manage VyOS configuration using REST API
+[vyos.rest.vyos_configure](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_configure_module.rst)|Send raw set/delete commands to a VyOS device via REST API.
+[vyos.rest.vyos_facts](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_facts_module.rst)|Get facts about VyOS devices using REST API
+[vyos.rest.vyos_firewall_global](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_firewall_global_module.rst)|Manage global firewall configuration on VyOS devices using REST API
+[vyos.rest.vyos_firewall_interfaces](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_firewall_interfaces_module.rst)|Manage firewall hook filters on VyOS devices using REST API
+[vyos.rest.vyos_firewall_rules](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_firewall_rules_module.rst)|Manage firewall rule sets on VyOS devices using REST API
+[vyos.rest.vyos_ha](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_ha_module.rst)|Manage VRRP and load balancer configuration on VyOS via REST API
+[vyos.rest.vyos_hostname](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_hostname_module.rst)|Manage the system hostname on a VyOS device via the REST API.
+[vyos.rest.vyos_interfaces](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_interfaces_module.rst)|Manage interface configuration on VyOS devices via REST API.
+[vyos.rest.vyos_l3_interfaces](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_l3_interfaces_module.rst)|Manage L3 interface configuration on VyOS devices via REST API.
+[vyos.rest.vyos_lag_interfaces](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_lag_interfaces_module.rst)|Manage LAG interface configuration on VyOS devices via REST API.
+[vyos.rest.vyos_lldp_global](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_lldp_global_module.rst)|Manage LLDP global configuration on VyOS via REST API.
+[vyos.rest.vyos_lldp_interfaces](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_lldp_interfaces_module.rst)|Manage LLDP interface configuration on VyOS devices via REST API.
+[vyos.rest.vyos_logging_global](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API
+[vyos.rest.vyos_nat](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_nat_module.rst)|Manage NAT configuration on VyOS devices using REST API
+[vyos.rest.vyos_ntp_global](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API
+[vyos.rest.vyos_ospf_interfaces](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_ospf_interfaces_module.rst)|Manage OSPF interface configuration on VyOS devices using REST API
+[vyos.rest.vyos_ospfv2](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_ospfv2_module.rst)|Manage OSPFv2 configuration on VyOS devices using REST API
+[vyos.rest.vyos_ospfv3](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_ospfv3_module.rst)|Manage OSPFv3 configuration on VyOS devices using REST API
+[vyos.rest.vyos_prefix_lists](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_prefix_lists_module.rst)|Manage prefix-list configuration on VyOS devices using REST API
+[vyos.rest.vyos_route_maps](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_route_maps_module.rst)|Manage route-map configuration on VyOS devices using REST API
+[vyos.rest.vyos_snmp_server](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API
+[vyos.rest.vyos_static_routes](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_static_routes_module.rst)|Manage static routes on VyOS devices via REST API.
+[vyos.rest.vyos_system](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_system_module.rst)|Manage system settings on VyOS devices using REST API
+[vyos.rest.vyos_user](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_user_module.rst)|Manage user accounts on VyOS devices using REST API
+[vyos.rest.vyos_vlan](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_vlan_module.rst)|Manage VLAN (vif) configuration on VyOS devices using REST API
+[vyos.rest.vyos_vrf](https://github.com/vyos/rest.vyos/blob/main/docs/vyos.rest.vyos_vrf_module.rst)|Manage VRF configuration on VyOS devices using REST API
<!--end collection content-->
<!--start requires_ansible-->
## Ansible version compatibility
-This collection has been tested against the following Ansible versions: **>=2.15.0**.
+This collection has been tested against the following Ansible versions: **>=2.16.0**.
Plugins and modules within a collection may be tested with only specific Ansible versions.
A collection may contain metadata that identifies these versions.
@@ -134,3 +149,13 @@ automation.
| Connection plugin | `ansible.netcommon.network_cli` | `ansible.netcommon.httpapi` |
| VyOS requirement | Any | VyOS 1.3+ with REST API enabled |
| Atomic commits | Per-command | Batch (single commit) |
+
+## Support
+
+The collection is maintained by the VyOS team. <!-- TODO: VyOS commercial support statement/link -->
+Community help is available through [GitHub issues](https://github.com/vyos/rest.vyos/issues), the [VyOS forum](https://forum.vyos.io/)
+and the [Ansible Forum](https://forum.ansible.com/).
+
+Once this collection is certified and available on Red Hat Ansible Automation Hub, Red Hat customers can open support cases
+through Ansible Automation Platform (AAP) using the **Create issue** button on the collection page in Automation Hub.
+<!-- TODO: when certified, replace the paragraph above with the standard support text from Red Hat's README template -->
diff --git a/changelogs/fragments/t8989_aap_prereq.yml b/changelogs/fragments/t8989_aap_prereq.yml
new file mode 100644
index 0000000..ef7a90d
--- /dev/null
+++ b/changelogs/fragments/t8989_aap_prereq.yml
@@ -0,0 +1,3 @@
+---
+minor_changes:
+ - Added / Modified the items to meet AAP requirements.
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>&nbsp;&larr;</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>&nbsp;&larr;</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>&nbsp;&larr;</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/galaxy.yml b/galaxy.yml
index 4b6d9a9..3ef0375 100644
--- a/galaxy.yml
+++ b/galaxy.yml
@@ -8,7 +8,7 @@ name: rest
description: Ansible Network Collection for REST-enabled VYOS devices.
namespace: vyos
readme: README.md
-repository: https://github.com/vyos/vyos.rest
+repository: https://github.com/vyos/rest.vyos
issues: https://vyos.dev
tags: [vyos, networking]
version: 1.0.0
diff --git a/meta/runtime.yml b/meta/runtime.yml
index 04ed2da..1db0f16 100644
--- a/meta/runtime.yml
+++ b/meta/runtime.yml
@@ -1,17 +1,5 @@
---
-requires_ansible: ">=2.15.0"
-
-# action_groups allow users to set shared connection params once using
-# module_defaults:
-#
-# - hosts: vyos_devices
-# module_defaults:
-# group/vyos.rest.api:
-# api_key: "{{ vyos_api_key }}"
-# verify_ssl: false
-#
-# This sets hostname/api_key/port/verify_ssl/timeout for ALL modules in
-# the group without repeating them on every task.
+requires_ansible: ">=2.16.0"
action_groups:
api:
- vyos_banner
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/requirements.txt b/requirements.txt
index ee91c10..ea07c16 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,4 @@
-paramiko
-scp
+# This collection needs no Python packages beyond ansible-core and the
+# collections declared in galaxy.yml. paramiko and scp belong to the SSH-based
+# vyos.vyos collection, not to this REST-based one. urllib3 is used when it is
+# installed (to silence insecure-request warnings) but is optional.
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))
diff --git a/tox-ansible.ini b/tox-ansible.ini
index 030a3b0..5d3c8dd 100644
--- a/tox-ansible.ini
+++ b/tox-ansible.ini
@@ -1,5 +1,4 @@
[ansible]
skip =
- py3.12
py3.13
py3.14