Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DC] Meraki Device Connector #290

Merged
Merged
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
c001f5e
first commit
kuannie1 Aug 23, 2019
1d8a45b
meraki connector skeleton (needs custom updating)
kuannie1 Aug 23, 2019
b0e4801
rebase
alldoami Aug 27, 2019
b30174b
meraki logo
alldoami Aug 26, 2019
24f0664
changed secrets
alldoami Aug 26, 2019
92b01f0
a connector that runs. next: making more tables and double checking v…
kuannie1 Aug 26, 2019
179325b
create two tables, has numeric value empty string is not recognized e…
alldoami Aug 27, 2019
f509053
removed None
alldoami Aug 27, 2019
d777a21
deleted unnecessary functions and included db.py functions
kuannie1 Aug 27, 2019
706624a
debug
alldoami Aug 27, 2019
81f4a11
ingesting meraki client & device info and inserting into landing table
kuannie1 Aug 27, 2019
327ae3f
Update src/connectors/meraki.py
kuannie1 Aug 27, 2019
fd457c3
first commit
kuannie1 Aug 23, 2019
fcc2520
meraki connector skeleton (needs custom updating)
kuannie1 Aug 23, 2019
2a8c475
rebase
alldoami Aug 27, 2019
bb027a5
meraki logo
alldoami Aug 26, 2019
d0eb88a
changed secrets
alldoami Aug 26, 2019
a881d9b
a connector that runs. next: making more tables and double checking v…
kuannie1 Aug 26, 2019
e413652
create two tables, has numeric value empty string is not recognized e…
alldoami Aug 27, 2019
d7bb036
removed None
alldoami Aug 27, 2019
47859d9
deleted unnecessary functions and included db.py functions
kuannie1 Aug 27, 2019
db03993
debug
alldoami Aug 27, 2019
e7487e1
ingesting meraki client & device info and inserting into landing table
kuannie1 Aug 27, 2019
b154502
Update src/connectors/meraki.py
kuannie1 Aug 27, 2019
d39979e
Merge branch 'aku/meraki_connector' of github.com:chanzuckerberg/Snow…
kuannie1 Aug 27, 2019
f78dc8a
made some successful changes but pushing before I break anything else
kuannie1 Aug 27, 2019
4bf02cb
combined table variables
alldoami Aug 27, 2019
a0f99f4
fixed try except network
alldoami Aug 27, 2019
409b2d0
fixed syntax
alldoami Aug 27, 2019
f7d259d
deleting redundant pictures
kuannie1 Aug 27, 2019
801d6cc
Merge branch 'aku/meraki_connector' of github.com:chanzuckerberg/Snow…
kuannie1 Aug 27, 2019
64276bc
remaining changes
kuannie1 Aug 27, 2019
54ef870
changed whitelist input type
alldoami Aug 27, 2019
279338c
inserting clients in batches
kuannie1 Aug 27, 2019
fe2fb8f
batching clients
kuannie1 Aug 27, 2019
e7be0aa
resolved merge conflicts
kuannie1 Aug 28, 2019
f903a47
small changes
kuannie1 Aug 28, 2019
a9f25dd
more small changes
kuannie1 Aug 28, 2019
dc91f32
default value for get
alldoami Aug 30, 2019
eef66e7
fix merge
alldoami Aug 30, 2019
09bffa6
fix get to default to None
alldoami Aug 30, 2019
26d1589
unfinished meraki file
kuannie1 Sep 6, 2019
2ff4139
only 1 input table in ingest()
kuannie1 Sep 6, 2019
6260259
Revert "unfinished meraki file"
kuannie1 Sep 6, 2019
9d8b261
only 1 table in ingest()
kuannie1 Sep 6, 2019
af7c6c4
Update src/connectors/__init__.py
kuannie1 Sep 6, 2019
a386650
Eduardo's suggestions from code review
kuannie1 Sep 6, 2019
c020007
Apply suggestions from code review
kuannie1 Sep 6, 2019
75e301e
renaming meraki to meraki_devices and code review suggestions
kuannie1 Sep 6, 2019
a8f2f38
andrey's feedback
kuannie1 Sep 6, 2019
7ae202a
Delete cisco_umbrella.png
sfc-gh-afedorov Sep 7, 2019
a0fb70c
Merge branch 'v1.8.6' into aku/meraki_connector
sfc-gh-afedorov Sep 7, 2019
c09881b
Update __init__.py
sfc-gh-afedorov Sep 7, 2019
383b997
Update meraki_devices.py
sfc-gh-afedorov Sep 7, 2019
b466ccc
followed pep8 standards
kuannie1 Sep 9, 2019
b147f05
Merge branch 'v1.8.6' into aku/meraki_connector
kuannie1 Sep 9, 2019
158ccd8
meraki works with WebUI
kuannie1 Sep 9, 2019
78acd49
Update meraki_devices.py
sfc-gh-afedorov Sep 10, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/connectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from . import tenable_settings
from . import crowdstrike_devices
from . import cisco_umbrella
from . import meraki_devices

