Skip to content

Commit

Permalink
Add options to control certificate validation for api module (#37)
Browse files Browse the repository at this point in the history
* Add options to control certificate validation for api module.

* Linting.

* Extend documentation.

* Fix validate_cert_hostname=true.

* Add documentation on setting up certificates on a RouterOS device.
  • Loading branch information
felixfontein authored Jun 28, 2021
1 parent 937aa0d commit 75b4b96
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 14 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ Example playbook:
password: "{{ password }}"
username: "{{ username }}"
path: "ip address"
ssl: true
tls: true
validate_certs: true
validate_cert_hostname: true
ca_path: /path/to/ca-certificate.pem
register: print_path
```

Expand Down
3 changes: 3 additions & 0 deletions changelogs/fragments/37-api-validate-cert-options.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
minor_changes:
- "api - rename option ``ssl`` to ``tls``, and keep the old name as an alias (https://github.com/ansible-collections/community.routeros/pull/37)."
- "api - add options ``validate_certs`` (default value ``true``), ``validate_cert_hostname`` (default value ``false``), and ``ca_path`` to control certificate validation (https://github.com/ansible-collections/community.routeros/pull/37)."
102 changes: 101 additions & 1 deletion docs/docsite/rst/api-guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ No special setup is needed; the module needs to be run on a host that can connec
password: "{{ password }}"
username: "{{ username }}"
path: "ip address"
ssl: true
# The following options configure TLS/SSL.
# Depending on your setup, these options need different values:
tls: true
validate_certs: true
validate_cert_hostname: true
# If you are using your own PKI, specify the path to your CA certificate here:
# ca_path: /path/to/ca-certificate.pem
register: print_path

- name: Show IP address of first interface
Expand All @@ -49,3 +55,97 @@ This results in the following output:
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Check out the documenation of the :ref:`community.routeros.api module <ansible_collections.community.routeros.api_module>` for details on the options.

Setting up encryption
---------------------

It is recommended to always use ``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:

:validate_certs: Setting to ``false`` disables any certificate validation. **This is discouraged to use in production**, but is needed when setting the device up. The default value is ``true``.
:validate_cert_hostname: Setting to ``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 ``true``; the default ``false`` is chosen for backwards compatibility to an older version of the module.
:ca_path: If you are not using a commerically 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.

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.

Setting up a PKI
^^^^^^^^^^^^^^^^

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.

Installing a certificate on a MikroTik router
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

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.

First you have to convert the certificate and its private key to a `PKCS #12 bundle <https://en.wikipedia.org/wiki/PKCS_12>`_. This can be done with the :ref:`community.crypto.openssl_pkcs12 <ansible_collections.community.crypto.openssl_pkcs12_module>`. 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.

.. code-block:: yaml+jinja

---
- name: Install certificates on devices
hosts: routers
gather_facts: false
tasks:
- block:
- set_fact:
random_password: "{{ lookup('community.general.random_string', length=32, override_all='0123456789abcdefghijklmnopqrstuvwxyz') }}"

- name: Create PKCS#12 bundle
openssl_pkcs12:
path: keys/{{ inventory_hostname }}.p12
certificate_path: keys/{{ inventory_hostname }}.pem
privatekey_path: keys/{{ inventory_hostname }}.key
friendly_name: '{{ inventory_hostname }}'
passphrase: "{{ random_password }}"
mode: "0600"
changed_when: false
delegate_to: localhost

- name: Copy router certificate onto router
ansible.netcommon.net_put:
src: 'keys/{{ inventory_hostname }}.p12'
dest: '{{ inventory_hostname }}.p12'

- name: Install router certificate and clean up
community.routeros.command:
commands:
# Import certificate:
- /certificate import name={{ inventory_hostname }} file-name={{ inventory_hostname }}.p12 passphrase="{{ random_password }}"
# Remove PKCS12 bundle:
- /file remove {{ inventory_hostname }}.p12
# Show certificates
- /certificate print
register: output

- name: Show result of certificate import
debug:
var: output.stdout_lines[0]

- name: Show certificates
debug:
var: output.stdout_lines[2]

always:
- name: Wipe PKCS12 bundle
command: wipe keys/{{ inventory_hostname }}.p12
changed_when: false
delegate_to: localhost

- name: Use certificate
community.routeros.command:
commands:
- /ip service set www-ssl address={{ admin_network }} certificate={{ inventory_hostname }} disabled=no tls-version=only-1.2
- /ip service set api-ssl address={{ admin_network }} certificate={{ inventory_hostname }} tls-version=only-1.2

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``.

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 :ref:`community.routeros.api module <ansible_collections.community.routeros.api_module>` module with TLS and certificate validation enabled:

.. code-block:: yaml+jinja

- community.routeros.api:
...
tls: true
validate_certs: true
validate_cert_hostname: true
ca_path: /path/to/ca-certificate.pem
69 changes: 57 additions & 12 deletions plugins/modules/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@
- RouterOS user password.
required: true
type: str
ssl:
tls:
description:
- If is set TLS will be used for RouterOS API connection.
required: false
type: bool
default: false
aliases:
- ssl
port:
description:
- RouterOS api port. If ssl is set, port will apply to ssl connection.
- RouterOS api port. If I(tls) is set, port will apply to TLS/SSL connection.
- Defaults are C(8728) for the HTTP API, and C(8729) for the HTTPS API.
type: int
path:
Expand Down Expand Up @@ -93,6 +95,30 @@
- Example path C(system script) and cmd C(run .id=*03) is equivalent in RouterOS CLI C(/system script run number=0).
- Example path C(ip address) and cmd C(print) is equivalent in RouterOS CLI C(/ip address print).
type: str
validate_certs:
description:
- Set to C(false) to skip validation of TLS certificates.
- See also I(validate_cert_hostname). Only used when I(tls=true).
- B(Note:) instead of simply deactivating certificate validations to "make things work",
please consider creating your own CA certificate and using it to sign certificates used
for your router. You can tell the module about your CA certificate with the I(ca_path)
option.
type: bool
default: true
version_added: 1.2.0
validate_cert_hostname:
description:
- Set to C(true) to validate hostnames in certificates.
- See also I(validate_certs). Only used when I(tls=true) and I(validate_certs=true).
type: bool
default: false
version_added: 1.2.0
ca_path:
description:
- PEM formatted file that contains a CA certificate to be used for certificate validation.
- See also I(validate_cert_hostname). Only used when I(tls=true) and I(validate_certs=true).
type: path
version_added: 1.2.0
'''

EXAMPLES = '''
Expand Down Expand Up @@ -249,18 +275,22 @@

class ROS_api_module:
def __init__(self):
module_args = (dict(
module_args = dict(
username=dict(type='str', required=True),
password=dict(type='str', required=True, no_log=True),
hostname=dict(type='str', required=True),
port=dict(type='int'),
ssl=dict(type='bool', default=False),
tls=dict(type='bool', default=False, aliases=['ssl']),
path=dict(type='str', required=True),
add=dict(type='str'),
remove=dict(type='str'),
update=dict(type='str'),
cmd=dict(type='str'),
query=dict(type='str')))
query=dict(type='str'),
validate_certs=dict(type='bool', default=True),
validate_cert_hostname=dict(type='bool', default=False),
ca_path=dict(type='path'),
)

self.module = AnsibleModule(argument_spec=module_args,
supports_check_mode=False,
Expand All @@ -275,7 +305,11 @@ def __init__(self):
self.module.params['password'],
self.module.params['hostname'],
self.module.params['port'],
self.module.params['ssl'])
self.module.params['tls'],
self.module.params['validate_certs'],
self.module.params['validate_cert_hostname'],
self.module.params['ca_path'],
)

