1 #!/usr/bin/env python |
|
2 |
|
3 """ |
|
4 Requirements: |
|
5 pydot |
|
6 """ |
|
7 |
|
8 import pvl.args |
|
9 import pvl.hosts |
|
10 from pvl.invoke import merge |
|
11 from pvl.snmp import snmp, lldp, vlan, bridge |
|
12 |
|
13 import logging; log = logging.getLogger('pvl.hosts-lldp') |
|
14 import optparse |
|
15 |
|
16 def hosts_snmp (options, hosts) : |
|
17 """ |
|
18 Discover SNMP-supporting hosts. |
|
19 |
|
20 Yields Host, snmpdata |
|
21 """ |
|
22 |
|
23 for host in hosts : |
|
24 host_snmp = host.extensions.get('snmp') |
|
25 |
|
26 if not host_snmp : |
|
27 log.debug("%s: skip non-snmp host", host) |
|
28 continue |
|
29 |
|
30 elif host.down : |
|
31 log.debug("%s: skip down host", host) |
|
32 continue |
|
33 |
|
34 else : |
|
35 log.debug("%s: %s", host, host_snmp) |
|
36 |
|
37 yield host, host_snmp |
|
38 |
|
39 def hosts_lldp (options, hosts) : |
|
40 """ |
|
41 Discover LLDP-supporting hosts. |
|
42 |
|
43 Yields Host, LLDPAgent |
|
44 """ |
|
45 |
|
46 for host, host_snmp in hosts_snmp(options, hosts) : |
|
47 agent = lldp.LLDPAgent.apply(options, host.fqdn(), community=host_snmp.get('community')) |
|
48 |
|
49 try : |
|
50 local = agent.local |
|
51 except snmp.SNMPError as ex : |
|
52 log.warning("%s: %s", host, ex) |
|
53 continue |
|
54 |
|
55 if not local : |
|
56 log.info("%s: no lldp support", host) |
|
57 continue |
|
58 |
|
59 log.info("%s: %s", host, local) |
|
60 |
|
61 if local['sys_name'] != host.host : |
|
62 log.warning("%s: SNMP sys_name mismatch: %s", host, local['sys_name']) |
|
63 |
|
64 yield host, agent |
|
65 |
|
66 def hosts_vlan (options, hosts) : |
|
67 """ |
|
68 Discover VLAN-supporting hosts. |
|
69 |
|
70 Yields Host, VLANAgent |
|
71 """ |
|
72 |
|
73 for host, host_snmp in hosts_snmp(options, hosts) : |
|
74 agent = vlan.VLANAgent.apply(options, host.fqdn(), community=host_snmp.get('community')) |
|
75 |
|
76 try : |
|
77 count = agent.count |
|
78 except snmp.SNMPError as ex : |
|
79 log.warning("%s: %s", host, ex) |
|
80 continue |
|
81 |
|
82 log.info("%s: %s", host, count) |
|
83 |
|
84 yield host, agent |
|
85 |
|
86 def hosts_bridge (options, hosts) : |
|
87 """ |
|
88 Discover Bridge-supporting hosts. |
|
89 |
|
90 Yields Host, BridgeAgent |
|
91 """ |
|
92 |
|
93 for host, host_snmp in hosts_snmp(options, hosts) : |
|
94 agent = bridge.BridgeAgent.apply(options, host.fqdn(), community=host_snmp.get('community')) |
|
95 |
|
96 try : |
|
97 agent.ping() |
|
98 except snmp.SNMPError as ex : |
|
99 log.warning("%s: %s", host, ex) |
|
100 continue |
|
101 |
|
102 log.info("%s", host) |
|
103 |
|
104 yield host, agent |
|
105 |
|
106 |
|
107 def apply_hosts_lldp (options, hosts) : |
|
108 """ |
|
109 Query host LLDP info. |
|
110 """ |
|
111 |
|
112 _hosts_lldp = list(hosts_lldp(options, hosts)) |
|
113 hosts_by_chassis = { } |
|
114 |
|
115 # first pass to discover hosts |
|
116 for host, agent in _hosts_lldp : |
|
117 chassis = agent.local['chassis'] |
|
118 log.info("%s: %s", host, chassis) |
|
119 |
|
120 hosts_by_chassis[chassis] = host |
|
121 |
|
122 # second pass to discver links |
|
123 for host, agent in _hosts_lldp : |
|
124 try : |
|
125 remotes = list(agent.remotes()) |
|
126 except snmp.SNMPError as ex : |
|
127 log.warn("%s: broken lldp remotes: %s", host, ex) |
|
128 continue |
|
129 |
|
130 for port, remote in remotes : |
|
131 port = agent.port(port) |
|
132 |
|
133 remote_chassis = remote['chassis'] |
|
134 remote_host = hosts_by_chassis.get(remote_chassis) |
|
135 |
|
136 log.info("%s: %s: %s (%s)", host, port, remote, remote_host) |
|
137 |
|
138 yield host, merge(agent.local, port), remote, remote_host |
|
139 |
|
140 import collections |
|
141 |
|
142 def apply_hosts_vlan (options, hosts) : |
|
143 """ |
|
144 Query host VLAN ports. |
|
145 |
|
146 Yields host, { port: (untagged, [tagged]) } |
|
147 """ |
|
148 |
|
149 _hosts_vlan = list(hosts_vlan(options, hosts)) |
|
150 |
|
151 for host, agent in _hosts_vlan : |
|
152 # only one untagged vlan / port |
|
153 vlan_untagged = { } |
|
154 |
|
155 # multiple taggd vlans / port |
|
156 vlan_tagged = collections.defaultdict(set) |
|
157 |
|
158 for vlan, (tagged, untagged) in agent.vlan_ports() : |
|
159 log.info("%s: %s: %s + %s", host, vlan, tagged, untagged) |
|
160 |
|
161 for port in tagged : |
|
162 vlan_tagged[port].add(vlan) |
|
163 |
|
164 for port in untagged : |
|
165 if port in vlan_untagged : |
|
166 log.warning("%s: duplicate untagged vlan %s for port %s on vlan %s", host, vlan, port, vlan_untagged[port]) |
|
167 |
|
168 vlan_untagged[port] = vlan |
|
169 |
|
170 # pack into {port: (untagged, [tagged]) } |
|
171 yield host, dict( |
|
172 ( |
|
173 port, (vlan_untagged.get(port), tuple(vlan_tagged[port])) |
|
174 ) for port in set(vlan_untagged) | set(vlan_tagged) |
|
175 ) |
|
176 |
|
177 def apply_hosts_bridge (options, hosts) : |
|
178 """ |
|
179 Query host bridge tables. |
|
180 |
|
181 Yields host, { port: (macs) } |
|
182 """ |
|
183 |
|
184 for host, agent in hosts_bridge(options, hosts) : |
|
185 ports = collections.defaultdict(list) |
|
186 |
|
187 try : |
|
188 vlan_fdb_ports = list(agent.vlan_fdb_ports()) |
|
189 except snmp.SNMPError as ex : |
|
190 log.warn("%s: broken dot1q fdb: %s", host, ex) |
|
191 continue |
|
192 |
|
193 if vlan_fdb_ports : |
|
194 log.info("%s: have dot1q ports", host) |
|
195 |
|
196 for ether, port, vlan in agent.vlan_fdb_ports() : |
|
197 if not port : |
|
198 # XXX: unknown? |
|
199 continue |
|
200 |
|
201 ports[(port, vlan)].append(ether) |
|
202 else : |
|
203 try : |
|
204 fdb_ports = list(agent.fdb_ports()) |
|
205 except snmp.SNMPError as ex : |
|
206 log.warn("%s: broken dot1q fdb: %s", host, ex) |
|
207 continue |
|
208 |
|
209 # fallback to dot1d fdb |
|
210 log.info("%s: fallback to dot1d", host) |
|
211 |
|
212 for ether, port in agent.fdb_ports() : |
|
213 if not port : |
|
214 # XXX: unknown? |
|
215 continue |
|
216 |
|
217 ports[(port, None)].append(ether) |
|
218 |
|
219 yield host, ports |
|
220 |
|
221 COLOR_VLANS = { |
|
222 1: 'grey', # pvl-lan |
|
223 2: 'blue', # pvl-lan2 |
|
224 3: 'red', # pvl-san |
|
225 4: 'green', # pvl-veturi |
|
226 7: 'orange', # pvl-ranssi |
|
227 8: 'yellow', # pvl-mgmt |
|
228 10: 'brown', # pvl-public |
|
229 100: 'navyblue', # pvl-test |
|
230 103: 'red4', # pvl-test-san |
|
231 104: 'red2', # pvl-ganeti |
|
232 192: 'purple', # paivola-services |
|
233 255: 'magenta', # pvl-sonera |
|
234 } |
|
235 |
|
236 def apply_graph (options, items, vlans={}) : |
|
237 import pydot |
|
238 |
|
239 dot = pydot.Dot(graph_name='lldp_hosts', graph_type='digraph', |
|
240 # XXX: breaks multi-edges? |
|
241 #splines = 'true', |
|
242 |
|
243 sep = '+25,25', |
|
244 overlap = 'scalexy', |
|
245 |
|
246 # only applies to loops |
|
247 nodesep = 0.5, |
|
248 ) |
|
249 dot.set_edge_defaults( |
|
250 labeldistance = 3.0, |
|
251 penwidth = 2.0, |
|
252 ) |
|
253 |
|
254 nodes = { } |
|
255 edges = { } |
|
256 vlan_colors = { } # { vlan: color } |
|
257 |
|
258 for host, local, remote, remote_host in items : |
|
259 # src |
|
260 src_name = str(host) |
|
261 src_label = '"{host.location}"'.format(host=host) |
|
262 |
|
263 if src_name in nodes : |
|
264 src = nodes[src_name] |
|
265 else : |
|
266 src = nodes[src_name] = pydot.Node(src_name, |
|
267 label = src_label, |
|
268 fontsize = 18, |
|
269 ) |
|
270 dot.add_node(src) |
|
271 |
|
272 # dst |
|
273 if remote_host : |
|
274 dst_name = str(remote_host) |
|
275 dst_label = '"{host.location}"'.format(host=remote_host) |
|
276 else : |
|
277 dst_name = remote['chassis'].replace(':', '-') |
|
278 |
|
279 # XXX: pydot is not smart enough to quote this |
|
280 if remote['sys_name'] : |
|
281 dst_label = '"{remote[chassis]} ({remote[sys_name]})"'.format(remote=remote) |
|
282 else : |
|
283 dst_label = '"{remote[chassis]}"'.format(remote=remote) |
|
284 |
|
285 if dst_name in nodes : |
|
286 dst = nodes[dst_name] |
|
287 else : |
|
288 dst = nodes[dst_name] = pydot.Node(dst_name, |
|
289 label = dst_label, |
|
290 fontsize = 18, |
|
291 ) |
|
292 dot.add_node(dst) |
|
293 |
|
294 # edges |
|
295 headlabel = '"{remote[port]}"'.format(remote=remote) |
|
296 taillabel = '"{local[port]}"'.format(local=local) |
|
297 fillcolor = 'black' |
|
298 color = 'black' |
|
299 untag = tagged = None |
|
300 |
|
301 # vlans? |
|
302 if vlans and host in vlans and local['port_id'] in vlans[host] : |
|
303 untag, tagged = vlans[host][local['port_id']] |
|
304 |
|
305 log.debug("%s#%s: %s+%s", host, local['port_id'], untag, tagged) |
|
306 |
|
307 colors = [] |
|
308 for tag in sorted(tagged) : |
|
309 if tag in COLOR_VLANS : |
|
310 colors.append(COLOR_VLANS[tag]) |
|
311 elif tag in vlan_colors : |
|
312 colors.append(vlan_colors[tag]) |
|
313 else : |
|
314 color = '/paired12/{count}'.format(count=1+len(vlan_colors)) |
|
315 |
|
316 log.info("%s#%s: chosing new vlan %s color %s", host, local['port_id'], tag, color) |
|
317 |
|
318 vlan_colors[tag] = color |
|
319 colors.append(color) |
|
320 |
|
321 if not untag : |
|
322 pass |
|
323 elif untag in COLOR_VLANS : |
|
324 fillcolor = COLOR_VLANS[untag] |
|
325 elif untag in vlan_colors : |
|
326 fillcolor = vlan_colors[untag] |
|
327 else : |
|
328 color = '/paired12/{count}'.format(count=1+len(vlan_colors)) |
|
329 |
|
330 log.warn("%s#%s: chosing new vlan %s color %s", host, local['port_id'], untag, color) |
|
331 |
|
332 fillcolor = vlan_colors[tag] = color |
|
333 |
|
334 # first color overrides fillcolor for heads |
|
335 if colors and untag : |
|
336 color = ':'.join([fillcolor] + colors) |
|
337 elif colors : |
|
338 color = ':'.join(colors) |
|
339 elif fillcolor : |
|
340 color = fillcolor |
|
341 |
|
342 elif vlans : |
|
343 # XXX: this happens when LLDP gives us the LACP ports but the VLANS are on the TRK port |
|
344 log.warn("%s#%s: unknown port for vlans: %s", host, local['port_id'], vlans.get(host)) |
|
345 |
|
346 # edge |
|
347 if (src_name, local['port'], dst_name, remote['port']) in edges : |
|
348 log.warning("%s:%s <- %s:%s: duplicate", src_name, local['port'], dst_name, remote['port']) |
|
349 |
|
350 elif (dst_name, remote['port'], src_name, local['port']) in edges : |
|
351 log.info("%s <-> %s", src_name, dst_name) |
|
352 edge = edges[(dst_name, remote['port'], src_name, local['port'])] |
|
353 |
|
354 if edge.get('headlabel') != taillabel : |
|
355 log.warn("%s -> %s: local port mismatch: %s vs %s", src_name, dst_name, local['port'], edge.get('headlabel')) |
|
356 |
|
357 if edge.get('taillabel') != headlabel : |
|
358 log.warn("%s -> %s: remote port mismatch: %s vs %s", src_name, dst_name, remote['port'], edge.get('taillabel')) |
|
359 |
|
360 if edge.get('fillcolor') != fillcolor : |
|
361 log.warn("%s#%s -> %s#%s: remote untag mismatch: %s vs %s", src_name, local['port'], dst_name, remote['port'], fillcolor, edge.get('fillcolor')) |
|
362 |
|
363 if edge.get('color') != '"' + color + '"' : |
|
364 log.warn("%s#%s -> %s#%s: remote tagged mismatch: %s vs %s", src_name, local['port'], dst_name, remote['port'], color, edge.get('color')) |
|
365 |
|
366 # mark as bidirectional |
|
367 edges[(src_name, local['port'], dst_name, remote['port'])] = edge |
|
368 |
|
369 edge.set('dir', 'both' if untag else 'none') |
|
370 |
|
371 # set second color for tail |
|
372 if untag : |
|
373 edge.set('color', '"{headcolor}:{tailcolor}{tagcolors}"'.format( |
|
374 headcolor = edge.get('fillcolor'), |
|
375 tailcolor = fillcolor, |
|
376 tagcolors = ':' + ':'.join(colors) if colors else '', |
|
377 )) |
|
378 |
|
379 else : |
|
380 edge = edges[(src_name, local['port'], dst_name, remote['port'])] = pydot.Edge(src, dst, |
|
381 dir = 'forward' if untag else 'none', |
|
382 headlabel = headlabel, |
|
383 taillabel = taillabel, |
|
384 color = '"{color}"'.format(color=color), |
|
385 fillcolor = fillcolor, |
|
386 ) |
|
387 |
|
388 dot.add_edge(edge) |
|
389 |
|
390 if options.graph_dot : |
|
391 dot.write(options.graph_dot) |
|
392 |
|
393 def main (argv) : |
|
394 """ |
|
395 SNMP polling. |
|
396 """ |
|
397 |
|
398 parser = optparse.OptionParser(main.__doc__) |
|
399 parser.add_option_group(pvl.args.parser(parser)) |
|
400 parser.add_option_group(pvl.hosts.optparser(parser)) |
|
401 parser.add_option_group(pvl.snmp.snmp.options(parser)) |
|
402 |
|
403 parser.add_option('--graph-dot', |
|
404 help="Output .dot graph data") |
|
405 |
|
406 parser.add_option('--no-vlans', action='store_const', dest='vlans', const=False, |
|
407 help="Do not color VLANs") |
|
408 |
|
409 # input |
|
410 options, args = parser.parse_args(argv[1:]) |
|
411 pvl.args.apply(options) |
|
412 |
|
413 hosts = pvl.hosts.apply(options, args) |
|
414 |
|
415 # lookup host-port-vlan mappings |
|
416 if options.vlans is False : |
|
417 vlans = None |
|
418 else : |
|
419 vlans = dict(apply_hosts_vlan(options, hosts)) |
|
420 |
|
421 # discover node/port graph |
|
422 items = apply_hosts_lldp(options, hosts) |
|
423 |
|
424 # discover edge nodes |
|
425 leafs = apply_hosts_bridge(options, hosts) |
|
426 |
|
427 # print |
|
428 if options.graph_dot : |
|
429 apply_graph(options, items, vlans) |
|
430 else : |
|
431 for host, local, remote, remote_host in items : |
|
432 if remote_host : |
|
433 print "{host:30} {host.location:>30} {local[port]:>25} <-> {remote[port]:<25} {remote_host.location:>30} # {remote[chassis]} ({remote_host})".format(host=host, local=local, remote=remote, remote_host=remote_host) |
|
434 else : |
|
435 print "{host:30} {host.location:>30} {local[port]:>25} --> {remote[port]:<25} {empty:30} # {remote[chassis]} ({remote[sys_name]})".format(host=host, local=local, remote=remote, empty='') |
|
436 |
|
437 for host, ports in vlans.iteritems() : |
|
438 for port, (untag, tagged) in ports.iteritems() : |
|
439 print "{host:30} {host.location:>30} {port:25} {untag}{tagged}".format(host=host, port=port, |
|
440 untag = '({untag}) '.format(untag=untag) if untag else '', |
|
441 tagged = ' '.join('<{tag}>'.format(tag=tag) for tag in tagged), |
|
442 ) |
|
443 |
|
444 for host, ports in leafs : |
|
445 for (port, vlan), ethers in ports.iteritems() : |
|
446 print "{host:30} {host.location:>30} {port:25} <-- {vlan} # {ethers}".format( |
|
447 host = host, |
|
448 port = port, |
|
449 vlan = '<{vlan}>'.format(vlan=vlan) if vlan else '', |
|
450 ethers = ' '.join(ethers), |
|
451 ) |
|
452 |
|
453 if __name__ == '__main__': |
|
454 pvl.args.main(main) |
|