umnetdb-utils 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.3
2
+ Name: umnetdb-utils
3
+ Version: 0.1.0
4
+ Summary: Helper classes for querying UMnet databases
5
+ License: MIT
6
+ Author: Amy Liebowitz
7
+ Author-email: amylieb@umich.edu
8
+ Requires-Python: >=3.10
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: oracledb (>=3.1.0,<4.0.0)
16
+ Requires-Dist: psycopg[binary] (>=3.2.9,<4.0.0)
17
+ Requires-Dist: sqlalchemy (>=2.0.41,<3.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # umnetdb-utils
21
+ Helper classes for gathering data from umnet databases
22
+
@@ -0,0 +1,2 @@
1
+ # umnetdb-utils
2
+ Helper classes for gathering data from umnet databases
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "umnetdb-utils"
3
+ version = "0.1.0"
4
+ description = "Helper classes for querying UMnet databases"
5
+ authors = [
6
+ {name = "Amy Liebowitz",email = "amylieb@umich.edu"}
7
+ ]
8
+ license = {text = "MIT"}
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "sqlalchemy (>=2.0.41,<3.0.0)",
13
+ "oracledb (>=3.1.0,<4.0.0)",
14
+ "psycopg[binary] (>=3.2.9,<4.0.0)"
15
+ ]
16
+
17
+ [tool.poetry]
18
+
19
+ [tool.poetry.group.dev.dependencies]
20
+ ruff = "^0.11.10"
21
+
22
+ [build-system]
23
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
24
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,4 @@
1
+ from .umnetequip import UMnetequip
2
+ from .umnetinfo import UMnetinfo
3
+ from .umnetdisco import Umnetdisco
4
+ from .umnetdb import UMnetdb
@@ -0,0 +1,140 @@
1
+
2
+ from os import getenv
3
+ import re
4
+ import logging
5
+
6
+ from sqlalchemy import create_engine, text
7
+ from sqlalchemy.orm import Session
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class UMnetdbBase:
12
+ """
13
+ Base helper class
14
+ """
15
+ # set in child classes - you can use environment variables within curly braces here
16
+ URL = None
17
+
18
+ def __init__(self):
19
+ """
20
+ Initiate a umnetdb object. Note that you must provide either a url or a path
21
+ to an env file that looks like the 'sample_env' provided in this repo.
22
+ If both are provided, the url takes precedence.
23
+ """
24
+ self.url = self._resolve_url()
25
+ self.engine = create_engine(self.url)
26
+ self.session = None
27
+
28
+ def _resolve_url(self) -> str:
29
+ """
30
+ Resolves any reference to environment variables in the url attribute
31
+ and returns the string. The string should use curly braces to indicate an environment
32
+ variable
33
+ """
34
+ url = self.URL
35
+ for m in re.finditer(r"{(\w+)}", url):
36
+ var = m.group(1)
37
+
38
+ if not getenv(var):
39
+ raise ValueError(f"Undefined environment variable {var} in {url}")
40
+ url = re.sub(r"{" + var + "}", getenv(var), url)
41
+
42
+ return url
43
+
44
+ def open(self):
45
+ """
46
+ Create a new session to the database if there isn't one already
47
+ """
48
+ if not self.session:
49
+ self.session = Session(self.engine)
50
+
51
+ def close(self):
52
+ """
53
+ Closes db session if there is one
54
+ """
55
+ if self.session:
56
+ self.session.close()
57
+ self.session = None
58
+
59
+ def __enter__(self):
60
+ self.open()
61
+ return self
62
+
63
+ def __exit__(self, fexc_type, exc_val, exc_tb):
64
+ self.close()
65
+
66
+ def __getattr__(self, val:str):
67
+ if self.session:
68
+ return getattr(self.session, val)
69
+
70
+ raise AttributeError(self)
71
+
72
+ def _build_select(self, select, table, joins=None, where=None, order_by=None, limit=None, group_by=None, distinct=False):
73
+ '''
74
+ Generic 'select' query string builder built from standard query components as input.
75
+ The user is required to generate substrings for the more complex inputs
76
+ (eg joins, where statements), this function just puts all the components
77
+ together in the right order with appropriately-added newlines (for debugging purposes)
78
+ and returns the result.
79
+
80
+ :select: a list of columns to select
81
+ ex: ["nip.mac", "nip.ip", "n.switch", "n.port"]
82
+ :table: a string representing a table (or comma-separated tables, with our without aliases)
83
+ ex: "node_ip nip"
84
+ :joins: a list of strings representing join statements. Include the actual 'join' part!
85
+ ex: ["join node n on nip.mac = n.mac", "join device d on d.ip = n.switch"]
86
+ :where: A list of 'where' statements without the 'where'. The list of statements are
87
+ "anded". If you need "or", embed it in one of your list items
88
+ ex: ["node_ip.ip = '1.2.3.4'", "node.switch = '10.233.0.5'"]
89
+ :order_by: A string representing a column name (or names) to order by
90
+ :group_by: A string representing a column name (or names) to group by
91
+ :limit: An integer
92
+
93
+ '''
94
+
95
+ # First part of the sql statement is the 'select'
96
+ distinct = 'distinct ' if distinct else ''
97
+ sql = f"select {distinct}" + ", ".join(select) + "\n"
98
+
99
+ # Next is the table
100
+ sql += f"from {table}\n"
101
+
102
+ # Now are the joins. The 'join' keyword(s) need to be provided
103
+ # as part of the input, allowing for different join types
104
+ if joins:
105
+ for j in joins:
106
+ sql += f"{j}\n"
107
+
108
+ # Next are the filters. They are 'anded'
109
+ if where:
110
+ sql += "where\n"
111
+ sql += " and\n".join(where) + "\n"
112
+
113
+ # Finally the other options
114
+ if order_by:
115
+ sql += f"order by {order_by}\n"
116
+
117
+ if group_by:
118
+ sql += f"group by {group_by}\n"
119
+
120
+ if limit:
121
+ sql += f"limit {limit}\n"
122
+
123
+ logger.debug(f"Generated SQL command:\n****\n{sql}\n****\n")
124
+
125
+ return sql
126
+
127
+ def _execute(self, sql, rows_as_dict=True):
128
+ '''
129
+ Generic sqlalchemy "execute this sql command and give me all the results"
130
+ '''
131
+ with self as session:
132
+ r = session.execute(text(sql))
133
+ rows = r.fetchall()
134
+
135
+ if rows and rows_as_dict:
136
+ return [r._mapping for r in rows]
137
+ elif rows:
138
+ return rows
139
+ else:
140
+ return []
@@ -0,0 +1,58 @@
1
+
2
+ from typing import List
3
+
4
+ from sqlalchemy import text
5
+ from .base import UMnetdbBase
6
+
7
+
8
+ class UMnetdb(UMnetdbBase):
9
+
10
+ URL="postgresql+psycopg://{UMNETDB_USER}:{UMNETDB_PASSWORD}@wintermute.umnet.umich.edu/umnetdb"
11
+
12
+ def get_device_neighbors(
13
+ self, device: str, known_devices_only: bool = True
14
+ ) -> List[dict]:
15
+ """
16
+ Gets a list of the neighbors of a particular device. If the port
17
+ has a parent in the LAG table that is included as well.
18
+ Neighbor hostname is also looked up in the device table and
19
+ the "source of truth" hostname is returned instead of what shows
20
+ up in lldp neighbor.
21
+
22
+ Setting 'known_devices_only' to true only returns neighbors that are found
23
+ in umnet_db's device table. Setting it to false will return all lldp neighbors.
24
+
25
+ Returns results as a list of dictionary entries keyed on column names.
26
+ """
27
+
28
+ if known_devices_only:
29
+ select = [
30
+ "n.port",
31
+ "n_d.name as remote_device",
32
+ "n.remote_port",
33
+ "l.parent",
34
+ "n_l.parent as remote_parent"
35
+ ]
36
+ else:
37
+ select = [
38
+ "n.port",
39
+ "coalesce(n_d.name, n.remote_device) as remote_device",
40
+ "n.remote_port",
41
+ "l.parent",
42
+ "n_l.parent as remote_parent"
43
+ ]
44
+
45
+ table = "neighbor n"
46
+
47
+ joins = [
48
+ "left outer join device n_d on n_d.hostname=n.remote_device",
49
+ "left outer join lag l on l.device=n.device and l.member=n.port",
50
+ "left outer join lag n_l on n_l.device=n_d.name and n_l.member=n.remote_port",
51
+ ]
52
+
53
+ where = [f"n.device='{device}'"]
54
+
55
+ query = self._build_select(select, table, joins, where)
56
+ result = self.session.execute(text(query))
57
+
58
+ return [dict(zip(result.keys(), r)) for r in result]
@@ -0,0 +1,233 @@
1
+
2
+
3
+ from .base import UMnetdbBase
4
+ from .utils import is_ip_address, is_mac_address
5
+
6
+
7
+ class Umnetdisco(UMnetdbBase):
8
+ URL="postgresql+psycopg://{NETDISCO_DB_USER}:{NETDISCO_DB_PASSWORD}@netdisco.umnet.umich.edu:5432/netdisco"
9
+
10
+ def host_arp(self, host, start_time=None, end_time=None, limit=1, active_only=False):
11
+ '''
12
+ Does an ARP query against the netdisco db for a single host.
13
+ :host: A string representing a MAC address or an IPv4 address
14
+ '''
15
+
16
+ # what table are we querying?
17
+ table = 'node_ip nip'
18
+ joins = ['join node n on nip.mac = n.mac']
19
+
20
+ # define select statements
21
+ select = ['nip.mac', 'nip.ip', 'n.switch', 'n.port', 'nip.time_first', 'nip.time_last']
22
+
23
+ # First determine if this host is a MAC address or an IP
24
+ where = []
25
+ if is_mac_address(host):
26
+ where.append(f"nip.mac ='{host}'")
27
+ elif is_ip_address(host, version=4):
28
+ where.append(f"nip.ip ='{host}'")
29
+ else:
30
+ raise ValueError(f"{host} is not a valid IP or mac address")
31
+
32
+ # filter for specific start/end times if specified
33
+ if start_time and end_time:
34
+ where.append(f"n.time_last between timestamp '{start_time}' and timestamp '{end_time}'")
35
+ elif start_time:
36
+ where.append(f"n.time_last > timestamp '{start_time}'")
37
+
38
+ # filter for active if defined
39
+ if active_only:
40
+ where.append("nip.active = true")
41
+
42
+ # order by last seen
43
+ sql = self._build_select(select, table, joins=joins, where=where, order_by="time_last", limit=limit)
44
+ result = self._execute(sql)
45
+
46
+ return result
47
+
48
+ def arp_count(self, prefix=None, start_time=None, end_time=None, active_only=False):
49
+ '''
50
+ Queries the host data in netdisco based on prefix and start/end time.
51
+ First any prefix greater than or equal to the inputted prefix is searched for
52
+ (in the 'device_ip' table).
53
+
54
+ Then the host table is queried.
55
+ '''
56
+
57
+ # We're counting all the host IPs by subnet
58
+ select = ['count(distinct nip.ip)', 'dip.subnet']
59
+ table = 'node_ip nip'
60
+
61
+ # postgres allows us to do a join based on an IPs (type inet) membership
62
+ # of a subnet (type cidr)
63
+ joins = ['join device_ip dip on nip.ip <<= dip.subnet']
64
+
65
+ # grouping by subnet is what gives us per-subnet host counts
66
+ group_by = 'dip.subnet'
67
+
68
+ # append all cli filtering options
69
+ where = [f"dip.subnet <<= inet '{prefix}'"]
70
+ if start_time and end_time:
71
+ where.append(f"nip.time_last between timestamp '{start_time}' and timestamp '{end_time}'")
72
+ elif start_time:
73
+ where.append(f"nip.time_last > timestamp '{start_time}'")
74
+
75
+ if active_only:
76
+ where.append("nip.active = true")
77
+
78
+ sql = self._build_select(select, table, joins=joins, where=where, group_by=group_by)
79
+ return self._execute(sql)
80
+
81
+ def neighbors(self, device=None):
82
+ '''
83
+ Queries the device_ip table in netdisco to get neighbors of a device.
84
+ If no device is specified, all neighbors are pulled.
85
+ The device input can be an IPv4 address or an FQDN.
86
+ '''
87
+
88
+ select = [ 'dp.ip as local_ip',
89
+ 'ld.dns as local_dns',
90
+ 'dp.port as local_port',
91
+ 'dp.remote_ip',
92
+ 'rd.dns as remote_dns',
93
+ 'dp.remote_port']
94
+ table = 'device d'
95
+ joins = ['join device_port dp on d.ip = dp.ip',
96
+ 'join device ld on dp.ip = ld.ip',
97
+ 'join device rd on dp.remote_ip = rd.ip']
98
+
99
+ where = ['dp.remote_ip is not null']
100
+ if is_ip_address(device):
101
+ where.append(f"d.ip = '{device}'")
102
+ elif device:
103
+ where.append(f"d.dns = '{device}'")
104
+
105
+ sql = self._build_select(select, table, joins, where)
106
+ return self._execute(sql)
107
+
108
+ def get_devices(self, match_subnets=None, exclude_subnets=None):
109
+ '''
110
+ Queries netdisco for a list of devices.
111
+ Optionally, limit it by a list of prefixes
112
+ '''
113
+
114
+ select = [ 'ip', 'dns', 'serial', 'model', 'vendor', 'os' ]
115
+ table = 'device'
116
+
117
+ where = []
118
+ if match_subnets:
119
+ where.append(" or\n".join([f"ip << inet '{s}'" for s in match_subnets]))
120
+
121
+ if exclude_subnets:
122
+ where.append("not\n" + " or\n".join([f"ip << inet '{s}'" for s in exclude_subnets]))
123
+
124
+ sql = self._build_select(select, table, where=where)
125
+ return self._execute(sql)
126
+
127
+ def get_device(self, name_or_ip):
128
+ '''
129
+ Pulls the device name, ip, model, and description (where version info often is)
130
+ from the netinfo device table
131
+ '''
132
+ select = [
133
+ "replace(lower(name), '.umnet.umich.edu', '') as name",
134
+ "device.ip as ip",
135
+ "model",
136
+ "description",
137
+ ]
138
+ table = "device"
139
+
140
+ if is_ip_address(name_or_ip):
141
+ joins = ["join device_ip on device.ip=device_ip.alias"]
142
+ where = [f"device_ip.alias='{name_or_ip}'"]
143
+ else:
144
+ joins = []
145
+ where = [f"lower(name) like '{name_or_ip.lower()}%'"]
146
+
147
+ sql = self._build_select(select, table, where=where, joins=joins, limit=1)
148
+ result = self._execute(sql)
149
+
150
+ if result:
151
+ return result[0]
152
+ else:
153
+ return None
154
+
155
+ def get_port_and_host_data(self, name_or_ip):
156
+ '''
157
+ Pulls a list of all ports on the switch and all hosts seen on those ports.
158
+ You will see multiple rows for an interface where multiple hosts have been seen.
159
+
160
+ Returns a list of ports along with:
161
+ - port name
162
+ - port description
163
+ - admin status
164
+ - oper status
165
+ - description
166
+ - host mac
167
+ - host IP
168
+ - vlan the host was seen on
169
+ - date last mac was seen
170
+
171
+ '''
172
+
173
+ # getting device by name or IP
174
+ if is_ip_address(name_or_ip):
175
+ device_ip = name_or_ip
176
+ else:
177
+ nd_device = self.get_device(name_or_ip)
178
+ if not(nd_device):
179
+ raise LookupError(f'Could not find {nd_device} in Netdisco')
180
+ device_ip = nd_device['ip']
181
+
182
+ # The port description is in different fields depending on the platform, so we need to do
183
+ # some if-else stuff that's not supported in our basic sql builer in the base class.
184
+ # That's why we're just doing a long sting here
185
+ sql = f'''
186
+ select
187
+ dp.port,
188
+ case
189
+ when dp.name=dp.port then dp.descr
190
+ else dp.name
191
+ end
192
+ as description,
193
+ REPLACE(dp.up,'lowerLayerDown','down') as oper_status,
194
+ dp.up_admin as admin_status,
195
+ dp.speed,
196
+ node.vlan,
197
+ dp.remote_ip,
198
+ node.mac,
199
+ node.time_last,
200
+ node_ip.ip
201
+ from device_port dp
202
+ left outer join node on dp.port = node.port and dp.ip = node.switch
203
+ left outer join node_ip on node.mac = node_ip.mac
204
+ where dp.ip='{device_ip}'
205
+ '''
206
+
207
+ result = self._execute(sql)
208
+
209
+ return result
210
+
211
+ def get_poe_data(self, name_or_ip):
212
+ """
213
+ Pulls PoE admin, status, class, and power columns from device_port_pwer
214
+ """
215
+
216
+ # getting device by name or IP
217
+ if is_ip_address(name_or_ip):
218
+ device_ip = name_or_ip
219
+ else:
220
+ nd_device = self.get_device(name_or_ip)
221
+ if not(nd_device):
222
+ raise LookupError(f'Could not find {nd_device} in Netdisco')
223
+ device_ip = nd_device['ip']
224
+
225
+ select = ["port", "admin", "status", "class", "power"]
226
+ table = "device_port_power"
227
+ where = [f"ip='{device_ip}'"]
228
+
229
+
230
+ sql = self._build_select(select, table, where=where)
231
+ result = self._execute(sql)
232
+
233
+ return result
@@ -0,0 +1,157 @@
1
+ from typing import Union, Optional
2
+
3
+ from .base import UMnetdbBase
4
+ from .utils import is_ip_address
5
+
6
+ class UMnetequip(UMnetdbBase):
7
+ '''
8
+ This class wraps helpful equip db queries in python.
9
+ '''
10
+ URL = "oracle+oracledb://{EQUIP_DB_USERNAME}:{EQUIP_DB_PASSWORD}@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=equipdb-prod.umnet.umich.edu)(PORT=1521))(CONNECT_DATA=(SID=KANNADA)))"
11
+
12
+ def get_devices_by_category(self, category, active_only=False):
13
+ '''
14
+ Queries equip db for devices by category. You can also
15
+ specify if you only want active devices.
16
+ '''
17
+
18
+ select = [ 'eq.monitored_device',
19
+ 'eq.rancid',
20
+ 'eq.off_line',
21
+ 'eq.dns_name',
22
+ 'eq.ip_address',
23
+ ]
24
+ table = 'ARADMIN1.UMNET_EQUIPMENT eq'
25
+
26
+ where = [f"eq.category = '{category}'"]
27
+
28
+ # Equipshim status numbers (see ARADMIN1.UMNET_EQUIPMENT_STATUS)
29
+ # 1: RESERVE, 2:ACTIVE, 3:RETIRED
30
+ if active_only:
31
+ where.append("eq.status = 2")
32
+
33
+ sql = self._build_select(select, table, where=where, distinct=True)
34
+ return self._execute(sql)
35
+
36
+
37
+ def get_device_type(self, ip):
38
+ '''
39
+ Queries equip db for a device by ip, returns the 'type' of the device.
40
+ By type we mean the UMNET_EQUIPMENT_TYPE: ACCESS LAYER, DISTRIBUTION LAYER, UMD, etc
41
+ '''
42
+ select = [ 'types as type' ]
43
+ table = 'ARADMIN1.UMNET_EQUIPMENT'
44
+ where = [f"ip_address='{ip}'"]
45
+
46
+ sql = self._build_select(select, table, where=where)
47
+ return self._execute(sql)
48
+
49
+ def get_devices_by_building_no(self,
50
+ building_no:Union[int, list],
51
+
52
+
53
+ active_only:bool=False,
54
+ types:Optional[list]=None,
55
+ location_info:bool=False,
56
+ ):
57
+ """
58
+ Queries equipdb for devices by building no. You can provide a single
59
+ building number, or a list of numbers. You can specify if you want 'active only' devices
60
+ (based on the 'status' field, defalut false) or you can limit to a certain device type (default all).
61
+ You can also get the location info for each device via 'location_info' (default false)
62
+ """
63
+ select = [ 'dns_name',
64
+ 'ip_address',
65
+ 'model_no_ as model',
66
+ 'types as device_type',
67
+ 'rancid',
68
+ 'off_line',
69
+ ]
70
+ if location_info:
71
+ select.extend([
72
+ 'bldg as building_name',
73
+ 'address as building_address',
74
+ 'room as room_no',
75
+ 'floor as floor',
76
+ 'bldg_code__ as building_no',
77
+ ])
78
+
79
+ table = 'ARADMIN1.UMNET_EQUIPMENT eq'
80
+
81
+ where = []
82
+ if isinstance(building_no, int):
83
+ where.append(f"eq.bldg_code__ = {building_no}")
84
+ elif isinstance(building_no, list):
85
+ bldgs_str = ",".join([str(bldg) for bldg in building_no])
86
+ where.append(f"eq.bldg_code__ in ({bldgs_str})")
87
+ else:
88
+ raise ValueError(f"{building_no} must be int or list!")
89
+
90
+ # Equipshim status numbers (see ARADMIN1.UMNET_EQUIPMENT_STATUS)
91
+ # 1: RESERVE, 2:ACTIVE, 3:RETIRED
92
+ if active_only:
93
+ where.append("eq.status = 2")
94
+
95
+ if types:
96
+ types_str = ",".join([f"'{t}'" for t in types])
97
+ where.append(f"eq.types in ({types_str})")
98
+
99
+ sql = self._build_select(select, table, where=where)
100
+ return self._execute(sql)
101
+
102
+ def get_device(self, name_or_ip:str, location_info:bool=False):
103
+
104
+ if is_ip_address(name_or_ip):
105
+ where = [f"ip_address='{name_or_ip}'"]
106
+ else:
107
+ name_or_ip = name_or_ip.replace(".umnet.umich.edu","")
108
+ where = [f"dns_name='{name_or_ip}'"]
109
+
110
+ select = [ 'dns_name',
111
+ 'ip_address',
112
+ 'model_no_ as model',
113
+ 'types as device_type',
114
+ 'rancid',
115
+ 'off_line',
116
+ ]
117
+ if location_info:
118
+ select.extend([
119
+ 'bldg as building_name',
120
+ 'address as building_address',
121
+ 'room as room_no',
122
+ 'floor as floor',
123
+ 'bldg_code__ as building_no',
124
+ ])
125
+ table = 'ARADMIN1.UMNET_EQUIPMENT eq'
126
+
127
+ sql = self._build_select(select, table, where=where)
128
+ return self._execute(sql)
129
+
130
+ def get_all_devices(self):
131
+ select = [ "bldg_code__ as bldg_code",
132
+ "bldg",
133
+ "room",
134
+ "ip_address",
135
+ "dns_name",
136
+ "category",
137
+ "types",
138
+ "manufacturer",
139
+ "serial_number",
140
+ "billing_code_ as billing_code",
141
+ "warehouse_item__ as warehouse_item",
142
+ "st.descr as status",
143
+ "sla_network",
144
+ "address",
145
+ "floor",
146
+ "model_no_ as model_no",
147
+ "customer_name",
148
+ "mat_l_item_description",
149
+ "rancid",
150
+ "off_line",
151
+ ]
152
+ table = 'ARADMIN1.UMNET_EQUIPMENT eq'
153
+ joins = ["join ARADMIN1.UMNET_EQUIPMENT_STATUS st on st.idnum=eq.status"]
154
+ where = ["eq.status != 3"]
155
+
156
+ sql = self._build_select(select, table,where=where, joins=joins)
157
+ return self._execute(sql)
@@ -0,0 +1,489 @@
1
+
2
+
3
+ import ipaddress
4
+ import re
5
+ from .base import UMnetdbBase
6
+ from .utils import is_ip_address, is_ip_network, is_mac_address
7
+
8
+ class UMnetinfo(UMnetdbBase):
9
+ """
10
+ Wraps helpful netinfo db queries into python
11
+ """
12
+ URL = "oracle+oracledb://{NETINFO_USERNAME}:{NETINFO_PASSWORD}@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=kannada.web.itd.umich.edu)(PORT=1521))(CONNECT_DATA=(SID=KANNADA)))"
13
+
14
+ def get_network_by_name(self, netname, active_only=False):
15
+ """
16
+ Looks up a network by netname.
17
+ """
18
+ select = [
19
+ "p.itemname as netname",
20
+ "i.itemname as ipv4_subnet",
21
+ "six_i.itemname as ipv6_subnet",
22
+ "NVL(n.vlanid,n.local_vlanid) as vlan_id",
23
+ "s.statusdes as status",
24
+ ]
25
+ table = "UMNET_ONLINE.ITEM p"
26
+ joins = [
27
+ "join UMNET_ONLINE.NETWORK n on p.itemidnum = n.itemidnum",
28
+ "join UMNET_ONLINE.STATUS_CODE s on p.statuscd = s.statuscd",
29
+ "left outer join UMNET_ONLINE.ITEM i on i.parentitemidnum = p.itemidnum",
30
+ "left outer join UMNET_ONLINE.IP_SUBNET ip on i.itemidnum = ip.itemidnum",
31
+ "left outer join UMNET_ONLINE.RELATED_ITEM r on p.itemidnum = r.relitemidnum",
32
+ "left outer join UMNET_ONLINE.IP6NET six on r.itemidnum = six.itemidnum",
33
+ "left outer join UMNET_ONLINE.ITEM six_i on six.itemidnum = six_i.itemidnum",
34
+ ]
35
+ where = [
36
+ f"p.itemname = '{netname}'",
37
+ ]
38
+
39
+ sql = self._build_select(select, table, joins=joins, where=where)
40
+ results = self._execute(sql)
41
+
42
+ # there are also IPv4 subnets that are "pending removal" or "pending activation"
43
+ # tied to the network in the RELATED_ITEM table, we need to find those too.
44
+ # doing it as a separate query to maintain existing results row structure
45
+ select = [
46
+ "p.itemname as netname",
47
+ "r_i.itemname as ipv4_subnet",
48
+ "'' as ipv6_subnet",
49
+ "NVL(n.vlanid,n.local_vlanid) as vlan_id",
50
+ "r.itemreltypcd as status",
51
+ ]
52
+ joins = [
53
+ "join UMNET_ONLINE.NETWORK n on p.itemidnum = n.itemidnum",
54
+ "left outer join UMNET_ONLINE.ITEM i on i.parentitemidnum = p.itemidnum",
55
+ "left outer join UMNET_ONLINE.RELATED_ITEM r on p.itemidnum = r.relitemidnum",
56
+ "left outer join UMNET_ONLINE.ITEM r_i on r.itemidnum = r_i.itemidnum",
57
+ ]
58
+ where = [
59
+ f"p.itemname = '{netname}'",
60
+ "r_i.itemcatcd = 'IP'",
61
+ ]
62
+ # if we're doing active only, we only want "pending removal"
63
+ if active_only:
64
+ where.append("r.itemreltypcd = 'IP-PRNET'")
65
+
66
+ sql = self._build_select(select, table, joins=joins, where=where)
67
+ more_results = self._execute(sql)
68
+
69
+ if more_results:
70
+ results.extend(more_results)
71
+
72
+ return results
73
+
74
+ def get_network_by_ip(self, ip, active_only=False):
75
+ """
76
+ Looks up a network based on an IPv4 or IPv6 address. Returns the netname,
77
+ vlan id, as well as *all active subnets* (IPv4 and IPv6) tied to the netname.
78
+ """
79
+
80
+ ip = ipaddress.ip_address(ip)
81
+
82
+ # to make our lives simpler we're breaking this up into two steps.
83
+ # first let's find the network entry in the 'item' table
84
+ if ip.version == 4:
85
+ select = ["NVL(p.itemidnum, r.relitemidnum) as id"]
86
+ table = "UMNET_ONLINE.IP_SUBNET ip"
87
+ joins = [
88
+ "join UMNET_ONLINE.ITEM i on ip.itemidnum = i.itemidnum",
89
+ "left outer join UMNET_ONLINE.RELATED_ITEM r on r.itemidnum = ip.itemidnum",
90
+ "left outer join UMNET_ONLINE.ITEM p on i.parentitemidnum = p.itemidnum",
91
+ ]
92
+ where = [
93
+ f"{int(ip)} >= ip.ADDRESS32BIT",
94
+ f"{int(ip)} <= ip.ENDADDRESS32BIT",
95
+ "NVL(p.itemidnum, r.relitemidnum) is not null",
96
+ ]
97
+
98
+ # IPv6 table is related to the 'item' table via the RELATED_ITEM table
99
+ elif ip.version == 6:
100
+ select = ["p.itemidnum as id"]
101
+ table = "UMNET_ONLINE.IP6NET ip"
102
+ joins = [
103
+ "join UMNET_ONLINE.RELATED_iTEM r on r.itemidnum = ip.itemidnum",
104
+ "join UMNET_ONLINE.ITEM p on p.itemidnum = r.relitemidnum",
105
+ ]
106
+ # and the start/end addresses are stored as hex strings
107
+ addr_str = ip.exploded.replace(":", "")
108
+ where = [
109
+ f"'{addr_str}' >= ip.ADDRESS128BIT",
110
+ f"'{addr_str}' <= ip.ENDADDRESS128BIT",
111
+ ]
112
+
113
+ sql = self._build_select(select, table, joins=joins, where=where)
114
+ network = self._execute(sql)
115
+
116
+ if not (network):
117
+ return False
118
+ net_id = network[0]["id"]
119
+
120
+ # Now let's use the network itemidnum to find all associated subnets
121
+ # with that network, as well as the netname and VLAN ID
122
+ select = [
123
+ "p.itemname as netname",
124
+ "i.itemname as ipv4_subnet",
125
+ "six_i.itemname as ipv6_subnet",
126
+ "NVL(n.vlanid,n.local_vlanid) AS vlan_id",
127
+ "i.itemcatcd as ITEMCAT",
128
+ "p.itemcatcd as PCAT",
129
+ "r.itemreltypcd as RELCAT",
130
+ ]
131
+ table = "UMNET_ONLINE.ITEM p"
132
+ joins = [
133
+ "join UMNET_ONLINE.NETWORK n on p.itemidnum = n.itemidnum",
134
+ "join UMNET_ONLINE.ITEM i on i.parentitemidnum = p.itemidnum",
135
+ "left outer join UMNET_ONLINE.RELATED_ITEM r on p.itemidnum = r.relitemidnum",
136
+ "left outer join UMNET_ONLINE.IP6NET six on r.itemidnum = six.itemidnum",
137
+ "left outer join UMNET_ONLINE.ITEM six_i on six.itemidnum = six_i.itemidnum",
138
+ ]
139
+ where = [
140
+ f"p.itemidnum = {net_id}",
141
+ ]
142
+
143
+ sql = self._build_select(select, table, joins=joins, where=where)
144
+ results = self._execute(sql)
145
+
146
+ # there are also IPv4 subnets that are "pending removal" or "pending activation"
147
+ # tied to the network in the RELATED_ITEM table, we need to find those too.
148
+ # doing it as a separate query to maintain existing results row structure
149
+ select = [
150
+ "p.itemname as netname",
151
+ "r_i.itemname as ipv4_subnet",
152
+ "'' as ipv6_subnet",
153
+ "NVL(n.vlanid,n.local_vlanid) AS vlan_id",
154
+ "r.itemreltypcd as RELTYPCD",
155
+ ]
156
+ joins = [
157
+ "join UMNET_ONLINE.NETWORK n on p.itemidnum = n.itemidnum",
158
+ "left outer join UMNET_ONLINE.ITEM i on i.parentitemidnum = p.itemidnum",
159
+ "left outer join UMNET_ONLINE.RELATED_ITEM r on p.itemidnum = r.relitemidnum",
160
+ "left outer join UMNET_ONLINE.ITEM r_i on r.itemidnum = r_i.itemidnum",
161
+ ]
162
+ where = [
163
+ f"p.itemidnum = {net_id}",
164
+ "r_i.itemcatcd = 'IP'",
165
+ ]
166
+ # if we're doing active only, we only want "pending removal"
167
+ if active_only:
168
+ where.append("r.itemreltypcd = 'IP-PRNET'")
169
+
170
+ sql = self._build_select(select, table, joins=joins, where=where)
171
+ more_results = self._execute(sql)
172
+
173
+ if more_results:
174
+ results.extend(more_results)
175
+
176
+ return results
177
+
178
+ def get_vrfs(self, vrf_name=None, rd=None):
179
+ """
180
+ Pulls data from the vrf table on netinfo. If you supply a name and/or rd, it filters only
181
+ for that vrf. Otherwise it returns all VRFs
182
+ """
183
+
184
+ select = [
185
+ "shortname",
186
+ "route_distinguisher",
187
+ "default_vrf",
188
+ "inside_vrf",
189
+ ]
190
+ table = "UMNET_ONLINE.VRF"
191
+
192
+ where = []
193
+ if vrf_name:
194
+ where.append(f"shortname = '{vrf_name}'")
195
+ if rd:
196
+ where.append(f"route_distinguisher = '{rd}'")
197
+
198
+ sql = self._build_select(select, table, where=where)
199
+ results = self._execute(sql)
200
+
201
+ return results
202
+
203
+ def get_special_acl(self, netname: str):
204
+ """
205
+ Looks for a special ACL assignment by netname
206
+ """
207
+ select = ["acl.itemname"]
208
+ table = "UMNET_ONLINE.ITEM net"
209
+ joins = [
210
+ "join UMNET_ONLINE.FILTER_NETWORK fn on fn.net_itemidnum = net.itemidnum",
211
+ "join UMNET_ONLINE.ITEM acl on fn.filter_itemidnum = acl.itemidnum",
212
+ ]
213
+ where = [f"net.itemname='{netname}'"]
214
+
215
+ sql = self._build_select(select, table, joins=joins, where=where)
216
+ results = self._execute(sql)
217
+
218
+ return results
219
+
220
+ def get_asns(self, name_filter: str = ""):
221
+ """
222
+ Pulls all the ASNs from the AUTONOMOUS_SYSTEM
223
+ table, optionally filtering by asname
224
+ """
225
+ select = ["ASNAME", "ASN"]
226
+ table = "UMNET_ONLINE.AUTONOMOUS_SYSTEM"
227
+
228
+ where = []
229
+ if name_filter:
230
+ where = [f"ASNAME like '%{name_filter}%'"]
231
+
232
+ sql = self._build_select(select, table, where=where)
233
+ results = self._execute(sql)
234
+
235
+ return results
236
+
237
+ def get_dlzone_buildings(self, zone: str):
238
+ """
239
+ given a dlzone shortname, returns a list of building numbers tied
240
+ to that zone.
241
+ """
242
+
243
+ select = ["BUILDINGNUM as building_no"]
244
+ table = "UMNET_ONLINE.AUTONOMOUS_SYSTEM asn"
245
+ joins = [
246
+ "join UMNET_ONLINE.BUILDING_AS b_asn on b_asn.ASID = asn.ASID",
247
+ ]
248
+ where = [f"asn.ASNAME = '{zone}'"]
249
+
250
+ sql = self._build_select(select, table, joins=joins, where=where)
251
+ results = self._execute(sql)
252
+
253
+ return results
254
+
255
+ def get_dlzone_by_building(self, bldg_num: int):
256
+ """
257
+ given a seven-digit building number, return the name of the
258
+ distribution-zone to which it is assigned
259
+ """
260
+
261
+ select = ["ASNAME"]
262
+ table = "UMNET_ONLINE.AUTONOMOUS_SYSTEM asn"
263
+ joins = [
264
+ "join UMNET_ONLINE.BUILDING_AS b_asn on b_asn.ASID = asn.ASID",
265
+ ]
266
+ where = [f"b_asn.BUILDINGNUM = '{bldg_num}'"]
267
+
268
+ sql = self._build_select(select, table, joins=joins, where=where)
269
+ results = self._execute(sql)
270
+
271
+ return results
272
+
273
+ def get_user_groups(self, username: str):
274
+ """
275
+ Looks up a user in netinfo and returns the groups they're in
276
+ """
277
+
278
+ select = ["e_grp.name"]
279
+ table = "UMNET_ONLINE.PERSON p"
280
+ joins = [
281
+ "join UMNET_ONLINE.PERSON_GROUP p_grp on p_grp.PERSON_ENTITYIDNUM = p.ENTITYIDNUM",
282
+ "join UMNET_ONLINE.ENTITY_GROUP e_grp on e_grp.ENTITYIDNUM = p_grp.GROUP_ENTITYIDNUM",
283
+ ]
284
+ where = [f"UNIQNAME='{username}'"]
285
+
286
+ sql = self._build_select(select, table, joins=joins, where=where)
287
+ results = self._execute(sql)
288
+
289
+ return results
290
+
291
+ def get_network_admins(
292
+ self, netname: str, entity_types: list = [], rel_types: list = []
293
+ ):
294
+ """
295
+ Looks up the users and user groups that are tied to this network.
296
+ You can fiter by entity types (see UMNET_ONLINE.ENTITY_TYPE_CODE), these are:
297
+ group, organization, person
298
+ You can also filter by relationship type (UMNET_ONLINE.ENTITEM_RELATIONSHIP_TYPE_CODE):
299
+ Owner, Worker, Business, Admin, Security
300
+ """
301
+
302
+ select = [
303
+ "NVL(NVL(e_g.name, p.uniqname), o.NAME) as name",
304
+ "e_t_c.ENTITYTYPDES as entity_type",
305
+ "i_a.ENTITYITEMRELTYPCD as rel_type",
306
+ ]
307
+
308
+ table = "UMNET_ONLINE.ITEM i"
309
+ joins = [
310
+ "join UMNET_ONLINE.ITEM_ADMIN i_a on i_a.ITEMIDNUM=i.ITEMIDNUM",
311
+ "join UMNET_ONLINE.ENTITY e on i_a.ENTITYIDNUM=e.ENTITYIDNUM",
312
+ "join UMNET_ONLINE.ENTITY_TYPE_CODE e_t_c on e_t_c.ENTITYTYPCD=e.ENTITYTYPCD",
313
+ "left outer join UMNET_ONLINE.ENTITY_GROUP e_g on e_g.ENTITYIDNUM=e.ENTITYIDNUM",
314
+ "left outer join UMNET_ONLINE.PERSON p on p.ENTITYIDNUM=e.ENTITYIDNUM",
315
+ "left outer join UMNET_ONLINE.ORGANIZATION o on o.ENTITYIDNUM=e.ENTITYIDNUM",
316
+ ]
317
+
318
+ where = [f"i.ITEMNAME='{netname}'"]
319
+
320
+ if entity_types:
321
+ e_where = " or ".join([f"e_t_c.ENTITYTYPDES='{e}'" for e in entity_types])
322
+ where.append(f"({e_where})")
323
+ if rel_types:
324
+ rel_where = " or ".join(
325
+ [f"i_a.ENTITYITEMRELTYPCD='{rel}'" for rel in rel_types]
326
+ )
327
+ where.append(f"({rel_where})")
328
+
329
+ sql = self._build_select(select, table, joins=joins, where=where)
330
+ results = self._execute(sql)
331
+
332
+ return results
333
+
334
+ def get_device_admins(
335
+ self, name_or_ip: str, entity_types: list = [], rel_types: list = []
336
+ ):
337
+ """
338
+ Looks up the users and user groups that are tied to this device.
339
+ You can fiter by entity types (see UMNET_ONLINE.ENTITY_TYPE_CODE), these are:
340
+ group, organization, person
341
+ You can also filter by relationship type (UMNET_ONLINE.ENTITEM_RELATIONSHIP_TYPE_CODE):
342
+ Owner, Worker, Business, Admin, Security
343
+ """
344
+
345
+ select = [
346
+ "NVL(NVL(e_g.name, p.uniqname), o.NAME) as name",
347
+ "e_t_c.ENTITYTYPDES as entity_type",
348
+ "i_a.ENTITYITEMRELTYPCD as rel_type",
349
+ ]
350
+
351
+ table = "UMNET_ONLINE.DEVICE d"
352
+ joins = [
353
+ "join UMNET_ONLINE.ITEM i on i.itemidnum=d.ITEMIDNUM",
354
+ "join UMNET_ONLINE.ITEM_ADMIN i_a on i_a.ITEMIDNUM=i.ITEMIDNUM",
355
+ "join UMNET_ONLINE.ENTITY e on i_a.ENTITYIDNUM=e.ENTITYIDNUM",
356
+ "join UMNET_ONLINE.ENTITY_TYPE_CODE e_t_c on e_t_c.ENTITYTYPCD=e.ENTITYTYPCD",
357
+ "left outer join UMNET_ONLINE.ENTITY_GROUP e_g on e_g.ENTITYIDNUM=e.ENTITYIDNUM",
358
+ "left outer join UMNET_ONLINE.PERSON p on p.ENTITYIDNUM=e.ENTITYIDNUM",
359
+ "left outer join UMNET_ONLINE.ORGANIZATION o on o.ENTITYIDNUM=e.ENTITYIDNUM",
360
+ ]
361
+
362
+ # netinfo IPv4 addresses are always stored as integers
363
+ if is_ip_address(name_or_ip):
364
+ where = [f"d.ADDRESS32={int(ipaddress.ip_address(name_or_ip))}"]
365
+
366
+ # dns names in the device table are fqdn and end in a dot (eg 'dl-arbl-1.umnet.umich.edu.')
367
+ else:
368
+
369
+ name_or_ip = name_or_ip.lower()
370
+ if "." not in name_or_ip:
371
+ name_or_ip += ".umnet.umich.edu"
372
+ if not (name_or_ip.endswith(".")):
373
+ name_or_ip += "."
374
+
375
+ where = [f"d.DNS_NAME='{name_or_ip}'"]
376
+
377
+ if entity_types:
378
+ e_where = " or ".join([f"e_t_c.ENTITYTYPDES='{e}'" for e in entity_types])
379
+ where.append(f"({e_where})")
380
+ if rel_types:
381
+ rel_where = " or ".join(
382
+ [f"i_a.ENTITYITEMRELTYPCD='{rel}'" for rel in rel_types]
383
+ )
384
+ where.append(f"({rel_where})")
385
+
386
+ sql = self._build_select(select, table, joins=joins, where=where)
387
+ results = self._execute(sql)
388
+
389
+ return results
390
+
391
+ def get_arphist(
392
+ self, query: str, device=None, interface=None, no_hist=False, no_device=False
393
+ ):
394
+ """
395
+ Given either a MAC address, IP address, or subnet, query UMNET.ARPHIST
396
+ and return the results.
397
+
398
+ If you don't care about history, set "no_hist"
399
+ If you don't care about which device the entries are routed on,
400
+ set "no_device"
401
+
402
+ Optionally limit the query by device and/or by interface
403
+ with the "device" and "interface" fields
404
+ """
405
+
406
+ # for 'no_hist' 'no_device' we are just straight up pulling ip->mac
407
+ # mappings
408
+ select = ["address32bit, mac_addr"]
409
+ if not no_hist:
410
+ select.extend(["first_seen", "last_seen"])
411
+ if not no_device:
412
+ select.extend(["device_name as device", "ifdescr as interface"])
413
+
414
+ table = "UMNET.ARPHIST arp"
415
+ where = []
416
+
417
+ # UMNET.ARPHIST stores MACs as strings without separators .:-, ie
418
+ # 0010.abcd.1010 => '0010abcd1010'
419
+ if is_mac_address(query):
420
+
421
+ arphist_mac = re.sub(r"[\.\:\-]", "", query)
422
+ where.append(f"arp.mac_addr = '{arphist_mac}'")
423
+
424
+ # UMNET.ARPHIST stores IPs as integers, ie
425
+ # 10.233.0.10 => 183042058. Also - only supports IPv4
426
+ # IPv6 is stored in IP6NEIGHBOR
427
+ elif is_ip_address(query, version=4):
428
+ ip = ipaddress.ip_address(query)
429
+ where.append(f"arp.address32bit = {int(ip)}")
430
+
431
+ elif is_ip_network(query, version=4):
432
+ net = ipaddress.ip_network(query)
433
+ where.extend(
434
+ [
435
+ f"arp.address32bit >= {int(net[0])}",
436
+ f"arp.address32bit <= {int(net[-1])}",
437
+ ]
438
+ )
439
+
440
+ else:
441
+ raise ValueError(
442
+ f"Unsupported input {query}, must be a MAC, a subnet, or an IP"
443
+ )
444
+
445
+ if device:
446
+ where.append(f"arp.device = '{device}'")
447
+ if interface:
448
+ where.append(f"arp.interface = '{interface}'")
449
+
450
+ sql = self._build_select(select, table, where=where, distinct=True)
451
+ results = self._execute(sql)
452
+
453
+ # normally we like to return the results unprocessed. But the IPs and
454
+ # MACs really need to get converted to make the ouptut useful.
455
+ # hopefully the type different (Sqlalchemy result object vs list[dict])
456
+ # isn't throwing anyone :-/
457
+ processed_results = []
458
+ for r in results:
459
+
460
+ processed = {
461
+ "ip": ipaddress.ip_address(r["address32bit"]),
462
+ "mac": f"{r['mac_addr'][0:4]}.{r['mac_addr'][4:8]}.{r['mac_addr'][8:12]}",
463
+ }
464
+ for col in ["device", "interface", "first_seen", "last_seen"]:
465
+ if col in r:
466
+ processed[col] = r[col]
467
+
468
+ processed_results.append(processed)
469
+
470
+ return processed_results
471
+
472
+ def get_prefix_lists(self, prefix_lists: list = None):
473
+ """
474
+ Queries UMNET_ONLINE.PREFIX_LISTS for a list of prefix list names (as defined in the database).
475
+ If you don't provide a list of names it will return all of them
476
+ """
477
+
478
+ select = ["name", "prefix"]
479
+ table = "UMNET_ONLINE.PREFIX_LIST"
480
+
481
+ where = []
482
+ if prefix_lists:
483
+ in_query = "(" + ",".join([f"'{p}'" for p in prefix_lists]) + ")"
484
+ where.append(f"name in {in_query}")
485
+
486
+ sql = self._build_select(select, table, where=where, order_by="1,2")
487
+ results = self._execute(sql)
488
+
489
+ return results
@@ -0,0 +1,39 @@
1
+ import ipaddress
2
+ import re
3
+ def is_ip_address(input_str, version=None):
4
+ try:
5
+ ip = ipaddress.ip_address(input_str)
6
+ except ValueError:
7
+ return False
8
+
9
+ if version and version != ip.version:
10
+ return False
11
+
12
+ return True
13
+
14
+ def is_ip_network(input_str, version=None):
15
+
16
+ # First check that this is a valid IP or network
17
+ try:
18
+ net = ipaddress.ip_network(input_str)
19
+ except ValueError:
20
+ return False
21
+
22
+ if version and version != net.version:
23
+ return False
24
+
25
+ return True
26
+
27
+ def is_mac_address(input_str):
28
+ '''
29
+ Validates the input string as a mac address. Valid formats are
30
+ XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, XXXX.XXXX.XXXX
31
+ where 'X' is a hexadecimal digit (upper or lowercase).
32
+ '''
33
+ mac = input_str.lower()
34
+ if re.match(r'[0-9a-f]{2}([-:])[0-9a-f]{2}(\1[0-9a-f]{2}){4}$', mac):
35
+ return True
36
+ if re.match(r'[0-9a-f]{4}\.[0-9a-f]{4}\.[0-9a-f]{4}$', mac):
37
+ return True
38
+
39
+ return False