From 71c631d78451dfe4f2ecc8d1b7494dba4a38c28f Mon Sep 17 00:00:00 2001 From: ffsb Date: Sun, 14 Jul 2024 09:45:25 -0400 Subject: [PATCH 1/7] after fixing the order of execution. --- front/plugins/omada_sdn_imp/README.md | 49 ++- .../omada_sdn_imp/omada_account_sample.png | Bin 0 -> 109506 bytes front/plugins/omada_sdn_imp/omada_sdn.py | 368 ++++++++++++++++-- front/plugins/omada_sdn_imp/testre.py | 100 +++++ 4 files changed, 490 insertions(+), 27 deletions(-) create mode 100644 front/plugins/omada_sdn_imp/omada_account_sample.png create mode 100644 front/plugins/omada_sdn_imp/testre.py diff --git a/front/plugins/omada_sdn_imp/README.md b/front/plugins/omada_sdn_imp/README.md index 1ebe573e..dc06d17e 100755 --- a/front/plugins/omada_sdn_imp/README.md +++ b/front/plugins/omada_sdn_imp/README.md @@ -1,12 +1,24 @@ ## Overview -Plugin functionality overview and links to external resources if relevant. Include use cases if available. +The OMADA SDN plugin aims at synchronizing data between NetAlertX and a TPLINK OMADA SND controler by leveraging a tplink omada python library. +#### features: +1. extract list of OMADA Clients from OMADA and sync them up with NetAlertX +2. extract list of OAMDA Devices (switches and access points) and sync them up with NetAlertX > [!TIP] > Some tip. ### Quick setup guide +1. You SHOULD (ie: strongly recommend) setting up a dedicated account in your OMADA SDN console dedicated to NetAlertX OMADA_SDN plugin. +- you should set USER TYPE = Local USer +- you should set USER ROLE = Administrator (if you use a read-only role you won't be able to sync names from NetAlerX to OMADA SDN) +- you can set Site Privileges = All Sites (or limit it to specific sites ) + +2. populate the variables in NetAlertX as instructed in the config plugin page. + + + To set up the plugin correctly, make sure... #### Required Settings @@ -20,6 +32,37 @@ To set up the plugin correctly, make sure... ### Notes -- Additional notes, limitations, Author info. +#### features not implemented yet: +3. extract list of OAMDA router Devices (er605...) and sync them up with NetAlertX +(I need to setup my own er605 however due to its limitations I have no use for it, and due to limitations of opensense dhcp servers, I can't deploy it yet without breaking dhcp self registration into opnsense unbound - see below) + +#### know limitations: +OMADA SDN limitation fixed by the plugin: +0. OMADA SDN can't use DNS for names and keep using MAC ref: https://community.tp-link.com/en/business/forum/topic/503782 +- when you use an OMADA user Role = Administrator, the plugin will attempt to fix OMADA's shortcoming and populat the NAME field from NetAlertX (from DNS/DHCP/...) +![OMADA SDN account page](Oamadaomada_sdn_imp.png) +- -Made with ❤ by [@FlyingToto](https://github.com/FlyingToto) 🙏 \ No newline at end of file +can not fix some of tplinks OMADA SDN own limitations/bugs: +1. OMADA SDN switches uplinks/downlinks is broken if the default router is not an OMADA native device +- (I try to circumvent that through a tree parsing heuristic but your mileage might vary...) +- ref: https://community.tp-link.com/en/business/forum/topic/673628 +2. OMADA SDN clients are sometimes mapped to the wrong switch/port... for instance: +- client -> access_switch1/port1 -> core_switch2/port2 sometimes shows as client -> core_switch2/port2 +- it is unclear if this issue is realted to (1) +3. OMADA er605 routers do not self register DHCP names with a remote unbound DNS (nor embded DNS): +- ref: https://community.tp-link.com/en/business/forum/topic/542472 +- it looks like some release candidate firmware might provide this feature... I will test when opnsesne Kea self registration get fixed as well(4)and I can get it dhcp proxy to work. +4. Opnsense dhcp doesn't support relay and self-registration at the same time... +- opnsense legacy ISC dhcp server doesn't support dhcp proxies: ref: https://forum.opnsense.org/index.php?topic=34254.0 +- opnsense new kea dhcp server doesn't support dns self registration (yet) ref: https://github.com/opnsense/core/pull/7362 +5. incompatible devices: +- OMADA EAP245 - to be fair to tp-link, this access point works inside OMADA SDN, so it might be an issue with our omada python library but we can't extract data from it. + + + + +### Other infos + +- Author : Flying Toto +- Date : 04-Jul-2024 - version 1.0 diff --git a/front/plugins/omada_sdn_imp/omada_account_sample.png b/front/plugins/omada_sdn_imp/omada_account_sample.png new file mode 100644 index 0000000000000000000000000000000000000000..04c180cdfd90d56a3d427f678988704c51782dd9 GIT binary patch literal 109506 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 0: # insert devices into the lats_result.log @@ -50,36 +211,195 @@ def main(): # { # "column": "Object_PrimaryID", <--------- the value I save into primaryId # "mapped_to_column": "cur_MAC", <--------- gets unserted into the CurrentScan DB table column cur_MAC - # - for device in device_data: - plugin_objects.add_object( - primaryId = device['some_id'], # MAC - secondaryId = device['some_id'], # IP - watched1 = device['some_id'], # NAME/HOSTNAME - watched2 = device['some_id'], # PARENT NETWORK NODE MAC - watched3 = device['some_id'], # PORT - watched4 = device['some_id'], # SSID - extra = device['some_id'], # SITENAME (cur_NetworkSite) or VENDOR (cur_Vendor) (PICK one and adjust config.json -> "column": "Extra") - foreignKey = device['some_id']) # usually MAC + # watched1 = 'null' , + # figure a way to run my udpate script delayed - mylog('verbose', [f'[{pluginName}] New entries: "{len(new_devices)}"']) + for device in device_data: + mylog('verbose', [f'[{pluginName}] main parsing device: "{device}"']) + if device[PORT_SSID].isdigit(): + myport = device[PORT_SSID] + myssid = '' + else: + myssid = device[PORT_SSID] + myport = '' + if device[SWITCH_AP] != 'Internet': + ParentNetworkNode = ieee2ietf_mac_formater(device[SWITCH_AP]) + else: + ParentNetworkNode = device[SWITCH_AP] + plugin_objects.add_object( + primaryId = ieee2ietf_mac_formater(device[MAC]), # MAC + secondaryId = device[IP], # IP + watched1 = device[NAME], # NAME/HOSTNAME + watched2 = ParentNetworkNode, # PARENT NETWORK NODE MAC + watched3 = myport, # PORT + watched4 = myssid, # SSID + extra = device[TYPE], + #omada_site, # SITENAME (cur_NetworkSite) or VENDOR (cur_Vendor) (PICK one and adjust config.json -> "column": "Extra") + foreignKey = device[MAC].lower().replace('-',':')) # usually MAC + mylog('verbose', [f'[{pluginName}] New entries: "{len(device_data)}"']) # log result - # plugin_objects.write_result_file() + plugin_objects.write_result_file() + + #mylog('verbose', [f'[{pluginName}] TEST name from MAC: {device_handler.getValueWithMac('dev_Name','00:e2:59:00:a0:8e')}']) + #mylog('verbose', [f'[{pluginName}] TEST MAC from IP: {get_mac_from_IP('192.168.0.1')} also {ietf2ieee_mac_formater(get_mac_from_IP('192.168.0.1'))}']) + return 0 + + + # ---------------------------------------------- # retrieve data -def get_device_data(some_setting): +def get_device_data(omada_clients_output,switches_and_aps,device_handler): - device_data = [] + + # sample omada devices input format: + # 0.MAC 1.IP 2.type 3.status 4.name 5.model + #40-AE-30-A5-A7-50 192.168.0.11 ap CONNECTED ompapaoffice EAP773(US) v1.0 + #B0-95-75-46-0C-39 192.168.0.4 switch CONNECTED pantry12 T1600G-52PS v4.0 + # + # sample target output: + # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, 5 TYPE + #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', '17', '40-AE-30-A5-A7-50, 'Switch']" + + sadevices_macbyname = {} + sadevices_macbymac = {} + sadevices_linksbymac = {} + port_byswitchmac_byclientmac = {} + device_data_bymac = {} + device_data_mac_byip = {} + omada_force_overwrite = get_setting_value('OMDSDN_force_overwrite') + + sadevices = switches_and_aps.splitlines() + mylog('verbose', [f'[{pluginName}] switches_and_aps rows: "{len(sadevices)}"']) + for sadevice in sadevices: + sadevice_data = sadevice.split() + thisswitch = sadevice_data[0] + sadevices_macbyname[sadevice_data[4]] = thisswitch + if sadevice_data[2] == 'ap': + sadevice_type = 'AP' + sadevice_details = callomada(['access-point', thisswitch]) + sadevice_links = extract_mac_addresses(sadevice_details) + sadevices_linksbymac[thisswitch] = sadevice_links[1:] + mylog('verbose', [f'[{pluginName}]adding switch details: "{sadevice_details}"']) + mylog('verbose', [f'[{pluginName}]links are: "{sadevice_links}"']) + mylog('verbose', [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + elif sadevice_data[2] == 'switch': + sadevice_type = 'Switch' + sadevice_details=callomada(['switch', thisswitch]) + sadevice_links=extract_mac_addresses(sadevice_details) + sadevices_linksbymac[thisswitch] = sadevice_links[1:] + # recovering the list of switches connected to sadevice switch and on which port... + switchdump = callomada(['-t','myomada','switch','-d',thisswitch]) + port_byswitchmac_byclientmac[thisswitch] = {} + for link in sadevices_linksbymac[thisswitch]: + port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(link)+r")" + myport = re.findall(port_pattern, switchdump,re.DOTALL) + port_byswitchmac_byclientmac[thisswitch][link] = myport[0] + mylog('verbose', [f'[{pluginName}]links are: "{sadevice_links}"']) + mylog('verbose', [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + mylog('verbose', [f'[{pluginName}]ports of each links are: "{port_byswitchmac_byclientmac[thisswitch]}"']) + mylog('verbose', [f'[{pluginName}]adding switch details: "{sadevice_details}"']) + + else: + sadevice_type = 'null' + sadevice_details='null' + device_data_bymac[thisswitch] = [thisswitch, sadevice_data[1], sadevice_data[4], 'null', 'null',sadevice_type] + device_data_mac_byip[sadevice_data[1]] = thisswitch + foo=[thisswitch, sadevice_data[1], sadevice_data[4], 'null', 'null'] + mylog('verbose', [f'[{pluginName}]adding switch: "{foo}"']) + + + + + # sadevices_macbymac[thisswitch] = thisswitch + + mylog('verbose', [f'[{pluginName}] switch_macbyname: "{sadevices_macbyname}"']) + mylog('verbose', [f'[{pluginName}] switches: "{device_data_bymac}"']) + # do some processing, call exteranl APIs, and return a device list # ... - # + """ MAC = 0 + IP = 1 + NAME = 2 + SWITCH_AP = 3 + PORT_SSID = 4 + TYPE = 5 """ + # sample omada clients input format: + # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, + #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', 'froggies2', '(ompapaoffice)']" + #17:27:10 [] token: "['50-02-91-29-E7-53', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" + #17:27:10 [] token: "['00-E2-59-00-A0-8E', '192.168.0.1', 'bastion', 'office24', '(23)']" + #17:27:10 [] token: "['60-DD-8E-CA-A4-B3', '192.168.0.226', 'brick', 'froggies3', '(ompapaoffice)']" + # sample target output: + # 0 MAC, 1 IP, 2 Name, 3 MAC of switch/AP, 4 port/SSID, 5 TYPE + #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', 'brick', 'ompapaoffice','froggies2', , 'Switch']" - return device_data + odevices = omada_clients_output.splitlines() + mylog('verbose', [f'[{pluginName}] omada_clients_outputs rows: "{len(odevices)}"']) + for odevice in odevices: + odevice_data = odevice.split() + odevice_data_reordered = [ MAC, IP, NAME, SWITCH_AP, PORT_SSID, TYPE] + odevice_data_reordered[MAC]=odevice_data[0] + odevice_data_reordered[IP]=odevice_data[1] + naxname = device_handler.getValueWithMac('dev_Name',ieee2ietf_mac_formater(odevice_data[MAC])) + if naxname == None or ietf2ieee_mac_formater(naxname) == odevice_data[MAC] or '('in naxname: + naxname = None + mylog('verbose', [f'[{pluginName}] TEST name from MAC: {naxname}']) + if odevice_data[MAC] == odevice_data[NAME]: + if naxname != None: + callomada(['set-client-name', odevice_data[MAC], naxname]) + odevice_data_reordered[NAME] = naxname + else: + odevice_data_reordered[NAME] = 'null' + else: + if omada_force_overwrite and naxname != None: + callomada(['set-client-name', odevice_data[MAC], naxname]) + odevice_data_reordered[NAME] = odevice_data[2] + mightbeport = odevice_data[4].lstrip('(') + mightbeport = mightbeport.rstrip(')') + if mightbeport.isdigit(): + odevice_data_reordered[SWITCH_AP] = odevice_data[3] + odevice_data_reordered[PORT_SSID] = mightbeport + else: + odevice_data_reordered[SWITCH_AP] = mightbeport + odevice_data_reordered[PORT_SSID] = odevice_data[3] + + # replacing the switch name with its MAC... + try: + mightbemac = sadevices_macbyname[odevice_data_reordered[SWITCH_AP]] + odevice_data_reordered[SWITCH_AP] = mightbemac + except KeyError: + mylog('verbose', [f'[{pluginName}] could not find the mac adddress for: "{odevice_data_reordered[SWITCH_AP]}"']) + # adding the type + odevice_data_reordered[TYPE] = '' + device_data_bymac[odevice_data_reordered[MAC]] = odevice_data_reordered + device_data_mac_byip[odevice_data_reordered[IP]] = odevice_data_reordered[MAC] + mylog('verbose', [f'[{pluginName}] tokens: "{odevice_data}"']) + mylog('verbose', [f'[{pluginName}] tokens_reordered: "{odevice_data_reordered}"']) + # populating the uplinks nodes of the omada switches and access points manually + # since OMADA SDN makes is unreliable if the gateway is not their own tplink hardware... + + + # step1 let's find the the default router + # + default_router_ip = find_default_gateway_ip() + default_router_mac = ietf2ieee_mac_formater(get_mac_from_IP(default_router_ip)) + device_data_bymac[default_router_mac][TYPE] = 'Firewall' + # step2 let's find the first switch and set the default router parent to internet + first_switch=device_data_bymac[default_router_mac][SWITCH_AP] + device_data_bymac[default_router_mac][SWITCH_AP] = 'Internet' + # step3 let's set the switch connected to the default gateway uplink to the default gateway and hardcode port to 1 for now: + #device_data_bymac[first_switch][SWITCH_AP]=default_router_mac + #device_data_bymac[first_switch][SWITCH_AP][PORT_SSID] = '1' + # step4, let's go recursively through switches other links to mark update their uplinks + # and pray it ends one day... + # + add_uplink(default_router_mac,first_switch, device_data_bymac,sadevices_linksbymac,port_byswitchmac_byclientmac) + return device_data_bymac.values() if __name__ == '__main__': main() diff --git a/front/plugins/omada_sdn_imp/testre.py b/front/plugins/omada_sdn_imp/testre.py new file mode 100644 index 00000000..c04e7655 --- /dev/null +++ b/front/plugins/omada_sdn_imp/testre.py @@ -0,0 +1,100 @@ +import re + +def extract_mac_addresses(text): + mac_pattern = r"([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})" + #mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})' + #r"(([0-9A-F]{2}-){5}[0-9A-F]{2})" + #r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})" + #r"([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})" + mac_addresses = re.findall(mac_pattern, text) + return ["".join(parts) for parts in mac_addresses] + +# Example usage: +foo = """ +Name: office +Address: 0C-80-63-69-C4-D1 (192.168.0.5) +Status: CONNECTED (CONNECTED) +Ports: 28 +Supports PoE: False +Model: T1600G-28TS v3.0 +LED Setting: SITE_SETTINGS +Uptime: 5day(s) 22h 39m 6s +Uplink switch: D8-07-B6-71-FF-7F office24 +Downlink devices: +- 40-AE-30-A5-A7-50 ompapaoffice +- B0-95-75-46-0C-39 pantry12 +""" + +mac_list = extract_mac_addresses(foo) +print("mac list",mac_list) +# ['0C-80-63-69-C4-D1', 'D8-07-B6-71-FF-7F', '40-AE-30-A5-A7-50', 'B0-95-75-46-0C-39'] +# ['C4-:D1', 'FF-:7F', 'A7-:50', '0C-:39'] + +linked_switches_and_ports_by_mac = {} + + +foo = """" +something +some BOB12 +blah BOB23 +--- BEGIN --- +something else BOB12 +blah BOB23 +--- END --- +""" +def extract_BOB_patterns(foo): + pattern = r"BOB\d{2}(?=.*BEGIN)" + matches = re.findall(pattern, foo, re.DOTALL) + return matches + +BOBresult = extract_BOB_patterns(foo) +print("BOB:",BOBresult) # Output: ['BOB12', 'BOB23'] + + +#0C-80-63-69-C4-D1 +clientmac_by_switchmac_by_switchportSSID = {} +switch_mac_and_ports_by_clientmac = {} + +def extract_uplinks_mac_and_ports(tplink_device_dump): + mac_switches = [] + mac_pattern = r"([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})(?=.*BEGIN)" + mac_addresses = re.findall(mac_pattern, tplink_device_dump,re.DOTALL) + mac_switches = ["".join(parts) for parts in mac_addresses] + print(" mac_switches1=",mac_switches) + mymac = mac_switches[0] + mylinks = mac_switches[1:] + for mylink in mylinks: + port_pattern = r"(?=\{.*\"port\"\: )([0-9]+)(?=.*"+re.escape(mylink)+r")" + port_pattern = r"(?:{/s\"port\"\: )([0-9]+)(?:[!\}].*"+re.escape(mylink)+r")" + #port_pattern = rf"{{.*?{found_mac}.*?port\s*:\s*(\d+).*?}}" + #port_pattern = rf"{{.*?.*?port\s*:\s*(\d+)[!\\}]*{mylink}?}}" + port_pattern = r"(?:\{[!\}]port/s:/s)([0-9]+\,)(?:[!\}]*"+re.escape(mylink)+r"[!\{]*\})" + #port_pattern = r"(?:\{.*\"port\"\: )([0-9]+)(?=.*"+re.escape(mylink)+r")" + port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(mylink)+r")" + + myport = re.findall(port_pattern, tplink_device_dump,re.DOTALL) + print("myswitch=",mymac, "- link_switch=", mylink, "myport=", myport) + return(0) + + + +with open('/tmp/switch.bigroom.dump.json', 'r') as file: + foo3 = file_content = file.read() +print("bigroom", end="") +extract_uplinks_mac_and_ports(foo3) +with open('/tmp/switch.office.dump.json', 'r') as file: + foo4 = file_content = file.read() +print("office", end="") +extract_uplinks_mac_and_ports(foo4) + + +import netifaces +gw = netifaces.gateways() +print(gw['default'][netifaces.AF_INET][0]) + + +d = {'a': ['0', 'Arthur'], 'b': ['foo', 'Belling']} + +print(d.items()) +print(d.keys()) +print(d.values()) From d706a156c01fabb313eaac25982b27ca0b27979d Mon Sep 17 00:00:00 2001 From: ffsb Date: Sun, 14 Jul 2024 09:46:14 -0400 Subject: [PATCH 2/7] after fixing order of execution --- .env.omada.ffsb42 | 1 + docker-compose.yml.ffsb42 | 1 + front/plugins/omada_sdn_imp/config.json.v6 | 690 +++++++++++++++++++++ 3 files changed, 692 insertions(+) create mode 120000 .env.omada.ffsb42 create mode 120000 docker-compose.yml.ffsb42 create mode 100755 front/plugins/omada_sdn_imp/config.json.v6 diff --git a/.env.omada.ffsb42 b/.env.omada.ffsb42 new file mode 120000 index 00000000..81e55650 --- /dev/null +++ b/.env.omada.ffsb42 @@ -0,0 +1 @@ +../.env.omada.ffsb42 \ No newline at end of file diff --git a/docker-compose.yml.ffsb42 b/docker-compose.yml.ffsb42 new file mode 120000 index 00000000..115af5fa --- /dev/null +++ b/docker-compose.yml.ffsb42 @@ -0,0 +1 @@ +../docker-compose.yml.ffsb42 \ No newline at end of file diff --git a/front/plugins/omada_sdn_imp/config.json.v6 b/front/plugins/omada_sdn_imp/config.json.v6 new file mode 100755 index 00000000..f9648365 --- /dev/null +++ b/front/plugins/omada_sdn_imp/config.json.v6 @@ -0,0 +1,690 @@ +{ + "code_name": "omada_sdn_imp", + "unique_prefix": "OMDSDN", + "plugin_type": "device_scanner", + "enabled": true, + "data_source": "script", + "mapped_to_table": "CurrentScan", + "data_filters": [ + { + "compare_column": "Object_PrimaryID", + "compare_operator": "==", + "compare_field_id": "txtMacFilter", + "compare_js_template": "'{value}'.toString()", + "compare_use_quotes": true + } + ], + "show_ui": true, + "localized": ["display_name", "description", "icon"], + "display_name": [ + { + "language_code": "en_us", + "string": "OMADA SDN import" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Plugin to import data from OMADA SDN." + } + ], + "icon": [ + { + "language_code": "en_us", + "string": "" + } + ], + "params": [], + "settings": [ + { + "function": "RUN", + "events": ["run"], + "type": { + "dataType": "string", + "elements": [ + { "elementType": "select", "elementOptions": [], "transformers": [] } + ] + }, + + "default_value": "disabled", + "options": ["disabled", "once", "schedule", "always_after_scan"], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "When to run" + }, + { + "language_code": "es_es", + "string": "Cuándo ejecutar" + }, + { + "language_code": "de_de", + "string": "Wann laufen" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "When the scan should run. Good options are: schedule" + } + ] + }, + { + "function": "RUN_SCHD", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + + "default_value": "*/5 * * * *", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Schedule" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Only enabled if you select schedule in the SYNC_RUN setting. Make sure you enter the schedule in the correct cron-like format (e.g. validate at crontab.guru). For example entering 0 4 * * * will run the scan after 4 am in the TIMEZONE you set above. Will be run NEXT time the time passes." + }, + { + "language_code": "es_es", + "string": "Solo está habilitado si selecciona schedule en la configuración SYNC_RUN. Asegúrese de ingresar la programación en el formato similar a cron correcto (por ejemplo, valide en crontab.guru). Por ejemplo, ingresar 0 4 * * * ejecutará el escaneo después de las 4 a.m. en el TIMEZONE que configuró arriba. Se ejecutará la PRÓXIMA vez que pase el tiempo." + }, + { + "language_code": "de_de", + "string": "Nur aktiviert, wenn Sie schedule in der SYNC_RUN-Einstellung auswählen. Stellen Sie sicher, dass Sie den Zeitplan im richtigen Cron-ähnlichen Format eingeben (z. B. validieren unter crontab.guru). Wenn Sie beispielsweise 0 4 * * * eingeben, wird der Scan nach 4 Uhr morgens in der TIMEZONE den Sie oben festgelegt haben. Wird das NÄCHSTE Mal ausgeführt, wenn die Zeit vergeht." + } + ] + }, + { + "function": "url", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "maxLength": 50, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "URL" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Enter full URL with protocol https://CHANGEME_omada.mylocaldomain." + } + ] + }, + { + "function": "sites", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "input", + "elementOptions": [ + { "placeholder": "Enter value" }, + { "suffix": "_in" }, + { "cssClasses": "col-sm-10" }, + { "prefillValue": "null" } + ], + "transformers": [] + }, + { + "elementType": "button", + "elementOptions": [ + { "sourceSuffixes": ["_in"] }, + { "separator": "" }, + { "cssClasses": "col-xs-12" }, + { "onClick": "addList(this, false)" }, + { "getStringKey": "Gen_Add" } + ], + "transformers": [] + }, + { + "elementType": "button", + "elementOptions": [ + { "sourceSuffixes": [] }, + { "separator": "" }, + { "cssClasses": "col-xs-6" }, + { "onClick": "removeAllOptions(this)" }, + { "getStringKey": "Gen_Remove_All" } + ], + "transformers": [] + }, + { + "elementType": "button", + "elementOptions": [ + { "sourceSuffixes": [] }, + { "separator": "" }, + { "cssClasses": "col-xs-6" }, + { "onClick": "removeFromList(this)" }, + { "getStringKey": "Gen_Remove_Last" } + ], + "transformers": [] + }, + { + "elementType": "select", + "elementOptions": [ + { "multiple": "true" }, + { "readonly": "true" }, + { "editable": "true" } + ], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "OMADA sites" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Omada SDN site IDs. You can get it by..." + } + ] + }, + { + "function": "username", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "maxLength": 50, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "User name" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Omada SDN instance user name." + } + ] + }, + { + "function": "password", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "password" }], + "transformers": [] + } + ] + }, + "maxLength": 50, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Password" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Omada SDN instance password" + } + ] + }, + { + "function": "force_overwrite", + "type": { + "dataType": "boolean", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "checkbox" }], + "transformers": [] + } + ] + }, + "default_value": false, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Force overwrite" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "The plugin synchronizes names from NetAlertX to OMADA. By default NetAlertX will only populate missing names in OMADASDN devices (i.e.: where the name is defaulting to the device MAC address); with this setting toggled, it will overwrite existing values regardless." + } + ] + }, + { + "function": "CMD", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "readonly": "true" }], + "transformers": [] + } + ] + }, + "default_value": "python3 /app/front/plugins/omada_sdn_imp/omada_sdn.py", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Command" + }, + { + "language_code": "es_es", + "string": "Comando" + }, + { + "language_code": "de_de", + "string": "Befehl" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Command to run. This can not be changed" + }, + { + "language_code": "es_es", + "string": "Comando a ejecutar. Esto no se puede cambiar" + }, + { + "language_code": "de_de", + "string": "Befehl zum Ausführen. Dies kann nicht geändert werden" + } + ] + }, + { + "function": "RUN_TIMEOUT", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "number" }], + "transformers": [] + } + ] + }, + "default_value": 30, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Run timeout" + }, + { + "language_code": "es_es", + "string": "Tiempo límite de ejecución" + }, + { + "language_code": "de_de", + "string": "Zeitüberschreitung" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." + }, + { + "language_code": "es_es", + "string": "Tiempo máximo en segundos para esperar a que finalice el script. Si se supera este tiempo, el script se cancela." + }, + { + "language_code": "de_de", + "string": "Maximale Zeit in Sekunden, die auf den Abschluss des Skripts gewartet werden soll. Bei Überschreitung dieser Zeit wird das Skript abgebrochen." + } + ] + }, + { + "default_value": [], + "description": [ + { + "language_code": "en_us", + "string": "Send a notification if selected values change. Use CTRL + Click to select/deselect.
  • Watched_Value1 is Hostname
  • Watched_Value2 is Parent Node
  • Watched_Value3 is Port
  • Watched_Value4 is SSID
