| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- # Read-only L2 topology collector: ARP + BRIDGE-MIB FDB + ifName/ifAlias.
- # No device config changes. Outputs /tmp/fdb_hosts.tsv, /tmp/fdb_links.tsv, /tmp/fdb_summary.txt
- import json, urllib.request, subprocess, re, sys, os, time
- import concurrent.futures as cf
- import l2cfg
- TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
- API = 'http://192.168.87.20:8080/api_jsonrpc.php'
- def call(m, p):
- d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
- q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
- return json.loads(urllib.request.urlopen(q, timeout=60).read().decode())
- AUTH = {0: 'MD5', 1: 'SHA', 2: 'SHA-224', 3: 'SHA-256', 4: 'SHA-384', 5: 'SHA-512'}
- PRIV = {0: 'DES', 1: 'AES', 2: 'AES-192', 3: 'AES-256', 4: 'AES', 5: 'AES-256'}
- def get_hosts():
- hs = l2cfg.hosts(call, output=['hostid', 'name'])
- res = []
- for h in hs:
- for i in h['interfaces']:
- if i.get('type') != '2':
- continue
- det = i.get('details') or {}
- ver = str(det.get('version', '1'))
- args = None
- if ver == '3':
- lvl = int(det.get('securitylevel', 0))
- args = ['-v3', '-l', {0: 'noAuthNoPriv', 1: 'authNoPriv', 2: 'authPriv'}.get(lvl, 'authPriv'),
- '-u', det.get('securityname', '')]
- if lvl >= 1:
- args += ['-a', AUTH.get(int(det.get('authprotocol', 0)), 'SHA'), '-A', det.get('authpassphrase', '')]
- if lvl >= 2:
- args += ['-x', PRIV.get(int(det.get('privprotocol', 0)), 'AES'), '-X', det.get('privpassphrase', '')]
- else:
- args = ['-v2c', '-c', det.get('community', 'public')]
- res.append({'ip': i['ip'], 'zname': h['name'], 'args': args})
- break
- return res
- def walk(args, ip, oid):
- try:
- r = subprocess.run(['snmpbulkwalk', '-On'] + args + ['-t', '3', '-r', '1', ip, oid],
- capture_output=True, text=True, timeout=30)
- return r.stdout
- except Exception:
- return ''
- def parse(text, base):
- out = []
- for line in text.splitlines():
- if ' = ' not in line:
- continue
- left, right = line.split(' = ', 1)
- o = left.strip().lstrip('.')
- if not o.startswith(base):
- continue
- suf = o[len(base):].lstrip('.')
- out.append((suf, right.strip()))
- return out
- def num(v):
- m = re.search(r'-?\d+', v)
- return int(m.group()) if m else None
- def strval(v):
- # strip type prefix like STRING: / INTEGER: / Hex-STRING:
- if ':' in v:
- return v.split(':', 1)[1].strip().strip('"')
- return v.strip()
- def normmac(s):
- s = s.strip().lower().replace('-', ':').replace(' ', ':')
- parts = re.split(r'[:.]', s)
- if len(parts) == 6:
- try:
- return ':'.join('%02x' % int(p, 16) for p in parts)
- except Exception:
- return s
- return s
- BASE_FDB = '1.3.6.1.2.1.17.4.3.1'
- BASE_BPIF = '1.3.6.1.2.1.17.1.4.1.2'
- OID_IFNAME = '1.3.6.1.2.1.31.1.1.1.1'
- OID_IFALIAS = '1.3.6.1.2.1.31.1.1.1.18'
- OID_ARP = '1.3.6.1.2.1.4.22.1.2'
- OID_SYSNAME = '1.3.6.1.2.1.1.5.0'
- OID_BRIDGE = '1.3.6.1.2.1.17.1.1.0'
- def collect(h):
- ip = h['ip']; args = h['args']
- d = {'ip': ip, 'zname': h['zname'], 'name': h['zname'], 'ok': False}
- sn = walk(args, ip, OID_SYSNAME)
- v = parse(sn, OID_SYSNAME)
- if v:
- d['name'] = strval(v[0][1])
- bm = walk(args, ip, OID_BRIDGE)
- vb = parse(bm, OID_BRIDGE)
- d['bridge_mac'] = normmac(strval(vb[0][1])) if vb else ''
- # base port -> ifIndex
- bp = {}
- for suf, val in parse(walk(args, ip, BASE_BPIF), BASE_BPIF):
- port = num(suf.split('.')[-1])
- ifx = num(val)
- if port is not None and ifx is not None:
- bp[port] = ifx
- # ifName / ifAlias
- ifn = {}
- for suf, val in parse(walk(args, ip, OID_IFNAME), OID_IFNAME):
- ifn[num(suf.split('.')[-1])] = strval(val)
- ifa = {}
- for suf, val in parse(walk(args, ip, OID_IFALIAS), OID_IFALIAS):
- ifa[num(suf.split('.')[-1])] = strval(val)
- # FDB: address table .1 idx=mac, port table .2 idx=mac -> bridgePort
- fdb_addr = {}
- for suf, val in parse(walk(args, ip, BASE_FDB + '.1'), BASE_FDB + '.1'):
- mac = normmac(strval(val))
- fdb_addr[suf] = mac
- fdb = {} # mac -> ifIndex
- portcnt = {}
- for suf, val in parse(walk(args, ip, BASE_FDB + '.2'), BASE_FDB + '.2'):
- port = num(val)
- mac = fdb_addr.get(suf)
- if mac is None:
- mac = normmac(suf) # suf is m1.m2..m6
- if port is None:
- continue
- portcnt[port] = portcnt.get(port, 0) + 1
- ifx = bp.get(port)
- if ifx is None:
- continue
- fdb[mac] = ifx
- # ARP
- arp = {}
- for suf, val in parse(walk(args, ip, OID_ARP), OID_ARP):
- parts = suf.split('.')
- if len(parts) >= 5:
- ipx = '.'.join(parts[-4:])
- ipx = '.'.join(str(int(x)) for x in parts[-4:])
- mac = normmac(strval(val))
- if mac and mac != '0:0:0:0:0:0':
- arp[ipx] = mac
- ifx_cnt = {}
- for mac, ifx in fdb.items():
- ifx_cnt[ifx] = ifx_cnt.get(ifx, 0) + 1
- d.update({'ok': True, 'bp': bp, 'ifn': ifn, 'ifa': ifa, 'fdb': fdb,
- 'portcnt': portcnt, 'ifx_cnt': ifx_cnt, 'arp': arp})
- return d
- def main():
- hosts = get_hosts()
- sys.stderr.write("hosts=%d\n" % len(hosts))
- results = []
- with cf.ThreadPoolExecutor(max_workers=24) as ex:
- futs = {ex.submit(collect, h): h for h in hosts}
- for k, f in enumerate(cf.as_completed(futs)):
- try:
- r = f.result()
- except Exception as e:
- h = futs[f]; r = {'ip': h['ip'], 'zname': h['zname'], 'name': h['zname'], 'ok': False}
- results.append(r)
- if (k + 1) % 20 == 0:
- sys.stderr.write("done %d/%d\n" % (k + 1, len(hosts)))
- ok = [r for r in results if r.get('ok')]
- # switch bridge mac index
- sw_by_bmac = {}
- for r in ok:
- bm = r.get('bridge_mac')
- if bm:
- sw_by_bmac[bm] = r
- # mac -> locations
- mac_loc = {}
- for r in ok:
- for mac, ifx in r['fdb'].items():
- mac_loc.setdefault(mac, []).append((r, ifx, r['ifx_cnt'].get(ifx, 0)))
- def portname(r, ifx):
- return r['ifn'].get(ifx, 'if%d' % ifx)
- def portalias(r, ifx):
- return r['ifa'].get(ifx, '')
- # helper to get port num for ifIndex (reverse bp)
- # ip -> mac globally
- ip2mac = {}
- for r in ok:
- for ip, mac in r['arp'].items():
- ip2mac.setdefault(ip, mac)
- # zname by ip
- zname = {h['ip']: h['zname'] for h in hosts}
- # host edges: ip -> mac -> edge switch port (min port mac count)
- host_rows = []
- for ip, mac in sorted(ip2mac.items()):
- if mac in sw_by_bmac:
- continue
- locs = mac_loc.get(mac, [])
- if not locs:
- continue
- best = min(locs, key=lambda t: t[2])
- r, ifx, pc = best
- host_rows.append([ip, zname.get(ip, ''), mac, r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), str(pc)])
- # switch links: for switch S, fdb mac that is another switch bridge mac
- links = []
- seen = set()
- for r in ok:
- for mac, ifx in r['fdb'].items():
- tgt = sw_by_bmac.get(mac)
- if tgt is None or tgt['ip'] == r['ip']:
- continue
- key = tuple(sorted([r['ip'], tgt['ip']])) + (portname(r, ifx),)
- if key in seen:
- continue
- seen.add(key)
- links.append([r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), tgt['name'], tgt['ip']])
- # candidate uplinks: ports (non-loop) with high mac count
- uplinks = []
- for r in ok:
- # invert fdb to port -> count already in portcnt per bridge port; map to ifIndex
- inv = {}
- for mac, ifx in r['fdb'].items():
- inv[ifx] = inv.get(ifx, 0) + 1
- for ifx, c in inv.items():
- if c >= 8:
- uplinks.append([r['name'], r['ip'], portname(r, ifx), portalias(r, ifx), str(c)])
- uplinks.sort(key=lambda x: -int(x[4]))
- with open('/tmp/fdb_hosts.tsv', 'w', encoding='utf-8') as f:
- f.write('ip\tzabbix_name\tmac\tswitch_name\tswitch_ip\tport\tport_alias\tport_mac_count\n')
- for row in host_rows:
- f.write('\t'.join(row) + '\n')
- with open('/tmp/fdb_links.tsv', 'w', encoding='utf-8') as f:
- f.write('a_name\ta_ip\ta_port\ta_alias\tb_name\tb_ip\n')
- for row in links:
- f.write('\t'.join(row) + '\n')
- with open('/tmp/fdb_uplinks.tsv', 'w', encoding='utf-8') as f:
- f.write('switch_name\tswitch_ip\tport\tport_alias\tmac_count\n')
- for row in uplinks:
- f.write('\t'.join(row) + '\n')
- with open('/tmp/fdb_summary.txt', 'w', encoding='utf-8') as f:
- f.write('switches_ok=%d / total=%d\n' % (len(ok), len(hosts)))
- f.write('host_edges=%d\n' % len(host_rows))
- f.write('switch_links=%d\n' % len(links))
- f.write('uplink_ports(>=8mac)=%d\n' % len(uplinks))
- f.write('ips_with_mac=%d\n' % len(ip2mac))
- print(open('/tmp/fdb_summary.txt').read())
- if __name__ == '__main__':
- main()
|