├── .dockerignore ├── .env.sample ├── .gitignore ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── app.py ├── cico_a4e.py ├── cico_combined.py ├── cico_common.py ├── cico_meraki.py ├── cico_spark_call.py ├── cico_umbrella.py ├── docker-compose.yml ├── images ├── a4e_create_token.png ├── a4e_new_token.png ├── a4e_show_token.png ├── a4e_token_config.png ├── aws_iam.png ├── aws_iam_adduser.png ├── aws_iam_adduser_complete.png ├── aws_iam_adduser_permissions.png ├── aws_iam_adduser_review.png ├── aws_iam_users.png ├── aws_s3_delete_readme.png ├── aws_s3_lifecycle.png ├── aws_s3_lifecycle_1.png ├── aws_s3_lifecycle_2.png ├── bot_check.png ├── bot_health.png ├── copytoken.png ├── enterdetails.png ├── meraki_all_networks.png ├── meraki_client_rename.png ├── meraki_client_select.png ├── meraki_client_show.png ├── meraki_clients.png ├── meraki_combine.png ├── meraki_combine_confirm.png ├── meraki_enable_api_access.png ├── meraki_key.png ├── meraki_networks.png ├── meraki_profile.png ├── meraki_sm_client_rename.png ├── meraki_sm_client_save.png ├── meraki_sm_client_select.png ├── meraki_sm_client_show.png ├── meraki_sm_client_tag.png ├── meraki_sm_clients.png ├── newapp.png ├── newbot.png ├── postman_org.png ├── spark_call_select_user.png ├── spark_call_user_select.png ├── spark_call_user_show.png ├── spark_call_users.png ├── spark_call_verify_privileges.png ├── spark_get_token.png ├── umbrella_roaming.png ├── umbrella_roaming_rename.png └── umbrella_roaming_select.png ├── meraki_dashboard_link_parser.py ├── requirements.txt ├── start.sh └── umbrella_log_collector.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | //SPARK_BOT_URL=https://mypublicsite.io *(your public facing webserver, or the forwarding address from ngrok)* 2 | //SPARK_BOT_TOKEN= 3 | //SPARK_BOT_EMAIL= 4 | //SPARK_BOT_APP_NAME= 5 | //SPARK_BOT_HELP_MSG= 6 | //MERAKI_API_TOKEN= 7 | //MERAKI_ORG= 8 | //MERAKI_HTTP_USERNAME= 9 | //MERAKI_HTTP_PASSWORD= 10 | //SPARK_API_TOKEN= 11 | //S3_BUCKET= 12 | //S3_ACCESS_KEY_ID= 13 | //S3_SECRET_ACCESS_KEY= 14 | //A4E_CLIENT_ID= 15 | //A4E_CLIENT_SECRET= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | venv 3 | .idea/ 4 | __pycache__/ 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . /app 9 | 10 | CMD [ "python3", "app.py" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 imapex 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: python3 app.py 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spark Operations Bot 2 | Spark Operations Bot, also known as Cisco Infrastructure Chat Ops (CICO). 3 | 4 | This bot leverages the Spark Bot framework found [here](https://github.com/imapex/ciscosparkbot). It has two primary functions: 5 | 6 | 1. The bot is able to provide an overall health of your environment, which can include Meraki, Spark Calling, and Umbrella. It will tell you if you have any network devices down in your Meraki network, and optionally give you a way to cross-launch directly into the Meraki dashboard to troubleshoot. It will show how many phones are configured in Spark Call, and how many are offline by device type. And, it will show you if there is any malicious traffic detected by Umbrella. 7 | ![bot_health](images/bot_health.png) 8 | 9 | 2. The bot is able to give you a status of an individual user, which can include Meraki, Spark Calling, and Umbrella. It will show you the device(s) detected for that user in the Meraki dashboard clients list (also including Systems Manager if available), optionally giving you a way to cross-launch directly into the Meraki dashboard to troubleshoot any issues on the network device. It also lists any phones configured for that user in Spark Call, also with an optional link to cross-launch back into the Meraki dashboard for troubleshooting. Finally, it is able to show you whether there is any malicious traffic detected for that specific user in Umbrella. 10 | ![bot_check](images/bot_check.png) 11 | 12 | # Contents 13 | - [Prerequisites](#prerequisites) 14 | - [Installation](#installation) 15 | - [ngrok](#ngrok) 16 | - [Meraki Integration](#meraki) 17 | - [Enable API Access](#meraki-api-access) 18 | - [Get API Token](#meraki-api-token) 19 | - [Get Organization ID](#meraki-org-id) 20 | - [Environment](#meraki-env-setup) 21 | - [Spark Call Integration](#sparkcall) 22 | - [Verify Admin Rights](#sparkcall-admin) 23 | - [Get API Token](#sparkcall-token) 24 | - [Environment](#sparkcall-env-setup) 25 | - [Umbrella Integration](#umbrella) 26 | - [Create S3 Bucket](#umbrella-s3) 27 | - [Enable S3 Log Export](#umbrella-export) 28 | - [Create S3 API User](#umbrella-s3-api) 29 | - [Set S3 Lifecycle](#umbrella-s3-retention) 30 | - [Environment](#umbrella-env-setup) 31 | - [AMP for Endpoints Integration](#a4e) 32 | - [Create API Credential](#a4e-token) 33 | - [Bot Usage](#usage) 34 | - [Execute Locally](#local-run) 35 | - [Docker](#docker-run) 36 | - [Heroku](#heroku-run) 37 | 38 | 39 | # Prerequisites 40 | 41 | If you don't already have a Cisco Spark account, go ahead and register for one. They are free. 42 | You'll need to start by adding your bot to the Cisco Spark website. 43 | 44 | [https://developer.ciscospark.com/add-app.html](https://developer.ciscospark.com/add-app.html) 45 | 46 | ![add-app](images/newapp.png) 47 | 48 | 1. Click create bot 49 | 50 | ![add-bot](images/newbot.png) 51 | 52 | 2. Fill out all the details about your bot, including a publicly hosted avatar image. A sample avatar is available at [http://cs.co/devnetlogosq](http://cs.co/devnetlogosq). 53 | 54 | ![enter-details](images/enterdetails.png) 55 | 56 | 3. Click "Add Bot", make sure to copy your access token, you will need this in a second 57 | 58 | ![copy-token](images/copytoken.png) 59 | 60 | # Installation 61 | 62 | Create a virtualenv and install the module 63 | 64 | ``` 65 | virtualenv venv 66 | source venv/bin/activate 67 | pip install -r requirements.txt 68 | git clone https://github.com/meraki/spark-operations-bot.git 69 | ``` 70 | 71 | ## ngrok - Skip this step if you already have an Internet reachable web-server 72 | 73 | ngrok will make easy for you to develop your code with a live bot. 74 | 75 | If you are running a Mac with Homebrew, you can easily install via "brew cask install ngrok". Additional installation instructions can be found here: https://ngrok.com/download 76 | 77 | After you've installed ngrok, in another window start the service 78 | 79 | 80 | `ngrok http 5000` 81 | 82 | 83 | You should see a screen that looks like this: 84 | 85 | ``` 86 | ngrok by @inconshreveable (Ctrl+C to quit) 87 | 88 | Session Status online 89 | Version 2.2.4 90 | Region United States (us) 91 | Web Interface http://127.0.0.1:4040 92 | Forwarding http://this.is.the.url.you.need -> localhost:5000 93 | Forwarding **https://this.is.the.url.you.need** -> localhost:5000 94 | 95 | Connections ttl opn rt1 rt5 p50 p90 96 | 2 0 0.00 0.00 0.77 1.16 97 | 98 | HTTP Requests 99 | ------------- 100 | 101 | POST / 200 OK 102 | ``` 103 | 104 | # Meraki Integration 105 | 106 | --- 107 | 108 | ## Enable your Meraki organization for API Access 109 | 110 | 1. Log in to the Meraki Dashboard. Choose your organization if prompted to do so. 111 | 112 | 2. On the left Navigation bar, go to Organization and select Settings. 113 | 114 | 3. Scroll down to the Dashboard API Access section, and turn on API Access. 115 | 116 | ![meraki-enable-api-access](images/meraki_enable_api_access.png) 117 | 118 | ## Obtain your Meraki API Token 119 | 120 | 1. Log in to the Meraki Dashboard. Choose your organization if prompted to do so. 121 | 122 | 2. Under your username, select My profile 123 | 124 | ![meraki-my-profile](images/meraki_profile.png) 125 | 126 | 3. Scroll down to the API access section, and copy your API key. You'll need it to get your Organization ID and to set your environment variables. 127 | 128 | ![meraki-my-key](images/meraki_key.png) 129 | 130 | ## Obtain your Meraki Organization ID 131 | 132 | If you have only a single Organization, this step is not required. If you have multiple Organizations, and you do not provide this value, the first network (alphabetically) will be used. You can use Postman to run this GET: 133 | 134 | ![postman-meraki-org](images/postman_org.png) 135 | 136 | Or you can do it from the command line, with a curl command like this: 137 | 138 | `curl --header "X-Cisco-Meraki-API-Key: put-your-meraki-api-token-here" https://dashboard.meraki.com/api/v0/organizations/` 139 | 140 | You should see output with one or more networks like this: 141 | 142 | ``` 143 | [{"id":your-meraki-org-id-here,"name":"Your Meraki Network"}] 144 | ``` 145 | 146 | Copy your Meraki organization ID to use for the environment variables below. 147 | 148 | ## Environment 149 | 150 | Note: This bot has only been tested with a "Combined" Meraki Network or a standalone Meraki SM Network (no 'health' information is shown for SM-only networks). If you attempt to use the bot with separate wired/wireless/appliance networks, it will likely not work correctly. 151 | 152 | ### Create Combined Network (if your network is not already configured this way) 153 | 154 | 1. Log in to the Meraki Dashboard. Choose your oganization if prompted to do so. 155 | 156 | 2. On the left navigation pane, locate the dropdown arrow representing the currently selected Network. 157 | 158 | ![meraki_networks](images/meraki_networks.png) 159 | 160 | 3. Click the dropdown arrow, then select View All Networks. 161 | 162 | ![meraki_all_networks](images/meraki_all_networks.png) 163 | 164 | 4. Check the boxes for the networks that you wish to combine (may be some or all depending on your specific needs). Next, click on the "Combine" dropdown. 165 | 166 | ![meraki_combine](images/meraki_combine.png) 167 | 168 | 5. Enter a new name for your combined network, and click the Combine button. 169 | 170 | ![meraki_combine_confirm](images/meraki_combine_confirm.png) 171 | 172 | ### Verify client naming conventions (to understand/change how the bot locates users in the network) 173 | 174 | 1. Log in to the Meraki Dashboard. Choose your oganization if prompted to do so. 175 | 176 | 2. On the left navigation pane, navigate to Network-Wide and Clients. 177 | 178 | ![meraki_clients](images/meraki_clients.png) 179 | 180 | 3. Select a client from the list of clients. 181 | 182 | ![meraki_client_select](images/meraki_client_select.png) 183 | 184 | 4. By default, when performing a "check <username>", the bot should match the currently displayed name for the selected client. This should match the host name of the client. You might want to search for users by username, which means that the clients will have to be renamed to represent the username of each respective client. In order to change the selected client's name, click the pencil icon next to the name of the client. 185 | 186 | ![meraki_client_show](images/meraki_client_show.png) 187 | 188 | 5. Set a new name for the client, then click Save. 189 | 190 | ![meraki_client_rename](images/meraki_client_rename.png) 191 | 192 | ### Verify Systems Manager client naming conventions (optional; if you use SM, you can also coorelate SM data alongisde network client data) 193 | 194 | 1. Log in to the Meraki Dashboard. Choose your oganization if prompted to do so. 195 | 196 | 2. On the left navigation pane, navigate to Systems Manager and Clients 197 | 198 | ![meraki_sm_clients](images/meraki_sm_clients.png) 199 | 200 | 3. Select a client from the list of clients. 201 | 202 | ![meraki_sm_client_select](images/meraki_sm_client_select.png) 203 | 204 | 4. By default, when performing a "check <username>", the bot should match the currently displayed name for the selected client. This should match the host name of the client. You might want to search for users by username, which means that the clients will have to be renamed to represent the username of each respective client. In order to change the selected client's name, click the Edit Details link next at the top right of the Client Details section. 205 | 206 | ![meraki_sm_client_show](images/meraki_sm_client_show.png) 207 | 208 | 5. In order to coorelate client details to the network client, the name of the network client must match the name of the SM client. Set a name to match the network client, then click Save. 209 | 210 | ![meraki_sm_client_rename](images/meraki_sm_client_rename.png) 211 | ![meraki_sm_client_save](images/meraki_sm_client_save.png) 212 | 213 | # Spark Call Integration 214 | 215 | Note: The Spark Call APIs being used have not been officially published. As such, they are subject to change at any time without notification. 216 | --- 217 | 218 | ## Verify Spark Call Administrator Privileges 219 | 220 | Go to https://admin.ciscospark.com, and log in with a user that has Full Admin rights to your Spark Call organization. 221 | 222 | Select Users on the left, then find your Admin user and click on that user. An overview box will slide in. 223 | 224 | ![spark_call_select_user](images/spark_call_select_user.png) 225 | 226 | Click "Roles and Security" 227 | 228 | ![spark_call_verify_privileges](images/spark_call_verify_privileges.png) 229 | 230 | Ensure that your Admin user has "Full administrator privileges" marked 231 | 232 | ## Obtain your Spark Call Administrator Token 233 | 234 | Go to https://developer.ciscospark.com, and log in with a user that has Full Admin rights to your Spark Call organization. 235 | 236 | In the upper right, click the user portrait, then click the "Copy" button to copy your Token for the environment variables below. 237 | 238 | ![spark_get_token](images/spark_get_token.png) 239 | 240 | ## Environment 241 | 242 | By default, when performing a "check <username>", the bot will match clients in Spark Call where some portion of the First Name, Last Name, User Name, or Display Name match. 243 | 244 | 1. Log in to the Spark Call Dashboard. 245 | 246 | 2. On the left navigation pane, click on Users. From the list, you can see the First Name, Last Name, Display Name, and Email for the clients. 247 | 248 | ![spark_call_users](images/spark_call_users.png) 249 | 250 | 3. Select a user from the list of users. 251 | 252 | ![spark_call_user_select](images/spark_call_user_select.png) 253 | 254 | 4. Update the First Name, Last Name, or Display Name as desired, then click Save. 255 | 256 | ![spark_call_user_show](images/spark_call_user_show.png) 257 | 258 | # Umbrella Integration 259 | 260 | ## Create S3 Bucket 261 | 262 | Please reference the Umbrella documentation for information on how to set up your S3 bucket. 263 | [https://support.umbrella.com/hc/en-us/articles/231248448-Cisco-Umbrella-Log-Management-in-Amazon-S3](https://support.umbrella.com/hc/en-us/articles/231248448-Cisco-Umbrella-Log-Management-in-Amazon-S3) 264 | 265 | ## Enable S3 Log Export 266 | 267 | Please reference the Umbrella documentation for information on how to enable S3 Log Export. 268 | [https://support.umbrella.com/hc/en-us/articles/231248448-Cisco-Umbrella-Log-Management-in-Amazon-S3](https://support.umbrella.com/hc/en-us/articles/231248448-Cisco-Umbrella-Log-Management-in-Amazon-S3) 269 | 270 | ## Create S3 API User 271 | 272 | Access Amazon Identiy and Access Management (IAM) here: 273 | [https://console.aws.amazon.com/iam](https://console.aws.amazon.com/iam) 274 |
Click on the link for Users: 1 (or, whatever quantity of users you have defined) 275 | 276 | ![umbrella_iam](images/aws_iam.png) 277 | 278 | Click Add User 279 | 280 | ![umbrella_users](images/aws_iam_users.png) 281 | 282 | Give your user a name, and check the box for "Programmatic access" 283 | 284 | ![umbrella_adduser](images/aws_iam_adduser.png) 285 | 286 | Click the button for "Attach existing policies directly", then search for or scroll to AmazonS3ReadOnlyAccess, and check the box next to that. Then click Next. 287 | 288 | ![umbrella_adduser_perm](images/aws_iam_adduser_permissions.png) 289 | 290 | Verify the settings, then click Create user. 291 | 292 | ![umbrella_adduser_rev](images/aws_iam_adduser_review.png) 293 | 294 | Save your Access Key ID and Secret Access Key to add to the required Environment Variables. 295 | 296 | ![umbrella_adduser_ver](images/aws_iam_adduser_complete.png) 297 | 298 | ## Set S3 Lifecycle
299 | 300 | In Amazon AWS, access the bucket you are utilizing for Umbrella log exports. Click on the Management tab after selecting the bucket. 301 |
Click "Add lifecycle rule" 302 | 303 | ![umbrella_lifecycle](images/aws_s3_lifecycle.png) 304 | 305 | Give your lifecycle a rule, then click through until the Expiration tab. 306 | 307 | ![umbrella_lifecycle1](images/aws_s3_lifecycle_1.png) 308 | 309 | Check "Current version", check "Expire current version of object", and set the duration to 1 day. Click through and save the lifecycle rule. 310 | 311 | ![umbrella_lifecycle2](images/aws_s3_lifecycle_2.png) 312 | 313 | ## Environment
314 | 315 | By default, when performing a "check <username>", the bot will match clients in Umbrella based on their host name. 316 | 317 | 1. Log in to the Umbrella Dashboard. 318 | 319 | 2. On the left navigation pane, click on Identities and Roaming Computers. 320 | 321 | ![umbrella_roaming](images/umbrella_roaming.png) 322 | 323 | 3. Select a user from the list of users. 324 | 325 | ![umbrella_roaming_select](images/umbrella_roaming_select.png) 326 | 327 | 4. Update the client name as desired, then click Save. 328 | 329 | ![umbrella_roaming_rename](images/umbrella_roaming_rename.png) 330 | 331 | # AMP for Endpoints Integration 332 | 333 | ## Create an A4E API Credential 334 | 335 | Go to https://console.amp.cisco.com, and log in with a user that has Full Admin rights to your AMP for Endpoints organization. 336 | 337 | In the top menu, click Accounts dropdown menu, then click the "API Credentials" menu option. 338 | 339 | ![a4e_new_token](images/a4e_new_token.png) 340 | 341 | Click the button for "New API Credential". 342 | 343 | ![a4e_create_token](images/a4e_create_token.png) 344 | 345 | Give your API Credential a recognizable name, leave the Scope as "Read-only", then click the "Create" button. 346 | 347 | ![a4e_token_config](images/a4e_token_config.png) 348 | 349 | Save the Client ID and API Key for the environment variables below. 350 | 351 | ![a4e_show_token](images/a4e_show_token.png) 352 | 353 | 354 | # Bot Usage 355 | 356 | There are several ways to run the bot. Use one of the methods below to start up the bot. Once it's running, you can start interacting with it! 357 | If you are in a 1:1 space with your bot, you can simply type either health or check . If you are in a group, you will first need to @mention your bot, followed by health or check . 358 | 359 | ## Execute Locally 360 | 361 | The easiest way to use this module is to set a few environment variables. On Windows, use "set" instead of "export". See the ngrok section below if you do not have a web server already facing the Internet. These are the Environment variables that are required to run the bot itself (app.py): 362 | 363 | ``` 364 | # Required for Bot Operation 365 | export SPARK_BOT_URL=https://mypublicsite.io *(your public facing webserver, or the forwarding address from ngrok)* 366 | export SPARK_BOT_TOKEN= 367 | export SPARK_BOT_EMAIL= 368 | export SPARK_BOT_APP_NAME= 369 | export SPARK_BOT_HELP_MSG= 370 | # Enable Meraki Integration 371 | export MERAKI_API_TOKEN= 372 | export MERAKI_ORG= 373 | export MERAKI_HTTP_USERNAME= 374 | export MERAKI_HTTP_PASSWORD= 375 | # Enable Spark Call Integration 376 | export SPARK_API_TOKEN= 377 | # Enable Umbrella Integration 378 | export S3_BUCKET= 379 | export S3_ACCESS_KEY_ID= 380 | export S3_SECRET_ACCESS_KEY= 381 | # Enable AMP for Endpoints Integration 382 | export A4E_CLIENT_ID= 383 | export A4E_CLIENT_SECRET= 384 | ``` 385 | 386 | Now launch your bot!! 387 | 388 | `python app.py` 389 | 390 | ## Docker 391 | 392 | First, make a copy of the .env.sample file, naming it .env, and editing it to set your environment variables. 393 | 394 | You can build the container yourself: 395 | ``` 396 | docker build -t joshand/spark-operations-bot . 397 | docker run -p 5000:5000 -it --env-file .env joshand/spark-operations-bot 398 | ``` 399 | 400 | Or, you can use the published container: 401 | ``` 402 | ./start.sh 403 | ``` 404 | 405 | ## Heroku 406 | 407 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cisco Spark Operations Bot", 3 | "description": "Cisco Spark Operations Bot", 4 | "repository": "https://github.com/meraki/spark-operations-bot", 5 | "addons": [], 6 | "env": { 7 | "SPARK_BOT_URL": { 8 | "description": "The address at which your bot can be reached. The public address of your Heroku app will be 'https://.herokuapp.com", 9 | "required": true 10 | }, 11 | "SPARK_BOT_TOKEN": { 12 | "description": "The bot's access token from Cisco Spark. Create a new bot account at https://developer.ciscospark.com/add-bot.html if you do not have one.", 13 | "required": true 14 | }, 15 | "SPARK_BOT_EMAIL": { 16 | "description": "The bot's email address from Cisco Spark.", 17 | "required": true 18 | }, 19 | "SPARK_BOT_APP_NAME": { 20 | "description": "The bot's name from Cisco Spark.", 21 | "required": true 22 | }, 23 | "SPARK_BOT_HELP_MSG": { 24 | "description": "(Optional) Used to customize Bot Help Banner", 25 | "required": false 26 | }, 27 | "MERAKI_API_TOKEN": { 28 | "description": "(Required for Meraki Support) The API token from the Meraki Dashboard.", 29 | "required": false 30 | }, 31 | "MERAKI_ORG": { 32 | "description": "(Required for Meraki Support) The Organization ID from the Meraki API.", 33 | "required": false 34 | }, 35 | "MERAKI_HTTP_USERNAME": { 36 | "description": "(Optional for Meraki Support) Meraki Dashboard username. Used to generate cross-launch links from Spark client to Dashboard.", 37 | "required": false 38 | }, 39 | "MERAKI_HTTP_PASSWORD": { 40 | "description": "(Optional for Meraki Support) Meraki Dashboard password. Used to generate cross-launch links from Spark client to Dashboard.", 41 | "required": false 42 | }, 43 | "SPARK_API_TOKEN": { 44 | "description": "(Required for Spark Call Support) API Token for Cisco Spark Call Admin user.", 45 | "required": false 46 | }, 47 | "S3_BUCKET": { 48 | "description": "(Required for Umbrella Support) Amazon S3 Bucket name for Umbrella log import.", 49 | "required": false 50 | }, 51 | "S3_ACCESS_KEY_ID": { 52 | "description": "(Required for Umbrella Support) Amazon S3 Access Key ID for Umbrella log import.", 53 | "required": false 54 | }, 55 | "S3_SECRET_ACCESS_KEY": { 56 | "description": "(Required for Umbrella Support) Amazon S3 Secret Access Key for Umbrella log import.", 57 | "required": false 58 | }, 59 | "A4E_CLIENT_ID": { 60 | "description": "(Required for AMP for Endpoints Support) AMP for Endpoints 3rd Party API Client ID.", 61 | "required": false 62 | }, 63 | "A4E_CLIENT_SECRET": { 64 | "description": "(Required for AMP for Endpoints Support) AMP for Endpoints API Key.", 65 | "required": false 66 | } 67 | }, 68 | "keywords": ["meraki", "spark", "cisco", "ciscospark", "umbrella", "opendns", "bot", "botkit", "python", "a4e", "amp4endpoints", "amp"] 69 | } -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' 3 | This is the main entry point for the bot. All modules will be loaded from here. There are a number of environment 4 | variables that are required for this bot to function. 5 | See the README at https://github.com/meraki/spark-operations-bot 6 | ''' 7 | import os 8 | from ciscosparkbot import SparkBot 9 | import cico_meraki 10 | import cico_spark_call 11 | import cico_combined 12 | import cico_common 13 | import cico_umbrella 14 | import cico_a4e 15 | import sys 16 | import atexit 17 | from apscheduler.schedulers.background import BackgroundScheduler 18 | import umbrella_log_collector 19 | import meraki_dashboard_link_parser 20 | 21 | 22 | # ======================================================== 23 | # Load required parameters from environment variables 24 | # ======================================================== 25 | 26 | # If there is a PORT environment variable, use that to map the Flask port. Used for Heroku. 27 | # If no port set, use default of 5000. 28 | app_port = os.getenv("PORT") 29 | if not app_port: 30 | app_port = 5000 31 | else: 32 | app_port = int(app_port) 33 | 34 | # Load additional environment variables 35 | bot_email = os.getenv("SPARK_BOT_EMAIL") 36 | spark_token = os.getenv("SPARK_BOT_TOKEN") 37 | bot_url = os.getenv("SPARK_BOT_URL") 38 | bot_app_name = os.getenv("SPARK_BOT_APP_NAME") 39 | bot_help = os.getenv("SPARK_BOT_HELP_MSG") 40 | 41 | # If any of the bot environment variables are missing, terminate the application 42 | if not bot_email or not spark_token or not bot_url or not bot_app_name: 43 | print("app.py - Missing Environment Variable.") 44 | if not bot_email: 45 | print("SPARK_BOT_EMAIL") 46 | if not spark_token: 47 | print("SPARK_BOT_TOKEN") 48 | if not bot_url: 49 | print("SPARK_BOT_URL") 50 | if not bot_app_name: 51 | print("SPARK_BOT_APP_NAME") 52 | sys.exit() 53 | 54 | 55 | # ======================================================== 56 | # Monkey Patch Spark Bot send_help method to customize header 57 | # ======================================================== 58 | def new_send_help(self, post_data): 59 | """ 60 | Construct a help message for users. 61 | :param post_data: 62 | :return: 63 | """ 64 | if bot_help: 65 | message = bot_help + "\n" 66 | else: 67 | message = "Hello! " 68 | message += "I understand the following commands: \n" 69 | for c in self.commands.items(): 70 | if c[1]["help"][0] != "*": 71 | message += "* **%s**: %s \n" % (c[0], c[1]["help"]) 72 | return message 73 | 74 | 75 | SparkBot.send_help = new_send_help 76 | 77 | 78 | # ======================================================== 79 | # Initialize Program - Run any pre-flight actions required 80 | # ======================================================== 81 | 82 | # This function is called by the scheduler to download logs from Amazon S3 (for Umbrella) 83 | def job_function(): 84 | umbrella_log_collector.get_logs() 85 | 86 | 87 | # Check to see if a dashboard username and password has been provided. If so, scrape the dashboard to build 88 | # cross-launch resources to use for the bot, otherwise initialize to None 89 | if cico_common.meraki_dashboard_support(): 90 | print("Attempting to resolve Dashboard references...") 91 | dbmap = meraki_dashboard_link_parser.get_meraki_http_info() 92 | cico_meraki.meraki_dashboard_map = dbmap 93 | print("Dbmap=", dbmap) 94 | else: 95 | cico_meraki.meraki_dashboard_map = None 96 | 97 | 98 | # If the Umbrella environment variables (aka Amazon S3) have been configured, enable the job scheduler to run every 99 | # 5 minutes to download logs. 100 | if cico_common.umbrella_support(): 101 | cron = BackgroundScheduler() 102 | 103 | # Explicitly kick off the background thread 104 | cron.start() 105 | job = cron.add_job(job_function, 'interval', minutes=5) 106 | print("Beginning Umbrella Log Collection...") 107 | job_function() 108 | 109 | # Shutdown your cron thread if the web process is stopped 110 | atexit.register(lambda: cron.shutdown(wait=False)) 111 | 112 | # ======================================================== 113 | # Initialize Bot - Register commands and start web server 114 | # ======================================================== 115 | 116 | # Create a new bot 117 | bot = SparkBot(bot_app_name, spark_bot_token=spark_token, 118 | spark_bot_url=bot_url, spark_bot_email=bot_email, default_action="help", debug=True) 119 | 120 | bot.add_command('help', 'Get help.', bot.send_help) 121 | bot.remove_command('/echo') 122 | bot.remove_command('/help') 123 | 124 | # Add bot commands. 125 | # If Meraki environment variables have been enabled, add Meraki-specifc commands. 126 | if cico_common.meraki_support(): 127 | bot.add_command('meraki-health', 'Get health of Meraki environment.', cico_meraki.get_meraki_health_html) 128 | bot.add_command('meraki-check', 'Check Meraki user status.', cico_meraki.get_meraki_clients_html) 129 | # If Spark Call environment variables have been enabled, add Spark Call-specifc commands. 130 | if cico_common.spark_call_support(): 131 | bot.add_command('spark-health', 'Get health of Spark environment.', cico_spark_call.get_spark_call_health_html) 132 | bot.add_command('spark-check', 'Check Spark user status.', cico_spark_call.get_spark_call_clients_html) 133 | # If Umbrella (S3) environment variables have been enabled, add Umbrella-specifc commands. 134 | if cico_common.umbrella_support(): 135 | bot.add_command('umbrella-health', 'Get health of Umbrella envrionment.', cico_umbrella.get_umbrella_health_html) 136 | bot.add_command('umbrella-check', 'Check Umbrella user status.', cico_umbrella.get_umbrella_clients_html) 137 | # If Amp for Endpoints environment variables have been enabled, add A4E-specifc commands. 138 | if cico_common.a4e_support(): 139 | bot.add_command('a4e-health', 'Get health of AMP for Endpoints envrionment.', cico_a4e.get_a4e_health_html) 140 | bot.add_command('a4e-check', 'Check AMP for Endpoints user status.', cico_a4e.get_a4e_clients_html) 141 | # Add generic commands. 142 | bot.add_command('health', 'Get health of entire environment.', cico_combined.get_health) 143 | bot.add_command('check', 'Get user status.', cico_combined.get_clients) 144 | 145 | 146 | # Run Bot 147 | bot.run(host='0.0.0.0', port=app_port) -------------------------------------------------------------------------------- /cico_a4e.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | import json 4 | 5 | 6 | a4e_client_id = os.getenv("A4E_CLIENT_ID") 7 | a4e_client_secret = os.getenv("A4E_CLIENT_SECRET") 8 | header = {"Accept-Encoding": "gzip"} 9 | 10 | 11 | def get_a4e_events(): 12 | # Get a list of all A4E events 13 | url = "https://api.amp.cisco.com/v1/events?event_type[]=1090519054&event_type[]=553648143&event_type[]=2164260880&event_type[]=553648145" 14 | evlist = requests.get(url, headers=header, auth=(a4e_client_id, a4e_client_secret)) 15 | evjson = json.loads(evlist.content.decode("utf-8")) 16 | return evjson 17 | 18 | 19 | def get_a4e_health(incoming_msg, rettype): 20 | # Get a list of all events 21 | evjson = get_a4e_events() 22 | totalevents = evjson["metadata"]["results"]["current_item_count"] 23 | 24 | processed_events = 0 25 | threat_detected_count = 0 26 | threat_quarantined_count = 0 27 | threat_quarantine_failed_count = 0 28 | threat_detected_excluded_count = 0 29 | erricon = "" 30 | 31 | retmsg = "