" + } + ], + "function": "WATCH", + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Watched" + }, + { + "language_code": "es_es", + "string": "Visto" + } + ], + "options": [ + "Watched_Value1", + "Watched_Value2", + "Watched_Value3", + "Watched_Value4" + ], + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true" }], + "transformers": [] + } + ] + } + }, + { + "default_value": ["new", "watched-changed"], + "description": [ + { + "language_code": "en_us", + "string": "Send a notification only on these statuses. new means a new unique (unique combination of PrimaryId and SecondaryId) object was discovered. watched-changed means that selected Watched_ValueN columns changed." + } + ], + "function": "REPORT_ON", + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Report on" + } + ], + "options": [ + "new", + "watched-changed", + "watched-not-changed", + "missing-in-last-scan" + ], + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true" }], + "transformers": [] + } + ] + } + } + ], + "database_column_definitions": [ + { + "column": "Object_PrimaryID", + "mapped_to_column": "cur_MAC", + "css_classes": "col-sm-2", + "show": true, + "type": "device_name_mac", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "MAC" + }, + { + "language_code": "es_es", + "string": "MAC" + }, + { + "language_code": "de_de", + "string": "MAC" + } + ] + }, + { + "column": "Object_SecondaryID", + "mapped_to_column": "cur_IP", + "css_classes": "col-sm-2", + "show": true, + "type": "device_ip", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "IP" + }, + { + "language_code": "es_es", + "string": "IP" + }, + { + "language_code": "de_de", + "string": "IP" + } + ] + }, + { + "column": "Watched_Value1", + "mapped_to_column": "cur_Name", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Name" + } + ] + }, + { + "column": "Watched_Value2", + "mapped_to_column": "cur_NetworkNodeMAC", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Parent Network MAC" + } + ] + }, + { + "column": "Watched_Value3", + "mapped_to_column": "cur_PORT", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Port" + } + ] + }, + { + "column": "Watched_Value4", + "mapped_to_column": "cur_SSID", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "SSID" + } + ] + }, + { + "column": "Extra", + "mapped_to_column": "cur_Type", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Site or Vendor" + } + ] + }, + { + "column": "Dummy", + "mapped_to_column": "cur_ScanMethod", + "mapped_to_column_data": { + "value": "OMDSDN" + }, + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Scan method" + }, + { + "language_code": "es_es", + "string": "Método de escaneo" + }, + { + "language_code": "de_de", + "string": "Scanmethode" + } + ] + }, + { + "column": "DateTimeCreated", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Created" + }, + { + "language_code": "es_es", + "string": "Creado" + }, + { + "language_code": "de_de", + "string": "Erstellt" + } + ] + }, + { + "column": "DateTimeChanged", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Changed" + }, + { + "language_code": "es_es", + "string": "Cambiado" + }, + { + "language_code": "de_de", + "string": "Geändert" + } + ] + }, + { + "column": "Status", + "css_classes": "col-sm-1", + "show": true, + "type": "replace", + "default_value": "", + "options": [ + { + "equals": "watched-not-changed", + "replacement": "
" + }, + { + "equals": "watched-changed", + "replacement": "
" + }, + { + "equals": "new", + "replacement": "
" + }, + { + "equals": "missing-in-last-scan", + "replacement": "
" + } + ], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Status" + }, + { + "language_code": "es_es", + "string": "Estado" + }, + { + "language_code": "de_de", + "string": "Status" + } + ] + } + ] +} From bf2ce3262d8fd876f7e157908520a3a33463f0e9 Mon Sep 17 00:00:00 2001 From: ffsb Date: Mon, 15 Jul 2024 16:30:24 -0400 Subject: [PATCH 3/7] 0.2 added retries --- front/plugins/omada_sdn_imp/config.json | 2 +- front/plugins/omada_sdn_imp/omada_sdn.py | 159 +++++++++++++---------- front/plugins/omada_sdn_imp/testre.py | 46 ++++++- 3 files changed, 137 insertions(+), 70 deletions(-) diff --git a/front/plugins/omada_sdn_imp/config.json b/front/plugins/omada_sdn_imp/config.json index 8bc11949..76b1a95e 100755 --- a/front/plugins/omada_sdn_imp/config.json +++ b/front/plugins/omada_sdn_imp/config.json @@ -2,7 +2,7 @@ "code_name": "omada_sdn_imp", "unique_prefix": "OMDSDN", "plugin_type": "device_scanner", - "execution_order" : "Layer_1", + "execution_order" : "Layer_0", "enabled": true, "data_source": "script", "mapped_to_table": "CurrentScan", diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py b/front/plugins/omada_sdn_imp/omada_sdn.py index 2e521966..365f5633 100755 --- a/front/plugins/omada_sdn_imp/omada_sdn.py +++ b/front/plugins/omada_sdn_imp/omada_sdn.py @@ -1,13 +1,11 @@ #!/usr/bin/env python __author__ = "ffsb" -__version__ = "0.1" +__version__ = "0.1" #initial +__version__ = "0.2" # added logic to retry omada api call once as it seems to sometimes fail for some reasons, and error handling logic... # query OMADA SDN to populate NetAlertX witch omada switches, access points, clients. # try to identify and populate their connections by switch/accesspoints and ports/SSID # try to differentiate root bridges from accessory -# how to rebuild and re-run... -# sudo docker-compose --env-file ../.env.omada.ffsb42 -f ../docker-compose.yml.ffsb42 up - # # sample code to update unbound on opnsense - for reference... @@ -48,15 +46,14 @@ pluginName = 'OMDSDN' # sample target output: # 0)MAC, 1)IP, 2)Name, 3)switch/AP, 4)port/SSID, 5)TYPE # "['9C-04-A0-82-67-45', '192.168.0.217', 'foo', '40-AE-30-A5-A7-50, '17', 'Switch']" - - +# constants: MAC = 0 IP = 1 NAME = 2 SWITCH_AP = 3 PORT_SSID = 4 TYPE = 5 - +OMDLOGLEVEL='debug' # # translate MAC address from standard ieee model to ietf draft # AA-BB-CC-DD-EE-FF to aa:bb:cc:dd:ee:ff @@ -79,7 +76,7 @@ def get_mac_from_IP(target_IP): else: return None except Exception as e: - mylog('verbose', [f'[{pluginName}] get_mac_from_IP ERROR:{e}']) + mylog('minimal', [f'[{pluginName}] get_mac_from_IP ERROR:{e}']) return None @@ -93,14 +90,18 @@ def callomada(myargs): mylog('verbose', [f'[{pluginName}] callomada:{arguments}']) from tplink_omada_client.cli import main as omada from contextlib import redirect_stdout - try: - mf = io.StringIO() - with redirect_stdout(mf): - bar = omada(myargs) - omada_output = mf.getvalue() - except Exception as e: - mylog('verbose', [f'[{pluginName}] ERROR WHILE CALLING callomada:{arguments}\n {mf}']) - omada_output= '' + omada_output = '' + retries = 2 + while omada_output == '' and retries > 1: + retries = retries - 1 + try: + mf = io.StringIO() + with redirect_stdout(mf): + bar = omada(myargs) + omada_output = mf.getvalue() + except Exception as e: + mylog('minimal', [f'[{pluginName}] ERROR WHILE CALLING callomada:{arguments}\n {mf}']) + omada_output= '' return(omada_output) # @@ -125,13 +126,13 @@ def find_default_gateway_ip (): """ def find_port_of_uplink_switch(switch_mac, uplink_mac): - mylog('verbose', [f'[{pluginName}] find_port uplink="{uplink_mac}" on switch="{switch_mac}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] find_port uplink="{uplink_mac}" on switch="{switch_mac}"']) myport = [] switchdump = callomada(['-t','myomada','switch','-d',switch_mac]) port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(uplink_mac)+r")" myport = re.findall(port_pattern, switchdump,re.DOTALL) # print("myswitch=",mymac, "- link_switch=", mylink, "myport=", myport) - mylog('verbose', [f'[{pluginName}] finding port="{myport}" of uplink switch="{uplink_mac}" on switch="{switch_mac}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] finding port="{myport}" of uplink switch="{uplink_mac}" on switch="{switch_mac}"']) try: myport2=myport[0] except IndexError: @@ -142,8 +143,8 @@ def find_port_of_uplink_switch(switch_mac, uplink_mac): def add_uplink (uplink_mac, switch_mac, device_data_bymac, sadevices_linksbymac,port_byswitchmac_byclientmac): - mylog('verbose', [f'[{pluginName}] trying to add uplink="{uplink_mac}" to switch="{switch_mac}"']) - mylog('verbose', [f'[{pluginName}] before adding:"{device_data_bymac[switch_mac]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] trying to add uplink="{uplink_mac}" to switch="{switch_mac}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] before adding:"{device_data_bymac[switch_mac]}"']) if device_data_bymac[switch_mac][SWITCH_AP] == 'null': device_data_bymac[switch_mac][SWITCH_AP] = uplink_mac if device_data_bymac[switch_mac][TYPE] == 'Switch' and device_data_bymac[uplink_mac][TYPE] == 'Switch': @@ -152,7 +153,7 @@ def add_uplink (uplink_mac, switch_mac, device_data_bymac, sadevices_linksbymac, else: port_to_uplink=device_data_bymac[uplink_mac][PORT_SSID] device_data_bymac[switch_mac][PORT_SSID] = port_to_uplink - mylog('verbose', [f'[{pluginName}] after adding:"{device_data_bymac[switch_mac]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] after adding:"{device_data_bymac[switch_mac]}"']) for link in sadevices_linksbymac[switch_mac]: if device_data_bymac[link][SWITCH_AP] == 'null' and device_data_bymac[switch_mac][TYPE] == 'Switch': add_uplink(switch_mac, link, device_data_bymac, sadevices_linksbymac,port_byswitchmac_byclientmac) @@ -192,8 +193,8 @@ def main(): #some_setting = get_setting_value('OMDSDN_url') - #mylog('verbose', [f'[{pluginName}] some_setting value {some_setting}']) - mylog('verbose', [f'[{pluginName}] ffsb']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}] some_setting value {some_setting}']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] ffsb']) @@ -215,13 +216,13 @@ def main(): # figure a way to run my udpate script delayed for device in device_data: - mylog('verbose', [f'[{pluginName}] main parsing device: "{device}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] main parsing device: "{device}"']) if device[PORT_SSID].isdigit(): myport = device[PORT_SSID] - myssid = '' + myssid = 'null' else: myssid = device[PORT_SSID] - myport = '' + myport = 'null' if device[SWITCH_AP] != 'Internet': ParentNetworkNode = ieee2ietf_mac_formater(device[SWITCH_AP]) else: @@ -236,13 +237,13 @@ def main(): extra = device[TYPE], #omada_site, # SITENAME (cur_NetworkSite) or VENDOR (cur_Vendor) (PICK one and adjust config.json -> "column": "Extra") foreignKey = device[MAC].lower().replace('-',':')) # usually MAC - mylog('verbose', [f'[{pluginName}] New entries: "{len(device_data)}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] New entries: "{len(device_data)}"']) # log result plugin_objects.write_result_file() - #mylog('verbose', [f'[{pluginName}] TEST name from MAC: {device_handler.getValueWithMac('dev_Name','00:e2:59:00:a0:8e')}']) - #mylog('verbose', [f'[{pluginName}] TEST MAC from IP: {get_mac_from_IP('192.168.0.1')} also {ietf2ieee_mac_formater(get_mac_from_IP('192.168.0.1'))}']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}] TEST name from MAC: {device_handler.getValueWithMac('dev_Name','00:e2:59:00:a0:8e')}']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}] TEST MAC from IP: {get_mac_from_IP('192.168.0.1')} also {ietf2ieee_mac_formater(get_mac_from_IP('192.168.0.1'))}']) return 0 @@ -263,7 +264,13 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): # sample target output: # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, 5 TYPE #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', '17', '40-AE-30-A5-A7-50, 'Switch']" - + #constants + dMAC = 0 + dIP = 1 + dTYPE = 2 + dSTATUS = 3 + dNAME = 4 + dMODEL = 5 sadevices_macbyname = {} sadevices_macbymac = {} sadevices_linksbymac = {} @@ -273,23 +280,29 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): omada_force_overwrite = get_setting_value('OMDSDN_force_overwrite') sadevices = switches_and_aps.splitlines() - mylog('verbose', [f'[{pluginName}] switches_and_aps rows: "{len(sadevices)}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] switches_and_aps rows: "{len(sadevices)}"']) for sadevice in sadevices: sadevice_data = sadevice.split() - thisswitch = sadevice_data[0] + thisswitch = sadevice_data[dMAC] sadevices_macbyname[sadevice_data[4]] = thisswitch - if sadevice_data[2] == 'ap': + if sadevice_data[dTYPE] == 'ap': sadevice_type = 'AP' sadevice_details = callomada(['access-point', thisswitch]) - sadevice_links = extract_mac_addresses(sadevice_details) + if sadevice_details == '': + sadevice_links = [thisswitch] + else: + sadevice_links = extract_mac_addresses(sadevice_details) sadevices_linksbymac[thisswitch] = sadevice_links[1:] - mylog('verbose', [f'[{pluginName}]adding switch details: "{sadevice_details}"']) - mylog('verbose', [f'[{pluginName}]links are: "{sadevice_links}"']) - mylog('verbose', [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) - elif sadevice_data[2] == 'switch': + mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + elif sadevice_data[dTYPE] == 'switch': sadevice_type = 'Switch' sadevice_details=callomada(['switch', thisswitch]) - sadevice_links=extract_mac_addresses(sadevice_details) + if sadevice_details == '': + sadevice_links = [thisswitch] + else: + sadevice_links=extract_mac_addresses(sadevice_details) sadevices_linksbymac[thisswitch] = sadevice_links[1:] # recovering the list of switches connected to sadevice switch and on which port... switchdump = callomada(['-t','myomada','switch','-d',thisswitch]) @@ -298,26 +311,25 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(link)+r")" myport = re.findall(port_pattern, switchdump,re.DOTALL) port_byswitchmac_byclientmac[thisswitch][link] = myport[0] - mylog('verbose', [f'[{pluginName}]links are: "{sadevice_links}"']) - mylog('verbose', [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) - mylog('verbose', [f'[{pluginName}]ports of each links are: "{port_byswitchmac_byclientmac[thisswitch]}"']) - mylog('verbose', [f'[{pluginName}]adding switch details: "{sadevice_details}"']) - + mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]ports of each links are: "{port_byswitchmac_byclientmac[thisswitch]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) else: sadevice_type = 'null' sadevice_details='null' - device_data_bymac[thisswitch] = [thisswitch, sadevice_data[1], sadevice_data[4], 'null', 'null',sadevice_type] - device_data_mac_byip[sadevice_data[1]] = thisswitch + device_data_bymac[thisswitch] = [thisswitch, sadevice_data[dIP], sadevice_data[dNAME], 'null', 'null',sadevice_type] + device_data_mac_byip[sadevice_data[dIP]] = thisswitch foo=[thisswitch, sadevice_data[1], sadevice_data[4], 'null', 'null'] - mylog('verbose', [f'[{pluginName}]adding switch: "{foo}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch: "{foo}"']) # sadevices_macbymac[thisswitch] = thisswitch - mylog('verbose', [f'[{pluginName}] switch_macbyname: "{sadevices_macbyname}"']) - mylog('verbose', [f'[{pluginName}] switches: "{device_data_bymac}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] switch_macbyname: "{sadevices_macbyname}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] switches: "{device_data_bymac}"']) # do some processing, call exteranl APIs, and return a device list @@ -334,52 +346,65 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): #17:27:10 [] token: "['50-02-91-29-E7-53', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" #17:27:10 [] token: "['00-E2-59-00-A0-8E', '192.168.0.1', 'bastion', 'office24', '(23)']" #17:27:10 [] token: "['60-DD-8E-CA-A4-B3', '192.168.0.226', 'brick', 'froggies3', '(ompapaoffice)']" + cMAC = 0 + cIP = 1 + cNAME = 2 + cSWITCH_AP = 3 + cPORT_SSID = 4 + # sample target output: # 0 MAC, 1 IP, 2 Name, 3 MAC of switch/AP, 4 port/SSID, 5 TYPE #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', 'brick', 'ompapaoffice','froggies2', , 'Switch']" odevices = omada_clients_output.splitlines() - mylog('verbose', [f'[{pluginName}] omada_clients_outputs rows: "{len(odevices)}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] omada_clients_outputs rows: "{len(odevices)}"']) for odevice in odevices: odevice_data = odevice.split() odevice_data_reordered = [ MAC, IP, NAME, SWITCH_AP, PORT_SSID, TYPE] - odevice_data_reordered[MAC]=odevice_data[0] - odevice_data_reordered[IP]=odevice_data[1] - naxname = device_handler.getValueWithMac('dev_Name',ieee2ietf_mac_formater(odevice_data[MAC])) - if naxname == None or ietf2ieee_mac_formater(naxname) == odevice_data[MAC] or '('in naxname: + odevice_data_reordered[MAC]=odevice_data[cMAC] + odevice_data_reordered[IP]=odevice_data[cIP] + real_naxname = device_handler.getValueWithMac('dev_Name',ieee2ietf_mac_formater(odevice_data[cMAC])) + + # + # if the name stored in Nax for a device is empty or the MAC addres or has some parenthhesis or is the same as in omada + # don't bother updating omada's name at all. + # + if real_naxname == None or ietf2ieee_mac_formater(real_naxname) == odevice_data[cMAC] or '('in real_naxname or real_naxname == odevice_data[cNAME] or real_naxname == 'null': naxname = None - mylog('verbose', [f'[{pluginName}] TEST name from MAC: {naxname}']) - if odevice_data[MAC] == odevice_data[NAME]: + else: + naxname = real_naxname + mylog('debug', [f'[{pluginName}] TEST name from MAC: {naxname}']) + if odevice_data[cMAC] == odevice_data[cNAME]: if naxname != None: - callomada(['set-client-name', odevice_data[MAC], naxname]) + callomada(['set-client-name', odevice_data[cMAC], naxname]) odevice_data_reordered[NAME] = naxname else: - odevice_data_reordered[NAME] = 'null' + odevice_data_reordered[NAME] = real_naxname else: if omada_force_overwrite and naxname != None: - callomada(['set-client-name', odevice_data[MAC], naxname]) - odevice_data_reordered[NAME] = odevice_data[2] - mightbeport = odevice_data[4].lstrip('(') + callomada(['set-client-name', odevice_data[cMAC], naxname]) + odevice_data_reordered[NAME] = odevice_data[cNAME] + mightbeport = odevice_data[cPORT_SSID].lstrip('(') mightbeport = mightbeport.rstrip(')') if mightbeport.isdigit(): - odevice_data_reordered[SWITCH_AP] = odevice_data[3] + odevice_data_reordered[SWITCH_AP] = odevice_data[cSWITCH_AP] odevice_data_reordered[PORT_SSID] = mightbeport else: odevice_data_reordered[SWITCH_AP] = mightbeport - odevice_data_reordered[PORT_SSID] = odevice_data[3] + odevice_data_reordered[PORT_SSID] = odevice_data[cSWITCH_AP] # replacing the switch name with its MAC... try: mightbemac = sadevices_macbyname[odevice_data_reordered[SWITCH_AP]] odevice_data_reordered[SWITCH_AP] = mightbemac except KeyError: - mylog('verbose', [f'[{pluginName}] could not find the mac adddress for: "{odevice_data_reordered[SWITCH_AP]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] could not find the mac adddress for: "{odevice_data_reordered[SWITCH_AP]}"']) # adding the type - odevice_data_reordered[TYPE] = '' + odevice_data_reordered[TYPE] = 'null' device_data_bymac[odevice_data_reordered[MAC]] = odevice_data_reordered device_data_mac_byip[odevice_data_reordered[IP]] = odevice_data_reordered[MAC] - mylog('verbose', [f'[{pluginName}] tokens: "{odevice_data}"']) - mylog('verbose', [f'[{pluginName}] tokens_reordered: "{odevice_data_reordered}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] tokens: "{odevice_data}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] tokens_reordered: "{odevice_data_reordered}"']) # populating the uplinks nodes of the omada switches and access points manually # since OMADA SDN makes is unreliable if the gateway is not their own tplink hardware... diff --git a/front/plugins/omada_sdn_imp/testre.py b/front/plugins/omada_sdn_imp/testre.py index c04e7655..bf5648cf 100644 --- a/front/plugins/omada_sdn_imp/testre.py +++ b/front/plugins/omada_sdn_imp/testre.py @@ -1,5 +1,38 @@ import re +"""" +how to rebuild and re-run... +savefolder=~/naxdev/NetAlertX.v6 +cd ~/naxdev +mv NetAlertX $savefolder +gh repo clone FlyingToto/NetAlertX +cd NetAlertX +ln -s ../docker-compose.yml.ffsb42 . +ln -s ../.env.omada.ffsb42 . +cd front/plugins/omada_sdn_imp/ +cp -p $savefoder/front/plugins/omada_sdn_imp/omada_sdn.py . +cp -p $savefoder/front/plugins/omada_sdn_imp/README.md . +cp -p $savefoder/front/plugins/omada_sdn_imp/omada_account_sample.png . +cp -p $savefoder/front/plugins/omada_sdn_imp/testre.py . +cp -p $savefoder/front/plugins/omada_sdn_imp/config.json config.json.v6 +cd ~/naxdev/NetAlertX +sudo docker-compose --env-file .env.omada.ffsb42 -f ./docker-compose.yml.ffsb42 up + +to gather data for Boris: +today=$(date +%Y_%m_%d__%H_%M) +mkdir /drives/c/temp/4boris/$today +cd /drives/c/temp/4boris/$today +scp hal:~/naxdev/logs/app.log . +scp hal:~/naxdev/NetAlertX/front/plugins/omada_sdn_imp/* . +gzip -c app.log > app.$today.log.gz + + + +""" + + + + def extract_mac_addresses(text): mac_pattern = r"([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})" #mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})' @@ -77,7 +110,7 @@ def extract_uplinks_mac_and_ports(tplink_device_dump): return(0) - +''' with open('/tmp/switch.bigroom.dump.json', 'r') as file: foo3 = file_content = file.read() print("bigroom", end="") @@ -86,7 +119,7 @@ with open('/tmp/switch.office.dump.json', 'r') as file: foo4 = file_content = file.read() print("office", end="") extract_uplinks_mac_and_ports(foo4) - +''' import netifaces gw = netifaces.gateways() @@ -98,3 +131,12 @@ d = {'a': ['0', 'Arthur'], 'b': ['foo', 'Belling']} print(d.items()) print(d.keys()) print(d.values()) + + +foo = 2 +#while foo > 0: +# foo = 'toto' + +print("foo is ",foo) + + From 147166e46eb21d5d5beadf48d5a3ad4086466d0a Mon Sep 17 00:00:00 2001 From: ffsb Date: Tue, 16 Jul 2024 17:47:34 -0400 Subject: [PATCH 4/7] 0.4 saving api to files --- front/plugins/omada_sdn_imp/README.md | 13 +- front/plugins/omada_sdn_imp/omada_sdn.py | 163 ++++-- front/plugins/omada_sdn_imp/omada_sdn.py.0.3 | 498 +++++++++++++++++++ front/plugins/omada_sdn_imp/testre.py | 47 +- 4 files changed, 665 insertions(+), 56 deletions(-) create mode 100755 front/plugins/omada_sdn_imp/omada_sdn.py.0.3 diff --git a/front/plugins/omada_sdn_imp/README.md b/front/plugins/omada_sdn_imp/README.md index dc06d17e..671cc73d 100755 --- a/front/plugins/omada_sdn_imp/README.md +++ b/front/plugins/omada_sdn_imp/README.md @@ -6,11 +6,11 @@ The OMADA SDN plugin aims at synchronizing data between NetAlertX and a TPLINK O 2. extract list of OAMDA Devices (switches and access points) and sync them up with NetAlertX > [!TIP] -> Some tip. +> some omada devices are apparently not fully compatible with the API which might lead to partial results. ### Quick setup guide -1. You SHOULD (ie: strongly recommend) setting up a dedicated account in your OMADA SDN console dedicated to NetAlertX OMADA_SDN plugin. +1. You SHOULD (ie: strongly recommend) set up an account in your OMADA SDN console dedicated to NetAlertX OMADA_SDN plugin. - you should set USER TYPE = Local USer - you should set USER ROLE = Administrator (if you use a read-only role you won't be able to sync names from NetAlerX to OMADA SDN) - you can set Site Privileges = All Sites (or limit it to specific sites ) @@ -19,12 +19,15 @@ The OMADA SDN plugin aims at synchronizing data between NetAlertX and a TPLINK O -To set up the plugin correctly, make sure... #### Required Settings -- When to run `PREF_RUN` -- +- OMDSDN_url +- OMDSDN_sites +- OMDSDN_username +- OMDSDN_password +- OMDSDN_force_overwrite + ### Usage diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py b/front/plugins/omada_sdn_imp/omada_sdn.py index 365f5633..ff807327 100755 --- a/front/plugins/omada_sdn_imp/omada_sdn.py +++ b/front/plugins/omada_sdn_imp/omada_sdn.py @@ -2,6 +2,7 @@ __author__ = "ffsb" __version__ = "0.1" #initial __version__ = "0.2" # added logic to retry omada api call once as it seems to sometimes fail for some reasons, and error handling logic... +__version__ = "0.3" # split devices API calls to allow multithreading but had to stop due to concurency issues. # query OMADA SDN to populate NetAlertX witch omada switches, access points, clients. # try to identify and populate their connections by switch/accesspoints and ports/SSID # try to differentiate root bridges from accessory @@ -21,6 +22,8 @@ import importlib.util import time import io import re +import concurrent.futures + #import netifaces # Define the installation path and extend the system path for plugin imports @@ -38,22 +41,35 @@ from notification import write_notification CUR_PATH = str(pathlib.Path(__file__).parent.resolve()) LOG_FILE = os.path.join(CUR_PATH, 'script.log') RESULT_FILE = os.path.join(CUR_PATH, 'last_result.log') +OMADA_API_RETURN_FILE = os.path.join(CUR_PATH, 'omada_api_return') # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) - -pluginName = 'OMDSDN' +# # sample target output: -# 0)MAC, 1)IP, 2)Name, 3)switch/AP, 4)port/SSID, 5)TYPE -# "['9C-04-A0-82-67-45', '192.168.0.217', 'foo', '40-AE-30-A5-A7-50, '17', 'Switch']" -# constants: -MAC = 0 -IP = 1 -NAME = 2 -SWITCH_AP = 3 -PORT_SSID = 4 -TYPE = 5 -OMDLOGLEVEL='debug' +# 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, 5 TYPE +#17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', '17', '40-AE-30-A5-A7-50, 'Switch']" + +# Constants for array indices +MAC, IP, NAME, SWITCH_AP, PORT_SSID, TYPE = range(6) + +# sample omada devices input format: +# +# 0.MAC 1.IP 2.type 3.status 4.name 5.model +#40-AE-30-A5-A7-50 192.168.0.11 ap CONNECTED ompapaoffice EAP773(US) v1.0 +#B0-95-75-46-0C-39 192.168.0.4 switch CONNECTED pantry12 T1600G-52PS v4.0 +dMAC, dIP, dTYPE, dSTATUS, dNAME, dMODEL = range(6) + +# sample omada clients input format: +# 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, +#17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', 'froggies2', '(ompapaoffice)']" +#17:27:10 [] token: "['50-02-91-29-E7-53', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" +#17:27:10 [] token: "['00-E2-59-00-A0-8E', '192.168.0.1', 'bastion', 'office24', '(23)']" +#17:27:10 [] token: "['60-DD-8E-CA-A4-B3', '192.168.0.226', 'brick', 'froggies3', '(ompapaoffice)']" +cMAC, cIP, cNAME, cSWITCH_AP, cPORT_SSID = range(5) + +OMDLOGLEVEL = 'debug' +pluginName = 'OMDSDN' # # translate MAC address from standard ieee model to ietf draft # AA-BB-CC-DD-EE-FF to aa:bb:cc:dd:ee:ff @@ -217,18 +233,12 @@ def main(): for device in device_data: mylog(OMDLOGLEVEL, [f'[{pluginName}] main parsing device: "{device}"']) - if device[PORT_SSID].isdigit(): - myport = device[PORT_SSID] - myssid = 'null' - else: - myssid = device[PORT_SSID] - myport = 'null' - if device[SWITCH_AP] != 'Internet': - ParentNetworkNode = ieee2ietf_mac_formater(device[SWITCH_AP]) - else: - ParentNetworkNode = device[SWITCH_AP] + myport = device[PORT_SSID] if device[PORT_SSID].isdigit() else '' + myssid = device[PORT_SSID] if not device[PORT_SSID].isdigit() else '' + ParentNetworkNode = ieee2ietf_mac_formater(device[SWITCH_AP]) if device[SWITCH_AP] != 'Internet' else 'Internet' + mymac = ieee2ietf_mac_formater(device[MAC]) plugin_objects.add_object( - primaryId = ieee2ietf_mac_formater(device[MAC]), # MAC + primaryId = mymac, # MAC secondaryId = device[IP], # IP watched1 = device[NAME], # NAME/HOSTNAME watched2 = ParentNetworkNode, # PARENT NETWORK NODE MAC @@ -237,17 +247,46 @@ def main(): extra = device[TYPE], #omada_site, # SITENAME (cur_NetworkSite) or VENDOR (cur_Vendor) (PICK one and adjust config.json -> "column": "Extra") foreignKey = device[MAC].lower().replace('-',':')) # usually MAC - mylog(OMDLOGLEVEL, [f'[{pluginName}] New entries: "{len(device_data)}"']) + + mylog('verbose', [f'[{pluginName}] New entries: "{mymac:<18}, {device[IP]:<16}, {device[NAME]:<63}, {ParentNetworkNode:<18}, {myport:<4}, {myssid:<32}, {device[TYPE]}"']) + mylog('verbose', [f'[{pluginName}] New entries: "{len(device_data)}"']) # log result plugin_objects.write_result_file() #mylog(OMDLOGLEVEL, [f'[{pluginName}] TEST name from MAC: {device_handler.getValueWithMac('dev_Name','00:e2:59:00:a0:8e')}']) #mylog(OMDLOGLEVEL, [f'[{pluginName}] TEST MAC from IP: {get_mac_from_IP('192.168.0.1')} also {ietf2ieee_mac_formater(get_mac_from_IP('192.168.0.1'))}']) + end_time = time.time() + mylog('verbose', [f'[{pluginName}] execution completed in {end_time - start_time:.2f} seconds']) return 0 +def get_omada_devices_details(msadevice_data): + mthisswitch = msadevice_data[dMAC] + mtype = msadevice_data[dTYPE] + mswitch_detail = '' + mswitch_dump = '' + if mtype == 'ap': + mswitch_detail = callomada(['access-point', mthisswitch]) + elif mtype == 'switch': + mswitch_detail = callomada(['switch', mthisswitch]) + mswitch_dump = callomada(['-t','myomada','switch','-d',mthisswitch]) + else: + mswitch_detail = '' + nswitch_dump = '' + details_outfile = OMADA_API_RETURN_FILE+"_"+mthisswitch+"_det" + dump_outfile = OMADA_API_RETURN_FILE+"_"+mthisswitch+"_dmp" + for tmpdfle in [details_outfile+".tmp", dump_outfile+".tmp", details_outfile+".txt", dump_outfile+".txt"]: + if os.path.exists(tmpdfle): + os.remove(tmpdfle) + with open(details_outfile+".tmp", 'w') as f: + f.write(mswitch_detail) + with open(dump_outfile+".tmp", 'w') as f: + f.write(mswitch_dump) + os.rename(details_outfile+".tmp", details_outfile+".txt") + os.rename(dump_outfile+".tmp", dump_outfile+".txt") + return mswitch_detail, mswitch_dump @@ -265,12 +304,6 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, 5 TYPE #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', '17', '40-AE-30-A5-A7-50, 'Switch']" #constants - dMAC = 0 - dIP = 1 - dTYPE = 2 - dSTATUS = 3 - dNAME = 4 - dMODEL = 5 sadevices_macbyname = {} sadevices_macbymac = {} sadevices_linksbymac = {} @@ -278,16 +311,30 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): device_data_bymac = {} device_data_mac_byip = {} omada_force_overwrite = get_setting_value('OMDSDN_force_overwrite') + switch_details = {} + switch_dumps = {} sadevices = switches_and_aps.splitlines() mylog(OMDLOGLEVEL, [f'[{pluginName}] switches_and_aps rows: "{len(sadevices)}"']) + + for sadevice in sadevices: + sadevice_data = sadevice.split() + thisswitch = sadevice_data[dMAC] + thistype = sadevice_data[dTYPE] + switch_details[thisswitch], switch_dumps[thisswitch] = get_omada_devices_details(sadevice_data) + + mylog('verbose', [f'[{pluginName}] switches details collected "{len(switch_details)}"']) + mylog('verbose', [f'[{pluginName}] dump details collected "{len(switch_details)}"']) + # Using ThreadPoolExecutor for parallel execution + for sadevice in sadevices: sadevice_data = sadevice.split() thisswitch = sadevice_data[dMAC] sadevices_macbyname[sadevice_data[4]] = thisswitch if sadevice_data[dTYPE] == 'ap': sadevice_type = 'AP' - sadevice_details = callomada(['access-point', thisswitch]) + #sadevice_details = callomada(['access-point', thisswitch]) + sadevice_details = switch_details[thisswitch] if sadevice_details == '': sadevice_links = [thisswitch] else: @@ -298,23 +345,27 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) elif sadevice_data[dTYPE] == 'switch': sadevice_type = 'Switch' - sadevice_details=callomada(['switch', thisswitch]) + #sadevice_details=callomada(['switch', thisswitch]) + sadevice_details = switch_details[thisswitch] if sadevice_details == '': sadevice_links = [thisswitch] else: sadevice_links=extract_mac_addresses(sadevice_details) sadevices_linksbymac[thisswitch] = sadevice_links[1:] # recovering the list of switches connected to sadevice switch and on which port... - switchdump = callomada(['-t','myomada','switch','-d',thisswitch]) + #switchdump = callomada(['-t','myomada','switch','-d',thisswitch]) + switchdump = switch_dumps[thisswitch] + mylog(OMDLOGLEVEL, [f'[{pluginName}] switchdump: {switchdump}']) port_byswitchmac_byclientmac[thisswitch] = {} for link in sadevices_linksbymac[thisswitch]: port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(link)+r")" myport = re.findall(port_pattern, switchdump,re.DOTALL) - port_byswitchmac_byclientmac[thisswitch][link] = myport[0] - mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) - mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) - mylog(OMDLOGLEVEL, [f'[{pluginName}]ports of each links are: "{port_byswitchmac_byclientmac[thisswitch]}"']) - mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] switchdump: link={link} myport:{myport}']) + port_byswitchmac_byclientmac[thisswitch][link] = myport[0] if myport else '' + #mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}]ports of each links are: "{port_byswitchmac_byclientmac[thisswitch]}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) else: sadevice_type = 'null' sadevice_details='null' @@ -334,24 +385,13 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): # do some processing, call exteranl APIs, and return a device list # ... - """ MAC = 0 - IP = 1 - NAME = 2 - SWITCH_AP = 3 - PORT_SSID = 4 - TYPE = 5 """ # sample omada clients input format: # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', 'froggies2', '(ompapaoffice)']" #17:27:10 [] token: "['50-02-91-29-E7-53', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" #17:27:10 [] token: "['00-E2-59-00-A0-8E', '192.168.0.1', 'bastion', 'office24', '(23)']" #17:27:10 [] token: "['60-DD-8E-CA-A4-B3', '192.168.0.226', 'brick', 'froggies3', '(ompapaoffice)']" - cMAC = 0 - cIP = 1 - cNAME = 2 - cSWITCH_AP = 3 - cPORT_SSID = 4 - + # sample target output: # 0 MAC, 1 IP, 2 Name, 3 MAC of switch/AP, 4 port/SSID, 5 TYPE #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', 'brick', 'ompapaoffice','froggies2', , 'Switch']" @@ -369,6 +409,7 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): # if the name stored in Nax for a device is empty or the MAC addres or has some parenthhesis or is the same as in omada # don't bother updating omada's name at all. # + ''' if real_naxname == None or ietf2ieee_mac_formater(real_naxname) == odevice_data[cMAC] or '('in real_naxname or real_naxname == odevice_data[cNAME] or real_naxname == 'null': naxname = None else: @@ -384,6 +425,28 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): if omada_force_overwrite and naxname != None: callomada(['set-client-name', odevice_data[cMAC], naxname]) odevice_data_reordered[NAME] = odevice_data[cNAME] + ''' + + naxname = real_naxname + if real_naxname != None: + if '(' in real_naxname: + # removing parenthesis and domains from the name + naxname = real_naxname.split('(')[0] + if naxname != None and '.' in naxname: + naxname = naxname.split('.')[0] + if naxname in ( None, 'null', '' ): + naxname = odevice_data[cNAME] if odevice_data[cNAME] != '' else odevice_data[cMAC] + naxname = naxname.strip() + mylog('debug', [f'[{pluginName}] TEST name from MAC: {naxname}']) + if odevice_data[cNAME] in (odevice_data[cMAC], 'null', ''): + mylog('verbose', [f'[{pluginName}] updating omada server because odevice_data is: {odevice_data[cNAME]} and naxname is: "{naxname}"']) + callomada(['set-client-name', odevice_data[cMAC], naxname]) + odevice_data_reordered[NAME] = naxname + else: + if omada_force_overwrite and naxname != odevice_data[cNAME] : + mylog('verbose', [f'[{pluginName}] updating omada server because odevice_data is: "{odevice_data[cNAME]} and naxname is: "{naxname}"']) + callomada(['set-client-name', odevice_data[cMAC], naxname]) + odevice_data_reordered[NAME] = naxname mightbeport = odevice_data[cPORT_SSID].lstrip('(') mightbeport = mightbeport.rstrip(')') if mightbeport.isdigit(): diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py.0.3 b/front/plugins/omada_sdn_imp/omada_sdn.py.0.3 new file mode 100755 index 00000000..2adb2972 --- /dev/null +++ b/front/plugins/omada_sdn_imp/omada_sdn.py.0.3 @@ -0,0 +1,498 @@ +#!/usr/bin/env python +""" +Omada SDN Query Script + +This script queries the OMADA SDN to populate NetAlertX with Omada switches, access points, and clients. +It attempts to identify and populate their connections by switch/access points and ports/SSID, +and tries to differentiate root bridges from accessories. + +Author: ffsb +Version: 0.2 - Added logic to retry Omada API call once as it sometimes fails, and improved error handling. +""" +__author__ = "ffsb" +__version__ = "0.1" #initial +__version__ = "0.2" # added logic to retry omada api call once as it seems to sometimes fail for some reasons, and error handling logic... +__version__ = "0.3" # adding parallelism + +# +# sample code to update unbound on opnsense - for reference... +# curl -X POST -d '{"host":{"enabled":"1","hostname":"test","domain":"testdomain.com","rr":"A","mxprio":"","mx":"","server":"10.0.1.1","description":""}}' -H "Content-Type: application/json" -k -u $OPNS_KEY:$OPNS_SECRET https://$IPFW/api/unbound/settings/AddHostOverride +# +import os +import pathlib +import sys +import json +import sqlite3 +import tplink_omada_client +import importlib.util +import time +import io +import re +import concurrent.futures +from queue import Queue +import multiprocessing +from multiprocessing import Pool, Manager +import os + +# Define the installation path and extend the system path for plugin imports +INSTALL_PATH = "/app" +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 +from plugin_utils import get_plugins_configs +from logger import mylog +from const import pluginsPath, fullDbPath +from helper import timeNowTZ, get_setting_value +from notification import write_notification + +# Define the current path and log file paths +CUR_PATH = str(pathlib.Path(__file__).parent.resolve()) +LOG_FILE = os.path.join(CUR_PATH, 'script.log') +RESULT_FILE = os.path.join(CUR_PATH, 'last_result.log') + +# Initialize the Plugin obj output file +plugin_objects = Plugin_Objects(RESULT_FILE) + +pluginName = 'OMDSDN' +# +# sample target output: +# 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, 5 TYPE +#17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', '17', '40-AE-30-A5-A7-50, 'Switch']" + +# Constants for array indices +MAC, IP, NAME, SWITCH_AP, PORT_SSID, TYPE = range(6) + +# sample omada devices input format: +# +# 0.MAC 1.IP 2.type 3.status 4.name 5.model +#40-AE-30-A5-A7-50 192.168.0.11 ap CONNECTED ompapaoffice EAP773(US) v1.0 +#B0-95-75-46-0C-39 192.168.0.4 switch CONNECTED pantry12 T1600G-52PS v4.0 +dMAC, dIP, dTYPE, dSTATUS, dNAME, dMODEL = range(6) + +# sample omada clients input format: +# 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, +#17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', 'froggies2', '(ompapaoffice)']" +#17:27:10 [] token: "['50-02-91-29-E7-53', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" +#17:27:10 [] token: "['00-E2-59-00-A0-8E', '192.168.0.1', 'bastion', 'office24', '(23)']" +#17:27:10 [] token: "['60-DD-8E-CA-A4-B3', '192.168.0.226', 'brick', 'froggies3', '(ompapaoffice)']" +cMAC, cIP, cNAME, cSWITCH_AP, cPORT_SSID = range(5) + +OMDLOGLEVEL = 'verbose' + +def ieee2ietf_mac_formater(inputmac): + """Translate MAC address from standard IEEE model to IETF draft.""" + return inputmac.lower().replace('-', ':') + +def ietf2ieee_mac_formater(inputmac): + """Translate MAC address from IETF draft to standard IEEE model.""" + return inputmac.upper().replace(':', '-') + +def get_mac_from_IP(target_IP): + """Get MAC address from IP using ARP.""" + from scapy.all import ARP, Ether, srp + try: + arp_request = ARP(pdst=target_IP) + ether = Ether(dst="ff:ff:ff:ff:ff:ff") + packet = ether/arp_request + result = srp(packet, timeout=3, verbose=0)[0] + if result: + return result[0][1].hwsrc + else: + return None + except Exception as e: + mylog('minimal', [f'[{pluginName}] get_mac_from_IP ERROR:{e}']) + return None + +def callomada(myargs): + """Wrapper to call the Omada python library's own wrapper.""" + arguments = " ".join(myargs) + mylog('verbose', [f'[{pluginName}] callomada START:{arguments}']) + from tplink_omada_client.cli import main as omada + from contextlib import redirect_stdout + + omada_output = '' + retries = 2 + while omada_output == '' and retries > 0: + retries -= 1 + try: + mf = io.StringIO() + with redirect_stdout(mf): + omada(myargs) + omada_output = mf.getvalue() + except Exception as e: + mylog('minimal', [f'[{pluginName}] ERROR WHILE CALLING callomada:{arguments}\n {e}']) + omada_output = '' + mylog('verbose', [f'[{pluginName}] callomada END:{arguments}']) + return omada_output + +def extract_mac_addresses(text): + """Extract all the MAC addresses from multiline text.""" + mac_pattern = r"([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})" + return re.findall(mac_pattern, text) + +def find_default_gateway_ip(): + """Find the default gateway IP address.""" + from scapy.all import conf, Route + default_route = conf.route.route("0.0.0.0") + return default_route[2] if default_route[2] else None + +def add_uplink(uplink_mac, switch_mac, device_data_bymac, sadevices_linksbymac, port_byswitchmac_byclientmac): + """Add uplink information to switches recursively.""" + mylog(OMDLOGLEVEL, [f'[{pluginName}] trying to add uplink="{uplink_mac}" to switch="{switch_mac}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] before adding:"{device_data_bymac[switch_mac]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] this are the port_byswitchmac:"{port_byswitchmac_byclientmac[switch_mac]}"']) + + if device_data_bymac[switch_mac][SWITCH_AP] == 'null': + device_data_bymac[switch_mac][SWITCH_AP] = uplink_mac + if device_data_bymac[switch_mac][TYPE] == 'Switch' and device_data_bymac[uplink_mac][TYPE] == 'Switch': + port_to_uplink = port_byswitchmac_byclientmac[switch_mac][uplink_mac] + else: + port_to_uplink = device_data_bymac[uplink_mac][PORT_SSID] + device_data_bymac[switch_mac][PORT_SSID] = port_to_uplink + mylog(OMDLOGLEVEL, [f'[{pluginName}] after adding:"{device_data_bymac[switch_mac]}"']) + + for link in sadevices_linksbymac[switch_mac]: + if device_data_bymac[link][SWITCH_AP] == 'null' and device_data_bymac[switch_mac][TYPE] == 'Switch': + add_uplink(switch_mac, link, device_data_bymac, sadevices_linksbymac, port_byswitchmac_byclientmac) + +def main(): + """Main function to execute the script.""" + start_time = time.time() + mylog('verbose', [f'[{pluginName}] starting execution']) + + from database import DB + from device import Device_obj + + db = DB() + db.open() + device_handler = Device_obj(db) + + # Retrieve configuration settings + omada_username = get_setting_value('OMDSDN_username') + omada_password = get_setting_value('OMDSDN_password') + omada_sites = get_setting_value('OMDSDN_sites') + omada_site = omada_sites[0] + omada_url = get_setting_value('OMDSDN_url') + + # Login to Omada + omada_login = callomada(['-t', 'myomada', 'target', '--url', omada_url, '--user', omada_username, + '--password', omada_password, '--site', omada_site, '--set-default']) + mylog('verbose', [f'[{pluginName}] login to omada result is: {omada_login}']) + + # Get clients and devices + clients_list = callomada(['-t', 'myomada', 'clients']) + mylog('verbose', [f'[{pluginName}] clients found:"{clients_list.count("\n")}"\n{clients_list}']) + + switches_and_aps = callomada(['-t', 'myomada', 'devices']) + mylog('verbose', [f'[{pluginName}] omada devices (switches, access points) found:"{switches_and_aps.count("\n")}" \n {switches_and_aps}']) + + # Process data + device_data = get_device_data(clients_list, switches_and_aps, device_handler) + + mylog('verbose', [f'[{pluginName}] New entries to create: "{len(device_data)}"']) + if len(device_data) > 0: + for device in device_data: + mylog(OMDLOGLEVEL, [f'[{pluginName}] main parsing device: "{device}"']) + myport = device[PORT_SSID] if device[PORT_SSID].isdigit() else '' + myssid = device[PORT_SSID] if not device[PORT_SSID].isdigit() else '' + ParentNetworkNode = ieee2ietf_mac_formater(device[SWITCH_AP]) if device[SWITCH_AP] != 'Internet' else 'Internet' + plugin_objects.add_object( + primaryId = ieee2ietf_mac_formater(device[MAC]), + secondaryId = device[IP], + watched1 = device[NAME] if device[NAME] != 'null' else '', + watched2 = ParentNetworkNode, + watched3 = myport, + watched4 = myssid, + extra = device[TYPE] if device[TYPE] != 'null' else '', + foreignKey = ieee2ietf_mac_formater(device[MAC]) + ) + mylog(OMDLOGLEVEL, [f'[{pluginName}] New entries: "{len(device_data)}"']) + + # Write results + plugin_objects.write_result_file() + + end_time = time.time() + mylog('verbose', [f'[{pluginName}] execution completed in {end_time - start_time:.2f} seconds']) + + return 0 +''' +# version 0.3b +def get_omada_devices_details(sadevice_data,switch_details,switch_dumps): + """Get device details from Omada. saved into a dictionary of strings""" + mylog(OMDLOGLEVEL, [f'[{pluginName}]getting the omada devices details: "{sadevice_data}"']) + thisswitch = sadevice_data[dMAC] + if sadevice_data[dTYPE] == 'ap': + switch_details[thisswitch] = callomada(['access-point', thisswitch]) + elif sadevice_data[dTYPE] == 'switch': + switch_details[thisswitch] = callomada(['switch', thisswitch]) + switch_dumps[thisswitch] = callomada(['-t','myomada','switch','-d',thisswitch]) + else: + switch_details[thisswitch] = 'null' + switch_dumps[thisswitch] = 'null' + return +''' +''' +# version 0.3c +def get_omada_devices_details(sadevice_data): + mthisswitch = sadevice_data[dMAC] + mswitch_detail = '' + mswitch_dump = '' + if sadevice_data[dTYPE] == 'ap': + mswitch_detail = callomada(['access-point', mthisswitch]) + elif sadevice_data[dTYPE] == 'switch': + mswitch_detail = callomada(['switch', mthisswitch]) + mswitch_dump = callomada(['-t','myomada','switch','-d',mthisswitch]) + else: + mswitch_detail = 'null' + nswitch_dump = 'null' + return mthisswitch, mswitch_detail, mswitch_dump +''' + +def get_omada_devices_details(sadevice_data): + thisswitch = sadevice_data[dMAC] + try: + if sadevice_data[dTYPE] == 'ap': + switch_detail = callomada(['access-point', thisswitch]) + return thisswitch, switch_detail, None + elif sadevice_data[dTYPE] == 'switch': + switch_detail = callomada(['switch', thisswitch]) + switch_dump = callomada(['-t','myomada','switch','-d',thisswitch]) + return thisswitch, switch_detail, switch_dump + else: + return thisswitch, 'null', 'null' + except Exception as e: + mylog('error', [f'[{pluginName}] Error processing {thisswitch}: {str(e)}']) + return thisswitch, 'error', 'error' + + + +def get_device_data(omada_clients_output, switches_and_aps, device_handler): + """Process and return device data from Omada output.""" + """ + switch_dumps = {} + switch_details = {} + sadevices_macbyname = {} + sadevices_macbymac = {} + sadevices_linksbymac = {} + port_byswitchmac_byclientmac = {} + device_data_bymac = {} + device_data_mac_byip = {} + omada_force_overwrite = get_setting_value('OMDSDN_force_overwrite') + """ + manager = Manager() + switch_dumps = manager.dict() + switch_details = manager.dict() + sadevices_macbyname = manager.dict() + sadevices_macbymac = manager.dict() + sadevices_linksbymac = manager.dict() + port_byswitchmac_byclientmac = manager.dict() + device_data_bymac = manager.dict() + device_data_mac_byip = manager.dict() + omada_force_overwrite = get_setting_value('OMDSDN_force_overwrite') + + + sadevices = switches_and_aps.splitlines() + mylog(OMDLOGLEVEL, [f'[{pluginName}] switches_and_aps rows: "{len(sadevices)}"']) + ''' + for sadevice in sadevices: + sadevice_data = sadevice.split() + get_omada_devices_details(sadevice_data,switch_details,switch_dumps) + ''' + + ''' + # Create a ThreadPoolExecutor + # version 0.3b + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + # Submit tasks for each device + futures = [] + for sadevice in sadevices: + sadevice_data = sadevice.split() + future = executor.submit(get_omada_devices_details, sadevice_data, switch_details, switch_dumps) + futures.append(future) + + # Wait for all tasks to complete + concurrent.futures.wait(futures) + ''' + ''' + # version 0.3c + # Create a ThreadPoolExecutor + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + # Submit tasks for each device + future_to_device = {executor.submit(get_omada_devices_details, sadevice.split()): sadevice for sadevice in sadevices} + + # Process results as they complete + for future in concurrent.futures.as_completed(future_to_device): + csadevice = future_to_device[future] + try: + mylog('verbose', [f'[{pluginName}] processing results of: {csadevice}']) + cthisswitch, cswitch_detail, cswitch_dump = future.result() + switch_details[cthisswitch] = cswitch_detail + switch_dumps[cthisswitch] = cswitch_dump + except Exception as exc: + mylog('error', [f'[{pluginName}] {csadevice} generated an exception: {exc}']) + ''' + # Use multiprocessing Pool + with Pool(processes=3) as pool: + results = pool.map(get_omada_devices_details, [sadevice.split() for sadevice in sadevices]) + + + + mylog(OMDLOGLEVEL, [f'[{pluginName}] All API calls completed. Processing results...']) + + # Process results + for thisswitch, switch_detail, switch_dump in results: + switch_details[thisswitch] = switch_detail + if switch_dump is not None: + switch_dumps[thisswitch] = switch_dump + + mylog(OMDLOGLEVEL, [f'[{pluginName}] Finished collecting device details. Processing data...']) + + + + # Now process the collected data + + + for sadevice in sadevices: + sadevice_data = sadevice.split() + thisswitch = sadevice_data[dMAC] + sadevices_macbyname[sadevice_data[4]] = thisswitch + if sadevice_data[dTYPE] == 'ap': + sadevice_type = 'AP' + #sadevice_details = callomada(['access-point', thisswitch]) + sadevice_details = switch_details[thisswitch] + if sadevice_details == '': + sadevice_links = [thisswitch] + else: + sadevice_links = extract_mac_addresses(sadevice_details) + sadevices_linksbymac[thisswitch] = sadevice_links[1:] + mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + elif sadevice_data[dTYPE] == 'switch': + sadevice_type = 'Switch' + #sadevice_details=callomada(['switch', thisswitch]) + sadevice_details = switch_details[thisswitch] + if sadevice_details == '': + sadevice_links = [thisswitch] + else: + sadevice_links=extract_mac_addresses(sadevice_details) + sadevices_linksbymac[thisswitch] = sadevice_links[1:] + # recovering the list of switches connected to sadevice switch and on which port... + #switchdump = callomada(['-t','myomada','switch','-d',thisswitch]) + switchdump = switch_dumps[thisswitch] + port_byswitchmac_byclientmac[thisswitch] = {} + for link in sadevices_linksbymac[thisswitch]: + port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(link)+r")" + myport = re.findall(port_pattern, switchdump,re.DOTALL) + port_byswitchmac_byclientmac[thisswitch][link] = myport[0] + mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]ports of each links are: "{port_byswitchmac_byclientmac[thisswitch]}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) + else: + sadevice_type = 'null' + sadevice_details='null' + device_data_bymac[thisswitch] = [thisswitch, sadevice_data[dIP], sadevice_data[dNAME], 'null', 'null',sadevice_type] + device_data_mac_byip[sadevice_data[dIP]] = thisswitch + foo=[thisswitch, sadevice_data[1], sadevice_data[4], 'null', 'null'] + mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch: "{foo}"']) + + + + + # sadevices_macbymac[thisswitch] = thisswitch + + mylog(OMDLOGLEVEL, [f'[{pluginName}] switch_macbyname: "{sadevices_macbyname}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] switches: "{device_data_bymac}"']) + + + # do some processing, call exteranl APIs, and return a device list + # ... + """ MAC = 0 + IP = 1 + NAME = 2 + SWITCH_AP = 3 + PORT_SSID = 4 + TYPE = 5 """ + + # sample target output: + # 0 MAC, 1 IP, 2 Name, 3 MAC of switch/AP, 4 port/SSID, 5 TYPE + #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', 'brick', 'ompapaoffice','froggies2', , 'Switch']" + + odevices = omada_clients_output.splitlines() + mylog(OMDLOGLEVEL, [f'[{pluginName}] omada_clients_outputs rows: "{len(odevices)}"']) + for odevice in odevices: + odevice_data = odevice.split() + odevice_data_reordered = [ MAC, IP, NAME, SWITCH_AP, PORT_SSID, TYPE] + odevice_data_reordered[MAC]=odevice_data[cMAC] + odevice_data_reordered[IP]=odevice_data[cIP] + real_naxname = device_handler.getValueWithMac('dev_Name',ieee2ietf_mac_formater(odevice_data[cMAC])) + + # + # if the name stored in Nax for a device is empty or the MAC addres or has some parenthhesis or is the same as in omada + # don't bother updating omada's name at all. + # + naxname = real_naxname + if real_naxname != None: + if '(' in real_naxname: + # removing parenthesis and domains from the name + naxname = real_naxname.split('(')[0] + if naxname != None and '.' in naxname: + naxname = naxname.split('.')[0] + if naxname in ( None, 'null', '' ): + naxname = odevice_data[cNAME] if odevice_data[cNAME] != '' else odevice_data[cMAC] + naxname = naxname.strip() + mylog('debug', [f'[{pluginName}] TEST name from MAC: {naxname}']) + if odevice_data[cNAME] in (odevice_data[cMAC], 'null', ''): + mylog('verbose', [f'[{pluginName}] updating omada server because odevice_data is: {odevice_data[cNAME]} and naxname is: "{naxname}"']) + callomada(['set-client-name', odevice_data[cMAC], naxname]) + odevice_data_reordered[NAME] = naxname + else: + if omada_force_overwrite and naxname != odevice_data[cNAME] : + mylog('verbose', [f'[{pluginName}] updating omada server because odevice_data is: "{odevice_data[cNAME]} and naxname is: "{naxname}"']) + callomada(['set-client-name', odevice_data[cMAC], naxname]) + odevice_data_reordered[NAME] = naxname + mightbeport = odevice_data[cPORT_SSID].lstrip('(') + mightbeport = mightbeport.rstrip(')') + if mightbeport.isdigit(): + odevice_data_reordered[SWITCH_AP] = odevice_data[cSWITCH_AP] + odevice_data_reordered[PORT_SSID] = mightbeport + else: + odevice_data_reordered[SWITCH_AP] = mightbeport + odevice_data_reordered[PORT_SSID] = odevice_data[cSWITCH_AP] + + # replacing the switch name with its MAC... + try: + mightbemac = sadevices_macbyname[odevice_data_reordered[SWITCH_AP]] + odevice_data_reordered[SWITCH_AP] = mightbemac + except KeyError: + mylog(OMDLOGLEVEL, [f'[{pluginName}] could not find the mac adddress for: "{odevice_data_reordered[SWITCH_AP]}"']) + # adding the type + odevice_data_reordered[TYPE] = 'null' + device_data_bymac[odevice_data_reordered[MAC]] = odevice_data_reordered + device_data_mac_byip[odevice_data_reordered[IP]] = odevice_data_reordered[MAC] + mylog(OMDLOGLEVEL, [f'[{pluginName}] tokens: "{odevice_data}"']) + mylog(OMDLOGLEVEL, [f'[{pluginName}] tokens_reordered: "{odevice_data_reordered}"']) + # populating the uplinks nodes of the omada switches and access points manually + # since OMADA SDN makes is unreliable if the gateway is not their own tplink hardware... + + + # step1 let's find the the default router + # + default_router_ip = find_default_gateway_ip() + default_router_mac = ietf2ieee_mac_formater(get_mac_from_IP(default_router_ip)) + device_data_bymac[default_router_mac][TYPE] = 'Firewall' + # step2 let's find the first switch and set the default router parent to internet + first_switch=device_data_bymac[default_router_mac][SWITCH_AP] + device_data_bymac[default_router_mac][SWITCH_AP] = 'Internet' + # step3 let's set the switch connected to the default gateway uplink to the default gateway and hardcode port to 1 for now: + #device_data_bymac[first_switch][SWITCH_AP]=default_router_mac + #device_data_bymac[first_switch][SWITCH_AP][PORT_SSID] = '1' + # step4, let's go recursively through switches other links to mark update their uplinks + # and pray it ends one day... + # + add_uplink(default_router_mac,first_switch, device_data_bymac,sadevices_linksbymac,port_byswitchmac_byclientmac) + return device_data_bymac.values() + +if __name__ == '__main__': + main() diff --git a/front/plugins/omada_sdn_imp/testre.py b/front/plugins/omada_sdn_imp/testre.py index bf5648cf..f71ad123 100644 --- a/front/plugins/omada_sdn_imp/testre.py +++ b/front/plugins/omada_sdn_imp/testre.py @@ -136,7 +136,52 @@ print(d.values()) foo = 2 #while foo > 0: # foo = 'toto' - print("foo is ",foo) +if foo in ( 'bar', '', 'null'): + print("foo is bar") +else: + print("foo is not bar") +foo='192-168-0-150.local' +bar = foo.split('.')[0] +print("bar=",bar,"-") +bar2 = 'toto' +print("bar2=",bar2,"-") + + + +import concurrent.futures +import time +import random + +def phello(arg): + print('running phell',arg) + time.sleep(random.uniform(0, 6)) + return f"parallel hello : {arg}" + +def testparalel(): + arguments = ["Alice", "Bob", "Charlie", "David"] + results = {} + para = 10 + + # Using ThreadPoolExecutor for parallel execution + with concurrent.futures.ThreadPoolExecutor(max_workers=para) as executor: + # Submit tasks to the executor + future_to_arg = {executor.submit(phello, arg): arg for arg in arguments} + concurrent.futures.wait(future_to_arg) + + # Retrieve results as they complete + for future in concurrent.futures.as_completed(future_to_arg): + arg = future_to_arg[future] + try: + result = future.result() + results[arg] = result + except Exception as exc: + print(f"{arg} generated an exception: {exc}") + + # Print results + for arg, result in results.items(): + print(f"{arg}: {result}") + +testparalel() \ No newline at end of file From a8dc4099e83336403dd3701b8406025127277587 Mon Sep 17 00:00:00 2001 From: ffsb Date: Wed, 17 Jul 2024 17:58:43 -0400 Subject: [PATCH 5/7] 0.6 works but with port=null and ssid=null --- front/plugins/omada_sdn_imp/omada_sdn.py | 62 ++++-------------------- front/plugins/omada_sdn_imp/testre.py | 34 ++++++++----- 2 files changed, 30 insertions(+), 66 deletions(-) diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py b/front/plugins/omada_sdn_imp/omada_sdn.py index ff807327..7984652f 100755 --- a/front/plugins/omada_sdn_imp/omada_sdn.py +++ b/front/plugins/omada_sdn_imp/omada_sdn.py @@ -3,6 +3,7 @@ __author__ = "ffsb" __version__ = "0.1" #initial __version__ = "0.2" # added logic to retry omada api call once as it seems to sometimes fail for some reasons, and error handling logic... __version__ = "0.3" # split devices API calls to allow multithreading but had to stop due to concurency issues. +__version__ = "0.6" # found issue with multithreading - my omada calls redirect stdout which gets clubbered by normal stdout... not sure how to fix for now... # query OMADA SDN to populate NetAlertX witch omada switches, access points, clients. # try to identify and populate their connections by switch/accesspoints and ports/SSID # try to differentiate root bridges from accessory @@ -138,29 +139,12 @@ def find_default_gateway_ip (): return default_route[2] if default_route[2] else None - return('192.168.0.1') - -""" -def find_port_of_uplink_switch(switch_mac, uplink_mac): - mylog(OMDLOGLEVEL, [f'[{pluginName}] find_port uplink="{uplink_mac}" on switch="{switch_mac}"']) - myport = [] - switchdump = callomada(['-t','myomada','switch','-d',switch_mac]) - port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(uplink_mac)+r")" - myport = re.findall(port_pattern, switchdump,re.DOTALL) - # print("myswitch=",mymac, "- link_switch=", mylink, "myport=", myport) - mylog(OMDLOGLEVEL, [f'[{pluginName}] finding port="{myport}" of uplink switch="{uplink_mac}" on switch="{switch_mac}"']) - try: - myport2=myport[0] - except IndexError: - myport2 = 'defaultGateWay' - return(myport2) - - """ + #return('192.168.0.1') def add_uplink (uplink_mac, switch_mac, device_data_bymac, sadevices_linksbymac,port_byswitchmac_byclientmac): - mylog(OMDLOGLEVEL, [f'[{pluginName}] trying to add uplink="{uplink_mac}" to switch="{switch_mac}"']) - mylog(OMDLOGLEVEL, [f'[{pluginName}] before adding:"{device_data_bymac[switch_mac]}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}] trying to add uplink="{uplink_mac}" to switch="{switch_mac}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}] before adding:"{device_data_bymac[switch_mac]}"']) if device_data_bymac[switch_mac][SWITCH_AP] == 'null': device_data_bymac[switch_mac][SWITCH_AP] = uplink_mac if device_data_bymac[switch_mac][TYPE] == 'Switch' and device_data_bymac[uplink_mac][TYPE] == 'Switch': @@ -169,7 +153,7 @@ def add_uplink (uplink_mac, switch_mac, device_data_bymac, sadevices_linksbymac, else: port_to_uplink=device_data_bymac[uplink_mac][PORT_SSID] device_data_bymac[switch_mac][PORT_SSID] = port_to_uplink - mylog(OMDLOGLEVEL, [f'[{pluginName}] after adding:"{device_data_bymac[switch_mac]}"']) + # mylog(OMDLOGLEVEL, [f'[{pluginName}] after adding:"{device_data_bymac[switch_mac]}"']) for link in sadevices_linksbymac[switch_mac]: if device_data_bymac[link][SWITCH_AP] == 'null' and device_data_bymac[switch_mac][TYPE] == 'Switch': add_uplink(switch_mac, link, device_data_bymac, sadevices_linksbymac,port_byswitchmac_byclientmac) @@ -275,17 +259,6 @@ def get_omada_devices_details(msadevice_data): else: mswitch_detail = '' nswitch_dump = '' - details_outfile = OMADA_API_RETURN_FILE+"_"+mthisswitch+"_det" - dump_outfile = OMADA_API_RETURN_FILE+"_"+mthisswitch+"_dmp" - for tmpdfle in [details_outfile+".tmp", dump_outfile+".tmp", details_outfile+".txt", dump_outfile+".txt"]: - if os.path.exists(tmpdfle): - os.remove(tmpdfle) - with open(details_outfile+".tmp", 'w') as f: - f.write(mswitch_detail) - with open(dump_outfile+".tmp", 'w') as f: - f.write(mswitch_dump) - os.rename(details_outfile+".tmp", details_outfile+".txt") - os.rename(dump_outfile+".tmp", dump_outfile+".txt") return mswitch_detail, mswitch_dump @@ -340,9 +313,9 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): else: sadevice_links = extract_mac_addresses(sadevice_details) sadevices_linksbymac[thisswitch] = sadevice_links[1:] - mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) - mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) - mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}]adding switch details: "{sadevice_details}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) elif sadevice_data[dTYPE] == 'switch': sadevice_type = 'Switch' #sadevice_details=callomada(['switch', thisswitch]) @@ -360,7 +333,7 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): for link in sadevices_linksbymac[thisswitch]: port_pattern = r"(?:{[^}]*\"port\"\: )([0-9]+)(?=[^}]*"+re.escape(link)+r")" myport = re.findall(port_pattern, switchdump,re.DOTALL) - mylog(OMDLOGLEVEL, [f'[{pluginName}] switchdump: link={link} myport:{myport}']) + #mylog(OMDLOGLEVEL, [f'[{pluginName}] switchdump: link={link} myport:{myport}']) port_byswitchmac_byclientmac[thisswitch][link] = myport[0] if myport else '' #mylog(OMDLOGLEVEL, [f'[{pluginName}]links are: "{sadevice_links}"']) #mylog(OMDLOGLEVEL, [f'[{pluginName}]linksbymac are: "{sadevices_linksbymac[thisswitch]}"']) @@ -409,23 +382,6 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): # if the name stored in Nax for a device is empty or the MAC addres or has some parenthhesis or is the same as in omada # don't bother updating omada's name at all. # - ''' - if real_naxname == None or ietf2ieee_mac_formater(real_naxname) == odevice_data[cMAC] or '('in real_naxname or real_naxname == odevice_data[cNAME] or real_naxname == 'null': - naxname = None - else: - naxname = real_naxname - mylog('debug', [f'[{pluginName}] TEST name from MAC: {naxname}']) - if odevice_data[cMAC] == odevice_data[cNAME]: - if naxname != None: - callomada(['set-client-name', odevice_data[cMAC], naxname]) - odevice_data_reordered[NAME] = naxname - else: - odevice_data_reordered[NAME] = real_naxname - else: - if omada_force_overwrite and naxname != None: - callomada(['set-client-name', odevice_data[cMAC], naxname]) - odevice_data_reordered[NAME] = odevice_data[cNAME] - ''' naxname = real_naxname if real_naxname != None: diff --git a/front/plugins/omada_sdn_imp/testre.py b/front/plugins/omada_sdn_imp/testre.py index f71ad123..544e7858 100644 --- a/front/plugins/omada_sdn_imp/testre.py +++ b/front/plugins/omada_sdn_imp/testre.py @@ -2,7 +2,8 @@ import re """" how to rebuild and re-run... -savefolder=~/naxdev/NetAlertX.v6 + +savefolder=~/naxdev/NetAlertX.v7 cd ~/naxdev mv NetAlertX $savefolder gh repo clone FlyingToto/NetAlertX @@ -10,11 +11,11 @@ cd NetAlertX ln -s ../docker-compose.yml.ffsb42 . ln -s ../.env.omada.ffsb42 . cd front/plugins/omada_sdn_imp/ -cp -p $savefoder/front/plugins/omada_sdn_imp/omada_sdn.py . +cp -p $savefoder/front/plugins/omada_sdn_imp/omada_sdn.py* . cp -p $savefoder/front/plugins/omada_sdn_imp/README.md . cp -p $savefoder/front/plugins/omada_sdn_imp/omada_account_sample.png . cp -p $savefoder/front/plugins/omada_sdn_imp/testre.py . -cp -p $savefoder/front/plugins/omada_sdn_imp/config.json config.json.v6 +#cp -p $savefoder/front/plugins/omada_sdn_imp/config.json config.json.v6 cd ~/naxdev/NetAlertX sudo docker-compose --env-file .env.omada.ffsb42 -f ./docker-compose.yml.ffsb42 up @@ -24,10 +25,10 @@ mkdir /drives/c/temp/4boris/$today cd /drives/c/temp/4boris/$today scp hal:~/naxdev/logs/app.log . scp hal:~/naxdev/NetAlertX/front/plugins/omada_sdn_imp/* . -gzip -c app.log > app.$today.log.gz - +gzip -c app.log > app_$today.log.gz +scp hal:~/naxdev/NetAlertX/front/plugins/omada_sdn_imp/omada_sdn.py /drives/c/temp/4boris/ """ @@ -157,31 +158,38 @@ import random def phello(arg): print('running phell',arg) - time.sleep(random.uniform(0, 6)) - return f"parallel hello : {arg}" + delay = random.uniform(0, 6) + time.sleep(delay) + return f"parallel hello : {arg}", delay def testparalel(): arguments = ["Alice", "Bob", "Charlie", "David"] results = {} + results2 = {} para = 10 # Using ThreadPoolExecutor for parallel execution with concurrent.futures.ThreadPoolExecutor(max_workers=para) as executor: # Submit tasks to the executor future_to_arg = {executor.submit(phello, arg): arg for arg in arguments} - concurrent.futures.wait(future_to_arg) + + # Wait for all futures to complete + done, _ = concurrent.futures.wait(future_to_arg) - # Retrieve results as they complete - for future in concurrent.futures.as_completed(future_to_arg): + # Retrieve results + for future in done: arg = future_to_arg[future] try: - result = future.result() + result, result2 = future.result() results[arg] = result + results2[arg] = result2 except Exception as exc: print(f"{arg} generated an exception: {exc}") - # Print results + # Print results after all threads have completed + print("All threads completed. Results:") for arg, result in results.items(): - print(f"{arg}: {result}") + print(f"arg:{arg}, result={results[arg]}, result2={results2[arg]}") + testparalel() \ No newline at end of file From d65b07685f19e299186c827567275db5368ba211 Mon Sep 17 00:00:00 2001 From: ffsb Date: Wed, 17 Jul 2024 18:28:21 -0400 Subject: [PATCH 6/7] ready for pr --- front/plugins/omada_sdn_imp/config.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/plugins/omada_sdn_imp/config.json b/front/plugins/omada_sdn_imp/config.json index 76b1a95e..531ba07f 100755 --- a/front/plugins/omada_sdn_imp/config.json +++ b/front/plugins/omada_sdn_imp/config.json @@ -125,7 +125,7 @@ "description": [ { "language_code": "en_us", - "string": "Enter full URL with protocol https://CHANGEME_omada.mylocaldomain." + "string": "Enter full URL with protocol https://CHANGEME_omada.mylocaldomain:PORT." } ] }, @@ -200,7 +200,7 @@ "description": [ { "language_code": "en_us", - "string": "Omada SDN site IDs. You can get it by..." + "string": "Omada SDN site IDs. For now, we only process the first site listed since NetAlertX's other probes won't traverse across NAT and routers. But if needed please submit an issue in github with your specific use case for consideration: https://github.com/jokob-sk/NetAlertX/issues " } ] }, @@ -282,7 +282,7 @@ "description": [ { "language_code": "en_us", - "string": "The plugin synchronizes names from NetAlertX to OMADA. By default NetAlertX will only populate missing names in OMADASDN devices (i.e.: where the name is defaulting to the device MAC address); with this setting toggled, it will overwrite existing values regardless." + "string": "The plugin synchronizes names from NetAlertX to OMADA Clietnts. By default NetAlertX will only populate missing names in OMADASDN devices (i.e.: where the name is defaulting to the device MAC address); with this setting toggled, it will overwrite existing values regardless." } ] }, From 74b24327290d4c87e241ce1f7cbb4f4969e5069b Mon Sep 17 00:00:00 2001 From: ffsb Date: Wed, 17 Jul 2024 21:31:10 -0400 Subject: [PATCH 7/7] removed gitguardian secrets --- front/plugins/omada_sdn_imp/omada_sdn.py | 27 ++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py b/front/plugins/omada_sdn_imp/omada_sdn.py index 7984652f..fbbc3342 100755 --- a/front/plugins/omada_sdn_imp/omada_sdn.py +++ b/front/plugins/omada_sdn_imp/omada_sdn.py @@ -25,6 +25,7 @@ import io import re import concurrent.futures + #import netifaces # Define the installation path and extend the system path for plugin imports @@ -49,7 +50,7 @@ plugin_objects = Plugin_Objects(RESULT_FILE) # # sample target output: # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, 5 TYPE -#17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', '17', '40-AE-30-A5-A7-50, 'Switch']" +#17:27:10 [] token: "['1A-2B-3C-4D-5E-6F', '192.168.0.217', '1A-2B-3C-4D-5E-6F', '17', '40-AE-30-A5-A7-50, 'Switch']" # Constants for array indices MAC, IP, NAME, SWITCH_AP, PORT_SSID, TYPE = range(6) @@ -57,16 +58,16 @@ MAC, IP, NAME, SWITCH_AP, PORT_SSID, TYPE = range(6) # sample omada devices input format: # # 0.MAC 1.IP 2.type 3.status 4.name 5.model -#40-AE-30-A5-A7-50 192.168.0.11 ap CONNECTED ompapaoffice EAP773(US) v1.0 +#40-AE-30-A5-A7-50 192.168.0.11 ap CONNECTED office_Access_point EAP773(US) v1.0 #B0-95-75-46-0C-39 192.168.0.4 switch CONNECTED pantry12 T1600G-52PS v4.0 dMAC, dIP, dTYPE, dSTATUS, dNAME, dMODEL = range(6) # sample omada clients input format: # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, -#17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', 'froggies2', '(ompapaoffice)']" -#17:27:10 [] token: "['50-02-91-29-E7-53', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" -#17:27:10 [] token: "['00-E2-59-00-A0-8E', '192.168.0.1', 'bastion', 'office24', '(23)']" -#17:27:10 [] token: "['60-DD-8E-CA-A4-B3', '192.168.0.226', 'brick', 'froggies3', '(ompapaoffice)']" +#17:27:10 [] token: "['1A-2B-3C-4D-5E-6F', '192.168.0.217', '1A-2B-3C-4D-5E-6F', 'myssid_name2', '(office_Access_point)']" +#17:27:10 [] token: "['1A-2B-3C-4D-5E-01', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" +#17:27:10 [] token: "['1A-2B-3C-4D-5E-02', '192.168.0.1', 'bastion', 'office24', '(23)']" +#17:27:10 [] token: "['1A-2B-3C-4D-5E-03', '192.168.0.226', 'brick', 'myssid_name3', '(office_Access_point)']" cMAC, cIP, cNAME, cSWITCH_AP, cPORT_SSID = range(5) OMDLOGLEVEL = 'debug' @@ -270,12 +271,12 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): # sample omada devices input format: # 0.MAC 1.IP 2.type 3.status 4.name 5.model - #40-AE-30-A5-A7-50 192.168.0.11 ap CONNECTED ompapaoffice EAP773(US) v1.0 + #40-AE-30-A5-A7-50 192.168.0.11 ap CONNECTED office_Access_point EAP773(US) v1.0 #B0-95-75-46-0C-39 192.168.0.4 switch CONNECTED pantry12 T1600G-52PS v4.0 # # sample target output: # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, 5 TYPE - #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', '17', '40-AE-30-A5-A7-50, 'Switch']" + #17:27:10 [] token: "['1A-2B-3C-4D-5E-6F', '192.168.0.217', '1A-2B-3C-4D-5E-6F', '17', '40-AE-30-A5-A7-50, 'Switch']" #constants sadevices_macbyname = {} sadevices_macbymac = {} @@ -360,14 +361,14 @@ def get_device_data(omada_clients_output,switches_and_aps,device_handler): # ... # sample omada clients input format: # 0 MAC, 1 IP, 2 Name, 3 switch/AP, 4 port/SSID, - #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', '9C-04-A0-82-67-45', 'froggies2', '(ompapaoffice)']" - #17:27:10 [] token: "['50-02-91-29-E7-53', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" - #17:27:10 [] token: "['00-E2-59-00-A0-8E', '192.168.0.1', 'bastion', 'office24', '(23)']" - #17:27:10 [] token: "['60-DD-8E-CA-A4-B3', '192.168.0.226', 'brick', 'froggies3', '(ompapaoffice)']" + #17:27:10 [] token: "['1A-2B-3C-4D-5E-6F', '192.168.0.217', '1A-2B-3C-4D-5E-6F', 'myssid_name2', '(office_Access_point)']" + #17:27:10 [] token: "['1A-2B-3C-4D-5E-01', '192.168.0.153', 'frontyard_ESP_29E753', 'pantry12', '(48)']" + #17:27:10 [] token: "['1A-2B-3C-4D-5E-02', '192.168.0.1', 'bastion', 'office24', '(23)']" + #17:27:10 [] token: "['1A-2B-3C-4D-5E-03', '192.168.0.226', 'brick', 'myssid_name3', '(office_Access_point)']" # sample target output: # 0 MAC, 1 IP, 2 Name, 3 MAC of switch/AP, 4 port/SSID, 5 TYPE - #17:27:10 [] token: "['9C-04-A0-82-67-45', '192.168.0.217', 'brick', 'ompapaoffice','froggies2', , 'Switch']" + #17:27:10 [] token: "['1A-2B-3C-4D-5E-6F', '192.168.0.217', 'brick', 'office_Access_point','myssid_name2', , 'Switch']" odevices = omada_clients_output.splitlines() mylog(OMDLOGLEVEL, [f'[{pluginName}] omada_clients_outputs rows: "{len(odevices)}"'])