├── changelogs ├── fragments │ ├── .keep │ ├── 426_ip_service_vrf.yml │ ├── 422_add_path_disk_settings.yml │ ├── 425_ospf-use-bfd.yml │ ├── 423_add_missing_parameters_for_interface_bridge.yml │ └── 424_add_traffic_processing_field.yml ├── changelog.yaml.license └── config.yaml ├── LICENSES ├── GPL-3.0-or-later.txt └── BSD-2-Clause.txt ├── tests ├── sanity │ ├── ignore-2.12.txt │ ├── ignore-2.13.txt │ ├── ignore-2.14.txt │ ├── ignore-2.15.txt │ ├── ignore-2.16.txt │ ├── ignore-2.17.txt │ ├── ignore-2.18.txt │ ├── ignore-2.19.txt │ ├── ignore-2.20.txt │ ├── ignore-2.21.txt │ ├── ignore-2.10.txt.license │ ├── ignore-2.11.txt.license │ ├── ignore-2.12.txt.license │ ├── ignore-2.13.txt.license │ ├── ignore-2.14.txt.license │ ├── ignore-2.15.txt.license │ ├── ignore-2.16.txt.license │ ├── ignore-2.17.txt.license │ ├── ignore-2.18.txt.license │ ├── ignore-2.19.txt.license │ ├── ignore-2.20.txt.license │ ├── ignore-2.21.txt.license │ ├── ignore-2.9.txt.license │ ├── ignore-2.11.txt │ ├── ignore-2.10.txt │ └── ignore-2.9.txt ├── unit │ ├── plugins │ │ ├── modules │ │ │ ├── fixtures │ │ │ │ ├── facts │ │ │ │ │ ├── system_identity_print_without-paging │ │ │ │ │ ├── ipv6_address_print_detail_without-paging_no-ipv6 │ │ │ │ │ ├── export.license │ │ │ │ │ ├── export_verbose.license │ │ │ │ │ ├── ip_route_print_detail_without-paging.license │ │ │ │ │ ├── system_identity_print_without-paging.license │ │ │ │ │ ├── system_resource_print_without-paging.license │ │ │ │ │ ├── interface_print_detail_without-paging.license │ │ │ │ │ ├── ip_address_print_detail_without-paging.license │ │ │ │ │ ├── ip_neighbor_print_detail_without-paging.license │ │ │ │ │ ├── ipv6_address_print_detail_without-paging.license │ │ │ │ │ ├── system_routerboard_print_without-paging.license │ │ │ │ │ ├── ipv6_address_print_detail_without-paging │ │ │ │ │ ├── routing_bgp_peer_print_detail_without-paging.license │ │ │ │ │ ├── ipv6_address_print_detail_without-paging_no-ipv6.license │ │ │ │ │ ├── routing_bgp_instance_print_detail_without-paging.license │ │ │ │ │ ├── routing_bgp_vpnv4-route_print_detail_without-paging.license │ │ │ │ │ ├── routing_ospf_instance_print_detail_without-paging.license │ │ │ │ │ ├── routing_ospf_neighbor_print_detail_without-paging.license │ │ │ │ │ ├── system_routerboard_print_without-paging │ │ │ │ │ ├── routing_ospf_neighbor_print_detail_without-paging │ │ │ │ │ ├── ip_address_print_detail_without-paging │ │ │ │ │ ├── routing_bgp_vpnv4-route_print_detail_without-paging │ │ │ │ │ ├── routing_bgp_instance_print_detail_without-paging │ │ │ │ │ ├── system_resource_print_without-paging │ │ │ │ │ ├── routing_ospf_instance_print_detail_without-paging │ │ │ │ │ ├── routing_bgp_peer_print_detail_without-paging │ │ │ │ │ ├── export │ │ │ │ │ ├── ip_route_print_detail_without-paging │ │ │ │ │ ├── export_verbose │ │ │ │ │ ├── ip_neighbor_print_detail_without-paging │ │ │ │ │ └── interface_print_detail_without-paging │ │ │ │ ├── system_package_print.license │ │ │ │ ├── system_resource_print.license │ │ │ │ ├── system_resource_print │ │ │ │ └── system_package_print │ │ │ ├── routeros_module.py │ │ │ ├── test_command.py │ │ │ └── fake_api.py │ │ └── module_utils │ │ │ ├── test__api_data.py │ │ │ ├── test_quoting.py │ │ │ └── test__api_helper.py │ ├── requirements.yml │ └── requirements.txt ├── ee │ ├── roles │ │ ├── filter_quoting │ │ │ ├── aliases │ │ │ └── tasks │ │ │ │ └── main.yml │ │ └── smoke │ │ │ └── tasks │ │ │ └── main.yml │ └── all.yml ├── integration │ ├── requirements.yml │ └── targets │ │ └── filter_quoting │ │ ├── aliases │ │ └── tasks │ │ └── main.yml ├── config.yml └── update-docs.py ├── CHANGELOG.md.license ├── CHANGELOG.rst.license ├── meta ├── ee-requirements.txt ├── execution-environment.yml └── runtime.yml ├── docs └── docsite │ ├── config.yml │ ├── extra-docs.yml │ ├── links.yml │ └── rst │ ├── quoting.rst │ ├── ssh-guide.rst │ └── api-guide.rst ├── codecov.yml ├── .git-blame-ignore-revs ├── .github ├── patchback.yml ├── dependabot.yml └── workflows │ ├── nox.yml │ ├── docs-push.yml │ └── docs-pr.yml ├── REUSE.toml ├── plugins ├── module_utils │ ├── version.py │ ├── _api_helper.py │ ├── api.py │ ├── routeros.py │ └── quoting.py ├── filter │ ├── quote_argument.yml │ ├── quote_argument_value.yml │ ├── split.yml │ ├── join.yml │ ├── list_to_dict.yml │ └── quoting.py ├── terminal │ └── routeros.py ├── cliconf │ └── routeros.py ├── doc_fragments │ ├── attributes.py │ └── api.py └── modules │ ├── command.py │ └── api_find_and_modify.py ├── galaxy.yml ├── .yamllint ├── .yamllint-extra-docs ├── .yamllint-docs ├── .yamllint-examples ├── noxfile.py ├── .gitignore ├── antsibull-nox.toml └── README.md /changelogs/fragments/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSES/GPL-3.0-or-later.txt: -------------------------------------------------------------------------------- 1 | ../COPYING -------------------------------------------------------------------------------- /tests/sanity/ignore-2.12.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.13.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.14.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.15.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.16.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.17.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.18.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.19.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.20.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.21.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py shebang 2 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/system_identity_print_without-paging: -------------------------------------------------------------------------------- 1 | name: MikroTik 2 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ipv6_address_print_detail_without-paging_no-ipv6: -------------------------------------------------------------------------------- 1 | bad command name address (line 1 column 7) 2 | -------------------------------------------------------------------------------- /changelogs/fragments/426_ip_service_vrf.yml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - api_info, api_modify - add ``vrf`` to ``ip service`` (https://github.com/ansible-collections/community.routeros/pull/426). 3 | -------------------------------------------------------------------------------- /changelogs/fragments/422_add_path_disk_settings.yml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - api_info, api_modify - add support for path ``disk settings`` (https://github.com/ansible-collections/community.routeros/pull/422). -------------------------------------------------------------------------------- /changelogs/fragments/425_ospf-use-bfd.yml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - api_info, api_modify - add ``use-bfd`` to ``routing ospf interface-template`` path (https://github.com/ansible-collections/community.routeros/pull/425). 3 | -------------------------------------------------------------------------------- /CHANGELOG.md.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /CHANGELOG.rst.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /changelogs/changelog.yaml.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.10.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.11.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.12.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.13.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.14.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.15.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.16.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.17.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.18.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.19.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.20.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.21.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.9.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /meta/ee-requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | librouteros 6 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/export.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /changelogs/fragments/423_add_missing_parameters_for_interface_bridge.yml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - api_info, api_modify - add missing parameters to path ``interface bridge`` and ``interface bridge port`` (https://github.com/ansible-collections/community.routeros/pull/423). -------------------------------------------------------------------------------- /tests/sanity/ignore-2.11.txt: -------------------------------------------------------------------------------- 1 | tests/update-docs.py compile-2.6 2 | tests/update-docs.py compile-2.7 3 | tests/update-docs.py compile-3.5 4 | tests/update-docs.py future-import-boilerplate 5 | tests/update-docs.py metaclass-boilerplate 6 | tests/update-docs.py shebang 7 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/export_verbose.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/system_package_print.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/system_resource_print.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /changelogs/fragments/424_add_traffic_processing_field.yml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - api_info, api_modify - add ``traffic-processing`` field to path ``interface wifi datapath`` and ``interface wifi configuration`` (https://github.com/ansible-collections/community.routeros/pull/424). 3 | -------------------------------------------------------------------------------- /docs/docsite/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | changelog: 7 | write_changelog: true 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | fixes: 7 | - "ansible_collections/community/routeros/::" 8 | -------------------------------------------------------------------------------- /tests/ee/roles/filter_quoting/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | shippable/posix/group1 6 | skip/python2.6 7 | -------------------------------------------------------------------------------- /tests/integration/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | collections: 7 | - ansible.netcommon 8 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ip_route_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/system_identity_print_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/system_resource_print_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/interface_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ip_address_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ip_neighbor_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ipv6_address_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/system_routerboard_print_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | collections: 7 | - community.internal_test_tools 8 | -------------------------------------------------------------------------------- /tests/integration/targets/filter_quoting/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | shippable/posix/group1 6 | skip/python2.6 7 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ipv6_address_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: X - disabled, I - invalid, D - dynamic, G - global, L - link-local 2 | 0 DL address=fe80::21c:42ff:fe36:5290/64 from-pool="" interface=ether1 3 | actual-interface=ether1 eui-64=no advertise=no no-dad=no 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_bgp_peer_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ipv6_address_print_detail_without-paging_no-ipv6.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_bgp_instance_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_bgp_vpnv4-route_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_ospf_instance_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_ospf_neighbor_print_detail_without-paging.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/system_routerboard_print_without-paging: -------------------------------------------------------------------------------- 1 | routerboard: yes 2 | model: RouterBOARD 3011UiAS 3 | serial-number: 1234567890 4 | firmware-type: ipq8060 5 | factory-firmware: 3.41 6 | current-firmware: 3.41 7 | upgrade-firmware: 6.42.2 8 | -------------------------------------------------------------------------------- /meta/execution-environment.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | version: 1 7 | dependencies: 8 | python: meta/ee-requirements.txt 9 | -------------------------------------------------------------------------------- /tests/unit/requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | unittest2 ; python_version <= '2.6' 6 | ordereddict ; python_version <= '2.6' 7 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_ospf_neighbor_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | 0 instance=default router-id=10.10.100.1 address=10.10.1.2 interface=GRE_TYRMA priority=1 2 | dr-address=0.0.0.0 backup-dr-address=0.0.0.0 state="Full" state-changes=15 ls-retransmits=0 3 | ls-requests=0 db-summaries=0 adjacency=6h8m46s 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | # Reformat YAML: https://github.com/ansible-collections/community.routeros/pull/369 6 | 08152376de116e7d933d19ee25318f7a2eb222ae 7 | -------------------------------------------------------------------------------- /docs/docsite/extra-docs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | sections: 7 | - title: Guides 8 | toctree: 9 | - api-guide 10 | - ssh-guide 11 | - quoting 12 | -------------------------------------------------------------------------------- /.github/patchback.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | backport_branch_prefix: patchback/backports/ 7 | backport_label_prefix: backport- 8 | target_branch_prefix: stable- 9 | ... 10 | -------------------------------------------------------------------------------- /meta/runtime.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | requires_ansible: '>=2.15.0' 7 | action_groups: 8 | api: 9 | - api 10 | - api_facts 11 | - api_find_and_modify 12 | - api_info 13 | - api_modify 14 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.10.txt: -------------------------------------------------------------------------------- 1 | docs/docsite/rst/api-guide.rst rstcheck 2 | docs/docsite/rst/quoting.rst rstcheck 3 | docs/docsite/rst/ssh-guide.rst rstcheck 4 | tests/update-docs.py compile-2.6 5 | tests/update-docs.py compile-2.7 6 | tests/update-docs.py compile-3.5 7 | tests/update-docs.py future-import-boilerplate 8 | tests/update-docs.py metaclass-boilerplate 9 | tests/update-docs.py shebang 10 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.9.txt: -------------------------------------------------------------------------------- 1 | docs/docsite/rst/api-guide.rst rstcheck 2 | docs/docsite/rst/quoting.rst rstcheck 3 | docs/docsite/rst/ssh-guide.rst rstcheck 4 | tests/update-docs.py compile-2.6 5 | tests/update-docs.py compile-2.7 6 | tests/update-docs.py compile-3.5 7 | tests/update-docs.py future-import-boilerplate 8 | tests/update-docs.py metaclass-boilerplate 9 | tests/update-docs.py shebang 10 | -------------------------------------------------------------------------------- /tests/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | # See template for more information: 7 | # https://github.com/ansible/ansible/blob/devel/test/lib/ansible_test/config/config.yml 8 | modules: 9 | python_requires: default 10 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | version = 1 6 | 7 | [[annotations]] 8 | path = "changelogs/fragments/**" 9 | precedence = "aggregate" 10 | SPDX-FileCopyrightText = "Ansible Project" 11 | SPDX-License-Identifier = "GPL-3.0-or-later" 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | ci: 14 | patterns: 15 | - "*" 16 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ip_address_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: X - disabled, I - invalid, D - dynamic 2 | 0 ;;; defconf 3 | address=192.168.88.1/24 network=192.168.88.0 interface=ether1 4 | actual-interface=ether1 5 | 6 | 1 D address=10.37.129.3/24 network=10.37.129.0 interface=ether1 7 | actual-interface=ether1 8 | 9 | 2 D address=10.37.0.0/24 network=10.37.0.1 interface=pppoe 10 | actual-interface=pppoe 11 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_bgp_vpnv4-route_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: L - label-present 2 | 0 L route-distinguisher=64520:666 dst-address=10.10.66.8/30 gateway=10.10.100.1 3 | interface=GRE_TYRMA in-label=6136 out-label=6136 bgp-local-pref=100 4 | bgp-origin=incomplete bgp-ext-communities="RT:64520:666" 5 | 6 | 1 L route-distinguisher=64520:666 dst-address=10.10.66.0/30 interface=bridge1 7 | in-label=1790 bgp-ext-communities="RT:64520:666" 8 | -------------------------------------------------------------------------------- /plugins/module_utils/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2021, Felix Fontein 4 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """Provide version object to compare version numbers.""" 8 | 9 | from __future__ import absolute_import, division, print_function 10 | __metaclass__ = type 11 | 12 | 13 | from ansible.module_utils.compat.version import LooseVersion # pylint: disable=unused-import 14 | -------------------------------------------------------------------------------- /tests/ee/all.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | - hosts: localhost 7 | tasks: 8 | - name: Find all roles 9 | find: 10 | paths: 11 | - "{{ (playbook_dir | default('.')) ~ '/roles' }}" 12 | file_type: directory 13 | depth: 1 14 | register: result 15 | - name: Include all roles 16 | include_role: 17 | name: "{{ item }}" 18 | loop: "{{ result.files | map(attribute='path') | map('regex_replace', '.*/', '') | sort }}" 19 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_bgp_instance_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: * - default, X - disabled 2 | 0 *X name="default" as=65530 router-id=0.0.0.0 redistribute-connected=no 3 | redistribute-static=no redistribute-rip=no redistribute-ospf=no 4 | redistribute-other-bgp=no out-filter="" client-to-client-reflection=yes 5 | ignore-as-path-len=no routing-table="" 6 | 7 | 1 name="MAIN_AS_STARKDV" as=64520 router-id=10.10.50.1 8 | redistribute-connected=no redistribute-static=no redistribute-rip=no 9 | redistribute-ospf=no redistribute-other-bgp=no out-filter="" 10 | client-to-client-reflection=yes ignore-as-path-len=no routing-table="" 11 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/system_resource_print_without-paging: -------------------------------------------------------------------------------- 1 | uptime: 3h28m52s 2 | version: 6.42.5 (stable) 3 | build-time: Jun/26/2018 12:12:08 4 | free-memory: 988.3MiB 5 | total-memory: 1010.8MiB 6 | cpu: Intel(R) 7 | cpu-count: 2 8 | cpu-frequency: 2496MHz 9 | cpu-load: 0% 10 | free-hdd-space: 63.4GiB 11 | total-hdd-space: 63.5GiB 12 | write-sect-since-reboot: 4576 13 | write-sect-total: 4576 14 | architecture-name: x86 15 | board-name: x86 16 | platform: MikroTik 17 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/system_resource_print: -------------------------------------------------------------------------------- 1 | [admin@RB1100test] /system resource> print 2 | uptime: 2w1d23h34m57s 3 | version: "5.0rc1" 4 | free-memory: 385272KiB 5 | total-memory: 516708KiB 6 | cpu: "e500v2" 7 | cpu-count: 1 8 | cpu-frequency: 799MHz 9 | cpu-load: 9% 10 | free-hdd-space: 466328KiB 11 | total-hdd-space: 520192KiB 12 | write-sect-since-reboot: 1411 13 | write-sect-total: 70625 14 | bad-blocks: 0.2% 15 | architecture-name: "powerpc" 16 | board-name: "RB1100" 17 | platform: "MikroTik" 18 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_ospf_instance_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: X - disabled, * - default 2 | 0 * name="default" router-id=10.10.50.1 distribute-default=never redistribute-connected=no 3 | redistribute-static=no redistribute-rip=no redistribute-bgp=no redistribute-other-ospf=no 4 | metric-default=1 metric-connected=20 metric-static=20 metric-rip=20 metric-bgp=auto 5 | metric-other-ospf=auto in-filter=ospf-in out-filter=ospf-out 6 | 7 | 1 name="OSPF_ALTEGRO" router-id=10.10.66.1 distribute-default=never redistribute-connected=no 8 | redistribute-static=no redistribute-rip=no redistribute-bgp=no redistribute-other-ospf=no 9 | metric-default=1 metric-connected=20 metric-static=20 metric-rip=20 metric-bgp=auto 10 | metric-other-ospf=auto in-filter=ospf-in out-filter=ospf-out routing-table=altegro 11 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/routing_bgp_peer_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: X - disabled, E - established 2 | 0 E name="iBGP_BRAS.TYRMA" instance=MAIN_AS_STARKDV remote-address=10.10.100.1 3 | remote-as=64520 tcp-md5-key="" nexthop-choice=default multihop=no 4 | route-reflect=yes hold-time=3m ttl=default in-filter="" out-filter="" 5 | address-families=ip,l2vpn,vpnv4 update-source=LAN_KHV 6 | default-originate=never remove-private-as=no as-override=no passive=no 7 | use-bfd=yes 8 | 9 | 1 E name="iBGP_BRAS_SAT" instance=MAIN_AS_STARKDV remote-address=10.10.50.230 10 | remote-as=64520 tcp-md5-key="" nexthop-choice=default multihop=no 11 | route-reflect=yes hold-time=3m ttl=default in-filter="" out-filter="" 12 | address-families=ip default-originate=never remove-private-as=no 13 | as-override=no passive=no use-bfd=yes 14 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/export: -------------------------------------------------------------------------------- 1 | # sep/25/2018 10:10:52 by RouterOS 6.42.5 2 | # software id = 9EER-511K 3 | # 4 | # 5 | # 6 | /interface wireless security-profiles 7 | set [ find default=yes ] supplicant-identity=MikroTik 8 | /tool user-manager customer 9 | set admin access=own-routers,own-users,own-profiles,own-limits,config-payment-gw 10 | /ip address 11 | add address=192.168.88.1/24 comment=defconf interface=ether1 network=192.168.88.0 12 | /ip dhcp-client 13 | add dhcp-options=hostname,clientid disabled=no interface=ether1 14 | /system lcd page 15 | set time disabled=yes display-time=5s 16 | set resources disabled=yes display-time=5s 17 | set uptime disabled=yes display-time=5s 18 | set packets disabled=yes display-time=5s 19 | set bits disabled=yes display-time=5s 20 | set version disabled=yes display-time=5s 21 | set identity disabled=yes display-time=5s 22 | set ether1 disabled=yes display-time=5s 23 | /tool user-manager database 24 | set db-path=user-manager 25 | -------------------------------------------------------------------------------- /plugins/filter/quote_argument.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | DOCUMENTATION: 7 | name: quote_argument 8 | short_description: Quote an argument 9 | version_added: 2.0.0 10 | description: 11 | - Quote an argument. 12 | options: 13 | _input: 14 | description: 15 | - An argument to quote. 16 | type: string 17 | required: true 18 | author: 19 | - Felix Fontein (@felixfontein) 20 | 21 | EXAMPLES: | 22 | --- 23 | - name: Quote a RouterOS CLI command argument 24 | ansible.builtin.set_fact: 25 | quoted: >- 26 | {{ 'comment=this is a "comment"' | community.routeros.quote_argument }} 27 | # Should result in 'comment="this is a \"comment\""' 28 | 29 | RETURN: 30 | _value: 31 | description: The quoted argument. 32 | type: string 33 | -------------------------------------------------------------------------------- /plugins/filter/quote_argument_value.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | DOCUMENTATION: 7 | name: quote_argument_value 8 | short_description: Quote an argument value 9 | version_added: 2.0.0 10 | description: 11 | - Quote an argument value. 12 | options: 13 | _input: 14 | description: 15 | - An argument value to quote. 16 | type: string 17 | required: true 18 | author: 19 | - Felix Fontein (@felixfontein) 20 | 21 | EXAMPLES: | 22 | --- 23 | - name: Quote a RouterOS CLI command argument's value 24 | ansible.builtin.set_fact: 25 | quoted: >- 26 | {{ 'this is a "comment"' | community.routeros.quote_argument_value }} 27 | # Should result in '"this is a \"comment\""' 28 | 29 | RETURN: 30 | _value: 31 | description: The quoted argument value. 32 | type: string 33 | -------------------------------------------------------------------------------- /plugins/filter/split.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | DOCUMENTATION: 7 | name: split 8 | short_description: Split a command into arguments 9 | version_added: 2.0.0 10 | description: 11 | - Split a command into arguments. 12 | options: 13 | _input: 14 | description: 15 | - A command. 16 | type: string 17 | required: true 18 | author: 19 | - Felix Fontein (@felixfontein) 20 | 21 | EXAMPLES: | 22 | --- 23 | - name: Split command into list of arguments 24 | ansible.builtin.set_fact: 25 | argument_list: >- 26 | {{ 'foo=bar comment="foo is bar" baz' | community.routeros.split }} 27 | # Should result in ['foo=bar', 'comment=foo is bar', 'baz'] 28 | 29 | RETURN: 30 | _value: 31 | description: The list of arguments. 32 | type: list 33 | elements: string 34 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ip_route_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: X - disabled, A - active, D - dynamic, 2 | C - connect, S - static, r - rip, b - bgp, o - ospf, m - mme, 3 | B - blackhole, U - unreachable, P - prohibit 4 | 0 ADC dst-address=10.10.66.0/30 pref-src=10.10.66.1 gateway=bridge1 5 | gateway-status=bridge1 reachable distance=0 scope=10 6 | routing-mark=altegro 7 | 8 | 2 A S dst-address=0.0.0.0/0 gateway=85.15.75.109 9 | gateway-status=85.15.75.109 reachable via Internet-VTK distance=1 10 | scope=30 target-scope=10 11 | 12 | 3 ADC dst-address=10.10.1.0/30 pref-src=10.10.1.1 gateway=GRE_TYRMA 13 | gateway-status=GRE_TYRMA reachable distance=0 scope=10 14 | 15 | 4 DC dst-address=10.10.1.4/30 pref-src=10.10.1.5 gateway=RB2011 16 | gateway-status=RB2011 unreachable distance=255 scope=10 17 | 18 | 5 ADC dst-address=10.10.2.0/30 pref-src=10.10.2.1 gateway=VLAN_SAT.ROUTER 19 | gateway-status=VLAN_SAT.ROUTER reachable distance=0 scope=10 20 | -------------------------------------------------------------------------------- /.github/workflows/nox.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | name: nox 7 | 'on': 8 | push: 9 | branches: 10 | - main 11 | - stable-* 12 | pull_request: 13 | # Run CI once per day (at 05:15 UTC) 14 | schedule: 15 | - cron: '15 5 * * *' 16 | workflow_dispatch: 17 | 18 | jobs: 19 | nox: 20 | runs-on: ubuntu-latest 21 | name: "Run extra sanity tests" 22 | steps: 23 | - name: Check out collection 24 | uses: actions/checkout@v6 25 | with: 26 | persist-credentials: false 27 | - name: Run nox 28 | uses: ansible-community/antsibull-nox@main 29 | 30 | ansible-test: 31 | uses: ansible-community/antsibull-nox/.github/workflows/reusable-nox-matrix.yml@main 32 | with: 33 | upload-codecov: true 34 | secrets: 35 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /plugins/filter/join.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | DOCUMENTATION: 7 | name: join 8 | short_description: Join a list of arguments to a command 9 | version_added: 2.0.0 10 | description: 11 | - Join and quotes a list of arguments to a command. 12 | options: 13 | _input: 14 | description: 15 | - A list of arguments to quote and join. 16 | type: list 17 | elements: string 18 | required: true 19 | author: 20 | - Felix Fontein (@felixfontein) 21 | 22 | EXAMPLES: | 23 | --- 24 | - name: Join arguments for a RouterOS CLI command 25 | ansible.builtin.set_fact: 26 | arguments: "{{ ['foo=bar', 'comment=foo is bar'] | community.routeros.join }}" 27 | # Should result in 'foo=bar comment="foo is bar"' 28 | 29 | RETURN: 30 | _value: 31 | description: The joined and quoted result. 32 | type: string 33 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/export_verbose: -------------------------------------------------------------------------------- 1 | # sep/25/2018 10:10:52 by RouterOS 6.42.5 2 | # software id = 9EER-511K 3 | # 4 | # 5 | # 6 | /interface wireless security-profiles 7 | set [ find default=yes ] supplicant-identity=MikroTik 8 | /tool user-manager customer 9 | set admin access=own-routers,own-users,own-profiles,own-limits,config-payment-gw 10 | /ip address 11 | add address=192.168.88.1/24 comment=defconf interface=ether1 network=192.168.88.0 12 | /ip dhcp-client 13 | add dhcp-options=hostname,clientid disabled=no interface=ether1 14 | /system lcd 15 | set contrast=0 enabled=no port=parallel type=24x4 16 | /system lcd page 17 | set time disabled=yes display-time=5s 18 | set resources disabled=yes display-time=5s 19 | set uptime disabled=yes display-time=5s 20 | set packets disabled=yes display-time=5s 21 | set bits disabled=yes display-time=5s 22 | set version disabled=yes display-time=5s 23 | set identity disabled=yes display-time=5s 24 | set ether1 disabled=yes display-time=5s 25 | /tool user-manager database 26 | set db-path=user-manager 27 | -------------------------------------------------------------------------------- /galaxy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | # See https://docs.ansible.com/ansible/latest/dev_guide/collections_galaxy_meta.html 7 | 8 | namespace: community 9 | name: routeros 10 | version: 3.15.0 11 | readme: README.md 12 | authors: 13 | - Egor Zaitsev (github.com/heuels) 14 | - Nikolay Dachev (github.com/NikolayDachev) 15 | - Felix Fontein (github.com/felixfontein) 16 | description: Modules and plugins for MikroTik RouterOS 17 | license: 18 | - GPL-3.0-or-later 19 | # license_file: COPYING 20 | tags: 21 | - network 22 | - mikrotik 23 | - routeros 24 | dependencies: 25 | ansible.netcommon: '>=1.0.0' 26 | repository: https://github.com/ansible-collections/community.routeros 27 | documentation: https://docs.ansible.com/ansible/devel/collections/community/routeros/ 28 | homepage: https://github.com/ansible-collections/community.routeros 29 | issues: https://github.com/ansible-collections/community.routeros/issues 30 | build_ignore: 31 | - .gitignore 32 | - changelogs/.plugin-cache.yaml 33 | -------------------------------------------------------------------------------- /LICENSES/BSD-2-Clause.txt: -------------------------------------------------------------------------------- 1 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 2 | 3 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 4 | 5 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 6 | 7 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 8 | 9 | -------------------------------------------------------------------------------- /changelogs/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | changelog_filename_template: ../CHANGELOG.rst 7 | changelog_filename_version_depth: 0 8 | changes_file: changelog.yaml 9 | changes_format: combined 10 | ignore_other_fragment_extensions: true 11 | keep_fragments: false 12 | mention_ancestor: true 13 | new_plugins_after_name: removed_features 14 | notesdir: fragments 15 | output_formats: 16 | - rst 17 | - md 18 | prelude_section_name: release_summary 19 | prelude_section_title: Release Summary 20 | sections: 21 | - - major_changes 22 | - Major Changes 23 | - - minor_changes 24 | - Minor Changes 25 | - - breaking_changes 26 | - Breaking Changes / Porting Guide 27 | - - deprecated_features 28 | - Deprecated Features 29 | - - removed_features 30 | - Removed Features (previously deprecated) 31 | - - security_fixes 32 | - Security Fixes 33 | - - bugfixes 34 | - Bugfixes 35 | - - known_issues 36 | - Known Issues 37 | title: Community RouterOS 38 | trivial_section_name: trivial 39 | use_fqcn: true 40 | add_plugin_period: true 41 | changelog_nice_yaml: true 42 | changelog_sort: version 43 | vcs: auto 44 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | # SPDX-FileCopyrightText: 2025 Felix Fontein 5 | 6 | extends: default 7 | 8 | ignore: | 9 | /changelogs/ 10 | 11 | rules: 12 | line-length: 13 | max: 300 14 | level: error 15 | document-start: 16 | present: true 17 | document-end: false 18 | truthy: 19 | level: error 20 | allowed-values: 21 | - 'true' 22 | - 'false' 23 | indentation: 24 | spaces: 2 25 | indent-sequences: true 26 | key-duplicates: enable 27 | trailing-spaces: enable 28 | new-line-at-end-of-file: disable 29 | hyphens: 30 | max-spaces-after: 1 31 | empty-lines: 32 | max: 2 33 | max-start: 0 34 | max-end: 0 35 | commas: 36 | max-spaces-before: 0 37 | min-spaces-after: 1 38 | max-spaces-after: 1 39 | colons: 40 | max-spaces-before: 0 41 | max-spaces-after: 1 42 | brackets: 43 | min-spaces-inside: 0 44 | max-spaces-inside: 0 45 | braces: 46 | min-spaces-inside: 0 47 | max-spaces-inside: 1 48 | octal-values: 49 | forbid-implicit-octal: true 50 | forbid-explicit-octal: true 51 | comments: 52 | min-spaces-from-content: 1 53 | comments-indentation: false 54 | -------------------------------------------------------------------------------- /.yamllint-extra-docs: -------------------------------------------------------------------------------- 1 | --- 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | # SPDX-FileCopyrightText: 2025 Felix Fontein 5 | 6 | extends: default 7 | 8 | ignore: | 9 | /changelogs/ 10 | 11 | rules: 12 | line-length: 13 | max: 160 14 | level: error 15 | document-start: disable 16 | document-end: 17 | present: false 18 | truthy: 19 | level: error 20 | allowed-values: 21 | - 'true' 22 | - 'false' 23 | indentation: 24 | spaces: 2 25 | indent-sequences: true 26 | key-duplicates: enable 27 | trailing-spaces: enable 28 | new-line-at-end-of-file: disable 29 | hyphens: 30 | max-spaces-after: 1 31 | empty-lines: 32 | max: 2 33 | max-start: 0 34 | max-end: 0 35 | commas: 36 | max-spaces-before: 0 37 | min-spaces-after: 1 38 | max-spaces-after: 1 39 | colons: 40 | max-spaces-before: 0 41 | max-spaces-after: 1 42 | brackets: 43 | min-spaces-inside: 0 44 | max-spaces-inside: 0 45 | braces: 46 | min-spaces-inside: 0 47 | max-spaces-inside: 1 48 | octal-values: 49 | forbid-implicit-octal: true 50 | forbid-explicit-octal: true 51 | comments: 52 | min-spaces-from-content: 1 53 | comments-indentation: false 54 | -------------------------------------------------------------------------------- /.yamllint-docs: -------------------------------------------------------------------------------- 1 | --- 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | # SPDX-FileCopyrightText: 2025 Felix Fontein 5 | 6 | extends: default 7 | 8 | ignore: | 9 | /changelogs/ 10 | 11 | rules: 12 | line-length: 13 | max: 160 14 | level: error 15 | document-start: 16 | present: false 17 | document-end: 18 | present: false 19 | truthy: 20 | level: error 21 | allowed-values: 22 | - 'true' 23 | - 'false' 24 | indentation: 25 | spaces: 2 26 | indent-sequences: true 27 | key-duplicates: enable 28 | trailing-spaces: enable 29 | new-line-at-end-of-file: disable 30 | hyphens: 31 | max-spaces-after: 1 32 | empty-lines: 33 | max: 2 34 | max-start: 0 35 | max-end: 0 36 | commas: 37 | max-spaces-before: 0 38 | min-spaces-after: 1 39 | max-spaces-after: 1 40 | colons: 41 | max-spaces-before: 0 42 | max-spaces-after: 1 43 | brackets: 44 | min-spaces-inside: 0 45 | max-spaces-inside: 0 46 | braces: 47 | min-spaces-inside: 0 48 | max-spaces-inside: 1 49 | octal-values: 50 | forbid-implicit-octal: true 51 | forbid-explicit-octal: true 52 | comments: 53 | min-spaces-from-content: 1 54 | comments-indentation: false 55 | -------------------------------------------------------------------------------- /.yamllint-examples: -------------------------------------------------------------------------------- 1 | --- 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | # SPDX-FileCopyrightText: 2025 Felix Fontein 5 | 6 | extends: default 7 | 8 | ignore: | 9 | /changelogs/ 10 | 11 | rules: 12 | line-length: 13 | max: 160 14 | level: error 15 | document-start: 16 | present: true 17 | document-end: 18 | present: false 19 | truthy: 20 | level: error 21 | allowed-values: 22 | - 'true' 23 | - 'false' 24 | indentation: 25 | spaces: 2 26 | indent-sequences: true 27 | key-duplicates: enable 28 | trailing-spaces: enable 29 | new-line-at-end-of-file: disable 30 | hyphens: 31 | max-spaces-after: 1 32 | empty-lines: 33 | max: 2 34 | max-start: 0 35 | max-end: 0 36 | commas: 37 | max-spaces-before: 0 38 | min-spaces-after: 1 39 | max-spaces-after: 1 40 | colons: 41 | max-spaces-before: 0 42 | max-spaces-after: 1 43 | brackets: 44 | min-spaces-inside: 0 45 | max-spaces-inside: 0 46 | braces: 47 | min-spaces-inside: 0 48 | max-spaces-inside: 1 49 | octal-values: 50 | forbid-implicit-octal: true 51 | forbid-explicit-octal: true 52 | comments: 53 | min-spaces-from-content: 1 54 | comments-indentation: false 55 | -------------------------------------------------------------------------------- /tests/ee/roles/smoke/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | - name: Run api module 7 | community.routeros.api: 8 | username: foo 9 | password: bar 10 | hostname: localhost 11 | path: ip address 12 | ignore_errors: true 13 | register: result 14 | 15 | - name: Validate result 16 | assert: 17 | that: 18 | - result is failed 19 | - result.msg in potential_errors 20 | vars: 21 | potential_errors: 22 | - "Error while connecting: [Errno 111] Connection refused" 23 | - "Error while connecting: [Errno 99] Cannot assign requested address" 24 | 25 | - name: Run command module 26 | community.routeros.command: 27 | commands: 28 | - /ip address print 29 | vars: 30 | ansible_host: localhost 31 | ansible_connection: ansible.netcommon.network_cli 32 | ansible_network_os: community.routeros.routeros 33 | ansible_user: foo 34 | ansible_ssh_pass: bar 35 | ansible_ssh_port: 12349 36 | ignore_errors: true 37 | register: result 38 | 39 | - name: Validate result 40 | assert: 41 | that: 42 | - result is failed 43 | - "'Unable to connect to port 12349 ' in result.msg or 'ssh connect failed: Connection refused' in result.msg" 44 | -------------------------------------------------------------------------------- /docs/docsite/links.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | edit_on_github: 7 | repository: ansible-collections/community.routeros 8 | branch: main 9 | path_prefix: '' 10 | 11 | extra_links: 12 | - description: Ask for help (RouterOS) 13 | url: https://forum.ansible.com/tags/c/help/6/none/routeros 14 | - description: Submit a bug report 15 | url: https://github.com/ansible-collections/community.routeros/issues/new?assignees=&labels=&template=bug_report.md 16 | - description: Request a feature 17 | url: https://github.com/ansible-collections/community.routeros/issues/new?assignees=&labels=&template=feature_request.md 18 | 19 | communication: 20 | matrix_rooms: 21 | - topic: General usage and support questions 22 | room: '#users:ansible.im' 23 | irc_channels: 24 | - topic: General usage and support questions 25 | network: Libera 26 | channel: '#ansible' 27 | forums: 28 | - topic: "Ansible Forum: General usage and support questions" 29 | # The following URL directly points to the "Get Help" section 30 | url: https://forum.ansible.com/c/help/6/none 31 | - topic: "Ansible Forum: Discussions about RouterOS" 32 | # The following URL directly points to the "routeros" tag 33 | url: https://forum.ansible.com/tag/routeros 34 | -------------------------------------------------------------------------------- /plugins/filter/list_to_dict.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | DOCUMENTATION: 7 | name: list_to_dict 8 | short_description: Convert a list of arguments to a dictionary 9 | version_added: 2.0.0 10 | description: 11 | - Convert a list of arguments to a dictionary. 12 | options: 13 | _input: 14 | description: 15 | - A list of assignments. Can be the result of the P(community.routeros.split#filter) filter. 16 | type: list 17 | elements: string 18 | required: true 19 | require_assignment: 20 | description: 21 | - Allows to accept arguments without values when set to V(false). 22 | type: boolean 23 | default: true 24 | skip_empty_values: 25 | description: 26 | - Allows to skip arguments whose value is empty when set to V(true). 27 | type: boolean 28 | default: false 29 | author: 30 | - Felix Fontein (@felixfontein) 31 | 32 | EXAMPLES: | 33 | --- 34 | - name: Convert a list to a dictionary 35 | ansible.builtin.set_fact: 36 | dictionary: "{{ ['foo=bar', 'comment=foo is bar'] | community.routeros.list_to_dict }}" 37 | # dictionary == {'foo': 'bar', 'comment': 'foo is bar'} 38 | 39 | RETURN: 40 | _value: 41 | description: A dictionary representation of the input data. 42 | type: dictionary 43 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | # The following metadata allows Python runners and nox to install the required 6 | # dependencies for running this Python script: 7 | # 8 | # /// script 9 | # dependencies = ["nox>=2025.02.09", "antsibull-nox"] 10 | # /// 11 | 12 | import os 13 | import sys 14 | 15 | import nox 16 | 17 | 18 | # We try to import antsibull-nox, and if that doesn't work, provide a more useful 19 | # error message to the user. 20 | try: 21 | import antsibull_nox 22 | except ImportError: 23 | print("You need to install antsibull-nox in the same Python environment as nox.") 24 | sys.exit(1) 25 | 26 | 27 | IN_CI = os.environ.get("CI") == "true" 28 | 29 | 30 | antsibull_nox.load_antsibull_nox_toml() 31 | 32 | 33 | @nox.session(name="update-docs", default=True) 34 | def update_docs_fragments(session: nox.Session) -> None: 35 | """ 36 | Update/check auto-generated parts of docs fragments. 37 | """ 38 | session.install("ansible-core") 39 | prepare = antsibull_nox.sessions.prepare_collections( 40 | session, install_in_site_packages=True 41 | ) 42 | if not prepare: 43 | return 44 | data = ["python", "tests/update-docs.py"] 45 | if IN_CI: 46 | data.append("--lint") 47 | session.run(*data) 48 | 49 | 50 | # Allow to run the noxfile with `python noxfile.py`, `pipx run noxfile.py`, or similar. 51 | # Requires nox >= 2025.02.09 52 | if __name__ == "__main__": 53 | nox.main() 54 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/ip_neighbor_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | 0 interface=ether2-master address=10.37.129.3 address4=10.37.129.3 mac-address=D4:CA:6D:C6:16:4C identity="router1" platform="MikroTik" version="6.42.2 (stable)" unpack=none age=59s 2 | uptime=3w19h11m36s software-id="1234-1234" board="RBwAPG-5HacT2HnD" interface-name="bridge" system-description="MikroTik RouterOS 6.42.2 (stable) RBwAPG-5HacT2HnD" 3 | system-caps="" system-caps-enabled="" 4 | 5 | 1 interface=ether3 address=10.37.129.4 address4=10.37.129.4 mac-address=D4:CA:6D:C6:18:2F identity="router2" platform="MikroTik" version="6.42.2 (stable)" unpack=none age=54s 6 | uptime=3w19h11m30s software-id="1234-1234" board="RBwAPG-5HacT2HnD" ipv6=no interface-name="bridge" system-description="MikroTik RouterOS 6.42.2 (stable) RBwAPG-5HacT2HnD" 7 | system-caps="" system-caps-enabled="" 8 | 9 | 2 interface=ether5 address=10.37.129.5 address4=10.37.129.5 mac-address=B8:69:F4:37:F0:C8 identity="router3" platform="MikroTik" version="6.40.8 (bugfix)" unpack=none age=43s 10 | uptime=3d14h25m31s software-id="1234-1234" board="RB960PGS" interface-name="ether1" system-description="MikroTik RouterOS 6.40.8 (bugfix) RB960PGS" system-caps="" 11 | system-caps-enabled="" 12 | 13 | 3 interface=ether10 address=10.37.129.6 address4=10.37.129.6 mac-address=6C:3B:6B:A1:0B:63 identity="router4" platform="MikroTik" version="6.42.2 (stable)" unpack=none age=54s 14 | uptime=3w6d1h11m44s software-id="1234-1234" board="RBSXTLTE3-7" interface-name="bridge" system-description="MikroTik RouterOS 6.42.2 (stable) RBSXTLTE3-7" system-caps="" 15 | system-caps-enabled="" 16 | -------------------------------------------------------------------------------- /plugins/terminal/routeros.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Red Hat Inc. 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from __future__ import (absolute_import, division, print_function) 6 | __metaclass__ = type 7 | 8 | import re 9 | 10 | from ansible.errors import AnsibleConnectionFailure 11 | from ansible.plugins.terminal import TerminalBase 12 | from ansible.utils.display import Display 13 | 14 | display = Display() 15 | 16 | 17 | class TerminalModule(TerminalBase): 18 | 19 | ansi_re = [ 20 | # check ECMA-48 Section 5.4 (Control Sequences) 21 | re.compile(br'(\x1b\[\?1h\x1b=)'), 22 | re.compile(br'((?:\x9b|\x1b\x5b)[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])'), 23 | re.compile(br'\x08.') 24 | ] 25 | 26 | terminal_initial_prompt = [ 27 | br'\x1bZ', 28 | ] 29 | 30 | terminal_initial_answer = b'\x1b/Z' 31 | 32 | terminal_stdout_re = [ 33 | re.compile(br"\x1b<"), 34 | re.compile( 35 | br"((\[[\w\-\.]+\@)|(\r\<(([\w\-\.]*\@)|)))" 36 | br"[\w\s\-\.\/]+\] ?( ?$"), 37 | re.compile(br"Please press \"Enter\" to continue!"), 38 | re.compile(br"Do you want to see the software license\? \[Y\/n\]: ?"), 39 | ] 40 | 41 | terminal_stderr_re = [ 42 | re.compile(br"\nbad command name"), 43 | re.compile(br"\nno such item"), 44 | re.compile(br"\ninvalid value for"), 45 | ] 46 | 47 | def on_open_shell(self): 48 | prompt = self._get_prompt() 49 | try: 50 | if prompt.strip().endswith(b':'): 51 | self._exec_cli_command(b' ') 52 | if prompt.strip().endswith(b'!'): 53 | self._exec_cli_command(b'\n') 54 | except AnsibleConnectionFailure: 55 | raise AnsibleConnectionFailure('unable to bypass license prompt') 56 | -------------------------------------------------------------------------------- /tests/update-docs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2022, Felix Fontein (@felixfontein) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | ''' 9 | Updates DOCUMENTATION of modules using module_utils._api_data with the correct list of supported paths. 10 | ''' 11 | 12 | import sys 13 | 14 | from ansible_collections.community.routeros.plugins.module_utils._api_data import ( 15 | PATHS, 16 | join_path, 17 | ) 18 | 19 | 20 | MODULES = [ 21 | 'plugins/modules/api_info.py', 22 | 'plugins/modules/api_modify.py', 23 | ] 24 | 25 | 26 | def update_file(file: str, begin_line: str, end_line: str, choice_line: str, path_choices: list[str]) -> bool: 27 | with open(file, 'r', encoding='utf-8') as f: 28 | lines = f.read().splitlines() 29 | begin_index = lines.index(begin_line) 30 | end_index = lines.index(end_line, begin_index + 1) 31 | new_lines = lines[:begin_index + 1] + [choice_line.format(choice=choice) for choice in path_choices] + lines[end_index:] 32 | if lines == new_lines: 33 | return False 34 | print(f'{file} has been updated') 35 | with open(file, 'w', encoding='utf-8') as f: 36 | f.write('\n'.join(new_lines) + '\n') 37 | return True 38 | 39 | 40 | def main(args: list[str]) -> int: 41 | path_choices = sorted([join_path(path) for path, path_info in PATHS.items() if path_info.fully_understood]) 42 | 43 | changes = False 44 | for file in MODULES: 45 | changes |= update_file(file, ' # BEGIN PATH LIST', ' # END PATH LIST', ' - {choice}', path_choices) 46 | 47 | lint = "--lint" in args 48 | if not lint or not changes: 49 | return 0 50 | 51 | print("Run 'nox -Re update-docs'!") 52 | return 1 53 | 54 | 55 | if __name__ == '__main__': 56 | sys.exit(main(sys.argv[1:])) 57 | -------------------------------------------------------------------------------- /.github/workflows/docs-push.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | name: Collection Docs 7 | concurrency: 8 | group: docs-push-${{ github.sha }} 9 | cancel-in-progress: true 10 | 'on': 11 | push: 12 | branches: 13 | - main 14 | - stable-* 15 | tags: 16 | - '*' 17 | # Run CI once per day (at 05:15 UTC) 18 | schedule: 19 | - cron: '15 5 * * *' 20 | # Allow manual trigger (for newer antsibull-docs, sphinx-ansible-theme, ... versions) 21 | workflow_dispatch: 22 | 23 | jobs: 24 | build-docs: 25 | permissions: 26 | contents: read 27 | name: Build Ansible Docs 28 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-push.yml@main 29 | with: 30 | collection-name: community.routeros 31 | init-lenient: true 32 | init-fail-on-error: true 33 | squash-hierarchy: true 34 | init-project: Community.Routeros Collection 35 | init-copyright: Community.Routeros Contributors 36 | init-title: Community.Routeros Collection Documentation 37 | init-html-short-title: Community.Routeros Collection Docs 38 | init-extra-html-theme-options: | 39 | documentation_home_url=https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/branch/main/ 40 | 41 | publish-docs-gh-pages: 42 | # for now we won't run this on forks 43 | if: github.repository == 'ansible-collections/community.routeros' 44 | permissions: 45 | contents: write 46 | pages: write 47 | id-token: write 48 | needs: [build-docs] 49 | name: Publish Ansible Docs 50 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main 51 | with: 52 | artifact-name: ${{ needs.build-docs.outputs.artifact-name }} 53 | publish-gh-pages-branch: true 54 | secrets: 55 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/facts/interface_print_detail_without-paging: -------------------------------------------------------------------------------- 1 | Flags: D - dynamic, X - disabled, R - running, S - slave 2 | 0 R name="ether1" default-name="ether1" type="ether" mtu=1500 actual-mtu=1500 3 | mac-address=00:1C:42:36:52:90 last-link-up-time=sep/25/2018 06:30:04 4 | link-downs=0 5 | 1 R name="ether2" default-name="ether2" type="ether" mtu=1500 actual-mtu=1500 6 | mac-address=00:1C:42:36:52:91 last-link-up-time=sep/25/2018 06:30:04 7 | link-downs=0 8 | 2 R name="ether3" default-name="ether3" type="ether" mtu=1500 actual-mtu=1500 9 | mac-address=00:1C:42:36:52:92 last-link-up-time=sep/25/2018 06:30:04 10 | link-downs=0 11 | 3 R name="ether4" default-name="ether4" type="ether" mtu=1500 actual-mtu=1500 12 | mac-address=00:1C:42:36:52:93 last-link-up-time=sep/25/2018 06:30:04 13 | link-downs=0 14 | 4 R name="ether5" default-name="ether5" type="ether" mtu=1500 actual-mtu=1500 15 | mac-address=00:1C:42:36:52:94 last-link-up-time=sep/25/2018 06:30:04 16 | link-downs=0 17 | 5 R name="ether6" default-name="ether6" type="ether" mtu=1500 actual-mtu=1500 18 | mac-address=00:1C:42:36:52:95 last-link-up-time=sep/25/2018 06:30:04 19 | link-downs=0 20 | 6 R name="ether7" default-name="ether7" type="ether" mtu=1500 actual-mtu=1500 21 | mac-address=00:1C:42:36:52:96 last-link-up-time=sep/25/2018 06:30:04 22 | link-downs=0 23 | 7 R name="ether8" default-name="ether8" type="ether" mtu=1500 actual-mtu=1500 24 | mac-address=00:1C:42:36:52:97 last-link-up-time=sep/25/2018 06:30:04 25 | link-downs=0 26 | 8 R name="ether9" default-name="ether9" type="ether" mtu=1500 actual-mtu=1500 27 | mac-address=00:1C:42:36:52:98 last-link-up-time=sep/25/2018 06:30:04 28 | link-downs=0 29 | 9 R name="ether10" default-name="ether10" type="ether" mtu=1500 actual-mtu=1500 30 | mac-address=00:1C:42:36:52:99 last-link-up-time=sep/25/2018 06:30:04 31 | link-downs=0 32 | 10 R name="pppoe" default-name="pppoe" type="ppp" mtu=1500 actual-mtu=1500 33 | mac-address=00:1C:42:36:52:00 last-link-up-time=sep/25/2018 06:30:04 34 | link-downs=0 35 | -------------------------------------------------------------------------------- /tests/ee/roles/filter_quoting/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | - name: "Test split filter" 7 | assert: 8 | that: 9 | - "'' | community.routeros.split == []" 10 | - "'foo bar' | community.routeros.split == ['foo', 'bar']" 11 | - > 12 | 'foo bar="a b c"' | community.routeros.split == ['foo', 'bar=a b c'] 13 | 14 | - name: "Test split filter error handling" 15 | set_fact: 16 | test: >- 17 | {{ 'a="' | community.routeros.split }} 18 | ignore_errors: true 19 | register: result 20 | 21 | - name: "Verify split filter error handling" 22 | assert: 23 | that: 24 | - >- 25 | "Unexpected end of string during escaped parameter" in result.msg 26 | 27 | - name: "Test quote_argument filter" 28 | assert: 29 | that: 30 | - > 31 | 'a=' | community.routeros.quote_argument == 'a=""' 32 | - > 33 | 'a=b' | community.routeros.quote_argument == 'a=b' 34 | - > 35 | 'a=b c' | community.routeros.quote_argument == 'a="b\\_c"' 36 | - > 37 | 'a=""' | community.routeros.quote_argument == 'a="\\"\\""' 38 | 39 | - name: "Test quote_argument_value filter" 40 | assert: 41 | that: 42 | - > 43 | '' | community.routeros.quote_argument_value == '""' 44 | - > 45 | 'foo' | community.routeros.quote_argument_value == 'foo' 46 | - > 47 | '"foo bar"' | community.routeros.quote_argument_value == '"\\"foo\\_bar\\""' 48 | 49 | - name: "Test join filter" 50 | assert: 51 | that: 52 | - > 53 | ['a=', 'b=c d'] | community.routeros.join == 'a="" b="c\\_d"' 54 | 55 | - name: "Test list_to_dict filter" 56 | assert: 57 | that: 58 | - > 59 | ['a=', 'b=c'] | community.routeros.list_to_dict == {'a': '', 'b': 'c'} 60 | - > 61 | ['a=', 'b=c'] | community.routeros.list_to_dict(skip_empty_values=True) == {'b': 'c'} 62 | - > 63 | ['a', 'b=c'] | community.routeros.list_to_dict(require_assignment=False) == {'a': none, 'b': 'c'} 64 | -------------------------------------------------------------------------------- /tests/integration/targets/filter_quoting/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | - name: "Test split filter" 7 | assert: 8 | that: 9 | - "'' | community.routeros.split == []" 10 | - "'foo bar' | community.routeros.split == ['foo', 'bar']" 11 | - > 12 | 'foo bar="a b c"' | community.routeros.split == ['foo', 'bar=a b c'] 13 | 14 | - name: "Test split filter error handling" 15 | set_fact: 16 | test: >- 17 | {{ 'a="' | community.routeros.split }} 18 | ignore_errors: true 19 | register: result 20 | 21 | - name: "Verify split filter error handling" 22 | assert: 23 | that: 24 | - >- 25 | "Unexpected end of string during escaped parameter" in result.msg 26 | 27 | - name: "Test quote_argument filter" 28 | assert: 29 | that: 30 | - > 31 | 'a=' | community.routeros.quote_argument == 'a=""' 32 | - > 33 | 'a=b' | community.routeros.quote_argument == 'a=b' 34 | - > 35 | 'a=b c' | community.routeros.quote_argument == 'a="b\\_c"' 36 | - > 37 | 'a=""' | community.routeros.quote_argument == 'a="\\"\\""' 38 | 39 | - name: "Test quote_argument_value filter" 40 | assert: 41 | that: 42 | - > 43 | '' | community.routeros.quote_argument_value == '""' 44 | - > 45 | 'foo' | community.routeros.quote_argument_value == 'foo' 46 | - > 47 | '"foo bar"' | community.routeros.quote_argument_value == '"\\"foo\\_bar\\""' 48 | 49 | - name: "Test join filter" 50 | assert: 51 | that: 52 | - > 53 | ['a=', 'b=c d'] | community.routeros.join == 'a="" b="c\\_d"' 54 | 55 | - name: "Test list_to_dict filter" 56 | assert: 57 | that: 58 | - > 59 | ['a=', 'b=c'] | community.routeros.list_to_dict == {'a': '', 'b': 'c'} 60 | - > 61 | ['a=', 'b=c'] | community.routeros.list_to_dict(skip_empty_values=True) == {'b': 'c'} 62 | - > 63 | ['a', 'b=c'] | community.routeros.list_to_dict(require_assignment=False) == {'a': none, 'b': 'c'} 64 | -------------------------------------------------------------------------------- /plugins/cliconf/routeros.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017 Red Hat Inc. 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from __future__ import (absolute_import, division, print_function) 6 | __metaclass__ = type 7 | 8 | DOCUMENTATION = r""" 9 | author: "Egor Zaitsev (@heuels)" 10 | name: routeros 11 | short_description: Use routeros cliconf to run command on MikroTik RouterOS platform 12 | description: 13 | - This routeros plugin provides low level abstraction APIs for sending and receiving CLI commands from MikroTik RouterOS 14 | network devices. 15 | """ 16 | 17 | import re 18 | import json 19 | 20 | from ansible.module_utils.common.text.converters import to_text 21 | from ansible.plugins.cliconf import CliconfBase 22 | 23 | 24 | class Cliconf(CliconfBase): 25 | 26 | def get_device_info(self): 27 | device_info = {} 28 | device_info['network_os'] = 'RouterOS' 29 | 30 | resource = self.get('/system resource print') 31 | data = to_text(resource, errors='surrogate_or_strict').strip() 32 | match = re.search(r'version: (\S+)', data) 33 | if match: 34 | device_info['network_os_version'] = match.group(1) 35 | 36 | routerboard = self.get('/system routerboard print') 37 | data = to_text(routerboard, errors='surrogate_or_strict').strip() 38 | match = re.search(r'model: (.+)$', data, re.M) 39 | if match: 40 | device_info['network_os_model'] = match.group(1) 41 | 42 | identity = self.get('/system identity print') 43 | data = to_text(identity, errors='surrogate_or_strict').strip() 44 | match = re.search(r'name: (.+)$', data, re.M) 45 | if match: 46 | device_info['network_os_hostname'] = match.group(1) 47 | 48 | return device_info 49 | 50 | def get_config(self, source='running', flags=None, format=None): 51 | return 52 | 53 | def edit_config(self, command): 54 | return 55 | 56 | def get(self, command, prompt=None, answer=None, sendonly=False, newline=True, check_all=False): 57 | return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, newline=newline, check_all=check_all) 58 | 59 | def get_capabilities(self): 60 | result = super(Cliconf, self).get_capabilities() 61 | return json.dumps(result) 62 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/routeros_module.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Red Hat Inc. 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | # Make coding more python3-ish 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | import os 10 | import json 11 | 12 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase 13 | 14 | 15 | fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') 16 | fixture_data = {} 17 | 18 | 19 | def load_fixture(name): 20 | path = os.path.join(fixture_path, name) 21 | 22 | if path in fixture_data: 23 | return fixture_data[path] 24 | 25 | with open(path) as f: 26 | data = f.read() 27 | 28 | try: 29 | data = json.loads(data) 30 | except Exception: 31 | pass 32 | 33 | fixture_data[path] = data 34 | return data 35 | 36 | 37 | class TestRouterosModule(ModuleTestCase): 38 | 39 | def execute_module(self, failed=False, changed=False, commands=None, sort=True, defaults=False): 40 | 41 | self.load_fixtures(commands) 42 | 43 | if failed: 44 | result = self.failed() 45 | self.assertTrue(result['failed'], result) 46 | else: 47 | result = self.changed(changed) 48 | self.assertEqual(result['changed'], changed, result) 49 | 50 | if commands is not None: 51 | if sort: 52 | self.assertEqual(sorted(commands), sorted(result['commands']), result['commands']) 53 | else: 54 | self.assertEqual(commands, result['commands'], result['commands']) 55 | 56 | return result 57 | 58 | def failed(self): 59 | with self.assertRaises(AnsibleFailJson) as exc: 60 | self.module.main() 61 | 62 | result = exc.exception.args[0] 63 | self.assertTrue(result['failed'], result) 64 | return result 65 | 66 | def changed(self, changed=False): 67 | with self.assertRaises(AnsibleExitJson) as exc: 68 | self.module.main() 69 | 70 | result = exc.exception.args[0] 71 | self.assertEqual(result['changed'], changed, result) 72 | return result 73 | 74 | def load_fixtures(self, commands=None): 75 | pass 76 | -------------------------------------------------------------------------------- /docs/docsite/rst/quoting.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright (c) Ansible Project 3 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | .. _ansible_collections.community.routeros.docsite.quoting: 7 | 8 | How to quote and unquote commands and arguments 9 | =============================================== 10 | 11 | When using the :ansplugin:`community.routeros.command module ` or the :ansplugin:`community.routeros.api module ` modules, you need to pass text data in quoted form. While in some cases quoting is not needed (when passing IP addresses or names without spaces, for example), in other cases it is required, like when passing a comment which contains a space. 12 | 13 | The community.routeros collection provides a set of Jinja2 filter plugins which helps you with these tasks: 14 | 15 | - The :ansplugin:`community.routeros.quote_argument_value filter ` quotes an argument value: ``'this is a "comment"' | community.routeros.quote_argument_value == '"this is a \\"comment\\""'``. 16 | - The :ansplugin:`community.routeros.quote_argument filter ` quotes an argument with or without a value: ``'comment=this is a "comment"' | community.routeros.quote_argument == 'comment="this is a \\"comment\\""'``. 17 | - The :ansplugin:`community.routeros.join filter ` quotes a list of arguments and joins them to one string: ``['foo=bar', 'comment=foo is bar'] | community.routeros.join == 'foo=bar comment="foo is bar"'``. 18 | - The :ansplugin:`community.routeros.split filter ` splits a command into a list of arguments (with or without values): ``'foo=bar comment="foo is bar"' | community.routeros.split == ['foo=bar', 'comment=foo is bar']`` 19 | - The :ansplugin:`community.routeros.list_to_dict filter ` splits a list of arguments with values into a dictionary: ``['foo=bar', 'comment=foo is bar'] | community.routeros.list_to_dict == {'foo': 'bar', 'comment': 'foo is bar'}``. It has two optional arguments: :ansopt:`community.routeros.list_to_dict#filter:require_assignment` (default value :ansval:`true`) allows to accept arguments without values when set to :ansval:`false`; and :ansopt:`community.routeros.list_to_dict#filter:skip_empty_values` (default value :ansval:`false`) allows to skip arguments whose value is empty. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | /tests/output/ 6 | /changelogs/.plugin-cache.yaml 7 | /tests/integration/inventory 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fixtures/system_package_print: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | MMM MMM KKK TTTTTTTTTTT KKK 18 | 19 | MMMM MMMM KKK TTTTTTTTTTT KKK 20 | 21 | MMM MMMM MMM III KKK KKK RRRRRR OOOOOO TTT III KKK KKK 22 | 23 | MMM MM MMM III KKKKK RRR RRR OOO OOO TTT III KKKKK 24 | 25 | MMM MMM III KKK KKK RRRRRR OOO OOO TTT III KKK KKK 26 | 27 | MMM MMM III KKK KKK RRR RRR OOOOOO TTT III KKK KKK 28 | 29 | 30 | 31 | MikroTik RouterOS 6.42.5 (c) 1999-2018 http://www.mikrotik.com/ 32 | 33 | 34 | [?] Gives the list of available commands 35 | 36 | command [?] Gives help on the command and list of arguments 37 | 38 | 39 | 40 | [Tab] Completes the command/word. If the input is ambiguous, 41 | 42 | a second [Tab] gives possible options 43 | 44 | 45 | 46 | / Move up to base level 47 | 48 | .. Move up one level 49 | 50 | /command Use command at the base level 51 | 52 |  53 | Z <[?47l[?7h[?5l[?25h 54 | 55 | 56 | 57 | [admin@MainRouter] > 58 | [admin@MainRouter] > /system routerboard print 59 | [admin@MainRouter] > /system routerboard print 60 | 61 | routerboard: yes 62 | model: 750GL 63 | serial-number: 1234567890AB 64 | firmware-type: ar7240 65 | factory-firmware: 3.09 66 | current-firmware: 6.41.2 67 | upgrade-firmware: 6.42.5 68 | 69 | 70 | 71 | 72 | 73 | [admin@MainRouter] > 74 | [admin@MainRouter] > /system identity print 75 | [admin@MainRouter] > /system identity print 76 | 77 | name: MikroTik 78 | 79 | 80 | 81 | 82 | 83 | [admin@MainRouter] > 84 | [admin@MainRouter] > /system package print 85 | [admin@MainRouter] > /system package print 86 | 87 | Flags: X - disabled 88 |  # NAME VERSION SCHEDULED 89 |  0 routeros-mipsbe 6.42.5 90 | 1 system 6.42.5 91 | 2 ipv6 6.42.5 92 | 3 wireless 6.42.5 93 | 4 hotspot 6.42.5 94 | 5 dhcp 6.42.5 95 | 6 mpls 6.42.5 96 | 7 routing 6.42.5 97 | 8 ppp 6.42.5 98 | 9 security 6.42.5 99 | 10 advanced-tools 6.42.5 100 | 101 | 102 | 103 | 104 | 105 | [admin@MainRouter] > 106 | [admin@MainRouter] > -------------------------------------------------------------------------------- /plugins/doc_fragments/attributes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) Ansible Project 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | 11 | class ModuleDocFragment(object): 12 | 13 | # Standard documentation fragment 14 | DOCUMENTATION = r""" 15 | options: {} 16 | attributes: 17 | check_mode: 18 | description: Can run in C(check_mode) and return changed status prediction without modifying target. 19 | diff_mode: 20 | description: Will return details on what has changed (or possibly needs changing in C(check_mode)), when in diff mode. 21 | platform: 22 | description: Target OS/families that can be operated against. 23 | support: N/A 24 | idempotent: 25 | description: 26 | - When run twice in a row outside check mode, with the same arguments, the second invocation indicates no change. 27 | - This assumes that the system controlled/queried by the module has not changed in a relevant way. 28 | """ 29 | 30 | # Should be used together with the standard fragment 31 | IDEMPOTENT_NOT_MODIFY_STATE = r""" 32 | options: {} 33 | attributes: 34 | idempotent: 35 | support: full 36 | details: 37 | - This action does not modify state. 38 | """ 39 | 40 | # Should be used together with the standard fragment 41 | INFO_MODULE = r''' 42 | options: {} 43 | attributes: 44 | check_mode: 45 | support: full 46 | details: 47 | - This action does not modify state. 48 | diff_mode: 49 | support: N/A 50 | details: 51 | - This action does not modify state. 52 | ''' 53 | 54 | ACTIONGROUP_API = r''' 55 | options: {} 56 | attributes: 57 | action_group: 58 | description: Use C(group/community.routeros.api) in C(module_defaults) to set defaults for this module. 59 | support: full 60 | membership: 61 | - community.routeros.api 62 | ''' 63 | 64 | CONN = r""" 65 | options: {} 66 | attributes: 67 | become: 68 | description: Is usable alongside C(become) keywords. 69 | connection: 70 | description: Uses the target's configured connection information to execute code on it. 71 | delegation: 72 | description: Can be used in conjunction with C(delegate_to) and related keywords. 73 | """ 74 | 75 | FACTS = r""" 76 | options: {} 77 | attributes: 78 | facts: 79 | description: Action returns an C(ansible_facts) dictionary that will update existing host facts. 80 | """ 81 | 82 | # Should be used together with the standard fragment and the FACTS fragment 83 | FACTS_MODULE = r''' 84 | options: {} 85 | attributes: 86 | check_mode: 87 | support: full 88 | details: 89 | - This action does not modify state. 90 | diff_mode: 91 | support: N/A 92 | details: 93 | - This action does not modify state. 94 | facts: 95 | support: full 96 | ''' 97 | 98 | FILES = r""" 99 | options: {} 100 | attributes: 101 | safe_file_operations: 102 | description: Uses Ansible's strict file operation functions to ensure proper permissions and avoid data corruption. 103 | """ 104 | 105 | FLOW = r""" 106 | options: {} 107 | attributes: 108 | action: 109 | description: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller. 110 | async: 111 | description: Supports being used with the C(async) keyword. 112 | """ 113 | -------------------------------------------------------------------------------- /plugins/filter/quoting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2021, Felix Fontein 4 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | __metaclass__ = type 9 | 10 | from ansible.errors import AnsibleFilterError 11 | from ansible.module_utils.common.text.converters import to_text 12 | 13 | from ansible_collections.community.routeros.plugins.module_utils.quoting import ( 14 | ParseError, 15 | convert_list_to_dictionary, 16 | join_routeros_command, 17 | quote_routeros_argument, 18 | quote_routeros_argument_value, 19 | split_routeros_command, 20 | ) 21 | 22 | 23 | def wrap_exception(fn, *args, **kwargs): 24 | try: 25 | return fn(*args, **kwargs) 26 | except ParseError as e: 27 | raise AnsibleFilterError(to_text(e)) 28 | 29 | 30 | def split(line): 31 | ''' 32 | Split a command into arguments. 33 | 34 | Example: 35 | 'add name=wrap comment="with space"' 36 | is converted to: 37 | ['add', 'name=wrap', 'comment=with space'] 38 | ''' 39 | return wrap_exception(split_routeros_command, line) 40 | 41 | 42 | def quote_argument_value(argument): 43 | ''' 44 | Quote an argument value. 45 | 46 | Example: 47 | 'with "space"' 48 | is converted to: 49 | r'"with \"space\""' 50 | ''' 51 | return wrap_exception(quote_routeros_argument_value, argument) 52 | 53 | 54 | def quote_argument(argument): 55 | ''' 56 | Quote an argument. 57 | 58 | Example: 59 | 'comment=with "space"' 60 | is converted to: 61 | r'comment="with \"space\""' 62 | ''' 63 | return wrap_exception(quote_routeros_argument, argument) 64 | 65 | 66 | def join(arguments): 67 | ''' 68 | Join a list of arguments to a command. 69 | 70 | Example: 71 | ['add', 'name=wrap', 'comment=with space'] 72 | is converted to: 73 | 'add name=wrap comment="with space"' 74 | ''' 75 | return wrap_exception(join_routeros_command, arguments) 76 | 77 | 78 | def list_to_dict(string_list, require_assignment=True, skip_empty_values=False): 79 | ''' 80 | Convert a list of arguments to a list of dictionary. 81 | 82 | Example: 83 | ['foo=bar', 'comment=with space', 'additional='] 84 | is converted to: 85 | {'foo': 'bar', 'comment': 'with space', 'additional': ''} 86 | 87 | If require_assignment is True (default), arguments without assignments are 88 | rejected. (Example: in ['add', 'name=foo'], 'add' is an argument without 89 | assignment.) If it is False, these are given value None. 90 | 91 | If skip_empty_values is True, arguments with empty value are removed from 92 | the result. (Example: in ['name='], 'name' has an empty value.) 93 | If it is False (default), these are kept. 94 | 95 | ''' 96 | return wrap_exception( 97 | convert_list_to_dictionary, 98 | string_list, 99 | require_assignment=require_assignment, 100 | skip_empty_values=skip_empty_values, 101 | ) 102 | 103 | 104 | class FilterModule(object): 105 | '''Ansible jinja2 filters for RouterOS command quoting and unquoting''' 106 | 107 | def filters(self): 108 | return { 109 | 'split': split, 110 | 'quote_argument': quote_argument, 111 | 'quote_argument_value': quote_argument_value, 112 | 'join': join, 113 | 'list_to_dict': list_to_dict, 114 | } 115 | -------------------------------------------------------------------------------- /antsibull-nox.toml: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | [collection_sources] 6 | "community.internal_test_tools" = "git+https://github.com/ansible-collections/community.internal_test_tools.git,main" 7 | "community.netcommon" = "git+https://github.com/ansible-collections/ansible.netcommon.git,main" 8 | "community.utils" = "git+https://github.com/ansible-collections/ansible.utils.git,main" 9 | 10 | [vcs] 11 | vcs = "git" 12 | development_branch = "main" 13 | stable_branches = [ "stable-*" ] 14 | 15 | [sessions] 16 | 17 | [sessions.lint] 18 | run_isort = false 19 | run_black = false 20 | run_flake8 = false 21 | run_pylint = false 22 | run_yamllint = true 23 | yamllint_config = ".yamllint" 24 | yamllint_config_plugins = ".yamllint-docs" 25 | yamllint_config_plugins_examples = ".yamllint-examples" 26 | yamllint_config_extra_docs = ".yamllint-extra-docs" 27 | run_mypy = false 28 | 29 | [sessions.docs_check] 30 | validate_collection_refs="all" 31 | codeblocks_restrict_types = [ 32 | "ansible-output", 33 | "ini", 34 | "yaml", 35 | "yaml+jinja", 36 | ] 37 | codeblocks_restrict_type_exact_case = true 38 | codeblocks_allow_without_type = false 39 | codeblocks_allow_literal_blocks = false 40 | 41 | [sessions.license_check] 42 | 43 | [sessions.extra_checks] 44 | run_no_unwanted_files = true 45 | no_unwanted_files_module_extensions = [".py"] 46 | no_unwanted_files_yaml_extensions = [".yml"] 47 | run_action_groups = true 48 | run_no_trailing_whitespace = true 49 | no_trailing_whitespace_skip_directories = [ 50 | "tests/unit/plugins/modules/fixtures/", 51 | ] 52 | run_avoid_characters = true 53 | 54 | [[sessions.extra_checks.action_groups_config]] 55 | name = "api" 56 | pattern = "^api.*$" 57 | exclusions = [] 58 | doc_fragment = "community.routeros.attributes.actiongroup_api" 59 | 60 | [[sessions.extra_checks.avoid_character_group]] 61 | name = "tab" 62 | regex = "\\x09" 63 | 64 | [sessions.build_import_check] 65 | run_galaxy_importer = true 66 | 67 | [sessions.ansible_test_sanity] 68 | include_devel = true 69 | 70 | [sessions.ansible_test_units] 71 | include_devel = true 72 | 73 | [sessions.ansible_test_integration_w_default_container] 74 | include_devel = true 75 | controller_python_versions_only = true 76 | 77 | [sessions.ansible_test_integration_w_default_container.core_python_versions] 78 | "2.15" = ["2.7", "3.6", "3.7"] 79 | "2.16" = ["3.10"] 80 | "2.17" = ["3.8"] 81 | "2.18" = ["3.9"] 82 | "2.19" = ["3.11"] 83 | 84 | [[sessions.ee_check.execution_environments]] 85 | name = "devel-ubi-9" 86 | description = "ansible-core devel @ RHEL UBI 9" 87 | test_playbooks = ["tests/ee/all.yml"] 88 | config.images.base_image.name = "docker.io/redhat/ubi9:latest" 89 | config.dependencies.ansible_core.package_pip = "https://github.com/ansible/ansible/archive/devel.tar.gz" 90 | config.dependencies.ansible_runner.package_pip = "ansible-runner" 91 | config.dependencies.python_interpreter.package_system = "python3.12 python3.12-pip python3.12-wheel python3.12-cryptography" 92 | config.dependencies.python_interpreter.python_path = "/usr/bin/python3.12" 93 | runtime_environment = {"ANSIBLE_PRIVATE_ROLE_VARS" = "true"} 94 | 95 | [[sessions.ee_check.execution_environments]] 96 | name = "2.15-rocky-9" 97 | description = "ansible-core 2.15 @ Rocky Linux 9" 98 | test_playbooks = ["tests/ee/all.yml"] 99 | config.images.base_image.name = "quay.io/rockylinux/rockylinux:9" 100 | config.dependencies.ansible_core.package_pip = "https://github.com/ansible/ansible/archive/stable-2.15.tar.gz" 101 | config.dependencies.ansible_runner.package_pip = "ansible-runner" 102 | runtime_environment = {"ANSIBLE_PRIVATE_ROLE_VARS" = "true"} 103 | -------------------------------------------------------------------------------- /.github/workflows/docs-pr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | name: Collection Docs 7 | concurrency: 8 | group: docs-pr-${{ github.head_ref }} 9 | cancel-in-progress: true 10 | 'on': 11 | pull_request_target: 12 | types: [opened, synchronize, reopened, closed] 13 | 14 | env: 15 | GHP_BASE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} 16 | 17 | jobs: 18 | build-docs: 19 | permissions: 20 | contents: read 21 | name: Build Ansible Docs 22 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-pr.yml@main 23 | with: 24 | collection-name: community.routeros 25 | init-lenient: false 26 | init-fail-on-error: true 27 | squash-hierarchy: true 28 | init-project: Community.Routeros Collection 29 | init-copyright: Community.Routeros Contributors 30 | init-title: Community.Routeros Collection Documentation 31 | init-html-short-title: Community.Routeros Collection Docs 32 | init-extra-html-theme-options: | 33 | documentation_home_url=https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/branch/main/ 34 | render-file-line: '> * `$` [$](https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr/${{ github.event.number }}/$)' 35 | provide-link-targets: | 36 | ansible_collections.ansible.netcommon.network_cli_connection__parameter-ssh_type 37 | 38 | publish-docs-gh-pages: 39 | # for now we won't run this on forks 40 | if: github.repository == 'ansible-collections/community.routeros' 41 | permissions: 42 | contents: write 43 | pages: write 44 | id-token: write 45 | needs: [build-docs] 46 | name: Publish Ansible Docs 47 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main 48 | with: 49 | artifact-name: ${{ needs.build-docs.outputs.artifact-name }} 50 | action: ${{ (github.event.action == 'closed' || needs.build-docs.outputs.changed != 'true') && 'teardown' || 'publish' }} 51 | publish-gh-pages-branch: true 52 | secrets: 53 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | comment: 56 | permissions: 57 | pull-requests: write 58 | runs-on: ubuntu-latest 59 | needs: [build-docs, publish-docs-gh-pages] 60 | name: PR comments 61 | steps: 62 | - name: PR comment 63 | uses: ansible-community/github-docs-build/actions/ansible-docs-build-comment@main 64 | with: 65 | body-includes: '## Docs Build' 66 | reactions: heart 67 | action: ${{ needs.build-docs.outputs.changed != 'true' && 'remove' || '' }} 68 | on-closed-body: | 69 | ## Docs Build 📝 70 | 71 | This PR is closed and any previously published docsite has been unpublished. 72 | on-merged-body: | 73 | ## Docs Build 📝 74 | 75 | Thank you for contribution!✨ 76 | 77 | This PR has been merged and the docs are now incorporated into `main`: 78 | ${{ env.GHP_BASE_URL }}/branch/main 79 | body: | 80 | ## Docs Build 📝 81 | 82 | Thank you for contribution!✨ 83 | 84 | The docs for **this PR** have been published here: 85 | ${{ env.GHP_BASE_URL }}/pr/${{ github.event.number }} 86 | 87 | You can compare to the docs for the `main` branch here: 88 | ${{ env.GHP_BASE_URL }}/branch/main 89 | 90 | The docsite for **this PR** is also available for download as an artifact from this run: 91 | ${{ needs.build-docs.outputs.artifact-url }} 92 | 93 | File changes: 94 | 95 | ${{ needs.build-docs.outputs.diff-files-rendered }} 96 | 97 | ${{ needs.build-docs.outputs.diff-rendered }} 98 | -------------------------------------------------------------------------------- /plugins/module_utils/_api_helper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2022, Felix Fontein (@felixfontein) 3 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | # The data inside here is private to this collection. If you use this from outside the collection, 7 | # you are on your own. There can be random changes to its format even in bugfix releases! 8 | 9 | from __future__ import absolute_import, division, print_function 10 | __metaclass__ = type 11 | 12 | import re 13 | 14 | from ansible.module_utils.common.text.converters import to_text 15 | 16 | 17 | def value_to_str(value, compat_bool=False, none_to_empty=False): 18 | if value is None: 19 | return '' if none_to_empty else None 20 | if value is True: 21 | return 'true' if compat_bool else 'yes' 22 | if value is False: 23 | return 'false' if compat_bool else 'no' 24 | return to_text(value) 25 | 26 | 27 | def validate_and_prepare_restrict(module, path_info, compat=True): 28 | restrict = module.params['restrict'] 29 | if restrict is None: 30 | return None 31 | restrict_data = [] 32 | for rule in restrict: 33 | field = rule['field'] 34 | if field.startswith('!'): 35 | module.fail_json(msg='restrict: the field name "{0}" must not start with "!"'.format(field)) 36 | f = path_info.fields.get(field) 37 | if f is None: 38 | module.fail_json(msg='restrict: the field "{0}" does not exist for this path'.format(field)) 39 | 40 | new_rule = dict( 41 | field=field, 42 | match_disabled=rule['match_disabled'], 43 | invert=rule['invert'], 44 | ) 45 | if rule['values'] is not None: 46 | if compat: 47 | new_rule['values'] = rule['values'] 48 | else: 49 | new_rule['values'] = [value_to_str(v, none_to_empty=False) for v in rule['values']] 50 | if rule['regex'] is not None: 51 | regex = rule['regex'] 52 | try: 53 | new_rule['regex'] = re.compile(regex) 54 | new_rule['regex_source'] = regex 55 | except Exception as exc: 56 | module.fail_json(msg='restrict: invalid regular expression "{0}": {1}'.format(regex, exc)) 57 | restrict_data.append(new_rule) 58 | return restrict_data 59 | 60 | 61 | def _test_rule_except_invert(value, rule, compat=False): 62 | if value is None and rule['match_disabled']: 63 | return True 64 | if 'values' in rule: 65 | v = value if compat else value_to_str(value, none_to_empty=False) 66 | if v in rule['values']: 67 | return True 68 | if 'regex' in rule and value is not None and rule['regex'].match(value_to_str(value, compat_bool=compat)): 69 | return True 70 | return False 71 | 72 | 73 | def restrict_entry_accepted(entry, path_info, restrict_data, compat=True): 74 | if restrict_data is None: 75 | return True 76 | for rule in restrict_data: 77 | # Obtain field and value 78 | field = rule['field'] 79 | field_info = path_info.fields[field] 80 | value = entry.get(field) 81 | if value is None: 82 | value = field_info.default 83 | if field not in entry and field_info.absent_value: 84 | value = field_info.absent_value 85 | 86 | # Check 87 | matches_rule = _test_rule_except_invert(value, rule, compat=compat) 88 | if rule['invert']: 89 | matches_rule = not matches_rule 90 | if not matches_rule: 91 | return False 92 | return True 93 | 94 | 95 | def restrict_argument_spec(): 96 | return dict( 97 | restrict=dict( 98 | type='list', 99 | elements='dict', 100 | options=dict( 101 | field=dict(type='str', required=True), 102 | match_disabled=dict(type='bool', default=False), 103 | values=dict(type='list', elements='raw'), 104 | regex=dict(type='str'), 105 | invert=dict(type='bool', default=False), 106 | ), 107 | ), 108 | ) 109 | -------------------------------------------------------------------------------- /plugins/module_utils/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2022, Felix Fontein (@felixfontein) 4 | # Copyright (c) 2020, Nikolay Dachev 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | __metaclass__ = type 10 | 11 | 12 | from ansible.module_utils.basic import missing_required_lib 13 | from ansible.module_utils.common.text.converters import to_native 14 | 15 | import ssl 16 | import traceback 17 | 18 | LIB_IMP_ERR = None 19 | try: 20 | from librouteros import connect 21 | from librouteros.exceptions import LibRouterosError # noqa: F401, pylint: disable=unused-import 22 | HAS_LIB = True 23 | except Exception as e: 24 | HAS_LIB = False 25 | LIB_IMP_ERR = traceback.format_exc() 26 | 27 | 28 | def check_has_library(module): 29 | if not HAS_LIB: 30 | module.fail_json( 31 | msg=missing_required_lib('librouteros'), 32 | exception=LIB_IMP_ERR, 33 | ) 34 | 35 | 36 | def api_argument_spec(): 37 | return dict( 38 | username=dict(type='str', required=True), 39 | password=dict(type='str', required=True, no_log=True), 40 | hostname=dict(type='str', required=True), 41 | port=dict(type='int'), 42 | tls=dict(type='bool', default=False, aliases=['ssl']), 43 | force_no_cert=dict(type='bool', default=False), 44 | validate_certs=dict(type='bool', default=True), 45 | validate_cert_hostname=dict(type='bool', default=False), 46 | ca_path=dict(type='path'), 47 | encoding=dict(type='str', default='ASCII'), 48 | timeout=dict(type='int', default=10), 49 | ) 50 | 51 | 52 | def _ros_api_connect(module, username, password, host, port, use_tls, force_no_cert, validate_certs, validate_cert_hostname, ca_path, encoding, timeout): 53 | '''Connect to RouterOS API.''' 54 | if not port: 55 | if use_tls: 56 | port = 8729 57 | else: 58 | port = 8728 59 | try: 60 | params = dict( 61 | username=username, 62 | password=password, 63 | host=host, 64 | port=port, 65 | encoding=encoding, 66 | timeout=timeout, 67 | ) 68 | if use_tls: 69 | ctx = ssl.create_default_context(cafile=ca_path) 70 | wrap_context = ctx.wrap_socket 71 | if force_no_cert: 72 | ctx.check_hostname = False 73 | ctx.set_ciphers("ADH:@SECLEVEL=0") 74 | elif not validate_certs: 75 | ctx.check_hostname = False 76 | ctx.verify_mode = ssl.CERT_NONE 77 | elif not validate_cert_hostname: 78 | ctx.check_hostname = False 79 | else: 80 | # Since librouteros does not pass server_hostname, 81 | # we have to do this ourselves: 82 | def wrap_context(*args, **kwargs): 83 | kwargs.pop('server_hostname', None) 84 | return ctx.wrap_socket(*args, server_hostname=host, **kwargs) 85 | params['ssl_wrapper'] = wrap_context 86 | api = connect(**params) 87 | except Exception as e: 88 | connection = { 89 | 'username': username, 90 | 'hostname': host, 91 | 'port': port, 92 | 'ssl': use_tls, 93 | 'status': 'Error while connecting: %s' % to_native(e), 94 | } 95 | module.fail_json(msg=connection['status'], connection=connection) 96 | return api 97 | 98 | 99 | def create_api(module): 100 | """Create an API object.""" 101 | return _ros_api_connect( 102 | module, 103 | module.params['username'], 104 | module.params['password'], 105 | module.params['hostname'], 106 | module.params['port'], 107 | module.params['tls'], 108 | module.params['force_no_cert'], 109 | module.params['validate_certs'], 110 | module.params['validate_cert_hostname'], 111 | module.params['ca_path'], 112 | module.params['encoding'], 113 | module.params['timeout'], 114 | ) 115 | 116 | 117 | def get_api_version(api): 118 | """Given an API object, query the system's version.""" 119 | system_info = list(api.path().join('system', 'resource'))[0] 120 | return system_info['version'].split(' ', 1)[0] 121 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_command.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Red Hat Inc. 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | # Make coding more python3-ish 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | import json 10 | 11 | from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch 12 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import set_module_args 13 | 14 | from ansible_collections.community.routeros.plugins.modules import command 15 | from .routeros_module import TestRouterosModule, load_fixture 16 | 17 | 18 | class TestRouterosCommandModule(TestRouterosModule): 19 | 20 | module = command 21 | 22 | def setUp(self): 23 | super(TestRouterosCommandModule, self).setUp() 24 | 25 | self.mock_run_commands = patch('ansible_collections.community.routeros.plugins.modules.command.run_commands') 26 | self.run_commands = self.mock_run_commands.start() 27 | 28 | def tearDown(self): 29 | super(TestRouterosCommandModule, self).tearDown() 30 | self.mock_run_commands.stop() 31 | 32 | def load_fixtures(self, commands=None): 33 | 34 | def load_from_file(*args, **kwargs): 35 | module, commands = args 36 | output = list() 37 | 38 | for item in commands: 39 | try: 40 | obj = json.loads(item) 41 | command = obj 42 | except ValueError: 43 | command = item 44 | filename = str(command).replace(' ', '_').replace('/', '') 45 | output.append(load_fixture(filename)) 46 | return output 47 | 48 | self.run_commands.side_effect = load_from_file 49 | 50 | def test_command_simple(self): 51 | with set_module_args(dict(commands=['/system resource print'])): 52 | result = self.execute_module(changed=True) 53 | self.assertEqual(len(result['stdout']), 1) 54 | self.assertTrue('platform: "MikroTik"' in result['stdout'][0]) 55 | 56 | def test_command_multiple(self): 57 | with set_module_args(dict(commands=['/system resource print', '/system resource print'])): 58 | result = self.execute_module(changed=True) 59 | self.assertEqual(len(result['stdout']), 2) 60 | self.assertTrue('platform: "MikroTik"' in result['stdout'][0]) 61 | 62 | def test_command_wait_for(self): 63 | wait_for = 'result[0] contains "MikroTik"' 64 | with set_module_args(dict(commands=['/system resource print'], wait_for=wait_for)): 65 | self.execute_module(changed=True) 66 | 67 | def test_command_wait_for_fails(self): 68 | wait_for = 'result[0] contains "test string"' 69 | with set_module_args(dict(commands=['/system resource print'], wait_for=wait_for)): 70 | self.execute_module(failed=True) 71 | self.assertEqual(self.run_commands.call_count, 10) 72 | 73 | def test_command_retries(self): 74 | wait_for = 'result[0] contains "test string"' 75 | with set_module_args(dict(commands=['/system resource print'], wait_for=wait_for, retries=2)): 76 | self.execute_module(failed=True) 77 | self.assertEqual(self.run_commands.call_count, 2) 78 | 79 | def test_command_match_any(self): 80 | wait_for = ['result[0] contains "MikroTik"', 81 | 'result[0] contains "test string"'] 82 | with set_module_args(dict(commands=['/system resource print'], wait_for=wait_for, match='any')): 83 | self.execute_module(changed=True) 84 | 85 | def test_command_match_all(self): 86 | wait_for = ['result[0] contains "MikroTik"', 87 | 'result[0] contains "RB1100"'] 88 | with set_module_args(dict(commands=['/system resource print'], wait_for=wait_for, match='all')): 89 | self.execute_module(changed=True) 90 | 91 | def test_command_match_all_failure(self): 92 | wait_for = ['result[0] contains "MikroTik"', 93 | 'result[0] contains "test string"'] 94 | commands = ['/system resource print', '/system resource print'] 95 | with set_module_args(dict(commands=commands, wait_for=wait_for, match='all')): 96 | self.execute_module(failed=True) 97 | 98 | def test_command_wait_for_2(self): 99 | wait_for = 'result[0] contains "wireless"' 100 | with set_module_args(dict(commands=['/system package print'], wait_for=wait_for)): 101 | self.execute_module(changed=True) 102 | -------------------------------------------------------------------------------- /plugins/doc_fragments/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2020, Nikolay Dachev 4 | # GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | 11 | class ModuleDocFragment(object): 12 | 13 | DOCUMENTATION = r""" 14 | options: 15 | hostname: 16 | description: 17 | - RouterOS hostname API. 18 | required: true 19 | type: str 20 | username: 21 | description: 22 | - RouterOS login user. 23 | required: true 24 | type: str 25 | password: 26 | description: 27 | - RouterOS user password. 28 | required: true 29 | type: str 30 | timeout: 31 | description: 32 | - Timeout for the request. 33 | type: int 34 | default: 10 35 | version_added: 2.3.0 36 | tls: 37 | description: 38 | - If is set TLS will be used for RouterOS API connection. 39 | required: false 40 | type: bool 41 | default: false 42 | aliases: 43 | - ssl 44 | port: 45 | description: 46 | - RouterOS API port. If O(tls) is set, port will apply to TLS/SSL connection. 47 | - Defaults are V(8728) for the HTTP API, and V(8729) for the HTTPS API. 48 | type: int 49 | force_no_cert: 50 | description: 51 | - Set to V(true) to connect without a certificate when O(tls=true). 52 | - See also O(validate_certs). 53 | - B(Note:) this forces the use of anonymous Diffie-Hellman (ADH) ciphers. The protocol is susceptible to Man-in-the-Middle 54 | attacks, because the keys used in the exchange are not authenticated. Instead of simply connecting without a certificate 55 | to "make things work" have a look at O(validate_certs) and O(ca_path). 56 | type: bool 57 | default: false 58 | version_added: 2.4.0 59 | validate_certs: 60 | description: 61 | - Set to V(false) to skip validation of TLS certificates. 62 | - See also O(validate_cert_hostname). Only used when O(tls=true). 63 | - B(Note:) instead of simply deactivating certificate validations to "make things work", please consider creating your 64 | own CA certificate and using it to sign certificates used for your router. You can tell the module about your CA certificate 65 | with the O(ca_path) option. 66 | type: bool 67 | default: true 68 | version_added: 1.2.0 69 | validate_cert_hostname: 70 | description: 71 | - Set to V(true) to validate hostnames in certificates. 72 | - See also O(validate_certs). Only used when O(tls=true) and O(validate_certs=true). 73 | type: bool 74 | default: false 75 | version_added: 1.2.0 76 | ca_path: 77 | description: 78 | - PEM formatted file that contains a CA certificate to be used for certificate validation. 79 | - See also O(validate_cert_hostname). Only used when O(tls=true) and O(validate_certs=true). 80 | type: path 81 | version_added: 1.2.0 82 | encoding: 83 | description: 84 | - Use the specified encoding when communicating with the RouterOS device. 85 | - Default is V(ASCII). Note that V(UTF-8) requires librouteros 3.2.1 or newer. 86 | type: str 87 | default: ASCII 88 | version_added: 2.1.0 89 | requirements: 90 | - librouteros 91 | - Python >= 3.6 (for librouteros) 92 | seealso: 93 | - ref: ansible_collections.community.routeros.docsite.api-guide 94 | description: How to connect to RouterOS devices with the RouterOS API. 95 | """ 96 | 97 | RESTRICT = r""" 98 | options: 99 | restrict: 100 | type: list 101 | elements: dict 102 | suboptions: 103 | field: 104 | description: 105 | - The field whose values to restrict. 106 | required: true 107 | type: str 108 | match_disabled: 109 | description: 110 | - Whether disabled or not provided values should match. 111 | type: bool 112 | default: false 113 | values: 114 | description: 115 | - The values of the field to limit to. 116 | - 'Note that the types of the values are important. If you provide a string V("0"), and librouteros converts the 117 | value returned by the API to the integer V(0), then this will not match. If you are not sure, better include both 118 | variants: both the string and the integer.' 119 | type: list 120 | elements: raw 121 | regex: 122 | description: 123 | - A regular expression matching values of the field to limit to. 124 | - Note that all values will be converted to strings before matching. 125 | - It is not possible to match disabled values with regular expressions. Set O(restrict[].match_disabled=true) if 126 | you also want to match disabled values. 127 | type: str 128 | invert: 129 | description: 130 | - Invert the condition. This affects O(restrict[].match_disabled), O(restrict[].values), and O(restrict[].regex). 131 | type: bool 132 | default: false 133 | """ 134 | -------------------------------------------------------------------------------- /tests/unit/plugins/module_utils/test__api_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2021, Felix Fontein (@felixfontein) 4 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | __metaclass__ = type 9 | 10 | import pytest 11 | 12 | from ansible_collections.community.routeros.plugins.module_utils._api_data import ( 13 | VersionedAPIData, 14 | KeyInfo, 15 | split_path, 16 | join_path, 17 | ) 18 | 19 | 20 | def test_api_data_errors(): 21 | with pytest.raises(ValueError) as exc: 22 | VersionedAPIData() 23 | assert exc.value.args[0] == 'fields must be provided' 24 | 25 | values = [ 26 | ('primary_keys', []), 27 | ('stratify_keys', []), 28 | ('has_identifier', True), 29 | ('single_value', True), 30 | ('unknown_mechanism', True), 31 | ] 32 | 33 | for index, (param, param_value) in enumerate(values): 34 | for param2, param2_value in values[index + 1:]: 35 | with pytest.raises(ValueError) as exc: 36 | VersionedAPIData(**{param: param_value, param2: param2_value}) 37 | assert exc.value.args[0] == 'primary_keys, stratify_keys, has_identifier, single_value, and unknown_mechanism are mutually exclusive' 38 | 39 | with pytest.raises(ValueError) as exc: 40 | VersionedAPIData(unknown_mechanism=True, fully_understood=True) 41 | assert exc.value.args[0] == 'unknown_mechanism and fully_understood cannot be combined' 42 | 43 | with pytest.raises(ValueError) as exc: 44 | VersionedAPIData(unknown_mechanism=True, fixed_entries=True) 45 | assert exc.value.args[0] == 'fixed_entries can only be used with primary_keys' 46 | 47 | with pytest.raises(ValueError) as exc: 48 | VersionedAPIData(primary_keys=['foo'], fields={}) 49 | assert exc.value.args[0] == 'Primary key foo must be in fields!' 50 | 51 | with pytest.raises(ValueError) as exc: 52 | VersionedAPIData(stratify_keys=['foo'], fields={}) 53 | assert exc.value.args[0] == 'Stratify key foo must be in fields!' 54 | 55 | with pytest.raises(ValueError) as exc: 56 | VersionedAPIData(required_one_of=['foo'], fields={}) 57 | assert exc.value.args[0] == 'Require one of element at index #1 must be a list!' 58 | 59 | with pytest.raises(ValueError) as exc: 60 | VersionedAPIData(required_one_of=[['foo']], fields={}) 61 | assert exc.value.args[0] == 'Require one of key foo must be in fields!' 62 | 63 | with pytest.raises(ValueError) as exc: 64 | VersionedAPIData(mutually_exclusive=['foo'], fields={}) 65 | assert exc.value.args[0] == 'Mutually exclusive element at index #1 must be a list!' 66 | 67 | with pytest.raises(ValueError) as exc: 68 | VersionedAPIData(mutually_exclusive=[['foo']], fields={}) 69 | assert exc.value.args[0] == 'Mutually exclusive key foo must be in fields!' 70 | 71 | 72 | def test_key_info_errors(): 73 | values = [ 74 | ('required', True), 75 | ('default', ''), 76 | ('automatically_computed_from', ()), 77 | ('can_disable', True), 78 | ] 79 | 80 | params_allowed_together = [ 81 | 'default', 82 | 'can_disable', 83 | ] 84 | 85 | emsg = 'required, default, automatically_computed_from, and can_disable are mutually exclusive besides default and can_disable which can be set together' 86 | for index, (param, param_value) in enumerate(values): 87 | for param2, param2_value in values[index + 1:]: 88 | if param in params_allowed_together and param2 in params_allowed_together: 89 | continue 90 | with pytest.raises(ValueError) as exc: 91 | KeyInfo(**{param: param_value, param2: param2_value}) 92 | assert exc.value.args[0] == emsg 93 | 94 | with pytest.raises(ValueError) as exc: 95 | KeyInfo('foo') 96 | assert exc.value.args[0] == 'KeyInfo() does not have positional arguments' 97 | 98 | with pytest.raises(ValueError) as exc: 99 | KeyInfo(remove_value='') 100 | assert exc.value.args[0] == 'remove_value can only be specified if can_disable=True' 101 | 102 | with pytest.raises(ValueError) as exc: 103 | KeyInfo(read_only=True, write_only=True) 104 | assert exc.value.args[0] == 'read_only and write_only cannot be used at the same time' 105 | 106 | with pytest.raises(ValueError) as exc: 107 | KeyInfo(read_only=True, default=0) 108 | assert exc.value.args[0] == 'read_only can not be combined with can_disable, remove_value, absent_value, default, or required' 109 | 110 | 111 | SPLIT_PATHS = [ 112 | ('', [], ''), 113 | (' ip ', ['ip'], 'ip'), 114 | ('ip', ['ip'], 'ip'), 115 | (' ip \t\n\raddress ', ['ip', 'address'], 'ip address'), 116 | ] 117 | 118 | 119 | @pytest.mark.parametrize("joined_input, split, joined_output", SPLIT_PATHS) 120 | def test_join_split_path(joined_input, split, joined_output): 121 | assert split_path(joined_input) == split 122 | assert join_path(split) == joined_output 123 | -------------------------------------------------------------------------------- /plugins/module_utils/routeros.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Red Hat Inc. 2 | # Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from __future__ import absolute_import, division, print_function 6 | __metaclass__ = type 7 | 8 | import json 9 | from ansible.module_utils.common.text.converters import to_native 10 | from ansible.module_utils.basic import env_fallback 11 | from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import to_list, ComplexList 12 | from ansible_collections.community.routeros.plugins.module_utils.version import LooseVersion 13 | from ansible.module_utils.connection import Connection, ConnectionError 14 | 15 | _DEVICE_CONFIGS = {} 16 | 17 | routeros_provider_spec = { 18 | 'host': dict(), 19 | 'port': dict(type='int'), 20 | 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), 21 | 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), 22 | 'ssh_keyfile': dict(fallback=(env_fallback, ['ANSIBLE_NET_SSH_KEYFILE']), type='path'), 23 | 'timeout': dict(type='int') 24 | } 25 | routeros_argument_spec = {} 26 | 27 | 28 | def get_provider_argspec(): 29 | return routeros_provider_spec 30 | 31 | 32 | def get_connection(module): 33 | if hasattr(module, '_routeros_connection'): 34 | return module._routeros_connection 35 | 36 | capabilities = get_capabilities(module) 37 | network_api = capabilities.get('network_api') 38 | if network_api == 'cliconf': 39 | module._routeros_connection = Connection(module._socket_path) 40 | else: 41 | module.fail_json(msg='Invalid connection type %s' % network_api) 42 | 43 | return module._routeros_connection 44 | 45 | 46 | def get_capabilities(module): 47 | if hasattr(module, '_routeros_capabilities'): 48 | return module._routeros_capabilities 49 | 50 | try: 51 | capabilities = Connection(module._socket_path).get_capabilities() 52 | module._routeros_capabilities = json.loads(capabilities) 53 | return module._routeros_capabilities 54 | except ConnectionError as exc: 55 | module.fail_json(msg=to_native(exc, errors='surrogate_then_replace')) 56 | 57 | 58 | def get_defaults_flag(module): 59 | connection = get_connection(module) 60 | 61 | try: 62 | out = connection.get('/system default-configuration print') 63 | except ConnectionError as exc: 64 | module.fail_json(msg=to_native(exc, errors='surrogate_then_replace')) 65 | 66 | out = to_native(out, errors='surrogate_then_replace') 67 | 68 | commands = set() 69 | for line in out.splitlines(): 70 | if line.strip(): 71 | commands.add(line.strip().split()[0]) 72 | 73 | if 'all' in commands: 74 | return ['all'] 75 | else: 76 | return ['full'] 77 | 78 | 79 | def get_config(module, flags=None): 80 | flag_str = ' '.join(to_list(flags)) 81 | 82 | try: 83 | return _DEVICE_CONFIGS[flag_str] 84 | except KeyError: 85 | connection = get_connection(module) 86 | 87 | try: 88 | out = connection.get_config(flags=flags) 89 | except ConnectionError as exc: 90 | module.fail_json(msg=to_native(exc, errors='surrogate_then_replace')) 91 | 92 | cfg = to_native(out, errors='surrogate_then_replace').strip() 93 | _DEVICE_CONFIGS[flag_str] = cfg 94 | return cfg 95 | 96 | 97 | def to_commands(module, commands): 98 | spec = { 99 | 'command': dict(key=True), 100 | 'prompt': dict(), 101 | 'answer': dict() 102 | } 103 | transform = ComplexList(spec, module) 104 | return transform(commands) 105 | 106 | 107 | def should_add_leading_space(module): 108 | """Determines whether adding a leading space to the command is needed 109 | to workaround prompt bug in 6.49 <= ROS < 7.2""" 110 | capabilities = get_capabilities(module) 111 | network_os_version = capabilities.get('device_info', {}).get('network_os_version') 112 | if network_os_version is None: 113 | return False 114 | return LooseVersion('6.49') <= LooseVersion(network_os_version) < LooseVersion('7.2') 115 | 116 | 117 | def run_commands(module, commands, check_rc=True): 118 | responses = list() 119 | connection = get_connection(module) 120 | 121 | for cmd in to_list(commands): 122 | if isinstance(cmd, dict): 123 | command = cmd['command'] 124 | prompt = cmd['prompt'] 125 | answer = cmd['answer'] 126 | else: 127 | command = cmd 128 | prompt = None 129 | answer = None 130 | 131 | if should_add_leading_space(module): 132 | command = " " + command 133 | 134 | try: 135 | out = connection.get(command, prompt, answer) 136 | except ConnectionError as exc: 137 | module.fail_json(msg=to_native(exc, errors='surrogate_then_replace')) 138 | 139 | try: 140 | out = to_native(out, errors='surrogate_or_strict') 141 | except UnicodeError: 142 | module.fail_json( 143 | msg=u'Failed to decode output from %s: %s' % (cmd, to_native(out))) 144 | 145 | responses.append(out) 146 | 147 | return responses 148 | 149 | 150 | def load_config(module, commands): 151 | connection = get_connection(module) 152 | 153 | out = connection.edit_config(commands) 154 | -------------------------------------------------------------------------------- /docs/docsite/rst/ssh-guide.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright (c) Ansible Project 3 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | .. _ansible_collections.community.routeros.docsite.ssh-guide: 7 | 8 | How to connect to RouterOS devices with SSH 9 | =========================================== 10 | 11 | The collection offers two modules to connect to RouterOS devies with SSH: 12 | 13 | - The :ansplugin:`community.routeros.facts module ` gathers facts about a RouterOS device; 14 | - The :ansplugin:`community.routeros.command module ` executes commands on a RouterOS device. 15 | 16 | The modules need the :ansplugin:`ansible.netcommon.network_cli connection plugin ` for this. 17 | 18 | Important notes 19 | --------------- 20 | 21 | 1. The SSH-based modules do not support arbitrary symbols in the router's identity. If you are having trouble connecting to your device, please make sure that your MikroTik's identity contains only alphanumeric characters and dashes. Also make sure that the identity string is not longer than 19 characters (`see issue for details `__). Similar problems can happen for unsupported characters in your username. 22 | 23 | 2. The :ansplugin:`community.routeros.command module ` does not support nesting commands and expects every command to start with a forward slash (``/``). Running the following command will produce an error: 24 | 25 | .. code-block:: yaml+jinja 26 | 27 | - community.routeros.command: 28 | commands: 29 | - /ip 30 | - print 31 | 32 | 3. When using the :ansplugin:`community.routeros.command module ` module, make sure to not specify too long commands. Alternatively, add something like ``+cet512w`` to the username (replace ``admin`` with ``admin+cet512w``) to tell RouterOS to not wrap before 512 characters in a line (`see issue for details `__). 33 | 34 | 4. The :ansplugin:`ansible.netcommon.network_cli connection plugin ` uses `paramiko `_ by default to connect to devices with SSH. You can set its :ansopt:`ansible.netcommon.network_cli#connection:ssh_type` option to :ansval:`libssh` to use `ansible-pylibssh `_ instead, which offers Python bindings to libssh. See its documentation for details. 35 | 36 | 5. User is **not allowed** to login via SSH by password to modern Mikrotik if SSH key for the user is added! 37 | 38 | Setting up an inventory 39 | ----------------------- 40 | 41 | An example inventory ``hosts`` file for a RouterOS device is as follows: 42 | 43 | .. code-block:: ini 44 | 45 | [routers] 46 | router ansible_host=192.168.2.1 47 | 48 | [routers:vars] 49 | ansible_connection=ansible.netcommon.network_cli 50 | ansible_network_os=community.routeros.routeros 51 | ansible_user=admin 52 | ansible_ssh_pass=test1234 53 | 54 | This tells Ansible that you have a RouterOS device called ``router`` with IP ``192.168.2.1``. Ansible should use the :ansplugin:`ansible.netcommon.network_cli connection plugin ` together with the the :ansplugin:`community.routeros.routeros cliconf plugin `. The credentials are stored as ``ansible_user`` and ``ansible_ssh_pass`` in the inventory. 55 | 56 | Connecting to the device 57 | ------------------------ 58 | 59 | With the above inventory, you can use the following playbook to execute ``/system resource print`` on the device 60 | 61 | .. code-block:: yaml+jinja 62 | 63 | --- 64 | - name: RouterOS test with network_cli connection 65 | hosts: routers 66 | gather_facts: false 67 | tasks: 68 | 69 | - name: Gather system resources 70 | community.routeros.command: 71 | commands: 72 | - /system resource print 73 | register: system_resource_print 74 | 75 | - name: Show system resources 76 | debug: 77 | var: system_resource_print.stdout_lines 78 | 79 | - name: Gather facts 80 | community.routeros.facts: 81 | 82 | - name: Show a fact 83 | debug: 84 | msg: "First IP address: {{ ansible_net_all_ipv4_addresses[0] }}" 85 | 86 | This results in the following output: 87 | 88 | .. code-block:: ansible-output 89 | 90 | PLAY [RouterOS test with network_cli connection] ***************************************************************** 91 | 92 | TASK [Gather system resources] *********************************************************************************** 93 | ok: [router] 94 | 95 | TASK [Show system resources] ************************************************************************************* 96 | ok: [router] => { 97 | "system_resource_print.stdout_lines": [ 98 | [ 99 | "uptime: 3d10h28m51s", 100 | " version: 6.48.3 (stable)", 101 | " build-time: May/25/2021 06:09:45", 102 | " free-memory: 31.2MiB", 103 | " total-memory: 64.0MiB", 104 | " cpu: MIPS 24Kc V7.4", 105 | " cpu-count: 1", 106 | " cpu-frequency: 400MHz", 107 | " cpu-load: 1%", 108 | " free-hdd-space: 54.2MiB", 109 | " total-hdd-space: 128.0MiB", 110 | " write-sect-since-reboot: 927", 111 | " write-sect-total: 51572981", 112 | " bad-blocks: 1%", 113 | " architecture-name: mipsbe", 114 | " board-name: RB750GL", 115 | " platform: MikroTik" 116 | ] 117 | ] 118 | } 119 | 120 | TASK [Gather facts] ********************************************************************************************** 121 | ok: [router] 122 | 123 | TASK [Show a fact] *********************************************************************************************** 124 | ok: [router] => { 125 | "msg": "First IP address: 192.168.2.1" 126 | } 127 | 128 | PLAY RECAP ******************************************************************************************************* 129 | router : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 130 | -------------------------------------------------------------------------------- /plugins/module_utils/quoting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2021, Felix Fontein (@felixfontein) 4 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | __metaclass__ = type 9 | 10 | 11 | import sys 12 | 13 | from ansible.module_utils.common.text.converters import to_native, to_bytes 14 | 15 | 16 | class ParseError(Exception): 17 | pass 18 | 19 | 20 | ESCAPE_SEQUENCES = { 21 | b'"': b'"', 22 | b'\\': b'\\', 23 | b'?': b'?', 24 | b'$': b'$', 25 | b'_': b' ', 26 | b'a': b'\a', 27 | b'b': b'\b', 28 | b'f': b'\xFF', 29 | b'n': b'\n', 30 | b'r': b'\r', 31 | b't': b'\t', 32 | b'v': b'\v', 33 | } 34 | 35 | ESCAPE_SEQUENCE_REVERSED = dict([(v, k) for k, v in ESCAPE_SEQUENCES.items()]) 36 | 37 | ESCAPE_DIGITS = b'0123456789ABCDEF' 38 | 39 | 40 | if sys.version_info[0] < 3: 41 | _int_to_byte = chr 42 | else: 43 | def _int_to_byte(value): 44 | return bytes((value, )) 45 | 46 | 47 | def parse_argument_value(line, start_index=0, must_match_everything=True): 48 | ''' 49 | Parse an argument value (quoted or not quoted) from ``line``. 50 | 51 | Will start at offset ``start_index``. Returns pair ``(parsed_value, 52 | end_index)``, where ``end_index`` is the first character after the 53 | attribute. 54 | 55 | If ``must_match_everything`` is ``True`` (default), will fail if 56 | ``end_index < len(line)``. 57 | ''' 58 | line = to_bytes(line) 59 | length = len(line) 60 | index = start_index 61 | if index == length: 62 | raise ParseError('Expected value, but found end of string') 63 | quoted = False 64 | if line[index:index + 1] == b'"': 65 | quoted = True 66 | index += 1 67 | current = [] 68 | while index < length: 69 | ch = line[index:index + 1] 70 | index += 1 71 | if not quoted and ch == b' ': 72 | index -= 1 73 | break 74 | elif ch == b'"': 75 | if quoted: 76 | quoted = False 77 | if line[index:index + 1] not in (b'', b' '): 78 | raise ParseError('Ending \'"\' must be followed by space or end of string') 79 | break 80 | raise ParseError('\'"\' must not appear in an unquoted value') 81 | elif ch == b'\\': 82 | if not quoted: 83 | raise ParseError('Escape sequences can only be used inside double quotes') 84 | if index == length: 85 | raise ParseError('\'\\\' must not be at the end of the line') 86 | ch = line[index:index + 1] 87 | index += 1 88 | if ch in ESCAPE_SEQUENCES: 89 | current.append(ESCAPE_SEQUENCES[ch]) 90 | else: 91 | d1 = ESCAPE_DIGITS.find(ch) 92 | if d1 < 0: 93 | raise ParseError('Invalid escape sequence \'\\{0}\''.format(to_native(ch))) 94 | if index == length: 95 | raise ParseError('Hex escape sequence cut off at end of line') 96 | ch2 = line[index:index + 1] 97 | d2 = ESCAPE_DIGITS.find(ch2) 98 | index += 1 99 | if d2 < 0: 100 | raise ParseError('Invalid hex escape sequence \'\\{0}\''.format(to_native(ch + ch2))) 101 | current.append(_int_to_byte(d1 * 16 + d2)) 102 | else: 103 | if not quoted and ch in (b"'", b'=', b'(', b')', b'$', b'[', b'{', b'`'): 104 | raise ParseError('"{0}" can only be used inside double quotes'.format(to_native(ch))) 105 | if ch == b'?': 106 | raise ParseError('"{0}" can only be used in escaped form'.format(to_native(ch))) 107 | current.append(ch) 108 | if quoted: 109 | raise ParseError('Unexpected end of string during escaped parameter') 110 | if must_match_everything and index < length: 111 | raise ParseError('Unexpected data at end of value') 112 | return to_native(b''.join(current)), index 113 | 114 | 115 | def split_routeros_command(line): 116 | line = to_bytes(line) 117 | result = [] 118 | current = [] 119 | index = 0 120 | length = len(line) 121 | parsing_attribute_name = False 122 | while index < length: 123 | ch = line[index:index + 1] 124 | index += 1 125 | if ch == b' ': 126 | if parsing_attribute_name: 127 | parsing_attribute_name = False 128 | result.append(b''.join(current)) 129 | current = [] 130 | elif ch == b'=' and parsing_attribute_name: 131 | current.append(ch) 132 | value, index = parse_argument_value(line, start_index=index, must_match_everything=False) 133 | current.append(to_bytes(value)) 134 | parsing_attribute_name = False 135 | result.append(b''.join(current)) 136 | current = [] 137 | elif ch in (b'"', b'\\', b"'", b'=', b'(', b')', b'$', b'[', b'{', b'`', b'?'): 138 | raise ParseError('Found unexpected "{0}"'.format(to_native(ch))) 139 | else: 140 | current.append(ch) 141 | parsing_attribute_name = True 142 | if parsing_attribute_name and current: 143 | result.append(b''.join(current)) 144 | return [to_native(part) for part in result] 145 | 146 | 147 | def quote_routeros_argument_value(argument): 148 | argument = to_bytes(argument) 149 | result = [] 150 | quote = False 151 | length = len(argument) 152 | index = 0 153 | while index < length: 154 | letter = argument[index:index + 1] 155 | index += 1 156 | if letter in ESCAPE_SEQUENCE_REVERSED: 157 | result.append(b'\\%s' % ESCAPE_SEQUENCE_REVERSED[letter]) 158 | quote = True 159 | continue 160 | elif ord(letter) < 32: 161 | v = ord(letter) 162 | v1 = v % 16 163 | v2 = v // 16 164 | result.append(b'\\%s%s' % (ESCAPE_DIGITS[v2:v2 + 1], ESCAPE_DIGITS[v1:v1 + 1])) 165 | quote = True 166 | continue 167 | elif letter in (b' ', b'=', b';', b"'"): 168 | quote = True 169 | result.append(letter) 170 | argument = to_native(b''.join(result)) 171 | if quote or not argument: 172 | argument = '"%s"' % argument 173 | return argument 174 | 175 | 176 | def quote_routeros_argument(argument): 177 | def check_attribute(attribute): 178 | if ' ' in attribute: 179 | raise ParseError('Attribute names must not contain spaces') 180 | return attribute 181 | 182 | if '=' not in argument: 183 | check_attribute(argument) 184 | return argument 185 | 186 | attribute, value = argument.split('=', 1) 187 | check_attribute(attribute) 188 | value = quote_routeros_argument_value(value) 189 | return '%s=%s' % (attribute, value) 190 | 191 | 192 | def join_routeros_command(arguments): 193 | return ' '.join([quote_routeros_argument(argument) for argument in arguments]) 194 | 195 | 196 | def convert_list_to_dictionary(string_list, require_assignment=True, skip_empty_values=False): 197 | dictionary = {} 198 | for p in string_list: 199 | if '=' not in p: 200 | if require_assignment: 201 | raise ParseError("missing '=' after '%s'" % p) 202 | dictionary[p] = None 203 | continue 204 | p = p.split('=', 1) 205 | if not skip_empty_values or p[1]: 206 | dictionary[p[0]] = p[1] 207 | return dictionary 208 | -------------------------------------------------------------------------------- /plugins/modules/command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright (c) Ansible Project 4 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | DOCUMENTATION = r""" 11 | module: command 12 | author: "Egor Zaitsev (@heuels)" 13 | short_description: Run commands on remote devices running MikroTik RouterOS 14 | description: 15 | - Sends arbitrary commands to an RouterOS node and returns the results read from the device. This module includes an argument 16 | that will cause the module to wait for a specific condition before returning or timing out if the condition is not met. 17 | - The module always indicates a (changed) status. You can use R(the changed_when task property,override_the_changed_result) 18 | to determine whether a command task actually resulted in a change or not. 19 | extends_documentation_fragment: 20 | - community.routeros.attributes 21 | attributes: 22 | check_mode: 23 | support: none 24 | details: 25 | - Before community.routeros 3.0.0, the module claimed to support check mode. It simply executed the command in check 26 | mode. 27 | diff_mode: 28 | support: none 29 | platform: 30 | support: full 31 | platforms: RouterOS 32 | idempotent: 33 | support: N/A 34 | details: 35 | - Whether the executed command is idempotent depends on the command. 36 | options: 37 | commands: 38 | description: 39 | - List of commands to send to the remote RouterOS device over the configured provider. The resulting output from the 40 | command is returned. If the O(wait_for) argument is provided, the module is not returned until the condition is satisfied 41 | or the number of retries has expired. 42 | required: true 43 | type: list 44 | elements: str 45 | wait_for: 46 | description: 47 | - List of conditions to evaluate against the output of the command. The task will wait for each condition to be true 48 | before moving forward. If the conditional is not true within the configured number of retries, the task fails. See 49 | examples. 50 | type: list 51 | elements: str 52 | match: 53 | description: 54 | - The O(match) argument is used in conjunction with the O(wait_for) argument to specify the match policy. Valid values 55 | are V(all) or V(any). If the value is set to V(all) then all conditionals in the wait_for must be satisfied. If the 56 | value is set to V(any) then only one of the values must be satisfied. 57 | default: all 58 | choices: ['any', 'all'] 59 | type: str 60 | retries: 61 | description: 62 | - Specifies the number of retries a command should by tried before it is considered failed. The command is run on the 63 | target device every retry and evaluated against the O(wait_for) conditions. 64 | default: 10 65 | type: int 66 | interval: 67 | description: 68 | - Configures the interval in seconds to wait between retries of the command. If the command does not pass the specified 69 | conditions, the interval indicates how long to wait before trying the command again. 70 | default: 1 71 | type: int 72 | seealso: 73 | - ref: ansible_collections.community.routeros.docsite.ssh-guide 74 | description: How to connect to RouterOS devices with SSH. 75 | - ref: ansible_collections.community.routeros.docsite.quoting 76 | description: How to quote and unquote commands and arguments. 77 | """ 78 | 79 | EXAMPLES = r""" 80 | --- 81 | - name: Run command on remote devices 82 | community.routeros.command: 83 | commands: /system routerboard print 84 | 85 | - name: Run command and check to see if output contains routeros 86 | community.routeros.command: 87 | commands: /system resource print 88 | wait_for: result[0] contains MikroTik 89 | 90 | - name: Run multiple commands on remote nodes 91 | community.routeros.command: 92 | commands: 93 | - /system routerboard print 94 | - /system identity print 95 | 96 | - name: Run multiple commands and evaluate the output 97 | community.routeros.command: 98 | commands: 99 | - /system routerboard print 100 | - /interface ethernet print 101 | wait_for: 102 | - result[0] contains x86 103 | - result[1] contains ether1 104 | """ 105 | 106 | RETURN = r""" 107 | stdout: 108 | description: The set of responses from the commands. 109 | returned: always apart from low level errors (such as action plugin) 110 | type: list 111 | sample: ['...', '...'] 112 | stdout_lines: 113 | description: The value of stdout split into a list. 114 | returned: always apart from low level errors (such as action plugin) 115 | type: list 116 | sample: [['...', '...'], ['...'], ['...']] 117 | failed_conditions: 118 | description: The list of conditionals that have failed. 119 | returned: failed 120 | type: list 121 | sample: ['...', '...'] 122 | """ 123 | 124 | import sys 125 | import time 126 | 127 | from ansible_collections.community.routeros.plugins.module_utils.routeros import run_commands 128 | from ansible_collections.community.routeros.plugins.module_utils.routeros import routeros_argument_spec 129 | from ansible.module_utils.basic import AnsibleModule 130 | from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.parsing import Conditional 131 | 132 | if sys.version_info[0] == 2: 133 | string_types = (basestring,) # noqa: F821, pylint: disable=undefined-variable 134 | else: 135 | string_types = (str,) 136 | 137 | 138 | def to_lines(stdout): 139 | for item in stdout: 140 | if isinstance(item, string_types): 141 | item = str(item).split('\n') 142 | yield item 143 | 144 | 145 | def main(): 146 | """main entry point for module execution 147 | """ 148 | argument_spec = dict( 149 | commands=dict(type='list', elements='str', required=True), 150 | 151 | wait_for=dict(type='list', elements='str'), 152 | match=dict(type='str', default='all', choices=['all', 'any']), 153 | 154 | retries=dict(default=10, type='int'), 155 | interval=dict(default=1, type='int') 156 | ) 157 | 158 | argument_spec.update(routeros_argument_spec) 159 | 160 | module = AnsibleModule(argument_spec=argument_spec, 161 | supports_check_mode=False) 162 | 163 | result = {'changed': False} 164 | 165 | wait_for = module.params['wait_for'] or list() 166 | conditionals = [Conditional(c) for c in wait_for] 167 | 168 | retries = module.params['retries'] 169 | interval = module.params['interval'] 170 | match = module.params['match'] 171 | 172 | while retries > 0: 173 | responses = run_commands(module, module.params['commands']) 174 | 175 | for item in list(conditionals): 176 | if item(responses): 177 | if match == 'any': 178 | conditionals = list() 179 | break 180 | conditionals.remove(item) 181 | 182 | if not conditionals: 183 | break 184 | 185 | time.sleep(interval) 186 | retries -= 1 187 | 188 | if conditionals: 189 | failed_conditions = [item.raw for item in conditionals] 190 | msg = 'One or more conditional statements have not been satisfied' 191 | module.fail_json(msg=msg, failed_conditions=failed_conditions) 192 | 193 | result.update({ 194 | 'changed': True, 195 | 'stdout': responses, 196 | 'stdout_lines': list(to_lines(responses)) 197 | }) 198 | 199 | module.exit_json(**result) 200 | 201 | 202 | if __name__ == '__main__': 203 | main() 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Community RouterOS Collection 8 | [![Documentation](https://img.shields.io/badge/docs-brightgreen.svg)](https://docs.ansible.com/ansible/devel/collections/community/routeros/) 9 | [![CI](https://github.com/ansible-collections/community.routeros/actions/workflows/nox.yml/badge.svg?branch=main)](https://github.com/ansible-collections/community.routeros/actions) 10 | [![Codecov](https://img.shields.io/codecov/c/github/ansible-collections/community.routeros)](https://codecov.io/gh/ansible-collections/community.routeros) 11 | [![REUSE status](https://api.reuse.software/badge/github.com/ansible-collections/community.routeros)](https://api.reuse.software/info/github.com/ansible-collections/community.routeros) 12 | 13 | Provides modules for [Ansible](https://www.ansible.com/community) to manage [MikroTik RouterOS](https://mikrotik.com/software) instances. 14 | 15 | You can find [documentation for the modules and plugins in this collection here](https://docs.ansible.com/ansible/devel/collections/community/routeros/). 16 | 17 | ## Code of Conduct 18 | 19 | We follow [Ansible Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) in all our interactions within this project. 20 | 21 | If you encounter abusive behavior violating the [Ansible Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html), please refer to the [policy violations](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html#policy-violations) section of the Code of Conduct for information on how to raise a complaint. 22 | 23 | ## Communication 24 | 25 | * Join the Ansible forum: 26 | * [Get Help](https://forum.ansible.com/c/help/6): get help or help others.Please add appropriate tags if you start new discussions, for example the `routeros` tag. 27 | * [Posts tagged with 'routeros'](https://forum.ansible.com/tag/routeros): subscribe to participate in RouterOS related conversations. 28 | * [Social Spaces](https://forum.ansible.com/c/chat/4): gather and interact with fellow enthusiasts. 29 | * [News & Announcements](https://forum.ansible.com/c/news/5): track project-wide announcements including social events. 30 | 31 | * The Ansible [Bullhorn newsletter](https://docs.ansible.com/ansible/devel/community/communication.html#the-bullhorn): used to announce releases and important changes. 32 | 33 | For more information about communication, see the [Ansible communication guide](https://docs.ansible.com/ansible/devel/community/communication.html). 34 | 35 | ## Tested with Ansible 36 | 37 | Tested with the current ansible-core 2.15, ansible-core 2.16, ansible-core 2.17, ansible-core 2.18, and ansible-core 2.19 releases and the current development version of ansible-core. Ansible 2.9, ansible-base 2.10, and ansible-core versions before 2.15.0 are not supported. 38 | 39 | ## External requirements 40 | 41 | The exact requirements for every module are listed in the module documentation. 42 | 43 | ### Supported connections 44 | 45 | The collection supports the `network_cli` connection. 46 | 47 | ### Edge cases 48 | 49 | Please note that `community.routeros.api` module does **not** support Windows jump hosts! 50 | 51 | ## Collection Documentation 52 | 53 | Browsing the [**latest** collection documentation](https://docs.ansible.com/ansible/latest/collections/community/routeros) will show docs for the _latest version released in the Ansible package_, not the latest version of the collection released on Galaxy. 54 | 55 | Browsing the [**devel** collection documentation](https://docs.ansible.com/ansible/devel/collections/community/routeros) shows docs for the _latest version released on Galaxy_. 56 | 57 | We also separately publish [**latest commit** collection documentation](https://ansible-collections.github.io/community.routeros/branch/main/) which shows docs for the _latest commit in the `main` branch_. 58 | 59 | If you use the Ansible package and do not update collections independently, use **latest**. If you install or update this collection directly from Galaxy, use **devel**. If you are looking to contribute, use **latest commit**. 60 | 61 | ## Included content 62 | 63 | - `community.routeros.api` 64 | - `community.routeros.api_facts` 65 | - `community.routeros.api_find_and_modify` 66 | - `community.routeros.api_info` 67 | - `community.routeros.api_modify` 68 | - `community.routeros.command` 69 | - `community.routeros.facts` 70 | 71 | You can find [documentation for the modules and plugins in this collection here](https://docs.ansible.com/ansible/devel/collections/community/routeros/). 72 | 73 | ## Using this collection 74 | 75 | See [Ansible Using collections](https://docs.ansible.com/ansible/latest/user_guide/collections_using.html) for general detail on using collections. 76 | 77 | There are two approaches for using this collection. The `command` and `facts` modules use the `network_cli` connection and connect with SSH. The `api` module connects with the HTTP/HTTPS API. 78 | 79 | ### Prerequisites 80 | 81 | The terminal-based modules in this collection (`community.routeros.command` and `community.routeros.facts`) do not support arbitrary symbols in router's identity. If you are having trouble connecting to your device, please make sure that your MikroTik's identity contains only alphanumeric characters and dashes. Also, the `community.routeros.command` module does not support nesting commands and expects every command to start with a forward slash (`/`). Running the following command will produce an error. 82 | 83 | ```yaml 84 | - community.routeros.command: 85 | commands: 86 | - /ip 87 | - print 88 | ``` 89 | 90 | ### Connecting with `network_cli` 91 | 92 | Example inventory `hosts` file: 93 | 94 | ```.ini 95 | [routers] 96 | router ansible_host=192.168.1.1 97 | 98 | [routers:vars] 99 | ansible_connection=ansible.netcommon.network_cli 100 | ansible_network_os=community.routeros.routeros 101 | ansible_user=admin 102 | ansible_ssh_pass=test1234 103 | ``` 104 | 105 | Example playbook: 106 | 107 | ```.yaml 108 | --- 109 | - name: RouterOS test with network_cli connection 110 | hosts: routers 111 | gather_facts: false 112 | tasks: 113 | - name: Run a command 114 | community.routeros.command: 115 | commands: 116 | - /system resource print 117 | register: system_resource_print 118 | - name: Print its output 119 | ansible.builtin.debug: 120 | var: system_resource_print.stdout_lines 121 | 122 | - name: Retrieve facts 123 | community.routeros.facts: 124 | - ansible.builtin.debug: 125 | msg: "First IP address: {{ ansible_net_all_ipv4_addresses[0] }}" 126 | ``` 127 | 128 | ### Connecting with HTTP/HTTPS API 129 | 130 | Example playbook: 131 | 132 | ```.yaml 133 | --- 134 | - name: RouterOS test with API 135 | hosts: localhost 136 | gather_facts: false 137 | vars: 138 | hostname: 192.168.1.1 139 | username: admin 140 | password: test1234 141 | module_defaults: 142 | group/community.routeros.api: 143 | hostname: "{{ hostname }}" 144 | password: "{{ password }}" 145 | username: "{{ username }}" 146 | tls: true 147 | force_no_cert: false 148 | validate_certs: true 149 | validate_cert_hostname: true 150 | ca_path: /path/to/ca-certificate.pem 151 | tasks: 152 | - name: Get "ip address print" 153 | community.routeros.api: 154 | path: ip address 155 | register: print_path 156 | - name: Print the result 157 | ansible.builtin.debug: 158 | var: print_path.msg 159 | 160 | - name: Change IP address to 192.168.1.1 for interface bridge 161 | community.routeros.api_find_and_modify: 162 | path: ip address 163 | find: 164 | interface: bridge 165 | values: 166 | address: "192.168.1.1/24" 167 | 168 | - name: Retrieve facts 169 | community.routeros.api_facts: 170 | - ansible.builtin.debug: 171 | msg: "First IP address: {{ ansible_net_all_ipv4_addresses[0] }}" 172 | ``` 173 | 174 | ## Contributing to this collection 175 | 176 | We're following the general Ansible contributor guidelines; see [Ansible Community Guide](https://docs.ansible.com/ansible/latest/community/index.html). 177 | 178 | If you want to clone this repositority (or a fork of it) to improve it, you can proceed as follows: 179 | 1. Create a directory `ansible_collections/community`; 180 | 2. In there, checkout this repository (or a fork) as `routeros`; 181 | 3. Add the directory containing `ansible_collections` to your [ANSIBLE_COLLECTIONS_PATH](https://docs.ansible.com/ansible/latest/reference_appendices/config.html#collections-paths). 182 | 183 | See [Ansible's dev guide](https://docs.ansible.com/ansible/devel/dev_guide/developing_collections.html#contributing-to-collections) for more information. 184 | 185 | ## Release notes 186 | 187 | See the [collection's changelog](https://github.com/ansible-collections/community.routeros/blob/main/CHANGELOG.md). 188 | 189 | ## Roadmap 190 | 191 | We plan to regularly release minor and patch versions, whenever new features are added or bugs fixed. Our collection follows [semantic versioning](https://semver.org/), so breaking changes will only happen in major releases. 192 | 193 | ## More information 194 | 195 | - [Ansible Collection overview](https://github.com/ansible-collections/overview) 196 | - [Ansible User guide](https://docs.ansible.com/ansible/latest/user_guide/index.html) 197 | - [Ansible Developer guide](https://docs.ansible.com/ansible/latest/dev_guide/index.html) 198 | - [Ansible Collections Checklist](https://github.com/ansible-collections/overview/blob/master/collection_requirements.rst) 199 | - [Ansible Community code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) 200 | - [The Bullhorn (the Ansible Contributor newsletter)](https://us19.campaign-archive.com/home/?u=56d874e027110e35dea0e03c1&id=d6635f5420) 201 | - [Changes impacting Contributors](https://github.com/ansible-collections/overview/issues/45) 202 | 203 | ## Licensing 204 | 205 | This collection is primarily licensed and distributed as a whole under the GNU General Public License v3.0 or later. 206 | 207 | See [LICENSES/GPL-3.0-or-later.txt](https://github.com/ansible-collections/community.routeros/blob/main/COPYING) for the full text. 208 | 209 | Parts of the collection are licensed under the [BSD 2-Clause license](https://github.com/ansible-collections/community.routeros/blob/main/LICENSES/BSD-2-Clause.txt). 210 | 211 | All files have a machine readable `SDPX-License-Identifier:` comment denoting its respective license(s) or an equivalent entry in an accompanying `.license` file. Only changelog fragments (which will not be part of a release) are covered by a blanket statement in `REUSE.toml`. This conforms to the [REUSE specification](https://reuse.software/spec/). 212 | -------------------------------------------------------------------------------- /tests/unit/plugins/module_utils/test_quoting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2021, Felix Fontein (@felixfontein) 4 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | __metaclass__ = type 9 | 10 | import pytest 11 | 12 | from ansible.module_utils.common.text.converters import to_native 13 | 14 | from ansible_collections.community.routeros.plugins.module_utils.quoting import ( 15 | ParseError, 16 | convert_list_to_dictionary, 17 | join_routeros_command, 18 | parse_argument_value, 19 | quote_routeros_argument, 20 | quote_routeros_argument_value, 21 | split_routeros_command, 22 | ) 23 | 24 | 25 | TEST_PARSE_ARGUMENT_VALUE = [ 26 | ('a', {}, ('a', 1)), 27 | ('a ', {'must_match_everything': False}, ('a', 1)), 28 | (r'"a b"', {}, ('a b', 5)), 29 | (r'"b\"f"', {}, ('b"f', 6)), 30 | (r'"\01"', {}, ('\x01', 5)), 31 | (r'"\1F"', {}, ('\x1f', 5)), 32 | (r'"\FF"', {}, (to_native(b'\xff'), 5)), 33 | (r'"\"e"', {}, ('"e', 5)), 34 | (r'"\""', {}, ('"', 4)), 35 | (r'"\\"', {}, ('\\', 4)), 36 | (r'"\?"', {}, ('?', 4)), 37 | (r'"\$"', {}, ('$', 4)), 38 | (r'"\_"', {}, (' ', 4)), 39 | (r'"\a"', {}, ('\a', 4)), 40 | (r'"\b"', {}, ('\b', 4)), 41 | (r'"\f"', {}, (to_native(b'\xff'), 4)), 42 | (r'"\n"', {}, ('\n', 4)), 43 | (r'"\r"', {}, ('\r', 4)), 44 | (r'"\t"', {}, ('\t', 4)), 45 | (r'"\v"', {}, ('\v', 4)), 46 | (r'"b=c"', {}, ('b=c', 5)), 47 | (r'""', {}, ('', 2)), 48 | (r'"" ', {'must_match_everything': False}, ('', 2)), 49 | ("'e", {'start_index': 1}, ('e', 2)), 50 | ] 51 | 52 | 53 | @pytest.mark.parametrize("command, kwargs, result", TEST_PARSE_ARGUMENT_VALUE) 54 | def test_parse_argument_value(command, kwargs, result): 55 | result_ = parse_argument_value(command, **kwargs) 56 | print(result_, result) 57 | assert result_ == result 58 | 59 | 60 | TEST_PARSE_ARGUMENT_VALUE_ERRORS = [ 61 | (r'"e', {}, 'Unexpected end of string during escaped parameter'), 62 | ("'e", {}, '"\'" can only be used inside double quotes'), 63 | (r'\FF', {}, 'Escape sequences can only be used inside double quotes'), 64 | (r'\"e', {}, 'Escape sequences can only be used inside double quotes'), 65 | ('e=f', {}, '"=" can only be used inside double quotes'), 66 | ('e$', {}, '"$" can only be used inside double quotes'), 67 | ('e(', {}, '"(" can only be used inside double quotes'), 68 | ('e)', {}, '")" can only be used inside double quotes'), 69 | ('e[', {}, '"[" can only be used inside double quotes'), 70 | ('e{', {}, '"{" can only be used inside double quotes'), 71 | ('e`', {}, '"`" can only be used inside double quotes'), 72 | ('?', {}, '"?" can only be used in escaped form'), 73 | (r'b"', {}, '\'"\' must not appear in an unquoted value'), 74 | (r'""a', {}, "Ending '\"' must be followed by space or end of string"), 75 | (r'"" ', {}, "Unexpected data at end of value"), 76 | ('"\\', {}, r"'\' must not be at the end of the line"), 77 | (r'"\A', {}, r'Hex escape sequence cut off at end of line'), 78 | (r'"\Z"', {}, r"Invalid escape sequence '\Z'"), 79 | (r'"\Aa"', {}, r"Invalid hex escape sequence '\Aa'"), 80 | ] 81 | 82 | 83 | @pytest.mark.parametrize("command, kwargs, message", TEST_PARSE_ARGUMENT_VALUE_ERRORS) 84 | def test_parse_argument_value_errors(command, kwargs, message): 85 | with pytest.raises(ParseError) as exc: 86 | parse_argument_value(command, **kwargs) 87 | print(exc.value.args[0], message) 88 | assert exc.value.args[0] == message 89 | 90 | 91 | TEST_SPLIT_ROUTEROS_COMMAND = [ 92 | ('', []), 93 | (' ', []), 94 | (r'a b c', ['a', 'b', 'c']), 95 | (r'a=b c d=e', ['a=b', 'c', 'd=e']), 96 | (r'a="b f" c d=e', ['a=b f', 'c', 'd=e']), 97 | (r'a="b\"f" c="\FF" d="\"e"', ['a=b"f', to_native(b'c=\xff'), 'd="e']), 98 | (r'a="b=c"', ['a=b=c']), 99 | (r'a=b ', ['a=b']), 100 | ] 101 | 102 | 103 | @pytest.mark.parametrize("command, result", TEST_SPLIT_ROUTEROS_COMMAND) 104 | def test_split_routeros_command(command, result): 105 | result_ = split_routeros_command(command) 106 | print(result_, result) 107 | assert result_ == result 108 | 109 | 110 | TEST_SPLIT_ROUTEROS_COMMAND_ERRORS = [ 111 | (r'a=', 'Expected value, but found end of string'), 112 | (r'a="b\"f" d="e', 'Unexpected end of string during escaped parameter'), 113 | ('d=\'e', '"\'" can only be used inside double quotes'), 114 | (r'c\FF', r'Found unexpected "\"'), 115 | (r'd=\"e', 'Escape sequences can only be used inside double quotes'), 116 | ('d=e=f', '"=" can only be used inside double quotes'), 117 | ('d=e$', '"$" can only be used inside double quotes'), 118 | ('d=e(', '"(" can only be used inside double quotes'), 119 | ('d=e)', '")" can only be used inside double quotes'), 120 | ('d=e[', '"[" can only be used inside double quotes'), 121 | ('d=e{', '"{" can only be used inside double quotes'), 122 | ('d=e`', '"`" can only be used inside double quotes'), 123 | ('d=?', '"?" can only be used in escaped form'), 124 | (r'a=b"', '\'"\' must not appear in an unquoted value'), 125 | (r'a=""a', "Ending '\"' must be followed by space or end of string"), 126 | ('a="\\', r"'\' must not be at the end of the line"), 127 | (r'a="\Z', r"Invalid escape sequence '\Z'"), 128 | (r'a="\Aa', r"Invalid hex escape sequence '\Aa'"), 129 | ] 130 | 131 | 132 | @pytest.mark.parametrize("command, message", TEST_SPLIT_ROUTEROS_COMMAND_ERRORS) 133 | def test_split_routeros_command_errors(command, message): 134 | with pytest.raises(ParseError) as exc: 135 | split_routeros_command(command) 136 | print(exc.value.args[0], message) 137 | assert exc.value.args[0] == message 138 | 139 | 140 | TEST_CONVERT_LIST_TO_DICTIONARY = [ 141 | (['a=b', 'c=d=e', 'e='], {}, {'a': 'b', 'c': 'd=e', 'e': ''}), 142 | (['a=b', 'c=d=e', 'e='], {'skip_empty_values': False}, {'a': 'b', 'c': 'd=e', 'e': ''}), 143 | (['a=b', 'c=d=e', 'e='], {'skip_empty_values': True}, {'a': 'b', 'c': 'd=e'}), 144 | (['a=b', 'c=d=e', 'e=', 'f'], {'require_assignment': False}, {'a': 'b', 'c': 'd=e', 'e': '', 'f': None}), 145 | ] 146 | 147 | 148 | @pytest.mark.parametrize("list, kwargs, expected_dict", TEST_CONVERT_LIST_TO_DICTIONARY) 149 | def test_convert_list_to_dictionary(list, kwargs, expected_dict): 150 | result = convert_list_to_dictionary(list, **kwargs) 151 | print(result, expected_dict) 152 | assert result == expected_dict 153 | 154 | 155 | TEST_CONVERT_LIST_TO_DICTIONARY_ERRORS = [ 156 | (['a=b', 'c=d=e', 'e=', 'f'], {}, "missing '=' after 'f'"), 157 | ] 158 | 159 | 160 | @pytest.mark.parametrize("list, kwargs, message", TEST_CONVERT_LIST_TO_DICTIONARY_ERRORS) 161 | def test_convert_list_to_dictionary_errors(list, kwargs, message): 162 | with pytest.raises(ParseError) as exc: 163 | result = convert_list_to_dictionary(list, **kwargs) 164 | print(exc.value.args[0], message) 165 | assert exc.value.args[0] == message 166 | 167 | 168 | TEST_JOIN_ROUTEROS_COMMAND = [ 169 | (['a=b', 'c=d=e', 'e=', 'f', 'g=h i j', 'h="h"'], r'a=b c="d=e" e="" f g="h\_i\_j" h="\"h\""'), 170 | ] 171 | 172 | 173 | @pytest.mark.parametrize("list, expected", TEST_JOIN_ROUTEROS_COMMAND) 174 | def test_join_routeros_command(list, expected): 175 | result = join_routeros_command(list) 176 | print(result, expected) 177 | assert result == expected 178 | 179 | 180 | TEST_QUOTE_ROUTEROS_ARGUMENT = [ 181 | (r'', r''), 182 | (r'a', r'a'), 183 | (r'a=b', r'a=b'), 184 | (r'a=b c', r'a="b\_c"'), 185 | (r'a="b c"', r'a="\"b\_c\""'), 186 | (r"a='b", "a=\"'b\""), 187 | (r"a=b'", "a=\"b'\""), 188 | (r'a=""', r'a="\"\""'), 189 | ] 190 | 191 | 192 | @pytest.mark.parametrize("argument, expected", TEST_QUOTE_ROUTEROS_ARGUMENT) 193 | def test_quote_routeros_argument(argument, expected): 194 | result = quote_routeros_argument(argument) 195 | print(result, expected) 196 | assert result == expected 197 | 198 | 199 | TEST_QUOTE_ROUTEROS_ARGUMENT_ERRORS = [ 200 | ('a b', 'Attribute names must not contain spaces'), 201 | ('a b=c', 'Attribute names must not contain spaces'), 202 | ] 203 | 204 | 205 | @pytest.mark.parametrize("argument, message", TEST_QUOTE_ROUTEROS_ARGUMENT_ERRORS) 206 | def test_quote_routeros_argument_errors(argument, message): 207 | with pytest.raises(ParseError) as exc: 208 | result = quote_routeros_argument(argument) 209 | print(exc.value.args[0], message) 210 | assert exc.value.args[0] == message 211 | 212 | 213 | TEST_QUOTE_ROUTEROS_ARGUMENT_VALUE = [ 214 | (r'', r'""'), 215 | (r";", r'";"'), 216 | (r" ", r'"\_"'), 217 | (r"=", r'"="'), 218 | (r'a', r'a'), 219 | (r'a=b', r'"a=b"'), 220 | (r'b c', r'"b\_c"'), 221 | (r'"b c"', r'"\"b\_c\""'), 222 | ("'b", "\"'b\""), 223 | ("b'", "\"b'\""), 224 | ('"', r'"\""'), 225 | ('\\', r'"\\"'), 226 | ('?', r'"\?"'), 227 | ('$', r'"\$"'), 228 | ('_', r'_'), 229 | (' ', r'"\_"'), 230 | ('\a', r'"\a"'), 231 | ('\b', r'"\b"'), 232 | # (to_native(b'\xff'), r'"\f"'), 233 | ('\n', r'"\n"'), 234 | ('\r', r'"\r"'), 235 | ('\t', r'"\t"'), 236 | ('\v', r'"\v"'), 237 | ('\x01', r'"\01"'), 238 | ('\x1f', r'"\1F"'), 239 | ] 240 | 241 | 242 | @pytest.mark.parametrize("argument, expected", TEST_QUOTE_ROUTEROS_ARGUMENT_VALUE) 243 | def test_quote_routeros_argument_value(argument, expected): 244 | result = quote_routeros_argument_value(argument) 245 | print(result, expected) 246 | assert result == expected 247 | 248 | 249 | TEST_ROUNDTRIP = [ 250 | {'a': 'b', 'c': 'd'}, 251 | {'script': ''':local host value=[/system identity get name]; 252 | :local date value=[/system clock get date]; 253 | :local day [ :pick $date 4 6 ]; 254 | :local month [ :pick $date 0 3 ]; 255 | :local year [ :pick $date 7 11 ]; 256 | :local name value=($host."-".$day."-".$month."-".$year); 257 | /system backup save name=$name; 258 | /export file=$name; 259 | /tool fetch address="192.168.1.1" user=ros password="PASSWORD" mode=ftp dst-path=("/mikrotik/rsc/".$name.".rsc") src-path=($name.".rsc") upload=yes; 260 | /tool fetch address="192.168.1.1" user=ros password="PASSWORD" mode=ftp dst-path=("/mikrotik/backup/".$name.".backup") src-path=($name.".backup") upload=yes; 261 | '''}, 262 | ] 263 | 264 | 265 | @pytest.mark.parametrize("dictionary", TEST_ROUNDTRIP) 266 | def test_roundtrip(dictionary): 267 | argument_list = ['%s=%s' % (k, v) for k, v in dictionary.items()] 268 | command = join_routeros_command(argument_list) 269 | resplit_list = split_routeros_command(command) 270 | print(resplit_list, argument_list) 271 | assert resplit_list == argument_list 272 | re_dictionary = convert_list_to_dictionary(resplit_list) 273 | print(re_dictionary, dictionary) 274 | assert re_dictionary == dictionary 275 | -------------------------------------------------------------------------------- /tests/unit/plugins/module_utils/test__api_helper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2021, Felix Fontein (@felixfontein) 4 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | __metaclass__ = type 9 | 10 | import re 11 | import sys 12 | 13 | import pytest 14 | 15 | from ansible_collections.community.routeros.plugins.module_utils._api_data import ( 16 | PATHS, 17 | ) 18 | 19 | from ansible_collections.community.routeros.plugins.module_utils._api_helper import ( 20 | value_to_str, 21 | _test_rule_except_invert, 22 | validate_and_prepare_restrict, 23 | restrict_entry_accepted, 24 | ) 25 | 26 | 27 | VALUE_TO_STR = [ 28 | (None, u'', {'none_to_empty': True}), 29 | (None, None, {'none_to_empty': False}), 30 | (None, None, {}), 31 | ('', u'', {}), 32 | ('foo', u'foo', {}), 33 | (True, u'true', {'compat_bool': True}), 34 | (False, u'false', {'compat_bool': True}), 35 | (True, u'yes', {'compat_bool': False}), 36 | (False, u'no', {'compat_bool': False}), 37 | (True, u'yes', {}), 38 | (False, u'no', {}), 39 | ('true', u'true', {}), 40 | ('false', u'false', {}), 41 | ('yes', u'yes', {}), 42 | ('no', u'no', {}), 43 | ([], u'[]', {}), 44 | ({}, u'{}', {}), 45 | (1, u'1', {}), 46 | (-42, u'-42', {}), 47 | (1.5, u'1.5', {}), 48 | (1.0, u'1.0', {}), 49 | ] 50 | 51 | 52 | @pytest.mark.parametrize("value, expected, kwargs", VALUE_TO_STR) 53 | def test_value_to_str(value, expected, kwargs): 54 | result = value_to_str(value, **kwargs) 55 | print(repr(result)) 56 | assert result == expected 57 | 58 | 59 | TEST_RULE_EXCEPT_INVERT = [ 60 | ( 61 | None, 62 | { 63 | 'field': u'foo', 64 | 'match_disabled': False, 65 | 'invert': False, 66 | }, 67 | False, 68 | ), 69 | ( 70 | None, 71 | { 72 | 'field': u'foo', 73 | 'match_disabled': True, 74 | 'invert': False, 75 | }, 76 | True, 77 | ), 78 | ( 79 | 1, 80 | { 81 | 'field': u'foo', 82 | 'match_disabled': False, 83 | 'invert': False, 84 | 'values': [1], 85 | }, 86 | True, 87 | ), 88 | ( 89 | 1, 90 | { 91 | 'field': u'foo', 92 | 'match_disabled': False, 93 | 'invert': False, 94 | 'values': ['1'], 95 | }, 96 | False, 97 | ), 98 | ( 99 | 1, 100 | { 101 | 'field': u'foo', 102 | 'match_disabled': False, 103 | 'invert': False, 104 | 'regex': re.compile(u'^1$'), 105 | 'regex_source': u'^1$', 106 | }, 107 | True, 108 | ), 109 | ( 110 | 1.10, 111 | { 112 | 'field': u'foo', 113 | 'match_disabled': False, 114 | 'invert': False, 115 | 'regex': re.compile(u'^1\\.1$'), 116 | 'regex_source': u'^1\\.1$', 117 | }, 118 | True, 119 | ), 120 | ( 121 | 10, 122 | { 123 | 'field': u'foo', 124 | 'match_disabled': False, 125 | 'invert': False, 126 | 'regex': re.compile(u'^1$'), 127 | 'regex_source': u'^1$', 128 | }, 129 | False, 130 | ), 131 | ] 132 | 133 | 134 | @pytest.mark.parametrize("value, rule, expected", TEST_RULE_EXCEPT_INVERT) 135 | def test_rule_except_invert(value, rule, expected): 136 | result = _test_rule_except_invert(value, rule, compat=True) 137 | print(repr(result)) 138 | assert result == expected 139 | 140 | 141 | _test_path = PATHS[('ip', 'firewall', 'filter')] 142 | _test_path.provide_version('7.0') 143 | TEST_PATH = _test_path.get_data() 144 | 145 | 146 | class FailJsonExc(Exception): 147 | def __init__(self, msg, kwargs): 148 | self.msg = msg 149 | self.kwargs = kwargs 150 | 151 | 152 | class FakeModule(object): 153 | def __init__(self, restrict_value): 154 | self.params = { 155 | 'restrict': restrict_value, 156 | } 157 | 158 | def fail_json(self, msg, **kwargs): 159 | raise FailJsonExc(msg, kwargs) 160 | 161 | 162 | TEST_VALIDATE_AND_PREPARE_RESTRICT = [ 163 | ( 164 | [{ 165 | 'field': u'chain', 166 | 'match_disabled': False, 167 | 'values': None, 168 | 'regex': None, 169 | 'invert': False, 170 | }], 171 | [{ 172 | 'field': u'chain', 173 | 'match_disabled': False, 174 | 'invert': False, 175 | }], 176 | ), 177 | ( 178 | [{ 179 | 'field': u'comment', 180 | 'match_disabled': True, 181 | 'values': None, 182 | 'regex': None, 183 | 'invert': False, 184 | }], 185 | [{ 186 | 'field': u'comment', 187 | 'match_disabled': True, 188 | 'invert': False, 189 | }], 190 | ), 191 | ( 192 | [{ 193 | 'field': u'comment', 194 | 'match_disabled': False, 195 | 'values': None, 196 | 'regex': None, 197 | 'invert': True, 198 | }], 199 | [{ 200 | 'field': u'comment', 201 | 'match_disabled': False, 202 | 'invert': True, 203 | }], 204 | ), 205 | ] 206 | 207 | if sys.version_info >= (2, 7, 17): 208 | # Somewhere between Python 2.7.15 (used by Ansible 3.9) and 2.7.17 (used by ansible-base 2.10) 209 | # something changed with ``==`` for ``re.Pattern``, at least for some patterns 210 | # (my guess is: for ``re.compile(u'')``) 211 | TEST_VALIDATE_AND_PREPARE_RESTRICT.extend([ 212 | ( 213 | [ 214 | { 215 | 'field': u'comment', 216 | 'match_disabled': False, 217 | 'values': [], 218 | 'regex': None, 219 | 'invert': False, 220 | }, 221 | { 222 | 'field': u'comment', 223 | 'match_disabled': False, 224 | 'values': [None, 1, 42.0, True, u'foo', [], {}], 225 | 'regex': None, 226 | 'invert': False, 227 | }, 228 | { 229 | 'field': u'chain', 230 | 'match_disabled': False, 231 | 'values': None, 232 | 'regex': u'', 233 | 'invert': True, 234 | }, 235 | { 236 | 'field': u'chain', 237 | 'match_disabled': False, 238 | 'values': None, 239 | 'regex': u'foo', 240 | 'invert': False, 241 | }, 242 | ], 243 | [ 244 | { 245 | 'field': u'comment', 246 | 'match_disabled': False, 247 | 'invert': False, 248 | 'values': [], 249 | }, 250 | { 251 | 'field': u'comment', 252 | 'match_disabled': False, 253 | 'invert': False, 254 | 'values': [None, 1, 42.0, True, u'foo', [], {}], 255 | }, 256 | { 257 | 'field': u'chain', 258 | 'match_disabled': False, 259 | 'invert': True, 260 | 'regex': re.compile(u''), 261 | 'regex_source': u'', 262 | }, 263 | { 264 | 'field': u'chain', 265 | 'match_disabled': False, 266 | 'invert': False, 267 | 'regex': re.compile(u'foo'), 268 | 'regex_source': u'foo', 269 | }, 270 | ], 271 | ), 272 | ]) 273 | 274 | 275 | @pytest.mark.parametrize("restrict_value, expected", TEST_VALIDATE_AND_PREPARE_RESTRICT) 276 | def test_validate_and_prepare_restrict(restrict_value, expected): 277 | fake_module = FakeModule(restrict_value) 278 | result = validate_and_prepare_restrict(fake_module, TEST_PATH) 279 | print(repr(result)) 280 | assert result == expected 281 | 282 | 283 | TEST_VALIDATE_AND_PREPARE_RESTRICT_FAIL = [ 284 | ( 285 | [{ 286 | 'field': u'!foo', 287 | 'match_disabled': False, 288 | 'values': None, 289 | 'regex': None, 290 | 'invert': False, 291 | }], 292 | ['restrict: the field name "!foo" must not start with "!"'], 293 | ), 294 | ( 295 | [{ 296 | 'field': u'foo', 297 | 'match_disabled': False, 298 | 'values': None, 299 | 'regex': None, 300 | 'invert': False, 301 | }], 302 | ['restrict: the field "foo" does not exist for this path'], 303 | ), 304 | ( 305 | [{ 306 | 'field': u'chain', 307 | 'match_disabled': False, 308 | 'values': None, 309 | 'regex': u'(', 310 | 'invert': False, 311 | }], 312 | [ 313 | 'restrict: invalid regular expression "(": missing ), unterminated subpattern at position 0', 314 | 'restrict: invalid regular expression "(": unbalanced parenthesis', 315 | ] 316 | ), 317 | ] 318 | 319 | 320 | @pytest.mark.parametrize("restrict_value, expected", TEST_VALIDATE_AND_PREPARE_RESTRICT_FAIL) 321 | def test_validate_and_prepare_restrict_fail(restrict_value, expected): 322 | fake_module = FakeModule(restrict_value) 323 | with pytest.raises(FailJsonExc) as exc: 324 | validate_and_prepare_restrict(fake_module, TEST_PATH) 325 | print(repr(exc.value.msg)) 326 | assert exc.value.msg in expected 327 | 328 | 329 | TEST_RESTRICT_ENTRY_ACCEPTED = [ 330 | ( 331 | { 332 | 'chain': 'input', 333 | }, 334 | [ 335 | { 336 | 'field': u'chain', 337 | 'match_disabled': False, 338 | 'invert': False, 339 | }, 340 | ], 341 | False, 342 | ), 343 | ( 344 | { 345 | 'chain': 'input', 346 | }, 347 | [ 348 | { 349 | 'field': u'chain', 350 | 'match_disabled': False, 351 | 'invert': True, 352 | }, 353 | ], 354 | True, 355 | ), 356 | ( 357 | { 358 | 'comment': 'foo', 359 | }, 360 | [ 361 | { 362 | 'field': u'comment', 363 | 'match_disabled': True, 364 | 'invert': False, 365 | }, 366 | ], 367 | False, 368 | ), 369 | ( 370 | {}, 371 | [ 372 | { 373 | 'field': u'comment', 374 | 'match_disabled': True, 375 | 'invert': False, 376 | }, 377 | ], 378 | True, 379 | ), 380 | ] 381 | 382 | 383 | @pytest.mark.parametrize("entry, restrict_data, expected", TEST_RESTRICT_ENTRY_ACCEPTED) 384 | def test_restrict_entry_accepted(entry, restrict_data, expected): 385 | result = restrict_entry_accepted(entry, TEST_PATH, restrict_data) 386 | print(repr(result)) 387 | assert result == expected 388 | -------------------------------------------------------------------------------- /docs/docsite/rst/api-guide.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright (c) Ansible Project 3 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | .. _ansible_collections.community.routeros.docsite.api-guide: 7 | 8 | How to connect to RouterOS devices with the RouterOS API 9 | ======================================================== 10 | 11 | You can use the :ansplugin:`community.routeros.api module ` to connect to a RouterOS device with the RouterOS API. More specific module to modify certain entries are the :ansplugin:`community.routeros.api_modify ` and :ansplugin:`community.routeros.api_find_and_modify ` modules. The :ansplugin:`community.routeros.api_info module ` allows to retrieve information on specific predefined paths that can be used as input for the :ansplugin:`community.routeros.api_modify ` module, and the :ansplugin:`community.routeros.api_facts module ` allows to retrieve Ansible facts using the RouterOS API. 12 | 13 | No special setup is needed; the module needs to be run on a host that can connect to the device's API. The most common case is that the module is run on ``localhost``, either by using ``hosts: localhost`` in the playbook, or by using ``delegate_to: localhost`` for the task. The following example shows how to run the equivalent of ``/ip address print``: 14 | 15 | .. code-block:: yaml+jinja 16 | 17 | --- 18 | - name: RouterOS test with API 19 | hosts: localhost 20 | gather_facts: false 21 | vars: 22 | hostname: 192.168.1.1 23 | username: admin 24 | password: test1234 25 | tasks: 26 | - name: Get "ip address print" 27 | community.routeros.api: 28 | hostname: "{{ hostname }}" 29 | password: "{{ password }}" 30 | username: "{{ username }}" 31 | path: "ip address" 32 | # The following options configure TLS/SSL. 33 | # Depending on your setup, these options need different values: 34 | tls: true 35 | validate_certs: true 36 | validate_cert_hostname: true 37 | # If you are using your own PKI, specify the path to your CA certificate here: 38 | # ca_path: /path/to/ca-certificate.pem 39 | register: print_path 40 | 41 | - name: Show IP address of first interface 42 | ansible.builtin.debug: 43 | msg: "{{ print_path.msg[0].address }}" 44 | 45 | This results in the following output: 46 | 47 | .. code-block:: ansible-output 48 | 49 | PLAY [RouterOS test] ********************************************************************************************* 50 | 51 | TASK [Get "ip address print"] ************************************************************************************ 52 | ok: [localhost] 53 | 54 | TASK [Show IP address of first interface] ************************************************************************ 55 | ok: [localhost] => { 56 | "msg": "192.168.2.1/24" 57 | } 58 | 59 | PLAY RECAP ******************************************************************************************************* 60 | localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 61 | 62 | Check out the documentation of the :ansplugin:`community.routeros.api module ` for details on the options. 63 | 64 | Using the ``community.routeros.api`` module defaults group 65 | ---------------------------------------------------------- 66 | 67 | To avoid having to specify common parameters for all the API based modules in every task, you can use the ``community.routeros.api`` :ref:`module defaults group `: 68 | 69 | .. code-block:: yaml+jinja 70 | 71 | --- 72 | - name: RouterOS test with API 73 | hosts: localhost 74 | gather_facts: false 75 | module_defaults: 76 | group/community.routeros.api: 77 | hostname: 192.168.1.1 78 | password: admin 79 | username: test1234 80 | # The following options configure TLS/SSL. 81 | # Depending on your setup, these options need different values: 82 | tls: true 83 | validate_certs: true 84 | validate_cert_hostname: true 85 | # If you are using your own PKI, specify the path to your CA certificate here: 86 | # ca_path: /path/to/ca-certificate.pem 87 | tasks: 88 | - name: Gather facts 89 | community.routeros.api_facts: 90 | 91 | - name: Get "ip address print" 92 | community.routeros.api: 93 | path: "ip address" 94 | 95 | - name: Change IP address to 192.168.1.1 for interface bridge 96 | community.routeros.api_find_and_modify: 97 | path: ip address 98 | find: 99 | interface: bridge 100 | values: 101 | address: "192.168.1.1/24" 102 | 103 | Here all three tasks will use the options set for the module defaults group. 104 | 105 | Setting up encryption 106 | --------------------- 107 | 108 | It is recommended to always use :ansopt:`tls=true` when connecting with the API, even if you are only connecting to the device through a trusted network. The following options control how TLS/SSL is used: 109 | 110 | :force_no_cert: Setting to :ansval:`true` connects to the device without a certificate. **This is discouraged to use in production and is susceptible to Man-in-the-Middle attacks**, but might be useful when setting the device up. The default value is :ansval:`false`. 111 | :validate_certs: Setting to :ansval:`false` disables any certificate validation. **This is discouraged to use in production**, but is needed when setting the device up. The default value is :ansval:`true`. 112 | :validate_cert_hostname: Setting to :ansval:`false` (default) disables hostname verification during certificate validation. This is needed if the hostnames specified in the certificate do not match the hostname used for connecting (usually the device's IP). It is recommended to set up the certificate correctly and set this to :ansval:`true`; the default :ansval:`false` is chosen for backwards compatibility to an older version of the module. 113 | :ca_path: If you are not using a commercially trusted CA certificate to sign your device's certificate, or have not included your CA certificate in Python's truststore, you need to point this option to the CA certificate. 114 | 115 | We recommend to create a CA certificate that is used to sign the certificates for your RouterOS devices, and have the certificates include the correct hostname(s), including the IP of the device. That way, you can fully enable TLS and be sure that you always talk to the correct device. 116 | 117 | Setting up a PKI 118 | ^^^^^^^^^^^^^^^^ 119 | 120 | Please follow the instructions in the ``community.crypto`` :ref:`ansible_collections.community.crypto.docsite.guide_ownca` guide to set up a CA certificate and sign a certificate for your router. You should add a Subject Alternative Name for the IP address (for example ``IP:192.168.1.1``) and - if available - for the DNS name (for example ``DNS:router.local``) to the certificate. 121 | 122 | Installing a certificate on a MikroTik router 123 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 124 | 125 | Installing the certificate is best done with the SSH connection. (See the :ref:`ansible_collections.community.routeros.docsite.ssh-guide` guide for more information.) Once the certificate has been installed, and the HTTPS API enabled, it's easier to work with the API, since it has a quite a few less problems, and returns data as JSON objects instead of text you first have to parse. 126 | 127 | First you have to convert the certificate and its private key to a `PKCS #12 bundle `_. This can be done with the :ansplugin:`community.crypto.openssl_pkcs12 `. The following playbook assumes that the certificate is available as ``keys/{{ inventory_hostname }}.pem``, and its private key is available as ``keys/{{ inventory_hostname }}.key``. It generates a random passphrase to protect the PKCS#12 file. 128 | 129 | .. code-block:: yaml+jinja 130 | 131 | --- 132 | - name: Install certificates on devices 133 | hosts: routers 134 | gather_facts: false 135 | tasks: 136 | - block: 137 | - set_fact: 138 | random_password: "{{ lookup('community.general.random_string', length=32, override_all='0123456789abcdefghijklmnopqrstuvwxyz') }}" 139 | 140 | - name: Create PKCS#12 bundle 141 | openssl_pkcs12: 142 | path: keys/{{ inventory_hostname }}.p12 143 | certificate_path: keys/{{ inventory_hostname }}.pem 144 | privatekey_path: keys/{{ inventory_hostname }}.key 145 | friendly_name: '{{ inventory_hostname }}' 146 | passphrase: "{{ random_password }}" 147 | mode: "0600" 148 | changed_when: false 149 | delegate_to: localhost 150 | 151 | - name: Copy router certificate onto router 152 | ansible.netcommon.net_put: 153 | src: 'keys/{{ inventory_hostname }}.p12' 154 | dest: '{{ inventory_hostname }}.p12' 155 | 156 | - name: Install router certificate and clean up 157 | community.routeros.command: 158 | commands: 159 | # Import certificate: 160 | - /certificate import name={{ inventory_hostname }} file-name={{ inventory_hostname }}.p12 passphrase="{{ random_password }}" 161 | # Remove PKCS12 bundle: 162 | - /file remove {{ inventory_hostname }}.p12 163 | # Show certificates 164 | - /certificate print 165 | register: output 166 | 167 | - name: Show result of certificate import 168 | debug: 169 | var: output.stdout_lines[0] 170 | 171 | - name: Show certificates 172 | debug: 173 | var: output.stdout_lines[2] 174 | 175 | always: 176 | - name: Wipe PKCS12 bundle 177 | command: wipe keys/{{ inventory_hostname }}.p12 178 | changed_when: false 179 | delegate_to: localhost 180 | 181 | - name: Use certificate 182 | community.routeros.command: 183 | commands: 184 | - /ip service set www-ssl address={{ admin_network }} certificate={{ inventory_hostname }} disabled=no tls-version=only-1.2 185 | - /ip service set api-ssl address={{ admin_network }} certificate={{ inventory_hostname }} tls-version=only-1.2 186 | 187 | The playbook also assumes that ``admin_network`` describes the network from which the HTTPS and API interface can be accessed. This can be for example ``192.168.1.0/24``. 188 | 189 | When this playbook completed successfully, you should be able to use the HTTPS admin interface (reachable in a browser from ``https://192.168.1.1/``, with the correct IP inserted), as well as the :ansplugin:`community.routeros.api module ` module with TLS and certificate validation enabled: 190 | 191 | .. code-block:: yaml+jinja 192 | 193 | - community.routeros.api: 194 | # ... 195 | tls: true 196 | validate_certs: true 197 | validate_cert_hostname: true 198 | ca_path: /path/to/ca-certificate.pem 199 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/fake_api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | # Make coding more python3-ish 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | from ansible_collections.community.routeros.plugins.module_utils._api_data import PATHS 10 | 11 | 12 | FAKE_ROS_VERSION = '7.5.0' 13 | 14 | 15 | class FakeLibRouterosError(Exception): 16 | def __init__(self, message): 17 | self.message = message 18 | super(FakeLibRouterosError, self).__init__(self.message) 19 | 20 | 21 | class TrapError(FakeLibRouterosError): 22 | def __init__(self, message='failure: already have interface with such name'): 23 | super(TrapError, self).__init__(message) 24 | 25 | 26 | # fixtures 27 | class fake_ros_api(object): 28 | def __init__(self, api, path): 29 | pass 30 | 31 | @classmethod 32 | def path(cls, api, path): 33 | fake_bridge = [{".id": "*DC", "name": "b2", "mtu": "auto", "actual-mtu": 1500, 34 | "l2mtu": 65535, "arp": "enabled", "arp-timeout": "auto", 35 | "mac-address": "3A:C1:90:D6:E8:44", "protocol-mode": "rstp", 36 | "fast-forward": "true", "igmp-snooping": "false", 37 | "auto-mac": "true", "ageing-time": "5m", "priority": 38 | "0x8000", "max-message-age": "20s", "forward-delay": "15s", 39 | "transmit-hold-count": 6, "vlan-filtering": "false", 40 | "dhcp-snooping": "false", "running": "true", "disabled": "false"}] 41 | return fake_bridge 42 | 43 | @classmethod 44 | def arbitrary(cls, api, path): 45 | def retr(self, *args, **kwargs): 46 | if 'name' not in kwargs.keys(): 47 | raise TrapError(message="no such command") 48 | dummy_test_string = '/interface/bridge add name=unit_test_brige_arbitrary' 49 | result = "/%s/%s add name=%s" % (path[0], path[1], kwargs['name']) 50 | return [result] 51 | return retr 52 | 53 | def add(self, name): 54 | if name == "unit_test_brige_exist": 55 | raise TrapError 56 | return '*A1' 57 | 58 | def remove(self, id): 59 | if id != "*A1": 60 | raise TrapError(message="no such item (4)") 61 | return '*A1' 62 | 63 | def update(self, **kwargs): 64 | if kwargs['.id'] != "*A1" or 'name' not in kwargs.keys(): 65 | raise TrapError(message="no such item (4)") 66 | return ["updated: {'.id': '%s' % kwargs['.id'], 'name': '%s' % kwargs['name']}"] 67 | 68 | def select(self, *args): 69 | dummy_bridge = [{".id": "*A1", "name": "dummy_bridge_A1"}, 70 | {".id": "*A2", "name": "dummy_bridge_A2"}, 71 | {".id": "*A3", "name": "dummy_bridge_A3"}] 72 | 73 | result = [] 74 | for dummy in dummy_bridge: 75 | found = {} 76 | for search in args: 77 | if search in dummy.keys(): 78 | found[search] = dummy[search] 79 | else: 80 | continue 81 | if len(found.keys()) == 2: 82 | result.append(found) 83 | 84 | if result: 85 | return result 86 | else: 87 | return [] 88 | 89 | @classmethod 90 | def select_where(cls, api, path): 91 | api_path = Where() 92 | return api_path 93 | 94 | 95 | class Where(object): 96 | def __init__(self): 97 | pass 98 | 99 | def select(self, *args): 100 | return self 101 | 102 | def where(self, *args): 103 | return [{".id": "*A1", "name": "dummy_bridge_A1"}] 104 | 105 | 106 | class Key(object): 107 | def __init__(self, name): 108 | self.name = name 109 | self.str_return() 110 | 111 | def str_return(self): 112 | return str(self.name) 113 | 114 | 115 | class Or(object): 116 | def __init__(self, *args): 117 | self.args = args 118 | self.str_return() 119 | 120 | def str_return(self): 121 | return repr(self.args) 122 | 123 | 124 | def _normalize_entry(entry, path_info, on_create=False): 125 | for key, data in path_info.fields.items(): 126 | if key not in entry and data.default is not None and (not data.can_disable or on_create): 127 | entry[key] = data.default 128 | if data.can_disable: 129 | if key in entry and entry[key] in (None, data.remove_value): 130 | del entry[key] 131 | if ('!%s' % key) in entry: 132 | entry.pop(key, None) 133 | del entry['!%s' % key] 134 | if data.absent_value is not None and key in entry and entry[key] == data.absent_value: 135 | del entry[key] 136 | 137 | 138 | def massage_expected_result_data(values, path, keep_all=False, remove_dynamic=False, remove_builtin=False): 139 | versioned_path_info = PATHS[path] 140 | versioned_path_info.provide_version(FAKE_ROS_VERSION) 141 | path_info = versioned_path_info.get_data() 142 | if remove_dynamic: 143 | values = [entry for entry in values if not entry.get('dynamic', False)] 144 | if remove_builtin: 145 | values = [entry for entry in values if not entry.get('builtin', False)] 146 | values = [entry.copy() for entry in values] 147 | for entry in values: 148 | _normalize_entry(entry, path_info) 149 | if not keep_all: 150 | for key in list(entry): 151 | if key == '.id' or key in path_info.fields: 152 | continue 153 | del entry[key] 154 | for key, data in path_info.fields.items(): 155 | if data.absent_value is not None and key not in entry: 156 | entry[key] = data.absent_value 157 | return values 158 | 159 | 160 | class Path(object): 161 | def __init__(self, path, initial_values, read_only=False): 162 | self._path = path 163 | versioned_path_info = PATHS[path] 164 | versioned_path_info.provide_version(FAKE_ROS_VERSION) 165 | self._path_info = versioned_path_info.get_data() 166 | self._values = [entry.copy() for entry in initial_values] 167 | for entry in self._values: 168 | _normalize_entry(entry, self._path_info) 169 | self._new_id_counter = 0 170 | self._read_only = read_only 171 | 172 | def _sanitize(self, entry): 173 | entry = entry.copy() 174 | for field, field_info in self._path_info.fields.items(): 175 | if field in entry: 176 | if field_info.write_only: 177 | del entry[field] 178 | return entry 179 | 180 | def __iter__(self): 181 | return [self._sanitize(entry) for entry in self._values].__iter__() 182 | 183 | def _find_id(self, id, required=False): 184 | for index, entry in enumerate(self._values): 185 | if entry['.id'] == id: 186 | return index 187 | if required: 188 | raise FakeLibRouterosError('Cannot find key "%s"' % id) 189 | return None 190 | 191 | def add(self, **kwargs): 192 | if self._path_info.fixed_entries or self._path_info.single_value: 193 | raise Exception('Cannot add entries') 194 | if self._read_only: 195 | raise Exception('Modifying read-only path: add %s' % repr(kwargs)) 196 | if '.id' in kwargs: 197 | raise Exception('Trying to create new entry with ".id" field: %s' % repr(kwargs)) 198 | if 'dynamic' in kwargs or 'builtin' in kwargs: 199 | raise Exception('Trying to add a dynamic or builtin entry') 200 | self._new_id_counter += 1 201 | id = '*NEW%d' % self._new_id_counter 202 | entry = { 203 | '.id': id, 204 | } 205 | for field, value in kwargs.items(): 206 | if field.startswith('!'): 207 | field = field[1:] 208 | if field not in self._path_info.fields: 209 | raise ValueError('Trying to set unknown field "{field}"'.format(field=field)) 210 | field_info = self._path_info.fields[field] 211 | if field_info.read_only: 212 | raise ValueError('Trying to set read-only field "{field}"'.format(field=field)) 213 | entry[field] = value 214 | _normalize_entry(entry, self._path_info, on_create=True) 215 | self._values.append(entry) 216 | return id 217 | 218 | def remove(self, *args): 219 | if self._path_info.fixed_entries or self._path_info.single_value: 220 | raise Exception('Cannot remove entries') 221 | if self._read_only: 222 | raise Exception('Modifying read-only path: remove %s' % repr(args)) 223 | for id in args: 224 | index = self._find_id(id, required=True) 225 | entry = self._values[index] 226 | if entry.get('dynamic', False) or entry.get('builtin', False): 227 | raise Exception('Trying to remove a dynamic or builtin entry') 228 | del self._values[index] 229 | 230 | def update(self, **kwargs): 231 | if self._read_only: 232 | raise Exception('Modifying read-only path: update %s' % repr(kwargs)) 233 | if 'dynamic' in kwargs or 'builtin' in kwargs: 234 | raise Exception('Trying to update dynamic builtin fields') 235 | if self._path_info.single_value: 236 | index = 0 237 | else: 238 | index = self._find_id(kwargs['.id'], required=True) 239 | entry = self._values[index] 240 | if entry.get('dynamic', False) or entry.get('builtin', False): 241 | raise Exception('Trying to update a dynamic or builtin entry') 242 | for field in kwargs: 243 | if field == '.id': 244 | continue 245 | if field.startswith('!'): 246 | field = field[1:] 247 | if field not in self._path_info.fields: 248 | raise ValueError('Trying to update unknown field "{field}"'.format(field=field)) 249 | field_info = self._path_info.fields[field] 250 | if field_info.read_only: 251 | raise ValueError('Trying to update read-only field "{field}"'.format(field=field)) 252 | entry.update(kwargs) 253 | _normalize_entry(entry, self._path_info) 254 | 255 | def __call__(self, command, *args, **kwargs): 256 | if self._read_only: 257 | raise Exception('Modifying read-only path: "%s" %s %s' % (command, repr(args), repr(kwargs))) 258 | if command != 'move': 259 | raise FakeLibRouterosError('Unsupported command "%s"' % command) 260 | if self._path_info.fixed_entries or self._path_info.single_value: 261 | raise Exception('Cannot move entries') 262 | yield None # make sure that nothing happens if the result isn't consumed 263 | source_index = self._find_id(kwargs.pop('numbers'), required=True) 264 | entry = self._values.pop(source_index) 265 | dest_index = self._find_id(kwargs.pop('destination'), required=True) 266 | self._values.insert(dest_index, entry) 267 | 268 | 269 | def create_fake_path(path, initial_values, read_only=False): 270 | def create(api, called_path): 271 | called_path = tuple(called_path) 272 | if path != called_path: 273 | raise AssertionError('Expected {path}, got {called_path}'.format(path=path, called_path=called_path)) 274 | return Path(path, initial_values, read_only=read_only) 275 | 276 | return create 277 | -------------------------------------------------------------------------------- /plugins/modules/api_find_and_modify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2022, Felix Fontein 5 | # GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import (absolute_import, division, print_function) 9 | __metaclass__ = type 10 | 11 | DOCUMENTATION = r""" 12 | module: api_find_and_modify 13 | author: 14 | - "Felix Fontein (@felixfontein)" 15 | short_description: Find and modify information using the API 16 | version_added: 2.1.0 17 | description: 18 | - Allows to find entries for a path by conditions and modify the values of these entries. 19 | - Use the M(community.routeros.api_find_and_modify) module to set all entries of a path to specific values, or change multiple 20 | entries in different ways in one step. 21 | notes: 22 | - "If you want to change values based on their old values (like change all comments 'foo' to 'bar') and make sure that there 23 | are at least N such values, you can use O(require_matches_min=N) together with O(allow_no_matches=true). This will make 24 | the module fail if there are less than N such entries, but not if there is no match. The latter case is needed for idempotency 25 | of the task: once the values have been changed, there should be no further match." 26 | extends_documentation_fragment: 27 | - community.routeros.api 28 | - community.routeros.attributes 29 | - community.routeros.attributes.actiongroup_api 30 | attributes: 31 | check_mode: 32 | support: full 33 | diff_mode: 34 | support: full 35 | platform: 36 | support: full 37 | platforms: RouterOS 38 | idempotent: 39 | support: full 40 | options: 41 | path: 42 | description: 43 | - Path to query. 44 | - An example value is V(ip address). This is equivalent to running C(/ip address) in the RouterOS CLI. 45 | required: true 46 | type: str 47 | find: 48 | description: 49 | - Fields to search for. 50 | - The module will only consider entries in the given O(path) that match all fields provided here. 51 | - Use YAML V(~), or prepend keys with V(!), to specify an unset value. 52 | - Note that if the dictionary specified here is empty, every entry in the path will be matched. 53 | required: true 54 | type: dict 55 | values: 56 | description: 57 | - On all entries matching the conditions in O(find), set the keys of this option to the values specified here. 58 | - Use YAML V(~), or prepend keys with V(!), to specify to unset a value. 59 | required: true 60 | type: dict 61 | require_matches_min: 62 | description: 63 | - Make sure that there are no less matches than this number. 64 | - If there are less matches, fail instead of modifying anything. 65 | type: int 66 | default: 0 67 | require_matches_max: 68 | description: 69 | - Make sure that there are no more matches than this number. 70 | - If there are more matches, fail instead of modifying anything. 71 | - If not specified, there is no upper limit. 72 | type: int 73 | allow_no_matches: 74 | description: 75 | - Whether to allow that no match is found. 76 | - If not specified, this value is induced from whether O(require_matches_min) is 0 or larger. 77 | type: bool 78 | ignore_dynamic: 79 | description: 80 | - Whether to ignore dynamic entries. 81 | - By default, they are considered. If set to V(true), they are not considered. 82 | - It is generally recommended to set this to V(true) unless when you really need to modify dynamic entries. 83 | type: bool 84 | default: false 85 | version_added: 3.7.0 86 | ignore_builtin: 87 | description: 88 | - Whether to ignore builtin entries. 89 | - By default, they are considered. If set to V(true), they are not considered. 90 | - It is generally recommended to set this to V(true) unless when you really need to modify builtin entries. 91 | type: bool 92 | default: false 93 | version_added: 3.7.0 94 | seealso: 95 | - module: community.routeros.api 96 | - module: community.routeros.api_facts 97 | - module: community.routeros.api_modify 98 | - module: community.routeros.api_info 99 | """ 100 | 101 | EXAMPLES = r""" 102 | --- 103 | - name: Rename bridge from 'bridge' to 'my-bridge' 104 | community.routeros.api_find_and_modify: 105 | hostname: "{{ hostname }}" 106 | password: "{{ password }}" 107 | username: "{{ username }}" 108 | path: interface bridge 109 | find: 110 | name: bridge 111 | values: 112 | name: my-bridge 113 | # Always ignore dynamic and builtin entries 114 | # (not relevant for this path, but generally recommended) 115 | ignore_dynamic: true 116 | ignore_builtin: true 117 | 118 | - name: Change IP address to 192.168.1.1 for interface bridge - assuming there is only one 119 | community.routeros.api_find_and_modify: 120 | hostname: "{{ hostname }}" 121 | password: "{{ password }}" 122 | username: "{{ username }}" 123 | path: ip address 124 | find: 125 | interface: bridge 126 | values: 127 | address: "192.168.1.1/24" 128 | # If there are zero entries, or more than one: fail! We expected that 129 | # exactly one is configured. 130 | require_matches_min: 1 131 | require_matches_max: 1 132 | # Always ignore dynamic and builtin entries 133 | # (not relevant for this path, but generally recommended) 134 | ignore_dynamic: true 135 | ignore_builtin: true 136 | """ 137 | 138 | RETURN = r""" 139 | old_data: 140 | description: 141 | - A list of all elements for the current path before a change was made. 142 | sample: 143 | - '.id': '*1' 144 | actual-interface: bridge 145 | address: "192.168.88.1/24" 146 | comment: defconf 147 | disabled: false 148 | dynamic: false 149 | interface: bridge 150 | invalid: false 151 | network: 192.168.88.0 152 | type: list 153 | elements: dict 154 | returned: success 155 | new_data: 156 | description: 157 | - A list of all elements for the current path after a change was made. 158 | sample: 159 | - '.id': '*1' 160 | actual-interface: bridge 161 | address: "192.168.1.1/24" 162 | comment: awesome 163 | disabled: false 164 | dynamic: false 165 | interface: bridge 166 | invalid: false 167 | network: 192.168.1.0 168 | type: list 169 | elements: dict 170 | returned: success 171 | match_count: 172 | description: 173 | - The number of entries that matched the criteria in O(find). 174 | sample: 1 175 | type: int 176 | returned: success 177 | modify__count: 178 | description: 179 | - The number of entries that were modified. 180 | sample: 1 181 | type: int 182 | returned: success 183 | """ 184 | 185 | from ansible.module_utils.basic import AnsibleModule 186 | from ansible.module_utils.common.text.converters import to_native 187 | 188 | from ansible_collections.community.routeros.plugins.module_utils.api import ( 189 | api_argument_spec, 190 | check_has_library, 191 | create_api, 192 | ) 193 | 194 | from ansible_collections.community.routeros.plugins.module_utils._api_data import ( 195 | split_path, 196 | ) 197 | 198 | from ansible_collections.community.routeros.plugins.module_utils._api_helper import ( 199 | value_to_str, 200 | ) 201 | 202 | try: 203 | from librouteros.exceptions import LibRouterosError 204 | except Exception: 205 | # Handled in api module_utils 206 | pass 207 | 208 | 209 | def compose_api_path(api, path): 210 | api_path = api.path() 211 | for p in path: 212 | api_path = api_path.join(p) 213 | return api_path 214 | 215 | 216 | def filter_entries(entries, ignore_dynamic=False, ignore_builtin=False): 217 | result = [] 218 | for entry in entries: 219 | if ignore_dynamic and entry.get('dynamic', False): 220 | continue 221 | if ignore_builtin and entry.get('builtin', False): 222 | continue 223 | result.append(entry) 224 | return result 225 | 226 | 227 | DISABLED_MEANS_EMPTY_STRING = ('comment', ) 228 | 229 | 230 | def main(): 231 | module_args = dict( 232 | path=dict(type='str', required=True), 233 | find=dict(type='dict', required=True), 234 | values=dict(type='dict', required=True), 235 | require_matches_min=dict(type='int', default=0), 236 | require_matches_max=dict(type='int'), 237 | allow_no_matches=dict(type='bool'), 238 | ignore_dynamic=dict(type='bool', default=False), 239 | ignore_builtin=dict(type='bool', default=False), 240 | ) 241 | module_args.update(api_argument_spec()) 242 | 243 | module = AnsibleModule( 244 | argument_spec=module_args, 245 | supports_check_mode=True, 246 | ) 247 | if module.params['allow_no_matches'] is None: 248 | module.params['allow_no_matches'] = module.params['require_matches_min'] <= 0 249 | 250 | find = module.params['find'] 251 | for key, value in sorted(find.items()): 252 | if key.startswith('!'): 253 | key = key[1:] 254 | if value not in (None, ''): 255 | module.fail_json(msg='The value for "!{key}" in `find` must not be non-trivial!'.format(key=key)) 256 | if key in find: 257 | module.fail_json(msg='`find` must not contain both "{key}" and "!{key}"!'.format(key=key)) 258 | values = module.params['values'] 259 | for key, value in sorted(values.items()): 260 | if key.startswith('!'): 261 | key = key[1:] 262 | if value not in (None, ''): 263 | module.fail_json(msg='The value for "!{key}" in `values` must not be non-trivial!'.format(key=key)) 264 | if key in values: 265 | module.fail_json(msg='`values` must not contain both "{key}" and "!{key}"!'.format(key=key)) 266 | 267 | ignore_dynamic = module.params['ignore_dynamic'] 268 | ignore_builtin = module.params['ignore_builtin'] 269 | 270 | check_has_library(module) 271 | api = create_api(module) 272 | 273 | path = split_path(module.params['path']) 274 | 275 | api_path = compose_api_path(api, path) 276 | 277 | old_data = filter_entries(list(api_path), ignore_dynamic=ignore_dynamic, ignore_builtin=ignore_builtin) 278 | new_data = [entry.copy() for entry in old_data] 279 | 280 | # Find matching entries 281 | matching_entries = [] 282 | for index, entry in enumerate(new_data): 283 | matches = True 284 | for key, value in find.items(): 285 | if key.startswith('!'): 286 | # Allow to specify keys that should not be present by prepending '!' 287 | key = key[1:] 288 | value = None 289 | current_value = entry.get(key) 290 | if key in DISABLED_MEANS_EMPTY_STRING and value == '' and current_value is None: 291 | current_value = value 292 | if value_to_str(current_value) != value_to_str(value): 293 | matches = False 294 | break 295 | if matches: 296 | matching_entries.append((index, entry)) 297 | 298 | # Check whether the correct amount of entries was found 299 | if matching_entries: 300 | if len(matching_entries) < module.params['require_matches_min']: 301 | module.fail_json(msg='Found %d entries, but expected at least %d' % (len(matching_entries), module.params['require_matches_min'])) 302 | if module.params['require_matches_max'] is not None and len(matching_entries) > module.params['require_matches_max']: 303 | module.fail_json(msg='Found %d entries, but expected at most %d' % (len(matching_entries), module.params['require_matches_max'])) 304 | elif not module.params['allow_no_matches']: 305 | module.fail_json(msg='Found no entries, but allow_no_matches=false') 306 | 307 | # Identify entries to update 308 | modifications = [] 309 | for index, entry in matching_entries: 310 | modification = {} 311 | for key, value in values.items(): 312 | if key.startswith('!'): 313 | # Allow to specify keys to remove by prepending '!' 314 | key = key[1:] 315 | value = None 316 | current_value = entry.get(key) 317 | if key in DISABLED_MEANS_EMPTY_STRING and value == '' and current_value is None: 318 | current_value = value 319 | if value_to_str(current_value) != value_to_str(value): 320 | if value is None: 321 | disable_key = '!%s' % key 322 | if key in DISABLED_MEANS_EMPTY_STRING: 323 | disable_key = key 324 | modification[disable_key] = '' 325 | entry.pop(key, None) 326 | else: 327 | modification[key] = value 328 | entry[key] = value 329 | if modification: 330 | if '.id' in entry: 331 | modification['.id'] = entry['.id'] 332 | modifications.append(modification) 333 | 334 | # Apply changes 335 | if not module.check_mode and modifications: 336 | for modification in modifications: 337 | try: 338 | api_path.update(**modification) 339 | except (LibRouterosError, UnicodeEncodeError) as e: 340 | module.fail_json( 341 | msg='Error while modifying for .id={id}: {error}'.format( 342 | id=modification['.id'], 343 | error=to_native(e), 344 | ) 345 | ) 346 | new_data = filter_entries(list(api_path), ignore_dynamic=ignore_dynamic, ignore_builtin=ignore_builtin) 347 | 348 | # Produce return value 349 | more = {} 350 | if module._diff: 351 | # Only include the matching values 352 | more['diff'] = { 353 | 'before': { 354 | 'values': [old_data[index] for index, entry in matching_entries], 355 | }, 356 | 'after': { 357 | 'values': [entry for index, entry in matching_entries], 358 | }, 359 | } 360 | module.exit_json( 361 | changed=bool(modifications), 362 | old_data=old_data, 363 | new_data=new_data, 364 | match_count=len(matching_entries), 365 | modify_count=len(modifications), 366 | **more 367 | ) 368 | 369 | 370 | if __name__ == '__main__': 371 | main() 372 | --------------------------------------------------------------------------------