AMP for Endpoints Details:

" 32 | retmsg += "
AMP for Endpoints Dashboard
    " 33 | for ev in evjson["data"]: 34 | if ev["event_type_id"] == 1090519054: 35 | threat_detected_count += 1 36 | elif ev["event_type_id"] == 553648143: 37 | threat_quarantined_count += 1 38 | elif ev["event_type_id"] == 2164260880: 39 | threat_quarantine_failed_count += 1 40 | erricon = chr(0x2757) + chr(0xFE0F) 41 | elif ev["event_type_id"] == 553648145: 42 | threat_detected_excluded_count += 1 43 | 44 | processed_events += 1 45 | retmsg += "
  • " + str(threat_detected_count) + " threat(s) detected. (" + str(threat_detected_excluded_count) + " in excluded locations.)
  • " 46 | # retmsg += "
  • " + str(threat_detected_excluded_count) + " threat(s) detected in excluded locations.
  • " 47 | retmsg += "
  • " + str(threat_quarantined_count) + " threat(s) quarantined.
  • " 48 | retmsg += "
  • " + str(threat_quarantine_failed_count) + " threat(s) quarantine failed." + erricon + "
  • " 49 | retmsg += "
Processed " + str(processed_events) + " of " + str(totalevents) + " threat event(s)." 50 | 51 | return retmsg 52 | 53 | 54 | def get_a4e_clients(incoming_msg, rettype): 55 | cmdlist = incoming_msg.text.split(" ") 56 | client_id = cmdlist[len(cmdlist)-1] 57 | 58 | evjson = get_a4e_events() 59 | totalevents = evjson["metadata"]["results"]["current_item_count"] 60 | 61 | processed_events = 0 62 | hostarr = {} 63 | erricon = "" 64 | 65 | for ev in evjson["data"]: 66 | compname = ev["computer"]["hostname"].upper() 67 | if compname not in hostarr: 68 | hostarr[compname] = {"threat_detected_count": 0, "threat_quarantined_count": 0, "threat_quarantine_failed_count": 0, "threat_detected_excluded_count": 0} 69 | 70 | if ev["event_type_id"] == 1090519054: 71 | hostarr[compname]["threat_detected_count"] += 1 72 | elif ev["event_type_id"] == 553648143: 73 | hostarr[compname]["threat_quarantined_count"] += 1 74 | elif ev["event_type_id"] == 2164260880: 75 | hostarr[compname]["threat_quarantine_failed_count"] += 1 76 | erricon = chr(0x2757) + chr(0xFE0F) 77 | elif ev["event_type_id"] == 553648145: 78 | hostarr[compname]["threat_detected_excluded_count"] += 1 79 | 80 | processed_events += 1 81 | 82 | if rettype == "json": 83 | return {"aggregate": {"total_events": totalevents, "processed_events": processed_events}, "clients": hostarr} 84 | else: 85 | retmsg = "

