diff --git a/lib/ansible/modules/network/meraki/meraki_ssid.py b/lib/ansible/modules/network/meraki/meraki_ssid.py new file mode 100644 index 0000000000..c41871f694 --- /dev/null +++ b/lib/ansible/modules/network/meraki/meraki_ssid.py @@ -0,0 +1,440 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Kevin Breit (@kbreit) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = r''' +--- +module: meraki_ssid +short_description: Manage wireless SSIDs in the Meraki cloud +version_added: "2.7" +description: +- Allows for management of SSIDs in a Meraki wireless environment. +notes: +- Deleting an SSID does not delete RADIUS servers. +options: + state: + description: + - Specifies whether SNMP information should be queried or modified. + choices: ['absent', 'query', 'present'] + default: present + number: + description: + - SSID number within network. + aliases: [ssid_number] + name: + description: + - Name of SSID. + org_name: + description: + - Name of organization. + org_id: + description: + - ID of organization. + net_name: + description: + - Name of network. + net_id: + description: + - ID of network. + enabled: + description: + - Enable or disable SSID network. + type: bool + auth_mode: + description: + - Set authentication mode of network. + choices: [open, psk, open-with-radius, 8021x-meraki, 8021x-radius] + encryption_mode: + description: + - Set encryption mode of network. + choices: [wpa, eap, wpa-eap] + psk: + description: + - Password for wireless network. + - Requires auth_mode to be set to psk. + wpa_encryption_mode: + description: + - Encryption mode within WPA2 specification. + choices: [WPA1 and WPA2, WPA2 only] + splash_page: + description: + - Set to enable splash page and specify type of splash. + choices: ['None', + 'Click-through splash page', + 'Billing', + 'Password-protected with Meraki RADIUS', + 'Password-protected with custom RADIUS', + 'Password-protected with Active Directory', + 'Password-protected with LDAP', + 'SMS authentication', + 'Systems Manager Sentry', + 'Facebook Wi-Fi', + 'Google OAuth', + 'Sponsored guest'] + radius_servers: + description: + - List of RADIUS servers. + suboptions: + host: + description: + - IP addres or hostname of RADIUS server. + port: + description: + - Port number RADIUS server is listening to. + secret: + description: + - RADIUS password. + radius_coa_enabled: + description: + - Enable or disable RADIUS CoA (Change of Authorization) on SSID. + type: bool + radius_failover_policy: + description: + - Set client access policy in case RADIUS servers aren't available. + choices: [Deny access, Allow access] + radius_load_balancing_policy: + description: + - Set load balancing policy when multiple RADIUS servers are specified. + choices: [Strict priority order, Round robin] + radius_accounting_enabled: + description: + - Enable or disable RADIUS accounting. + type: bool + radius_accounting_servers: + description: + - List of RADIUS servers for RADIUS accounting. + suboptions: + host: + description: + - IP addres or hostname of RADIUS server. + port: + description: + - Port number RADIUS server is listening to. + secret: + description: + - RADIUS password. + ip_assignment_mode: + description: + - Method of which SSID uses to assign IP addresses. + choices: ['NAT mode', + 'Bridge mode', + 'Layer 3 roaming', + 'Layer 3 roaming with a concentrator', + 'VPN'] + use_vlan_tagging: + description: + - Set whether to use VLAN tagging. + type: bool + default_vlan_id: + description: + - Default VLAN ID. + vlan_id: + description: + - ID number of VLAN on SSID. + ap_tags_vlan_ids: + description: + - List of VLAN tags. + suboptions: + tags: + description: + - List of AP tags. + vlan_id: + description: + - Numerical identifier that is assigned to the VLAN. + walled_garden_enabled: + description: + - Enable or disable walled garden functionality. + type: bool + walled_garden_ranges: + description: + - List of walled garden ranges. + min_bitrate: + description: + - Minimum bitrate (Mbps) allowed on SSID. + choices: [1, 2, 5.5, 6, 9, 11, 12, 18, 24, 36, 48, 54] + band_selection: + description: + - Set band selection mode. + choices: ['Dual band operation', '5 GHz band only', 'Dual band operation with Band Steering'] + per_client_bandwidth_limit_up: + description: + - Maximum bandwidth in Mbps devices on SSID can upload. + per_client_bandwidth_limit_down: + description: + - Maximum bandwidth in Mbps devices on SSID can download. + concentrator_network_id: + description: + - The concentrator to use for 'Layer 3 roaming with a concentrator' or 'VPN'. +author: +- Kevin Breit (@kbreit) +extends_documentation_fragment: meraki +''' + +EXAMPLES = r''' +''' + +RETURN = r''' +data: + description: Information about queried or updated object. + type: list + returned: info + sample: + "data": { + + } +''' + +import os +from ansible.module_utils.basic import AnsibleModule, json, env_fallback +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_native +from ansible.module_utils.network.meraki.meraki import MerakiModule, meraki_argument_spec + + +def get_available_number(data): + for item in data: + if 'Unconfigured SSID' in item['name']: + return item['number'] + return False + + +def get_ssid_number(name, data): + for ssid in data: + if name == ssid['name']: + return ssid['number'] + return False + + +def get_ssids(meraki, net_id): + path = meraki.construct_path('get_all', net_id=net_id) + return meraki.request(path, method='GET') + + +def main(): + + param_map = {'name': 'name', + 'enabled': 'enabled', + 'authMode': 'auth_mode', + 'encryptionMode': 'encryption_mode', + 'psk': 'psk', + 'wpaEncryptionMode': 'wpa_encryption_mode', + 'splashPage': 'splash_page', + 'radiusServers': 'radius_servers', + 'radiusCoaEnabled': 'radius_coa_enabled', + 'radiusFailoverPolicy': 'radius_failover_policy', + 'radiusLoadBalancingPolicy': 'radius_load_balancing_policy', + 'radiusAccountingEnabled': 'radius_accounting_enabled', + 'radiusAccountingServers': 'radius_accounting_servers', + 'ipAssignmentMode': 'ip_assignment_mode', + 'useVlanTagging': 'use_vlan_tagging', + 'concentratorNetworkId': 'concentrator_network_id', + 'vlanId': 'vlan_id', + 'defaultVlanId': 'default_vlan_id', + 'apTagsAndVlanIds': 'ap_tags_vlan_ids', + 'walledGardenEnabled': 'walled_garden_enabled', + 'walledGardenRanges': 'walled_garden_ranges', + 'minBitrate': 'min_bitrate', + 'bandSelection': 'band_selection', + 'perClientBandwidthLimitUp': 'per_client_bandwidth_limit_up', + 'perClientBandwidthLimitDown': 'per_client_bandwidth_limit_down', + } + + default_payload = {'name': 'Unconfigured SSID', + 'auth_mode': 'open', + 'splashPage': 'None', + 'perClientBandwidthLimitUp': 0, + 'perClientBandwidthLimitDown': 0, + 'ipAssignmentMode': 'NAT mode', + 'enabled': False, + 'bandSelection': 'Dual band operation', + 'minBitrate': 11, + } + + # define the available arguments/parameters that a user can pass to + # the module + radius_arg_spec = dict(host=dict(type='str', required=True), + port=dict(type='int'), + secret=dict(type='str', no_log=True), + ) + vlan_arg_spec = dict(tags=dict(type='list'), + vlan_id=dict(type='int'), + ) + + argument_spec = meraki_argument_spec() + argument_spec.update(state=dict(type='str', choices=['absent', 'present', 'query'], default='present'), + number=dict(type='int', aliases=['ssid_number']), + name=dict(type='str'), + org_name=dict(type='str', aliases=['organization']), + org_id=dict(type='int'), + net_name=dict(type='str'), + net_id=dict(type='str'), + enabled=dict(type='bool'), + auth_mode=dict(type='str', choices=['open', 'psk', 'open-with-radius', '8021x-meraki', '8021x-radius']), + encryption_mode=dict(type='str', choices=['wpa', 'eap', 'wpa-eap']), + psk=dict(type='str', no_log=True), + wpa_encryption_mode=dict(type='str', choices=['WPA1 and WPA2', 'WPA2 only']), + splash_page=dict(type='str', choices=['None', + 'Click-through splash page', + 'Billing', + 'Password-protected with Meraki RADIUS', + 'Password-protected with custom RADIUS', + 'Password-protected with Active Directory', + 'Password-protected with LDAP', + 'SMS authentication', + 'Systems Manager Sentry', + 'Facebook Wi-Fi', + 'Google OAuth', + 'Sponsored guest']), + radius_servers=dict(type='list', default=None, element='dict', options=radius_arg_spec), + radius_coa_enabled=dict(type='bool'), + radius_failover_policy=dict(type='str', choices=['Deny access', 'Allow access']), + radius_load_balancing_policy=dict(type='str', choices=['Strict priority order', 'Round robin']), + radius_accounting_enabled=dict(type='bool'), + radius_accounting_servers=dict(type='list', element='dict', options=radius_arg_spec), + ip_assignment_mode=dict(type='str', choices=['NAT mode', + 'Bridge mode', + 'Layer 3 roaming', + 'Layer 3 roaming with a concentrator', + 'VPN']), + use_vlan_tagging=dict(type='bool'), + concentrator_network_id=dict(type='str'), + vlan_id=dict(type='int'), + default_vlan_id=dict(type='int'), + ap_tags_vlan_ids=dict(type='list', default=None, element='dict', options=vlan_arg_spec), + walled_garden_enabled=dict(type='bool'), + walled_garden_ranges=dict(type='list'), + min_bitrate=dict(type='float', choices=[1, 2, 5.5, 6, 9, 11, 12, 18, 24, 36, 48, 54]), + band_selection=dict(type='str', choices=['Dual band operation', + '5 GHz band only', + 'Dual band operation with Band Steering']), + per_client_bandwidth_limit_up=dict(type='int'), + per_client_bandwidth_limit_down=dict(type='int'), + ) + + # seed the result dict in the object + # we primarily care about changed and state + # change is if this module effectively modified the target + # state will include any data that you want your module to pass back + # for consumption, for example, in a subsequent task + result = dict( + changed=False, + ) + # the AnsibleModule object will be our abstraction working with Ansible + # this includes instantiation, a couple of common attr would be the + # args/params passed to the execution, as well as if the module + # supports check mode + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + ) + meraki = MerakiModule(module, function='ssid') + meraki.params['follow_redirects'] = 'all' + + query_urls = {'ssid': '/networks/{net_id}/ssids'} + query_url = {'ssid': 'networks/{net_id}/ssids/'} + update_url = {'ssid': 'networks/{net_id}/ssids/'} + + meraki.url_catalog['get_all'].update(query_urls) + meraki.url_catalog['get_one'].update(query_url) + meraki.url_catalog['update'] = update_url + + payload = None + + # if the user is working with this module in only check mode we do not + # want to make any changes to the environment, just return the current + # state with no modifications + # FIXME: Work with Meraki so they can implement a check mode + if module.check_mode: + meraki.exit_json(**meraki.result) + + # execute checks for argument completeness + if meraki.params['psk']: + if meraki.params['auth_mode'] != 'psk': + meraki.fail_json(msg='PSK is only allowed when auth_mode is set to psk') + if meraki.params['encryption_mode'] != 'wpa': + meraki.fail_json(msg='PSK requires encryption_mode be set to wpa') + if meraki.params['radius_servers']: + if meraki.params['auth_mode'] not in ('open-with-radius', '8021x-radius'): + meraki.fail_json(msg='radius_servers requires auth_mode to be open-with-radius or 8021x-radius') + if meraki.params['radius_accounting_enabled'] is True: + if meraki.params['auth_mode'] not in ('open-with-radius', '8021x-radius'): + meraki.fails_json(msg='radius_accounting_enabled is only allowed when auth_mode is open-with-radius or 8021x-radius') + if meraki.params['radius_accounting_servers'] is True: + if meraki.params['auth_mode'] not in ('open-with-radius', '8021x-radius') or meraki.params['radius_accounting_enabled'] is False: + meraki.fail_json(msg='radius_accounting_servers is only allowed when auth_mode is open_with_radius or 8021x-radius and \ + radius_accounting_enabled is true') + + # manipulate or modify the state as needed (this is going to be the + # part where your module will do what it needs to do) + org_id = meraki.params['org_id'] + net_id = meraki.params['net_id'] + if org_id is None: + org_id = meraki.get_org_id(meraki.params['org_name']) + if net_id is None: + nets = meraki.get_nets(org_id=org_id) + net_id = meraki.get_net_id(org_id, meraki.params['net_name'], data=nets) + + if meraki.params['state'] == 'query': + if meraki.params['name']: + ssid_id = get_ssid_number(meraki.params['name'], get_ssids(meraki, net_id)) + path = meraki.construct_path('get_one', net_id=net_id) + str(ssid_id) + meraki.result['data'] = meraki.request(path, method='GET') + elif meraki.params['number']: + path = meraki.construct_path('get_one', net_id=net_id) + meraki.params['number'] + meraki.result['data'] = meraki.request(path, method='GET') + else: + meraki.result['data'] = get_ssids(meraki, net_id) + elif meraki.params['state'] == 'present': + payload = dict() + for k, v in param_map.items(): + if meraki.params[v] is not None: + payload[k] = meraki.params[v] + ssids = get_ssids(meraki, net_id) + original = ssids[get_ssid_number(meraki.params['name'], ssids)] + # meraki.fail_json(msg=meraki.is_update_required(original, payload), original=original, payload=payload) + if meraki.is_update_required(original, payload): + ssid_id = meraki.params['number'] + if ssid_id is None: # Name should be used to lookup number + ssid_id = get_ssid_number(meraki.params['name'], ssids) + if ssid_id is False: + ssid_id = get_available_number(ssids) + if ssid_id is False: + meraki.fail_json(msg='No unconfigured SSIDs are available. Specify a number.') + path = meraki.construct_path('update', net_id=net_id) + str(ssid_id) + result = meraki.request(path, 'PUT', payload=json.dumps(payload)) + meraki.result['data'] = result + meraki.result['changed'] = True + elif meraki.params['state'] == 'absent': + ssids = get_ssids(meraki, net_id) + ssid_id = meraki.params['number'] + if ssid_id is None: # Name should be used to lookup number + ssid_id = get_ssid_number(meraki.params['name'], ssids) + if ssid_id is False: + ssid_id = get_available_number(ssids) + if ssid_id is False: + meraki.fail_json(msg='No SSID found by specified name and no number was referenced.') + path = meraki.construct_path('update', net_id=net_id) + str(ssid_id) + payload = default_payload + payload['name'] = payload['name'] + ' ' + str(ssid_id + 1) + # meraki.fail_json(msg='Payload', payload=payload) + result = meraki.request(path, 'PUT', payload=json.dumps(payload)) + meraki.result['data'] = result + meraki.result['changed'] = True + + # in the event of a successful module execution, you will want to + # simple AnsibleModule.exit_json(), passing the key/value results + meraki.exit_json(**meraki.result) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/meraki_ssid/aliases b/test/integration/targets/meraki_ssid/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/test/integration/targets/meraki_ssid/aliases @@ -0,0 +1 @@ +unsupported diff --git a/test/integration/targets/meraki_ssid/tasks/main.yml b/test/integration/targets/meraki_ssid/tasks/main.yml new file mode 100644 index 0000000000..ab13d6e4a5 --- /dev/null +++ b/test/integration/targets/meraki_ssid/tasks/main.yml @@ -0,0 +1,282 @@ +# Test code for the Meraki SSID module +# Copyright: (c) 2018, Kevin Breit (@kbreit) + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Test an API key is provided + fail: + msg: Please define an API key + when: auth_key is not defined + +- name: Use an invalid domain + meraki_organization: + auth_key: '{{ auth_key }}' + host: marrrraki.com + state: present + org_name: IntTestOrg + output_level: debug + delegate_to: localhost + register: invalid_domain + ignore_errors: yes + +- name: Disable HTTP + meraki_organization: + auth_key: '{{ auth_key }}' + use_https: false + state: query + output_level: debug + delegate_to: localhost + register: http + ignore_errors: yes + +- name: Connection assertions + assert: + that: + - '"Failed to connect to" in invalid_domain.msg' + - '"http" in http.url' + +- name: Create test network + meraki_network: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + type: wireless + register: test_net + +- debug: + msg: '{{test_net}}' + +- name: Query all SSIDs + meraki_ssid: + auth_key: '{{auth_key}}' + state: query + org_name: '{{test_org_name}}' + net_name: TestNetSSID + delegate_to: localhost + register: query_all + +- debug: + msg: '{{query_all}}' + +- name: Enable and name SSID + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + enabled: true + delegate_to: localhost + register: enable_name_ssid + +- debug: + msg: '{{ enable_name_ssid }}' + +- assert: + that: + - query_all.data | length == 15 + - query_all.data.0.name == 'TestNetSSID WiFi' + - enable_name_ssid.data.name == 'AnsibleSSID' + +- name: Check for idempotency + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + enabled: true + delegate_to: localhost + register: enable_name_ssid_idempotent + +- debug: + msg: '{{ enable_name_ssid_idempotent }}' + +- assert: + that: + - enable_name_ssid_idempotent.changed == False + +- name: Query one SSIDs + meraki_ssid: + auth_key: '{{auth_key}}' + state: query + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + delegate_to: localhost + register: query_one + +- debug: + msg: '{{query_one}}' + +- assert: + that: + - query_one.data.name == 'AnsibleSSID' + +- name: Disable SSID without specifying number + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + enabled: false + delegate_to: localhost + register: disable_ssid + +- debug: + msg: '{{ disable_ssid.data.enabled }}' + +- assert: + that: + - disable_ssid.data.enabled == False + +- name: Enable SSID with number + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + number: 1 + enabled: true + delegate_to: localhost + register: enable_ssid_number + +- debug: + msg: '{{ enable_ssid_number.data.enabled }}' + +- assert: + that: + - enable_ssid_number.data.enabled == True + +- name: Set PSK with wrong mode + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + auth_mode: open + psk: abc1234 + delegate_to: localhost + register: psk_invalid + ignore_errors: yes + +- debug: + msg: '{{ psk_invalid }}' + +- assert: + that: + - psk_invalid.msg == 'PSK is only allowed when auth_mode is set to psk' + +- name: Set PSK with invalid encryption mode + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + auth_mode: psk + psk: abc1234 + encryption_mode: eap + delegate_to: localhost + register: psk_invalid_mode + ignore_errors: yes + +- debug: + msg: '{{ psk_invalid_mode }}' + +- assert: + that: + - psk_invalid_mode.msg == 'PSK requires encryption_mode be set to wpa' + +- name: Set PSK + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + auth_mode: psk + psk: abc1234567890 + encryption_mode: wpa + delegate_to: localhost + register: psk + +- debug: + msg: '{{ psk }}' + +- assert: + that: + - psk.data.authMode == 'psk' + - psk.data.encryptionMode == 'wpa' + - psk.data.wpaEncryptionMode == 'WPA2 only' + +- name: Enable click-through splash page + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + splash_page: Click-through splash page + delegate_to: localhost + register: splash_click + +- debug: + msg: '{{ splash_click }}' + +- assert: + that: + - splash_click.data.splashPage == 'Click-through splash page' + +- name: Configure RADIUS servers + meraki_ssid: + auth_key: '{{auth_key}}' + state: present + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + auth_mode: open-with-radius + radius_servers: + - host: 192.0.1.200 + port: 1234 + secret: abc98765 + delegate_to: localhost + register: set_radius_server + +- debug: + msg: '{{ set_radius_server }}' + +- assert: + that: + - set_radius_server.data.radiusServers.0.host == '192.0.1.200' + +- name: Delete SSID + meraki_ssid: + auth_key: '{{auth_key}}' + state: absent + org_name: '{{test_org_name}}' + net_name: TestNetSSID + name: AnsibleSSID + delegate_to: localhost + register: delete_ssid + +- debug: + msg: '{{ delete_ssid }}' + +- assert: + that: + - delete_ssid.data.name == 'Unconfigured SSID 2' + +- name: Delete test network + meraki_network: + auth_key: '{{auth_key}}' + state: absent + org_name: '{{test_org_name}}' + net_name: TestNetSSID + register: delete_net + +- debug: + msg: '{{delete_net}}' \ No newline at end of file