self.path = self.list_remove_empty(self.module.params['path'].split(' '))
self.add = self.module.params['add']
Expand Down Expand Up @@ -437,24 +471,35 @@ def errors(self, e):
self.result['message'].append("%s" % e)
self.return_result(False, False)

def ros_api_connect(self, username, password, host, port, use_ssl):
def ros_api_connect(self, username, password, host, port, use_tls, validate_certs, validate_cert_hostname, ca_path):
# connect to routeros api
conn_status = {"connection": {"username": username,
"hostname": host,
"port": port,
"ssl": use_ssl,
"ssl": use_tls,
"status": "Connected"}}
try:
if use_ssl is True:
if use_tls:
if not port:
port = 8729
conn_status["connection"]["port"] = port
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx = ssl.create_default_context(cafile=ca_path)
wrap_context = ctx.wrap_socket
if not validate_certs:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
elif not validate_cert_hostname:
ctx.check_hostname = False
else:
# Since librouteros doesn't pass server_hostname,
# we have to do this ourselves:
def wrap_context(*args, **kwargs):
kwargs.pop('server_hostname', None)
return ctx.wrap_socket(*args, server_hostname=host, **kwargs)
api = connect(username=username,
password=password,
host=host,
ssl_wrapper=ctx.wrap_socket,
ssl_wrapper=wrap_context,
port=port)
else:
if not port:
Expand Down

0 comments on commit 75b4b96

Please sign in to comment.