AMP for Endpoints Stats:

    " 86 | for cli in hostarr: 87 | if cli == client_id: 88 | retmsg += "
  • " + str(hostarr[cli]["threat_detected_count"]) + " threat(s) detected. (" + str(hostarr[cli]["threat_detected_excluded_count"]) + " in excluded locations.)
  • " 89 | retmsg += "
  • " + str(hostarr[cli]["threat_quarantined_count"]) + " threat(s) quarantined.
  • " 90 | retmsg += "
  • " + str(hostarr[cli]["threat_quarantine_failed_count"]) + " threat(s) quarantine failed." + erricon + "
  • " 91 | retmsg += "
Processed " + str(processed_events) + " of " + str(totalevents) + " threat event(s)." 92 | 93 | return retmsg 94 | 95 | 96 | def get_a4e_health_html(incoming_msg): 97 | return get_a4e_health(incoming_msg, "html") 98 | 99 | 100 | def get_a4e_clients_html(incoming_msg): 101 | return get_a4e_clients(incoming_msg, "html") 102 | -------------------------------------------------------------------------------- /cico_combined.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This module is specifically for interoperability between the various individual modules. Any generic product code 3 | should be placed into a product specific module, and any relevant integration should be added here. 4 | ''' 5 | import os 6 | import cico_meraki 7 | import cico_spark_call 8 | import cico_umbrella 9 | import cico_a4e 10 | import cico_common 11 | 12 | # ======================================================== 13 | # Load required parameters from environment variables 14 | # ======================================================== 15 | 16 | meraki_client_to = os.getenv("MERAKI_CLIENT_TIMESPAN") 17 | if not meraki_client_to: 18 | meraki_client_to = "86400" 19 | 20 | 21 | # ======================================================== 22 | # Initialize Program - Function Definitions 23 | # ======================================================== 24 | 25 | 26 | def get_health(incoming_msg): 27 | ''' 28 | This function is used to consolidate Health information from all of the individual product modules 29 | 30 | :param incoming_msg: String. this is the message that is posted in Spark 31 | :return: String. this is a fully formatted string that will be sent back to Spark 32 | ''' 33 | retval = "" 34 | 35 | # If Meraki environment variables have been enabled, retrieve Meraki health information 36 | if cico_common.meraki_support(): 37 | print("Meraki Support Enabled") 38 | retval += cico_meraki.get_meraki_health(incoming_msg, "html") 39 | # If Spark Call environment variables have been enabled, retrieve Spark Call health information 40 | if cico_common.spark_call_support(): 41 | print("Spark Call Support Enabled") 42 | if retval != "": 43 | retval += "

