-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathmac.py
executable file
·424 lines (362 loc) · 17.6 KB
/
mac.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
#!/usr/bin/env python3
"""
A utility class for MAC addresses.
Generates MAC addresses when run from the command line.
Usage:
mac.py
mac.py --help
mac.py --oui 00000C
mac.py --format 0 # normalized without separators
mac.py --format 2b --sep . # two-byte format, '.' separator
mac.py --local -n 100 -s :
mac.py -n10 --local
MAC formats (default separator is '-'):
- `0` : 1234567890AB
- `ieee` : 12-34-56-78-90-AB
- `1b`: 12-34-56-78-90-AB
- `2b`: 1234-5678-90AB
"""
__license__ = "MIT - https://mit-license.org/"
import argparse
import csv
import datetime
import enum
import os
import random
import re
import requests
import string
import sys
import time
class MAC:
# Class attributes
BITMASK_OR_LOCALLY_ADMINISTERED = 0b000000100000000000000000
BITMASK_AND_UNICAST = 0b111111101111111111111111
SEPARATORS = ":-."
TD_30D = datetime.timedelta(days=30)
TR_NO_PUNCTUATION = str.maketrans("", "", string.punctuation)
# lazy load singletons
ieee_oui_dict = None
log = None
def __init__(cls, load_ieee: bool = False, expiration: datetime.timedelta = TD_30D) -> None:
if not isinstance(load_ieee, bool):
raise TypeError(f"load_ieee is not bool")
# Lazy load IEEE data
cls.ieee_oui_dict = cls.get_ieee_oui_dict(expiration) if load_ieee else None
class FORMATS(enum.Enum):
NONE = "0" # no separators: XXXXXXXXXXXX
ONEBYTE = "1b" # onebyte + ':' separator (or any separator): XX:XX:XX:XX:XX:XX
TWOBYTE = "2b" # twobyte + ':' separator (or any separator): XXXX:XXXX:XXXX
IEEE = "ieee" # onebyte + '-' separator: XX-XX-XX-XX-XX-XX
@classmethod
def get(cls, value):
for attr in cls:
if attr.value == value.strip().lower():
return attr
raise ValueError(f"{value} is not valid for {cls.__name__}")
@classmethod
def get_ieee_oui_dict(cls, expiration: datetime.timedelta = TD_30D) -> dict:
"""
Returns the `ieee_oui_dict` singleton instance.
- returns (dict): dictionary of { "oui" : "organization", ... }
"""
if cls.ieee_oui_dict == None:
# lazy init logging
if cls.log == None:
import logging
logging.basicConfig(
stream=sys.stderr,
format="%(asctime)s.%(msecs)03d | %(levelname)s | %(module)s | %(funcName)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
level = "INFO"
cls.log = logging.getLogger()
cls.log.setLevel(level) # logging threshold
cls.log.debug(f"MAC logging initialized @ level={level})")
# lazy download & init IEEE OUI dict
cls.ieee_oui_dict = cls._load_ieee_oui_dict(expiration)
return cls.ieee_oui_dict
@classmethod
def _load_ieee_oui_dict(cls, expiration: datetime.timedelta = TD_30D):
"""
Return a dict of IEEE {OUI:Organization}, downloading the data from the IEEE, if necessary.
- expiration (datetime.timedelta) : time duration until the IEEE OUI file is expired and needs to be downloaded
"""
IEEE_OUI_URL = "https://standards-oui.ieee.org/oui/oui.txt"
IEEE_OUI_TXT_FILENAME = "ieee_ouis.txt"
IEEE_OUI_CSV_FILENAME = "ieee_ouis.csv"
# use a fake User-Agent or the IEEE site will reject your requests as a bot
USER_AGENT_HEADER = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/89.0"}
def download(url: str = None, headers: dict = None):
cls.log.critical(f"load_ieee_oui_dict().download({IEEE_OUI_URL})")
try:
response = requests.get(IEEE_OUI_URL, headers=USER_AGENT_HEADER)
with open(IEEE_OUI_TXT_FILENAME, "w") as file:
file.write(response.text)
cls.log.critical(f"load_ieee_oui_dict().download(): Saved {IEEE_OUI_TXT_FILENAME}")
except requests.exceptions.ConnectionError:
cls.log.error(f"load_ieee_oui_dict().download(): Connection problem.")
# Download IEEE OUIs locally if missing or older than `expiration`
if not os.path.exists(IEEE_OUI_TXT_FILENAME):
# No OUI file - download it
download(IEEE_OUI_URL, USER_AGENT_HEADER)
else:
# Check file modification, cache expiration, document Last-Modified header before downloading
now_dt = datetime.datetime.now()
ouis_text_modified_ts = os.path.getmtime(IEEE_OUI_TXT_FILENAME)
ouis_text_modified_dt = datetime.datetime.fromtimestamp(int(ouis_text_modified_ts)) # use int() to drop μseconds
ouis_text_expired_dt = ouis_text_modified_dt + expiration # expire after `expiration` timedelta
if now_dt.timestamp() > ouis_text_expired_dt.timestamp():
cls.info(f"IEEE file expired: {now_dt} (now) > {ouis_text_expired_dt} text expiration")
response = requests.head(IEEE_OUI_URL, headers=USER_AGENT_HEADER)
cls.log.info(f"IEEE OUI HEAD Request: {response.headers}")
# Convert Last-Modified to timestamp for easy comparison: ['Last-Modified']: Thu, 26 Dec 2024 17:01:23 GMT
url_last_modified_ts = datetime.datetime.strptime(response.headers["Last-Modified"], "%a, %d %b %Y %H:%M:%S %Z").timestamp()
url_last_modified_dt = datetime.datetime.fromtimestamp(url_last_modified_ts)
cls.log.info(
f"{IEEE_OUI_URL} modified {response.headers['Last-Modified']} ({url_last_modified_dt.strftime(FS_ISO8601_DT)})"
)
if url_last_modified_ts > ouis_text_modified_ts: # URL is newer?
cls.log.info(f"IEEE file updated: {url_last_modified_dt} > {ouis_text_expired_dt} text expiration")
download(IEEE_OUI_URL, USER_AGENT_HEADER)
else:
cls.log.info(f"IEEE file current: {ouis_text_modified_dt} < {ouis_text_expired_dt} expiration")
# Invalid OUI file?
if os.path.getsize(IEEE_OUI_TXT_FILENAME) < 1000000:
cls.log.debug(f"Invalid size: {IEEE_OUI_TXT_FILENAME}: Verify HTTP User-Agent ")
raise Exception("Invalid OUI file - should be much larger")
# Parse IEEE OUIs for only the "base 16" lines
if not os.path.exists(IEEE_OUI_CSV_FILENAME):
with open(IEEE_OUI_TXT_FILENAME, "r") as file:
lines = file.readlines()
oui_table = [["OUI", "Organization"]] # list of lists for CSV file
for line in lines:
if re.search(r"\s+\(base 16\)\s+", line):
# 00000C (base 16) Cisco Systems, Inc
oui, org = re.split(r"\s+\(base 16\)\s+", line)
oui_table.append([oui.strip(), org.strip()])
cls.log.debug(f"IEEE OUI base16 lines parsed")
# Save filtered oui_table to CSV file
with open(IEEE_OUI_CSV_FILENAME, "w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerows(oui_table)
cls.log.info(f"Saved {IEEE_OUI_CSV_FILENAME}")
# Read CSV ["OUI", "Organization"] into MAC lookup dictionary
with open(IEEE_OUI_CSV_FILENAME, "r", newline="") as csvfile:
csv_reader = csv.reader(csvfile)
oui_dict = {}
for row in csv_reader:
if row: # Ensure the row is not empty
oui_dict[row[0]] = row[1] # { "OUI" : "Organization" }
return oui_dict
@classmethod
def is_hex(cls, s: str = None):
"""
Returns True if all characters in string s are hexadecimal digits, False otherwise.
- c (str): a single character string.
"""
return all([c in string.hexdigits for c in list(s)])
@classmethod
def normalize(cls, mac: str = None):
"""
Returns a MAC address without any separators and all upppercase.
- mac (str): the MAC address in any format.
"""
if not isinstance(mac, str):
raise ValueError(f"mac is not a string: {mac}")
return mac.strip().translate(cls.TR_NO_PUNCTUATION).upper()
@classmethod
def is_mac(cls, mac: str = None):
"""
Returns True if mac is a normalized, 12-digit MAC address, False otherwise.
- mac (str): a MAC address.
"""
try:
assert isinstance(mac, str), f"mac ({mac}) is str"
assert mac[0] in string.hexdigits, f"first mac digit is hex"
assert mac[-1] in string.hexdigits, f"last mac digit is hex"
assert len(mac) in [
12, # 1234567890ab FORMATS.NONE (normalized)
14, # 1234.5678.90ab FORMATS.TWOBYTE
17, # 12:34:56:78:90:ab FORMATS.ONEBYTE
], f"mac length is normal"
assert len(mac.strip().translate(cls.TR_NO_PUNCTUATION).upper()) == 12, f"normalized mac ({mac}) OK)"
assert all([digit in (string.hexdigits + cls.SEPARATORS) for digit in list(mac)]), f"mac ({mac}) chars OK"
return True
except Exception as e:
return False
@classmethod
def is_local(cls, mac: str = None):
"""
Returns True if mac is a locally administered (random) MAC address, False otherwise.
A MAC address is considered random when "locally administered" (the 2nd hex digit is one of [26AE]).
This is typically done for endpoint privacy.
RADIUS:Calling-Station-ID MATCHES ^.[26AaEe].*
12:34:56:78:90:AB
0001 ──┘└── 0010
│├── 0: Unicast (evens)
│└── 1: Multicast (odds)
├── 0: globally unique [0,1,4,5,8,9,C,D]
└── 1: locally administered [2,6,A,E]
- mac (str): a MAC address.
"""
if not isinstance(mac, str):
raise ValueError(f"mac is not a string: {mac}")
return re.match("^.[26AE].*", mac) != None # match string start; Return None if no match
@classmethod
def local(cls, oui: str = None):
"""
Returns a random, unformatted, locally administered MAC address.
A convenience method for `MAC.random(local=True)`.
- oui (str): an optional OUI.
"""
oui = "{:06X}".format(random.randint(1, 16777216)) if oui == None else oui # 16777216 == 2^24
# RADIUS:Calling-Station-ID MATCHES ^.[26AaEe].*
# 12:34:56:78:90:AB
# 0001 ──┘└── 0010 = 2
# 0110 = 6
# 1010 = A
# 1110 = E
# │├── 0: Unicast (evens)
# │└── 1: Multicast (odds)
# ├── 0: globally unique [0,1,4,5,8,9,C,D]
# └── 1: locally administered [2,6,A,E]
#
# Set locally administered bit in OUI:
# OUI (hex): 0 0 0 0 0 0
# OUI (bin): 0000 0000 0000 0000 0000 0000
# Locally Administered Mask (bin): 0000 0010 0000 0000 0000 0000
# Unicast Mask (bin): 1111 1110 1111 1111 1111 1111
#
# 💡 Bitwise precedence requires & then |
oui = "{:06X}".format(int(oui, 16) & cls.BITMASK_AND_UNICAST | cls.BITMASK_OR_LOCALLY_ADMINISTERED)
mac = oui + "{:06X}".format(random.randint(1, 16777216)) # 'X' == capitalized hex
return mac # cls.to_format(mac)
@classmethod
def random(cls, oui: str = None):
"""
Returns a randomly generated, unformatted MAC address with the specified OUI and a randomized OUI if none is given.
It is not necessarily a random, locally administered MAC address unless `local=True`.
- oui (str): an optional OUI.
"""
oui = "{:06X}".format(random.randint(1, 16777216)) if oui == None else oui # 16777216 == 2^24
mac = oui + "{:06X}".format(random.randint(1, 16777216)) # 'X' == capitalized hex
return mac # cls.to_format(mac)
# 💡 "format()" is a built-in Python function
@classmethod
def to_format(cls, mac: str = None, format: str = FORMATS.IEEE, sep: str = "-"):
"""
Returns a formatted MAC address with the specified separator between groups of digits.
The default format is the IEEE802 format: <pre>xx-xx-xx-xx-xx-xx</pre>.
- mac (str): a MAC address.
- format (str): one of the supported formats (default: 'IEEE'):
`NONE` : 1234567890AB
`IEEE` : 12-34-56-78-90-AB
`ONEBYTE`: 12:34:56:78:90:AB
`TWOBYTE`: 1234:5678:90AB
"""
if not isinstance(mac, str):
raise ValueError(f"mac is not a string: {mac}")
if not isinstance(format, cls.FORMATS):
raise ValueError(f"format {format} is not a valid format: {cls.FORMATS}")
if format == cls.FORMATS.IEEE:
return cls.fmt(mac, sep, 2)
if format == cls.FORMATS.ONEBYTE:
return cls.fmt(mac, sep, 2)
if format == cls.FORMATS.TWOBYTE:
return cls.fmt(mac, sep, 4)
if format == cls.FORMATS.NONE:
return cls.normalize(mac)
# 💡 Function name is abbreviated as fmt because `format()` is a builtin Python function
@classmethod
def fmt(cls, mac: str = None, sep: str = "-", digits: int = 2):
"""
Returns a formatted MAC address with the specified separator between groups of digits.
The default format is the IEEE802 format: <pre>xx-xx-xx-xx-xx-xx</pre>.
- mac (str): a MAC address.
- sep (int): a separator character. Default: '-'.
- digits (int): the number of digits in a group (0,2,4). O means no separator. Default: 2.
"""
if not isinstance(mac, str):
raise ValueError(f"mac is not a string: {mac}")
if not isinstance(sep, str):
raise ValueError(f"separator is not a string: {sep}")
if not isinstance(digits, int):
raise ValueError(f"digits is not an int: {digits}")
if digits not in [0, 2, 4]:
raise ValueError(f"digits is not [2,4]: {digits}")
mac = cls.normalize(mac)
if digits == 0:
return mac
groups = [] # onebyte or twobyte groups
for n in range(0, 12, digits): # range(start, stop[, step])
groups.append(mac[n : n + digits])
return sep.join(groups)
@classmethod
def oui(cls, mac: str = None):
"""
Returns the organizationally unique identifier (OUI) (first 6 digits of the MAC address), regardless of delimiters.
- mac (str): a MAC address.
"""
if not isinstance(mac, str):
raise ValueError(f"mac is not a string: {mac}")
return cls.normalize(mac)[0:6]
@classmethod
def nic(cls, mac: str = None):
"""
Returns the network interface card (NIC) portion (last 6 digits) of the MAC address, regardless of delimiters.
- mac (str): a MAC address.
"""
if not isinstance(mac, str):
raise ValueError(f"mac is not a string: {mac}")
return cls.normalize(mac)[6:]
@classmethod
def org(cls, mac: str = None) -> str:
"""
Returns the IEEE organization name registered to the specified OUI or None is there is not one.
- mac (str): a MAC address or OUI.
"""
print(f"org({mac})")
if not isinstance(mac, str):
raise ValueError(f"mac is not a string: {mac}")
if cls.ieee_oui_dict is None:
cls.ieee_oui_dict = cls._load_ieee_oui_dict(cls.TD_30D) # lazy load the dict
return cls.ieee_oui_dict.get(cls.normalize(mac)[0:6], None) if len(mac) > 6 else cls.ieee_oui_dict.get(mac, None)
@classmethod
def ouis(cls, org: str = None) -> [str]:
"""
Returns all known OUIs for a given organization name.
- org (str): an organization name from the IEEE OUI list
- returns ([str]): a list of OUIs belonging to that organization in `org`
"""
print(f"ouis({org})")
oui_dict = cls.get_ieee_oui_dict()
# print(f"🐛 ouis({org})")
ouis = []
for k, v in oui_dict.items():
if v == org:
# print(f"{v} has {k}")
ouis.append(k)
print(f"org {org} has {len(ouis)} OUIs")
return ouis
if __name__ == "__main__":
argp = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
argp.add_argument(
"-f",
"--format",
choices=["0", "ieee", "1b", "2b"],
default="ieee",
type=str,
# type=MAC.FORMATS,
help="address format",
required=False,
)
argp.add_argument("-l", "--local", action="store_true", default=False, help="locally administered (random)")
argp.add_argument("-n", "--number", default=1, type=int, help="the number of MACs to create. Default is 1", required=False)
argp.add_argument("-o", "--oui", type=str, help="the base OUI", required=False)
argp.add_argument("-s", "--separator", default="-", type=str, help="the separator character", required=False)
args = argp.parse_args()
number = int(args.number) if args.number else 1 # number of MACs
for n in range(0, number):
mac = MAC.local(args.oui) if args.local else MAC.random(args.oui)
print(MAC.to_format(mac, MAC.FORMATS.get(args.format), args.separator), file=sys.stdout)