__all__ = [
'aws_inventory',
Expand All @@ -24,6 +25,7 @@
'azure_vm',
'github_webhooks_s3',
'gsuite_logs',
'meraki_devices',
'okta',
'tenable_settings',
'crowdstrike_devices',
Expand All @@ -41,6 +43,7 @@
'azure_vm': azure_vm,
'github_webhooks_s3': github_webhooks_s3,
'gsuite_logs': gsuite_logs,
'meraki': meraki_devices,
'okta': okta,
'tenable_settings': tenable_settings,
'crowdstrike_devices': crowdstrike_devices,
Expand Down
194 changes: 194 additions & 0 deletions src/connectors/meraki_devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Meraki Devices
Collect Meraki Device information using an API Token
"""

from runners.helpers import log
from runners.helpers import db
from runners.helpers.dbconfig import ROLE as SA_ROLE

from datetime import datetime

import snowflake
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove unused import -- pyflakes will help you spot these, as well. let me know if you'd like help setting it up with your text editor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if flake8 works for this purpose?

import requests
from urllib.error import HTTPError
from .utils import yaml_dump



PAGE_SIZE = 5


CONNECTION_OPTIONS = [
{
'name': 'api_token',
'title': "Meraki API Token",
'prompt': "Your Meraki API Token",
'type': 'str',
'secret': True,
'required': True,
},
{
'name': 'network_id_whitelist',
'title': "Meraki Network Ids Whitelist",
'prompt': "Whitelist of Network Ids",
'type': 'list',
'secret': False,
'required': True,
},
]

LANDING_TABLE_COLUMNS_CLIENT = [
('INSERT_ID', 'NUMBER IDENTITY START 1 INCREMENT 1'),
('SNAPSHOT_AT', 'TIMESTAMP_LTZ(9)'),
('RAW', 'VARIANT'),
('ID', 'VARCHAR(256)'),
('MAC', 'VARCHAR(256)'),
('DESCRIPTION', 'VARCHAR(256)'),
('MDNS_NAME', 'VARCHAR(256)'),
('DHCP_HOSTNAME', 'VARCHAR(256)'),
('IP', 'VARCHAR(256)'),
('SWITCHPORT', 'VARCHAR(256)'),
('VLAN', 'INT'),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here and in a couple of other places, there are stray spaces before the end of the line, which violates pep8 standards. could you set your text editor to remove extra spaces at the end of each line on save? to do it in Sublime, you can add this to preferences —

"trim_trailing_white_space_on_save": true,

happy to look up in other editors if you're not sure how

('USAGE_SENT', 'INT'),
('USAGE_RECV', 'INT'),
('SERIAL', 'VARCHAR(256)'),
]

LANDING_TABLE_COLUMNS_DEVICE = [
('INSERT_ID', 'NUMBER IDENTITY START 1 INCREMENT 1'),
('SNAPSHOT_AT', 'TIMESTAMP_LTZ(9)'),
('RAW', 'VARIANT'),
('SERIAL', 'VARCHAR(256)'),
('ADDRESS', 'VARCHAR(256)'),
('NAME', 'VARCHAR(256)'),
('NETWORK_ID', 'VARCHAR(256)'),
('MODEL', 'VARCHAR(256)'),
('MAC', 'VARCHAR(256)'),
('LAN_IP', 'VARCHAR(256)'),
('WAN_1_IP', 'VARCHAR(256)'),
('WAN_2_IP', 'VARCHAR(256)'),
('TAGS', 'VARCHAR(256)'),
('LNG', 'FLOAT'),
('LAT', 'FLOAT'),
]


def get_data(url: str, token: str, params: dict = {}) -> dict:
headers: dict = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Cisco-Meraki-API-Key": f"{token}",
}
try:
log.debug(f"Preparing GET: url={url} with params={params}")
req = requests.get(url, params=params, headers=headers)
req.raise_for_status()
except HTTPError as http_err:
log.error(f"Error GET: url={url}")
log.error(f"HTTP error occurred: {http_err}")
raise
log.debug(req.status_code)
return req.json()


def connect(connection_name, options):
landing_table_client = f'data.meraki_devices_{connection_name}_connection_client'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change the suffix here to _client_connection

landing_table_device = f'data.meraki_devices_{connection_name}_connection_device'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change the suffix here to _device_connection

options['network_id_whitelist'] = options.get('network_id_whitelist', '').split(',')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will return a Union[str, list] when I think you'd be better off with just a list, i.e. put [] in the second param of get.

also, up to you if you're comfortable, but I would have made this a change to connectors_runner.py and added the list type to be parsed there next to json. later on, we can also add a special UI element.

Copy link
Contributor

@kuannie1 kuannie1 Sep 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I have a few questions. This function "returns" a dictionary with a message but it creates a table and grants roles. What are you referring to when you say "This will return a Union[str, list]"? Are you referring to options?

Copy link
Contributor

@kuannie1 kuannie1 Sep 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would adding the list type in connectors_runner.py parse the user input as a list? When we tested this particular input, we got it as a string, like 'id1, id2, id3'.

Copy link
Contributor

@kuannie1 kuannie1 Sep 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this look? @andrey-snowflake
@greg-snowflake #306

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, thanks.

Err, had a bit of a think-o. The "return" type I was thinking of was of the expression

options.get('network_id_whitelist', '').split(',')

but since it splits the empty string, it's always a list.


comment = yaml_dump(module='meraki_devices', **options)

db.create_table(name=landing_table_client,
cols=LANDING_TABLE_COLUMNS_CLIENT, comment=comment)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a newline before "comment" please to adhere to PEP8

db.execute(f'GRANT INSERT, SELECT ON {landing_table_client} TO ROLE {SA_ROLE}')

db.create_table(name=landing_table_device,
cols=LANDING_TABLE_COLUMNS_DEVICE, comment=comment)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto -- it's easier to read if each param gets its own line

db.execute(f'GRANT INSERT, SELECT ON {landing_table_device} TO ROLE {SA_ROLE}')
return {
'newStage': 'finalized',
'newMessage': "Meraki ingestion tables created!",
}


def ingest(table_name, options):
ingest_type = ''
if (table_name.endswith('CONNECTION_CLIENT')) or (table_name.endswith('connection_client')):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what Greg was meaning to explain here earlier is that no table that ends with anything other than "_CONNECTION" will ever be passed into the ingest function. the runner that runs these ingest function is pretty bare-bones, check it out here.

it's not super apparent, but it also guarantees that the connections will be upper case, so you here you can do something like

ingest_type = 'client' if table_name.endswith('_CLIENT_CONNECTION') else 'device'

but you do also need to make sure that the tables are created ending in _CONNECTION above so that the runner knows they are "connection tables", i.e. should be populated by the connector runners.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I know the answer already, but does the table name have to be all-caps? (I'm guessing yes)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends. If you're working with the string in python, then yes; python is case-sensitive and so the strings '_connection' and '_CONNECTION' are not identical.

If you're working with the table in sql, then no; sql is generally case insensitive (more precisely, it will uppercase everything you say unless you explicitly tell it not to) and it will view select * from data.meraki_connection the same as SELECT * FROM DATA.MERAKI_CONNECTION.

Note that you can get around this by putting something in double quotes inside sql; that is,

select * from "DATA"."MERAKI_CONNECTION" is not the same as select * from "data"."meraki_connection"; one of those will look for a table with an uppercase name and one will look for a table with a lowercase name.

Does that answer your question? Happy to clarify if the above is confusing!

Copy link
Contributor

@kuannie1 kuannie1 Sep 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be annoying if I put:
ingest_type = 'client' if (table_name.endswith('_CLIENT_CONNECTION')or table_name.endswith('_client_connection')) else 'device' ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What situation are you trying to protect against? Snowflake defaults to uppercase names, so you have to explicitly specify that you want an object to have a lowercase name by putting it in double-quotes. We don't do that in the connectors - do you have reason to think that somebody might create a connection table manually and explicitly name it "meraki_client_connection", and still want the connector to work with it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh, I think I understand. Thank you for clarifying! I'm not worried about lower-case confusion anymore.

ingest_type = 'client'
else:
ingest_type = 'device'

landing_table = f'data.{table_name}'

timestamp = datetime.utcnow()
api_key = options['api_key']
whitelist = options['network_id_whitelist']

for network in whitelist:

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove extra blank line

try:
devices = get_data(f"https://api.meraki.com/api/v0/networks/{network}/devices", api_key)
except requests.exceptions.HTTPError as e:
log.error(f"{network} not accessible, ")
log.error(e)
continue

if (ingest_type == 'device'):
db.insert(
landing_table,
values=[(
timestamp,
device,
device.get('serial',None),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove all of these ,None, as they're vestigial (None is the default value when key is not present), and per pep8, the comma in param lists needs to be followed by a space or newline.

device.get('address',None),
device.get('name',None),
device.get('networkId',None),
device.get('model',None),
device.get('mac',None),
device.get('lanIp',None),
device.get('wan1Ip',None),
device.get('wan2Ip',None),
device.get('tags',None),
device.get('lng',None),
device.get('lat',None),
) for device in devices],
select=db.derive_insert_select(LANDING_TABLE_COLUMNS_DEVICE),
columns=db.derive_insert_columns(LANDING_TABLE_COLUMNS_DEVICE)
)
log.info(f'Inserted {len(devices)} rows ({landing_table}).')
yield len(devices)

else:

for device in devices:
serial_number = device['serial']

try:
clients = get_data(f"https://api.meraki.com/api/v0/devices/{serial_number}/clients", api_key)
except requests.exceptions.HTTPError as e:
log.error(f"{network} not accessible, ")
log.error(e)
continue

db.insert(
Copy link
Collaborator

@sfc-gh-afedorov sfc-gh-afedorov Sep 7, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry I wasn't more clear on this. could you please replace this db.insert expression with --

            db.insert(
                landing_table,
                values=[(
                    timestamp,
                    client,
                    client.get('id'),
                    client.get('mac'),
                    client.get('description'),
                    client.get('mdnsName'),
                    client.get('dhcpHostname'),
                    client.get('ip'),
                    client.get('switchport'),
                    client.get('vlan') or None,
                    client.get('usage', {}).get('sent') or None,
                    client.get('usage', {}).get('recv') or None,
                    serial_number,
                ) for client in clients],
                select=db.derive_insert_select(LANDING_TABLE_COLUMNS_CLIENT),
                columns=db.derive_insert_columns(LANDING_TABLE_COLUMNS_CLIENT)
            )

I think it will do the same thing but more succinctly.

to clarify x or y will return y if x if "falsy", i.e. if bool(x) is False, and dict's .get returns None as the default value if the key is not present

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, are you sure vlan needs to be treated differently than ip or switchport? this seems off.

Copy link
Contributor

@kuannie1 kuannie1 Sep 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes client.get('vlan') will return '' instead of None, which cannot be inserted into the database since the VLAN column requires a number. I like your suggested change though! I just can't treat vlan the same way as ip because that '' value is invalid in our case.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, interesting. If you're sure that the others never return '' then lgtm. Adding a comment like --

                client.get('vlan') or None,  # vlan sometimes set to ''

might help to future folks, as well

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've never seen that occur with usage:recv or usage:sent, but I think it'll be best if we used the same approach for those instances too. Thank you for this feedback!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgtm

landing_table,
values=[(
timestamp,
client,
client.get('id',None),
client.get('mac',None),
client.get('description',None),
client.get('mdnsName',None),
client.get('dhcpHostname',None),
client.get('ip',None),
client.get('switchport',None),
None if (client.get('vlan', None) == '') else client.get('vlan', None),
None if (client.get('usage', {}).get('sent', None) == '') else client.get('usage', {}).get('sent', None),
None if (client.get('usage', {}).get('recv', None) == '') else client.get('usage', {}).get('recv', None),
serial_number,
) for client in clients],
select=db.derive_insert_select(LANDING_TABLE_COLUMNS_CLIENT),
columns=db.derive_insert_columns(LANDING_TABLE_COLUMNS_CLIENT)
)
log.info(f'Inserted {len(clients)} rows ({landing_table}).')
yield len(clients)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.