" 44 | retval += cico_spark_call.get_spark_call_health(incoming_msg, "html") 45 | # If Umbrella (S3) environment variables have been enabled, retrieve Umbrella health information 46 | if cico_common.umbrella_support(): 47 | print("Umbrella Support Enabled") 48 | if retval != "": 49 | retval += "

" 50 | retval += cico_umbrella.get_umbrella_health(incoming_msg, "html") 51 | if cico_common.a4e_support(): 52 | if retval != "": 53 | retval += "" 54 | retval += cico_a4e.get_a4e_health(incoming_msg, "html") 55 | 56 | return retval 57 | 58 | 59 | def get_clients(incoming_msg): 60 | ''' 61 | This function is used to consolidate Client information from all of the individual product modules 62 | 63 | :param incoming_msg: this is the message that is posted in Spark 64 | :return: this is a fully formatted string that will be sent back to Spark 65 | ''' 66 | 67 | # initialize variables 68 | retm = "" 69 | retsc = "" 70 | retscn = "" 71 | retu = "" 72 | retmsg = "" 73 | devcount = 0 74 | 75 | sclients = {} 76 | mclients = {} 77 | uclients = {} 78 | aclients = {} 79 | netlist = [] 80 | newsmlist = [] 81 | smnetid = "" 82 | 83 | # Parse incoming message in order to retrieve the username of the client 84 | cmdlist = incoming_msg.text.split(" ") 85 | client_id = cmdlist[len(cmdlist)-1] 86 | 87 | # If Meraki environment variables have been enabled, retrieve Meraki client information 88 | if cico_common.meraki_support(): 89 | print("Meraki Support Enabled") 90 | # cico_spark_call.get_spark_call_clients_html(incoming_msg) 91 | mclients = cico_meraki.get_meraki_clients(incoming_msg, "json") 92 | netlist = mclients["client"] # Dashboard Clients 93 | newsmlist = mclients["sm"] # Systems Manager Clients 94 | smnetid = mclients["smnetid"] # Systems Manager Clients 95 | # If Spark Call environment variables have been enabled, retrieve Spark Call client information 96 | if cico_common.spark_call_support(): 97 | print("Spark Call Support Enabled") 98 | # cico_meraki.get_meraki_clients_html(incoming_msg) 99 | sclients = cico_spark_call.get_spark_call_clients(incoming_msg, "json") 100 | # If Umbrella (S3) environment variables have been enabled, retrieve Umbrella client information 101 | if cico_common.umbrella_support(): 102 | print("Umbrella Support Enabled") 103 | uclients = cico_umbrella.get_umbrella_clients(incoming_msg, "json") 104 | # If Amp for Endpoints environment variables have been enabled, retrieve A4E client information 105 | if cico_common.a4e_support(): 106 | print("Amp for Endpoints Support Enabled") 107 | aclients = cico_a4e.get_a4e_clients(incoming_msg, "json") 108 | 109 | # Initialize individual product return strings 110 | retm = "

