fdb_collect.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. # Read-only L2 topology collector: ARP + BRIDGE-MIB FDB + ifName/ifAlias.
  4. # No device config changes. Outputs /tmp/fdb_hosts.tsv, /tmp/fdb_links.tsv, /tmp/fdb_summary.txt
  5. import json, urllib.request, subprocess, re, sys, os, time
  6. import concurrent.futures as cf
  7. import l2cfg
  8. TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
  9. API = 'http://192.168.87.20:8080/api_jsonrpc.php'
  10. def call(m, p):
  11. d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
  12. q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
  13. return json.loads(urllib.request.urlopen(q, timeout=60).read().decode())
  14. AUTH = {0: 'MD5', 1: 'SHA', 2: 'SHA-224', 3: 'SHA-256', 4: 'SHA-384', 5: 'SHA-512'}
  15. PRIV = {0: 'DES', 1: 'AES', 2: 'AES-192', 3: 'AES-256', 4: 'AES', 5: 'AES-256'}
  16. def get_hosts():
  17. hs = l2cfg.hosts(call, output=['hostid', 'name'])
  18. res = []
  19. for h in hs:
  20. for i in h['interfaces']:
  21. if i.get('type') != '2':
  22. continue
  23. det = i.get('details') or {}
  24. ver = str(det.get('version', '1'))
  25. args = None
  26. if ver == '3':
  27. lvl = int(det.get('securitylevel', 0))
  28. args = ['-v3', '-l', {0: 'noAuthNoPriv', 1: 'authNoPriv', 2: 'authPriv'}.get(lvl, 'authPriv'),
  29. '-u', det.get('securityname', '')]
  30. if lvl >= 1:
  31. args += ['-a', AUTH.get(int(det.get('authprotocol', 0)), 'SHA'), '-A', det.get('authpassphrase', '')]
  32. if lvl >= 2:
  33. args += ['-x', PRIV.get(int(det.get('privprotocol', 0)), 'AES'), '-X', det.get('privpassphrase', '')]
  34. else:
  35. args = ['-v2c', '-c', det.get('community', 'public')]
  36. res.append({'ip': i['ip'], 'zname': h['name'], 'args': args})
  37. break
  38. return res
  39. def walk(args, ip, oid):
  40. try:
  41. r = subprocess.run(['snmpbulkwalk', '-On'] + args + ['-t', '3', '-r', '1', ip, oid],
  42. capture_output=True, text=True, timeout=30)
  43. return r.stdout
  44. except Exception:
  45. return ''
  46. def parse(text, base):
  47. out = []
  48. for line in text.splitlines():
  49. if ' = ' not in line:
  50. continue
  51. left, right = line.split(' = ', 1)
  52. o = left.strip().lstrip('.')
  53. if not o.startswith(base):
  54. continue
  55. suf = o[len(base):].lstrip('.')
  56. out.append((suf, right.strip()))
  57. return out
  58. def num(v):
  59. m = re.search(r'-?\d+', v)
  60. return int(m.group()) if m else None
  61. def strval(v):
  62. # strip type prefix like STRING: / INTEGER: / Hex-STRING:
  63. if ':' in v:
  64. return v.split(':', 1)[1].strip().strip('"')
  65. return v.strip()
  66. def normmac(s):
  67. s = s.strip().lower().replace('-', ':').replace(' ', ':')
  68. parts = re.split(r'[:.]', s)
  69. if len(parts) == 6:
  70. try:
  71. return ':'.join('%02x' % int(p, 16) for p in parts)
  72. except Exception:
  73. return s
  74. return s
  75. BASE_FDB = '1.3.6.1.2.1.17.4.3.1'
  76. BASE_BPIF = '1.3.6.1.2.1.17.1.4.1.2'
  77. OID_IFNAME = '1.3.6.1.2.1.31.1.1.1.1'
  78. OID_IFALIAS = '1.3.6.1.2.1.31.1.1.1.18'
  79. OID_ARP = '1.3.6.1.2.1.4.22.1.2'
  80. OID_SYSNAME = '1.3.6.1.2.1.1.5.0'
  81. OID_BRIDGE = '1.3.6.1.2.1.17.1.1.0'
  82. def collect(h):
  83. ip = h['ip']; args = h['args']
  84. d = {'ip': ip, 'zname': h['zname'], 'name': h['zname'], 'ok': False}
  85. sn = walk(args, ip, OID_SYSNAME)
  86. v = parse(sn, OID_SYSNAME)
  87. if v:
  88. d['name'] = strval(v[0][1])
  89. bm = walk(args, ip, OID_BRIDGE)
  90. vb = parse(bm, OID_BRIDGE)
  91. d['bridge_mac'] = normmac(strval(vb[0][1])) if vb else ''
  92. # base port -> ifIndex
  93. bp = {}
  94. for suf, val in parse(walk(args, ip, BASE_BPIF), BASE_BPIF):
  95. port = num(suf.split('.')[-1])
  96. ifx = num(val)
  97. if port is not None and ifx is not None:
  98. bp[port] = ifx
  99. # ifName / ifAlias
  100. ifn = {}
  101. for suf, val in parse(walk(args, ip, OID_IFNAME), OID_IFNAME):
  102. ifn[num(suf.split('.')[-1])] = strval(val)
  103. ifa = {}
  104. for suf, val in parse(walk(args, ip, OID_IFALIAS), OID_IFALIAS):
  105. ifa[num(suf.split('.')[-1])] = strval(val)
  106. # FDB: address table .1 idx=mac, port table .2 idx=mac -> bridgePort
  107. fdb_addr = {}
  108. for suf, val in parse(walk(args, ip, BASE_FDB + '.1'), BASE_FDB + '.1'):
  109. mac = normmac(strval(val))
  110. fdb_addr[suf] = mac
  111. fdb = {} # mac -> ifIndex
  112. portcnt = {}
  113. for suf, val in parse(walk(args, ip, BASE_FDB + '.2'), BASE_FDB + '.2'):
  114. port = num(val)
  115. mac = fdb_addr.get(suf)
  116. if mac is None:
  117. mac = normmac(suf) # suf is m1.m2..m6
  118. if port is None:
  119. continue
  120. portcnt[port] = portcnt.get(port, 0) + 1
  121. ifx = bp.get(port)
  122. if ifx is None:
  123. continue
  124. fdb[mac] = ifx
  125. # ARP
  126. arp = {}
  127. for suf, val in parse(walk(args, ip, OID_ARP), OID_ARP):
  128. parts = suf.split('.')
  129. if len(parts) >= 5:
  130. ipx = '.'.join(parts[-4:])
  131. ipx = '.'.join(str(int(x)) for x in parts[-4:])
  132. mac = normmac(strval(val))
  133. if mac and mac != '0:0:0:0:0:0':
  134. arp[ipx] = mac
  135. ifx_cnt = {}
  136. for mac, ifx in fdb.items():
  137. ifx_cnt[ifx] = ifx_cnt.get(ifx, 0) + 1
  138. d.update({'ok': True, 'bp': bp, 'ifn': ifn, 'ifa': ifa, 'fdb': fdb,
  139. 'portcnt': portcnt, 'ifx_cnt': ifx_cnt, 'arp': arp})
  140. return d
  141. def main():
  142. hosts = get_hosts()
  143. sys.stderr.write("hosts=%d\n" % len(hosts))
  144. results = []
  145. with cf.ThreadPoolExecutor(max_workers=24) as ex:
  146. futs = {ex.submit(collect, h): h for h in hosts}
  147. for k, f in enumerate(cf.as_completed(futs)):
  148. try:
  149. r = f.result()
  150. except Exception as e:
  151. h = futs[f]; r = {'ip': h['ip'], 'zname': h['zname'], 'name': h['zname'], 'ok': False}
  152. results.append(r)
  153. if (k + 1) % 20 == 0:
  154. sys.stderr.write("done %d/%d\n" % (k + 1, len(hosts)))
  155. ok = [r for r in results if r.get('ok')]
  156. # switch bridge mac index
  157. sw_by_bmac = {}
  158. for r in ok:
  159. bm = r.get('bridge_mac')
  160. if bm:
  161. sw_by_bmac[bm] = r
  162. # mac -> locations
  163. mac_loc = {}
  164. for r in ok:
  165. for mac, ifx in r['fdb'].items():
  166. mac_loc.setdefault(mac, []).append((r, ifx, r['ifx_cnt'].get(ifx, 0)))
  167. def portname(r, ifx):
  168. return r['ifn'].get(ifx, 'if%d' % ifx)
  169. def portalias(r, ifx):
  170. return r['ifa'].get(ifx, '')
  171. # helper to get port num for ifIndex (reverse bp)
  172. # ip -> mac globally
  173. ip2mac = {}
  174. for r in ok:
  175. for ip, mac in r['arp'].items():
  176. ip2mac.setdefault(ip, mac)
  177. # zname by ip
  178. zname = {h['ip']: h['zname'] for h in hosts}
  179. # host edges: ip -> mac -> edge switch port (min port mac count)
  180. host_rows = []
  181. for ip, mac in sorted(ip2mac.items()):
  182. if mac in sw_by_bmac:
  183. continue
  184. locs = mac_loc.get(mac, [])
  185. if not locs:
  186. continue
  187. best = min(locs, key=lambda t: t[2])
  188. r, ifx, pc = best
  189. host_rows.append([ip, zname.get(ip, ''), mac, r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), str(pc)])
  190. # switch links: for switch S, fdb mac that is another switch bridge mac
  191. links = []
  192. seen = set()
  193. for r in ok:
  194. for mac, ifx in r['fdb'].items():
  195. tgt = sw_by_bmac.get(mac)
  196. if tgt is None or tgt['ip'] == r['ip']:
  197. continue
  198. key = tuple(sorted([r['ip'], tgt['ip']])) + (portname(r, ifx),)
  199. if key in seen:
  200. continue
  201. seen.add(key)
  202. links.append([r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), tgt['name'], tgt['ip']])
  203. # candidate uplinks: ports (non-loop) with high mac count
  204. uplinks = []
  205. for r in ok:
  206. # invert fdb to port -> count already in portcnt per bridge port; map to ifIndex
  207. inv = {}
  208. for mac, ifx in r['fdb'].items():
  209. inv[ifx] = inv.get(ifx, 0) + 1
  210. for ifx, c in inv.items():
  211. if c >= 8:
  212. uplinks.append([r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), str(c)])
  213. uplinks.sort(key=lambda x: -int(x[4]))
  214. with open('/tmp/fdb_hosts.tsv', 'w', encoding='utf-8') as f:
  215. f.write('ip\tzabbix_name\tmac\tswitch_name\tswitch_ip\tport\tport_alias\tport_mac_count\n')
  216. for row in host_rows:
  217. f.write('\t'.join(row) + '\n')
  218. with open('/tmp/fdb_links.tsv', 'w', encoding='utf-8') as f:
  219. f.write('a_name\ta_ip\ta_port\ta_alias\tb_name\tb_ip\n')
  220. for row in links:
  221. f.write('\t'.join(row) + '\n')
  222. with open('/tmp/fdb_uplinks.tsv', 'w', encoding='utf-8') as f:
  223. f.write('switch_name\tswitch_ip\tport\tport_alias\tmac_count\n')
  224. for row in uplinks:
  225. f.write('\t'.join(row) + '\n')
  226. with open('/tmp/fdb_summary.txt', 'w', encoding='utf-8') as f:
  227. f.write('switches_ok=%d / total=%d\n' % (len(ok), len(hosts)))
  228. f.write('host_edges=%d\n' % len(host_rows))
  229. f.write('switch_links=%d\n' % len(links))
  230. f.write('uplink_ports(>=8mac)=%d\n' % len(uplinks))
  231. f.write('ips_with_mac=%d\n' % len(ip2mac))
  232. print(open('/tmp/fdb_summary.txt').read())
  233. if __name__ == '__main__':
  234. main()