Associated Clients:

" 111 | retsc = "

Collaboration:

" 112 | retsc += "Phones:" 113 | 114 | if netlist: 115 | # netlist is a list of all Meraki Networks in the supplied or derived organization. Iterate these, sorted by name. 116 | for net in sorted(netlist): 117 | # netlist[net]["devices"] represents a list of devices in the individual network that is being iterated. 118 | for dev in netlist[net]["devices"]: 119 | # netlist[net]["devices"][dev]["clients"] represents a list of clients attached to a specific device that 120 | # is being iterated. 121 | for cli in netlist[net]["devices"][dev]["clients"]: 122 | # The client should not be a string. If it is for some reason, do not process it. 123 | if not isinstance(cli, str): 124 | # If the description of the client matches the username specified in Spark, and if this specific 125 | # client has a switchport mapping, then continue (duplicate clients from MX security appliances 126 | # will also be in this list, the switchport check is used to exclude those) 127 | if cli["description"] == client_id and "switchport" in cli and cli["switchport"] is not None: 128 | devbase = netlist[net]["devices"][dev]["info"] 129 | # These functions generate the cross-launch links (if available) for the given 130 | # client/device/port 131 | showdev = cico_meraki.meraki_create_dashboard_link("devices", devbase["mac"], devbase["name"], "?timespan=" + meraki_client_to, 0) 132 | showport = cico_meraki.meraki_create_dashboard_link("devices", devbase["mac"], str(cli["switchport"]), "/ports/" + str(cli["switchport"]) + "?timespan=" + meraki_client_to, 1) 133 | showcli = cico_meraki.meraki_dashboard_client_mod(showdev, cli["id"], cli["dhcpHostname"]) 134 | if devcount > 0: 135 | retm += "
" 136 | devcount += 1 137 | retm += "Computer Name: " + showcli + "
" 138 | 139 | # Iterate the Systems Manager list to see if the client exists there. Iterate through networks. 140 | if net in newsmlist: 141 | # If this network has any devices, then we will attempt to cross reference the client data 142 | if "devices" in newsmlist[net]: 143 | # Our cross-reference point is mac address, as it will exist in both the dashboard 144 | # clients list as well as the systems manager clients list. 145 | if cli["mac"] in newsmlist[net]["devices"]: 146 | # If we are able to cross-reference, we will add some system-specific and 147 | # OS-specific details from SM 148 | smbase = newsmlist[net]["devices"][cli["mac"]] 149 | retm += "Model: " + smbase["systemModel"] + "
" 150 | retm += "OS: " + smbase["osName"] + "
" 151 | 152 | # Once we've checked for Systems Manager cross references, we will display the rest of the 153 | # client details 154 | retm += "IP: " + cli["ip"] + "
" 155 | retm += "MAC: " + cli["mac"] + "
" 156 | retm += "VLAN: " + str(cli["vlan"]) + "
" 157 | # This creates the description of the switch / port the client is connected to 158 | # --duplicate-- devbase = netlist[net]["devices"][dev]["info"] 159 | retm += "Connected To: " + showdev + " (" + devbase["model"] + "), Port " + showport + "
" 160 | 161 | # Now, check to see if there is cooresponding Amp for Endpoints data... 162 | if cico_common.a4e_support(): 163 | if any(cli["dhcpHostname"] in s for s in aclients["clients"]): 164 | retm += "

AMP for Endpoints Stats:

    " 165 | for acli in aclients["clients"]: 166 | if "." in acli: 167 | ahostname = acli.split(".")[0] 168 | else: 169 | ahostname = acli 170 | 171 | if ahostname == cli["dhcpHostname"]: 172 | retm += "
  • " + str(aclients["clients"][acli]["threat_detected_count"]) + " threat(s) detected. (" + str(aclients["clients"][acli]["threat_detected_excluded_count"]) + " in excluded locations.)
  • " 173 | retm += "
  • " + str(aclients["clients"][acli]["threat_quarantined_count"]) + " threat(s) quarantined.
  • " 174 | if aclients["clients"][acli]["threat_quarantine_failed_count"] > 0: 175 | erricon = chr(0x2757) + chr(0xFE0F) 176 | else: 177 | erricon = "" 178 | retm += "
  • " + str(aclients["clients"][acli]["threat_quarantine_failed_count"]) + " threat(s) quarantine failed." + erricon + "
  • " 179 | retm += "
Processed " + str(aclients["aggregate"]["processed_events"]) + " of " + str(aclients["aggregate"]["total_events"]) + " threat event(s)." 180 | else: 181 | retmsg += "

AMP for Endpoints Stats:

    " 182 | retmsg += "
  • No stats available for this user.
" 183 | 184 | # Here, we will also check to see if there is a phone associated to this user. If so, we will 185 | # follow a similar process to determine where the phone is connected and get client details for it. 186 | # If there are phones available, and if the mac address of the client being reviewed currently is 187 | # one of the devices in that list, and if there is a switchport field (again to eliminate 188 | # duplicate MX entries), then we will provide additional information for the phone. 189 | elif "phones" in sclients and cli["mac"] in sclients["phones"] and "switchport" in cli and cli["switchport"] is not None: 190 | devbase = netlist[net]["devices"][dev]["info"] 191 | # These functions generate the cross-launch links (if available) for the given 192 | # client/device/port 193 | showdev = cico_meraki.meraki_create_dashboard_link("devices", devbase["mac"], devbase["name"], "?timespan=" + meraki_client_to, 0) 194 | showport = cico_meraki.meraki_create_dashboard_link("devices", devbase["mac"], str(cli["switchport"]), "/ports/" + str(cli["switchport"]) + "?timespan=" + meraki_client_to, 1) 195 | showcli = cico_meraki.meraki_dashboard_client_mod(showdev, cli["id"], cli["dhcpHostname"]) 196 | 197 | # No Systems Manager references here, but we will add the cross-referened data for the phone 198 | # itself, like it's mac address, description, and whether it is registered. 199 | scbase = sclients["phones"][cli["mac"]] 200 | retsc += "
" + scbase["description"] + " (" + scbase["registrationStatus"] + ")
" 201 | retsc += "Device Name: " + showcli + "
" 202 | retsc += "IP: " + cli["ip"] + "
" 203 | retsc += "MAC: " + cli["mac"] + "
" 204 | retsc += "VLAN: " + str(cli["vlan"]) + "
" 205 | # This creates the description of the switch / port the client is connected to 206 | retsc += "Connected To: " + showdev + " (" + devbase["model"] + "), Port " + showport + "
" 207 | elif newsmlist: 208 | for cli in newsmlist[smnetid]["devices"]: 209 | smbase = newsmlist[smnetid]["devices"][cli] 210 | if client_id.lower() in smbase["name"].lower() or client_id.lower() in [x.lower() for x in smbase["tags"]]: 211 | if devcount > 0: 212 | retm += "
" 213 | devcount += 1 214 | retm += "Client Name: " + smbase["name"] + "
" 215 | retm += "Model: " + smbase["systemModel"] + "
" 216 | retm += "OS: " + smbase["osName"] + "
" 217 | retm += "MAC: " + smbase["wifiMac"] + "
" 218 | smssid = smbase["ssid"] 219 | if smssid is None: 220 | smssid = "N/A" 221 | retm += "Wireless SSID: " + smssid + "
" 222 | 223 | # If there are phone numbers defined in the Spark Call clients list, we want to add that to the output as well. 224 | if "numbers" in sclients: 225 | # There could potentially be multiple numbers, so iterate the list... 226 | for n in sclients["numbers"]: 227 | num = sclients["numbers"][n] 228 | retscn = "Numbers:
" 229 | # There are also internal and external numbers. Format this data for output based on what is available... 230 | if "external" in num: 231 | retscn += num["external"] + " (x" + num["internal"] + ")\n" 232 | else: 233 | retscn += "Extension " + num["internal"] + "
" 234 | 235 | # If there are stats in the Umbrella data, we want to add that to the output 236 | retu = "

Umbrella Client Stats (Last 24 hours):

    " 237 | if "Aggregate" in uclients: 238 | # This prints the aggregate statistics for this specific client 239 | retu += "
  • Total Requests: " + str(uclients["Aggregate"]["Total"]) + "
  • " 240 | # This prints the malicious and non-malicious stats for the traffic for this client 241 | for x in uclients["Aggregate"]: 242 | if x != "Total": 243 | retu += "
  • " + x + ": " + str(uclients["Aggregate"][x]) + " (" + str(round(uclients["Aggregate"][x] / uclients["Aggregate"]["Total"] * 100, 2)) + "%)
  • " 244 | retu += "
" 245 | 246 | # If there is malicious traffic, we want to display the last 5 blocked requests 247 | if len(uclients["Blocked"]) > 0: 248 | retu += "

Last 5 Blocked Requests:

    " 249 | 250 | # Iterate the list of blocked sites, and add that to the output 251 | for x in uclients["Blocked"]: 252 | retu += "
  • " + x["Timestamp"] + " " + x["Domain"] + " " + x["Categories"] + "
  • " 253 | 254 | retu += "
" 255 | else: 256 | retu += "
  • No stats available for this user.
  • " 257 | 258 | # If Meraki environment variables have been enabled, add Meraki client information to the output 259 | if cico_common.meraki_support(): 260 | retmsg += retm 261 | # If we expect more data, add a line (
    ) to the output to make it more readable 262 | if cico_common.umbrella_support() or cico_common.spark_call_support(): 263 | retmsg += "
    " 264 | # If Umbrella (S3) environment variables have been enabled, add Umbrella client information to the output 265 | if cico_common.umbrella_support(): 266 | retmsg += retu 267 | # If we expect more data, add a line (
    ) to the output to make it more readable 268 | if cico_common.spark_call_support(): 269 | retmsg += "
    " 270 | # If Spark Call environment variables have been enabled, add Spark Call client information to the output 271 | if cico_common.spark_call_support(): 272 | retmsg += retsc + "
    " + retscn 273 | 274 | return retmsg 275 | -------------------------------------------------------------------------------- /cico_common.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This module is specifically for common functions that are expected to be used in multiple places across the 3 | various modules 4 | ''' 5 | import os 6 | 7 | 8 | # ======================================================== 9 | # Load required parameters from environment variables 10 | # ======================================================== 11 | 12 | meraki_api_token = os.getenv("MERAKI_API_TOKEN") 13 | meraki_org = os.getenv("MERAKI_ORG") 14 | meraki_http_username = os.getenv("MERAKI_HTTP_USERNAME") 15 | meraki_http_password = os.getenv("MERAKI_HTTP_PASSWORD") 16 | spark_api_token = os.getenv("SPARK_API_TOKEN") 17 | s3_bucket = os.getenv("S3_BUCKET") 18 | s3_key = os.getenv("S3_ACCESS_KEY_ID") 19 | s3_secret = os.getenv("S3_SECRET_ACCESS_KEY") 20 | a4e_client_id = os.getenv("A4E_CLIENT_ID") 21 | a4e_client_secret = os.getenv("A4E_CLIENT_SECRET") 22 | 23 | 24 | # ======================================================== 25 | # Initialize Program - Function Definitions 26 | # ======================================================== 27 | 28 | 29 | def meraki_support(): 30 | ''' 31 | This function is used to check whether the Meraki environment variables have been set. It will return true 32 | if they have and false if they have not 33 | 34 | :return: true/false based on whether or not Meraki support is available 35 | ''' 36 | if meraki_api_token: # and meraki_org: --removed: org auto-selection has been added, so org not required-- 37 | return True 38 | else: 39 | return False 40 | 41 | 42 | def meraki_dashboard_support(): 43 | ''' 44 | This function is used to check whether the Meraki dashboard environment variables have been set. It will return true 45 | if they have and false if they have not. These variables are optional, and are used to build better cross-launch 46 | links to the dashboard 47 | 48 | :return: true/false based on whether or not Meraki dashboard support is available 49 | ''' 50 | if meraki_http_password and meraki_http_username: 51 | return True 52 | else: 53 | return False 54 | 55 | 56 | def spark_call_support(): 57 | ''' 58 | This function is used to check whether the Spark Call environment variables have been set. It will return true 59 | if they have and false if they have not 60 | 61 | :return: true/false based on whether or not Spark Call support is available 62 | ''' 63 | if spark_api_token: 64 | return True 65 | else: 66 | return False 67 | 68 | 69 | def umbrella_support(): 70 | ''' 71 | This function is used to check whether the Umbrella (S3) environment variables have been set. It will return true 72 | if they have and false if they have not 73 | 74 | :return: true/false based on whether or not Umbrella support is available 75 | ''' 76 | if s3_bucket and s3_key and s3_secret: 77 | return True 78 | else: 79 | return False 80 | 81 | def a4e_support(): 82 | ''' 83 | This function is used to check whether the Amp for Endpoints environment variables have been set. It will return 84 | true if they have and false if they have not 85 | 86 | :return: true/false based on whether or not A4E support is available 87 | ''' 88 | if a4e_client_id and a4e_client_secret: 89 | return True 90 | else: 91 | return False -------------------------------------------------------------------------------- /cico_meraki.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This module is specifically for Meraki-related operations. This is for the Meraki Dashboard API 3 | ''' 4 | 5 | import requests 6 | # There are some complications with Python 3.6 and various modules. We will need to monkey-patch some stuff to make 7 | # it work. Details were found here: https://github.com/kennethreitz/grequests/issues/103 8 | from gevent import monkey 9 | def stub(*args, **kwargs): # pylint: disable=unused-argument 10 | pass 11 | monkey.patch_all = stub 12 | import grequests 13 | import os 14 | import json 15 | from requests.adapters import HTTPAdapter 16 | from urllib3.util.retry import Retry 17 | 18 | # ======================================================== 19 | # Load required parameters from environment variables 20 | # ======================================================== 21 | 22 | meraki_client_to = os.getenv("MERAKI_CLIENT_TIMESPAN") 23 | if not meraki_client_to: 24 | meraki_client_to = "86400" 25 | meraki_api_token = os.getenv("MERAKI_API_TOKEN") 26 | meraki_over_dash = os.getenv("MERAKI_OVERRIDE_DASHBOARD") 27 | #meraki_dashboard_map = os.getenv("MERAKI_DASHBOARD_MAP") -- removed: enabled link generation at run-time -- 28 | header = {"X-Cisco-Meraki-API-Key": meraki_api_token} 29 | 30 | # ======================================================== 31 | # Initialize Program - Function Definitions 32 | # ======================================================== 33 | 34 | 35 | def get_meraki_orgs(): 36 | ''' 37 | Get a list of all organizations the user has access to 38 | 39 | :return: a list of dictionaries with all organizations 40 | ''' 41 | url = "https://dashboard.meraki.com/api/v0/organizations" 42 | netlist = requests.get(url, headers=header) 43 | netstr = netlist.content.decode("utf-8") 44 | if netstr.strip() != "": 45 | orgjson = json.loads(netstr) 46 | else: 47 | orgjson = {} 48 | return orgjson 49 | 50 | 51 | def get_meraki_one_org(): 52 | ''' 53 | Pick specific organization id if one is not provided. If only one organization is associated with the API key, it 54 | will be selected. If multiple organizations are present, alphabetically the first organization will be returned. 55 | 56 | :return: a string with the organization id, or 'none' if there was a problem 57 | ''' 58 | olist = get_meraki_orgs() 59 | newodict = {} 60 | newolist = [] 61 | # If there are 1 or more organizations are present, then parse the list to determine what to return 62 | if len(olist) >= 1: 63 | # Iterate the list of dictionaries 64 | for ol in range(0, len(olist)): 65 | # Build a dictionary that we can use to cross-reference back to the list of dictionaries. We need this for 66 | # accurate sorting 67 | newodict[olist[ol]["name"]] = ol 68 | print(olist[ol]["name"], olist[ol]["id"]) 69 | # Now, we sort the dictionary (case-insensitive) 70 | newolist = sorted(newodict, key=str.lower) 71 | # And we return a string of the ID for the first organization in the list 72 | thisorg = str(olist[newodict[newolist[0]]]["id"]) 73 | print("Selecting alphabetically first organization (" + newolist[0] + "), id #", thisorg) 74 | return thisorg 75 | else: 76 | # If no organizations were present, return 'none' 77 | print("Unable to select Organization") 78 | return "none" 79 | 80 | 81 | # ======================================================== 82 | # Delayed parameter load for the organization 83 | # ======================================================== 84 | meraki_org = os.getenv("MERAKI_ORG") 85 | if not meraki_org: 86 | meraki_org = get_meraki_one_org() 87 | # ======================================================== 88 | 89 | 90 | def get_meraki_networks(): 91 | ''' 92 | Get a list of all networks associated with the specified organization. Watch for 200 OK, because an organization 93 | can have 0 networks, which will generate a 404 instead. 94 | 95 | :return: a dictionary with all networks that are part of the specified/derived organization 96 | ''' 97 | url = "https://dashboard.meraki.com/api/v0/organizations/" + meraki_org + "/networks" 98 | netlist = requests.get(url, headers=header) 99 | if netlist.status_code == 200: 100 | netjson = json.loads(netlist.content.decode("utf-8")) 101 | else: 102 | netjson = {} 103 | print("Error retrieving Meraki networks:", netlist.status_code) 104 | return netjson 105 | 106 | 107 | def get_org_devices(netinfo): 108 | ''' 109 | Get a list of all devices in a given organization 110 | 111 | :return: a list with all devices that are part of the specified/derived organization 112 | ''' 113 | url = "https://dashboard.meraki.com/api/v0/organizations/" + meraki_org + "/devices" 114 | netlist = requests.get(url, headers=header) 115 | if netlist.status_code == 200: 116 | netjson = json.loads(netlist.content.decode("utf-8")) 117 | else: 118 | netjson = {} 119 | print("Error retrieving Meraki devices:", netlist.status_code) 120 | return netjson 121 | 122 | 123 | def get_org_device_statuses(netinfo): 124 | ''' 125 | Get a list of all devices/statuses in a given organization 126 | 127 | :return: a list with all devices that are part of the specified/derived organization 128 | ''' 129 | dev_list = get_org_devices(netinfo) 130 | out_netjson = {} 131 | url = "https://dashboard.meraki.com/api/v0/organizations/" + meraki_org + "/deviceStatuses" 132 | netlist = requests.get(url, headers=header) 133 | if netlist.status_code == 200: 134 | netjson = json.loads(netlist.content.decode("utf-8")) 135 | for n in netjson: 136 | dev_info = {} 137 | for d in dev_list: 138 | if d["serial"] == n["serial"]: 139 | dev_info = d 140 | break 141 | n_info = n 142 | n_info["info"] = dev_info 143 | if n["networkId"] in out_netjson: 144 | out_netjson[n["networkId"]]["devices"][n["serial"]] = n_info 145 | else: 146 | out_netjson[n["networkId"]] = {"devices": {n["serial"]: n_info}} 147 | 148 | for n in out_netjson: 149 | for m in netinfo: 150 | if m["id"] == n: 151 | out_netjson[n]["info"] = m 152 | else: 153 | print("Error retrieving Meraki devices:", netlist.status_code) 154 | return out_netjson 155 | 156 | 157 | def meraki_create_dashboard_link(linktype, linkname, displayval, urlappend, linknameid): 158 | ''' 159 | This function is used to create the dashboard cross-launch links for clients, networks and devices. For devices, 160 | it can also create a generic cross-launch link if the dashboard username/password were not provided, but that is 161 | not nearly as reliable. 162 | 163 | :param linktype: String. 'devices' or 'networks' 164 | :param linkname: String. for a device, this is the mac address. for a network, this is the name of the network 165 | :param displayval: String. the displayed portion of the hyerlink. 166 | :param urlappend: String. Anything that needs to be added to the end of the URL 167 | :param linknameid: Integer. 0 = Network / Device base link only. 1 = Device link including Port data (used when 168 | forming generic links; there is no generic link to the port level) 169 | :return: String. A hyperlink () linking to the dashboard if possible 170 | ''' 171 | 172 | shownet = displayval 173 | 174 | # Only run if we were able to get a dashboard map using the username password of the user 175 | if meraki_dashboard_map: 176 | # Used to work differently... just remapping the variable now. 177 | mapjson = meraki_dashboard_map #json.loads(meraki_dashboard_map.replace("'", '"')) 178 | # If 'devices' or 'networks' is present in the map (it should be) 179 | if linktype in mapjson: 180 | # If the given mac address or network name is present in the map (it should be) 181 | if linkname in mapjson[linktype]: 182 | # Create the hyperlink 183 | if not displayval: 184 | displayval = linkname 185 | shownet = "" + displayval + "" 186 | 187 | # If shownet is the same as displayval, it means there was a problem above. Try to add generic link... if this is 188 | # a device, and doesn't include port-level detail 189 | if shownet == displayval and linktype == "devices" and linknameid == 0: 190 | # Create the generic hyperlink 191 | shownet = "" + displayval + "" 192 | 193 | return shownet 194 | 195 | 196 | def meraki_dashboard_client_mod(netlink, cliid, clidesc): 197 | ''' 198 | This function is used as an extension of the meraki_create_dashboard_link function, and used to generate the 199 | client-specific cross-launch links. It can also create a generic cross-launch link if the dashboard 200 | username/password were not provided, but that is not nearly as reliable. 201 | 202 | :param netlink: String. The hyperlink from a 'network' call to the meraki_create_dashboard_link function. 203 | :param cliid: String. The unique ID of the client. 204 | :param clidesc: String. The name of the client. This is what gets displayed for the hyperlink. 205 | :return: 206 | ''' 207 | 208 | showcli = clidesc 209 | 210 | # If the meraki_create_dashboard_link function generated a hyperlink, and it was passed to this function, then 211 | # we will attempt to modify 212 | if netlink: 213 | # We are searching the string for /manage, and will strip everything after that and reconstruct 214 | if netlink.find("/manage") >= 0: 215 | # Create the hyperlink 216 | showcli = netlink.split("/manage")[0] + "/manage/usage/list#c=" + cliid + "'>" + clidesc + "" 217 | else: 218 | # Create the generic hyperlink 219 | showcli = "" + clidesc + "" 220 | 221 | return showcli 222 | 223 | 224 | def collect_url_list(jsondata, baseurl, attr1, attr2, battr1, battr2): 225 | ''' 226 | Iterates the jsondata list/dictionary and pulls out attributes to generate a list of URLs 227 | 228 | :param jsondata: list of dictionaries or dictionary of lists 229 | :param baseurl: String. base url to use. place a $1 to show where to substitute 230 | :param attr1: String. when using a list of dictionaries, this is the key that will be retrieved from each dict in 231 | the list when using a dictionary of lists, this is the key where all of the lists will be found 232 | :param attr2: String. (optional) pass "" to disable 233 | when using a dictionary of lists, this is the key that will be retrieved from each dict in each list 234 | 235 | These are both optional, and used if a second substitution is needed ($2) 236 | :param battr1: String. (optional) when using a list of dictionaries, this is the key that will be retrieved from 237 | each dict in the list when using a dictionary of lists, this is the key where all of the lists will 238 | be found 239 | :param battr2: String. (optional) pass "" to disable 240 | when using a dictionary of lists, this is the key that will be retrieved from each dict in each list 241 | :return: List. A list of all URLs derived from the base URL and the data source. 242 | 243 | urlnet = collect_url_list(netjson, "https://dashboard.meraki.com/api/v0/networks/$1/devices", "id", "", "", "") 244 | urlnetup = collect_url_list(netlist, "https://dashboard.meraki.com/api/v0/networks/$1/devices/$2/uplink", "info", "id", "devices", "serial") 245 | urlnet = collect_url_list(netjson, "https://dashboard.meraki.com/api/v0/networks/$1/devices", "id", "", "", "") 246 | smnet = collect_url_list(netjson, "https://dashboard.meraki.com/api/v0/networks/$1/sm/devices/", "id", "", "", "") 247 | urldev = collect_url_list(netlist, "https://dashboard.meraki.com/api/v0/devices/$1/clients?timespan=86400", "devices", "serial", "", "") 248 | ''' 249 | 250 | urllist = [] 251 | sub1 = "" 252 | # Iterate the data source 253 | for jsonitem in jsondata: 254 | # If attr2 is blank, we should have a list of dictionaries. We will search the current dictionary in the list 255 | # being iterated to see if it matches what was supplied in attr1 256 | if attr2 == "": 257 | if attr1 in jsonitem: 258 | # Found a match, add the URL to the list 259 | urllist.append(baseurl.replace("$1", jsonitem[attr1])) 260 | # If attr2 is present, we should have a dictionary of lists. We will see if the current dictionary entry has 261 | # a value for the key specified by attr1. 262 | else: 263 | if attr1 in jsondata[jsonitem]: 264 | # The key is present. The values should be in a list, so we will iterate them now. 265 | for jsonitem2 in jsondata[jsonitem][attr1]: 266 | # Check to see if the entry in the iterated list is a string or not. 267 | if isinstance(jsonitem2, str): 268 | # If it's a string, we will check to see if it matches what was specified by attr2. 269 | if jsonitem2 == attr2: 270 | # If battr1 has been specified, it means a second substitution will be needed. We will 271 | # handle that later 272 | if battr1 == "": 273 | # Found a match, add the URL to the list 274 | urllist.append(baseurl.replace("$1", jsondata[jsonitem][attr1][jsonitem2])) 275 | else: 276 | sub1 = jsondata[jsonitem][attr1][jsonitem2] 277 | else: 278 | # If it's not a string, it should be a dictionary. We will pull the value found in the key 279 | # specified in attr2. 280 | 281 | # If battr1 has been specified, it means a second substitution will be needed. We will handle 282 | # that later 283 | if battr1 == "": 284 | # Found a match, add the URL to the list 285 | urllist.append(baseurl.replace("$1", jsonitem2[attr2])) 286 | else: 287 | sub1 = jsonitem2[attr2] 288 | 289 | # We need a second substitution. Check our currently iterated dictionary entry to find the value specified 290 | # with battr1. 291 | if battr1 in jsondata[jsonitem]: 292 | # This should be tied to a list of items. Iterate that list 293 | for jsonitem2 in jsondata[jsonitem][battr1]: 294 | # Check to see if the currently iterated list item is a String or not 295 | if isinstance(jsonitem2, str): 296 | # If it's a string, we want to see whether the iterated list item matches what was searched 297 | # for with battr2 298 | if jsonitem2 == battr2: 299 | # Found a match, add the URL to the list 300 | urllist.append(baseurl.replace("$1", sub1).replace("$2", jsondata[jsonitem][battr1][jsonitem2])) 301 | else: 302 | # If it's not a string, it should be a dictionary, so we want to retrieve the value found in 303 | # the key specified by battr2 and use the value to substitute 304 | 305 | # Found a match, add the URL to the list 306 | urllist.append(baseurl.replace("$1", sub1).replace("$2", jsonitem2[battr2])) 307 | return urllist 308 | 309 | 310 | def do_multi_get(url_list, comp_list, comp_id1, comp_id2, comp_url_idx, comp_key, content_key): 311 | ''' 312 | Issues multiple GET requests to a list of URLs. Also will join dictionaries together based on returned content. 313 | 314 | :param url_list: List. list of URLs to issue GET requests to 315 | :param comp_list: List. (optional) pass [] to disable 316 | used to join the results of the GET operations to an existing dictionary 317 | :param comp_id1: String. when using a list of dictionaries, this is the key to retrieve from each dict in the list 318 | when using a dictionary of lists, this is the key where all of the lists will be found 319 | :param comp_id2: String. (optional) pass "" to disable 320 | when using a dictionary of lists, this is key that will be retrieved from each dict in each list 321 | :param comp_url_idx: Integer. (optional) pass -1 to disable 322 | when merging dictionaries, they can be merged either on a URL comparision or a matching key. Use 323 | this to specify that they be merged based on this specific index in the URL. So to match 324 | 'b' in http://a.com/b, you would specify 3 here, as that is the 3rd // section in the URL 325 | :param comp_key: String. (optional) pass "" to disable 326 | when merging dictionaries, they can be merged either on a URL comparision or a matching key. Use 327 | this to specify that they be merged based on this key found in the content coming back from the 328 | GET requests 329 | :param content_key: String. (optional when not merging, required when merging) pass "" to disable 330 | this is the base key added to the merged dictionary for the merged data 331 | :return: 332 | ''' 333 | 334 | # Create a session that can automatically retry when an error condition is encountered. 335 | s = requests.Session() 336 | retries = Retry(total=5, backoff_factor=0.2, status_forcelist=[403, 500, 502, 503, 504], raise_on_redirect=True, 337 | raise_on_status=True) 338 | s.mount('http://', HTTPAdapter(max_retries=retries)) 339 | s.mount('https://', HTTPAdapter(max_retries=retries)) 340 | 341 | # Execute all GET requests 342 | rs = (grequests.get(u, headers=header, session=s) for u in url_list) 343 | 344 | content_dict = {} 345 | # Parse request responses 346 | for itemlist in grequests.imap(rs, stream=False): 347 | # Pull out the content and convert into JSON 348 | icontent = itemlist.content.decode("utf-8") 349 | inlist = json.loads(icontent) 350 | # Only proceed if data is present 351 | if len(inlist) > 0: 352 | # Use the URL index if it was specified, otherwise use the comparision key 353 | if comp_url_idx >= 0: 354 | urllist = itemlist.url.split("/") 355 | matchval = urllist[comp_url_idx] 356 | else: 357 | matchval = inlist[0][comp_key] 358 | 359 | # Check to see if a comparision list was provided 360 | if len(comp_list) > 0: 361 | # comp_list was passed, iterate and merge dictionaries 362 | for net in comp_list: 363 | if comp_id2 == "": 364 | # This is a list of dictionaries. if this matches the search, add it to the content dict 365 | if matchval == net[comp_id1]: 366 | kid1 = net["id"] 367 | 368 | if kid1 not in content_dict: 369 | content_dict[kid1] = {} 370 | content_dict[kid1]["info"] = net 371 | content_dict[kid1][content_key] = inlist 372 | break 373 | else: 374 | # This is a dictionary of lists. if the match is present in this dictionary, continue parsing 375 | if matchval in json.dumps(comp_list[net][comp_id1]): 376 | kid1 = comp_list[net]["info"]["id"] 377 | 378 | for net2 in comp_list[net][comp_id1]: 379 | kid2 = net2["serial"] 380 | 381 | if comp_id2 in net2: 382 | if matchval == net2[comp_id2]: 383 | if kid1 not in content_dict: 384 | content_dict[kid1] = {} 385 | if comp_id1 not in content_dict[kid1]: 386 | content_dict[kid1][comp_id1] = {} 387 | if kid2 not in content_dict[kid1][comp_id1]: 388 | content_dict[kid1][comp_id1][kid2] = {} 389 | 390 | content_dict[kid1]["info"] = comp_list[net] 391 | content_dict[kid1][comp_id1][kid2]["info"] = net2 392 | content_dict[kid1][comp_id1][kid2][content_key] = inlist 393 | break 394 | else: 395 | # No comp_list was passed. 396 | if matchval not in content_dict: 397 | content_dict[matchval] = {} 398 | if content_key != "": 399 | if content_key not in content_dict[matchval]: 400 | content_dict[matchval][content_key] = {} 401 | content_dict[matchval][content_key] = inlist 402 | else: 403 | content_dict[matchval] = inlist 404 | 405 | return content_dict 406 | 407 | 408 | def decode_model(strmodel): 409 | ''' 410 | Decodes the Meraki model number into it's general type. 411 | 412 | :param strmodel: String. Model number of the product. 413 | :return: String. Return the Meraki device type based on the model. 414 | ''' 415 | outmodel = "" 416 | if "MX" in strmodel: 417 | outmodel = "appliance" 418 | if "MS" in strmodel: 419 | outmodel = "switch" 420 | if "MR" in strmodel: 421 | outmodel = "wireless" 422 | if "MV" in strmodel: 423 | outmodel = "camera" 424 | if "MC" in strmodel: 425 | outmodel = "phone" 426 | 427 | if outmodel == "": 428 | outmodel = strmodel[0:2] 429 | 430 | return outmodel 431 | 432 | 433 | def do_sort_smclients(in_smlist): 434 | ''' 435 | Rearranges the SM Dictionary to group clients by MAC address rather than a single list 436 | 437 | :param in_smlist: List. List of non-organized SM clients 438 | :return: Dictionary. Dictionary of SM clients organized by MAC address 439 | ''' 440 | out_smlist = {} 441 | 442 | # Iterate list of networks 443 | for net in in_smlist: 444 | # If there are devices in the network, iterate the list of networks 445 | if "devices" in in_smlist[net]: 446 | for cli in in_smlist[net]["devices"]: 447 | # If the network is not already in the return dictionary, add it now 448 | if net not in out_smlist: 449 | out_smlist[net] = {"devices": {}} 450 | # Add this client to a dictionary key cooresponding to the mac address of the client 451 | out_smlist[net]["devices"][cli["wifiMac"]] = cli 452 | 453 | return out_smlist 454 | 455 | 456 | def do_split_networks(in_netlist): 457 | ''' 458 | Splits out combined Meraki networks into individual device networks. The API will only provide combined networks. 459 | In order to build Dashboard cross-launch links, we will need to carve these combined networks up into the 460 | cooresponding individual networks. 461 | 462 | :param in_netlist: Dictionary. Dict of all networks for the provided/derived organization. 463 | :return: Dictionary. Updated to break out devices into their individual networks. 464 | ''' 465 | devdict = {} 466 | 467 | # Iterate dictionary of networks 468 | for net in in_netlist: 469 | base_name = in_netlist[net]["info"]["name"] 470 | # Iterate dictionary of devices in the currently iterated network 471 | for devsn in in_netlist[net]["devices"]: 472 | dev = in_netlist[net]["devices"][devsn] 473 | thisstat = {"status": in_netlist[net]["devices"][dev["serial"]]["status"]} 474 | # Don't try to un-combine already non-combined networks... 475 | if in_netlist[net]["info"]["type"] != "combined": 476 | newname = base_name 477 | newdev = {**dev, **thisstat} 478 | else: 479 | # Look up the Model number to determine what the dashboard name will be 480 | thisdevtype = decode_model(dev["info"]["model"]) 481 | # Also, retain uplink data for output dict 482 | # This is the format of the output network. 'Network Name - device type" 483 | newname = base_name + " - " + thisdevtype 484 | newdev = {**dev, **thisstat} 485 | 486 | # Append or create this entry in the output dict 487 | if newname in devdict: 488 | devdict[newname].append(newdev) 489 | else: 490 | devdict[newname] = [newdev] 491 | 492 | return devdict 493 | 494 | 495 | def get_meraki_health(incoming_msg, rettype): 496 | ''' 497 | This function will return health data for the Meraki networks that are part of the provided/derived organization 498 | 499 | :param incoming_msg: String. this is the message that is posted in Spark 500 | :param rettype: String. this is a fully formatted string that will be sent back to Spark 501 | :return: 502 | ''' 503 | 504 | # Get a list of all networks associated with the specified organization 505 | netjson = get_meraki_networks() 506 | 507 | # Get a list of all devices in the organization 508 | statlist = get_org_device_statuses(netjson) 509 | # Split network lists up by device type 510 | newnetlist = do_split_networks(statlist) 511 | 512 | totaldev = 0 513 | offdev = 0 514 | totaloffdev = 0 515 | devicon = "" 516 | retmsg = "

    Meraki Details:

    " 517 | if meraki_over_dash: 518 | retmsg += "Meraki Dashboard