# vim: tabstop=4 shiftwidth=4 softtabstop=4 # This is extracting the confluent slp support into # a standalone script, for use in environments that # can't run full confluent and/or would prefer # a utility style approach to SLP # Copyright 2017-2019 Lenovo # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os import random import select import socket import struct _slp_services = set([ 'service:management-hardware.IBM:integrated-management-module2', 'service:lenovo-smm', 'service:lenovo-smm2', 'service:ipmi', 'service:lighttpd', 'service:management-hardware.Lenovo:lenovo-xclarity-controller', 'service:management-hardware.IBM:chassis-management-module', 'service:management-hardware.Lenovo:chassis-management-module', 'service:io-device.Lenovo:management-module', ]) # SLP has a lot of ambition that was unfulfilled in practice. # So we have a static footer here to always use 'DEFAULT' scope, no LDAP # predicates, and no authentication for service requests srvreqfooter = b'\x00\x07DEFAULT\x00\x00\x00\x00' # An empty instance of the attribute list extension # which is defined in RFC 3059, used to indicate support for that capability attrlistext = b'\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00' try: IPPROTO_IPV6 = socket.IPPROTO_IPV6 except AttributeError: IPPROTO_IPV6 = 41 # Assume Windows value if socket is missing it def _parse_slp_header(packet): packet = bytearray(packet) if len(packet) < 16 or packet[0] != 2: # discard packets that are obviously useless return None parsed = { 'function': packet[1], } (offset, parsed['xid'], langlen) = struct.unpack('!IHH', bytes(b'\x00' + packet[7:14])) parsed['lang'] = packet[14:14 + langlen].decode('utf-8') parsed['payload'] = packet[14 + langlen:] if offset: parsed['offset'] = 14 + langlen parsed['extoffset'] = offset return parsed def _pop_url(payload): urllen = struct.unpack('!H', bytes(payload[3:5]))[0] url = bytes(payload[5:5+urllen]).decode('utf-8') if payload[5+urllen] != 0: raise Exception('Auth blocks unsupported') payload = payload[5+urllen+1:] return url, payload def _parse_SrvRply(parsed): """ Modify passed dictionary to have parsed data :param parsed: :return: """ payload = parsed['payload'] if len(payload) < 4: return ecode, ucount = struct.unpack('!HH', bytes(payload[0:4])) if ecode: parsed['errorcode'] = ecode payload = payload[4:] parsed['urls'] = [] while ucount: ucount -= 1 url, payload = _pop_url(payload) parsed['urls'].append(url) def _parse_slp_packet(packet, peer, rsps, xidmap): parsed = _parse_slp_header(packet) if not parsed: return addr = peer[0] if '%' in addr: addr = addr[:addr.index('%')] mac = None if addr in neightable: identifier = neightable[addr] mac = identifier else: identifier = addr if (identifier, parsed['xid']) in rsps: # avoid obviously duplicate entries parsed = rsps[(identifier, parsed['xid'])] else: rsps[(identifier, parsed['xid'])] = parsed if mac and 'hwaddr' not in parsed: parsed['hwaddr'] = mac if parsed['xid'] in xidmap: parsed['services'] = [xidmap[parsed['xid']]] if 'addresses' in parsed: if peer not in parsed['addresses']: parsed['addresses'].append(peer) else: parsed['addresses'] = [peer] if parsed['function'] == 2: # A service reply _parse_SrvRply(parsed) def _v6mcasthash(srvtype): # The hash algorithm described by RFC 3111 nums = bytearray(srvtype.encode('utf-8')) hashval = 0 for i in nums: hashval *= 33 hashval += i hashval &= 0xffff # only need to track the lowest 16 bits hashval &= 0x3ff hashval |= 0x1000 return '{0:x}'.format(hashval) def _generate_slp_header(payload, multicast, functionid, xid, extoffset=0): if multicast: flags = 0x2000 else: flags = 0 packetlen = len(payload) + 16 # we have a fixed 16 byte header supported if extoffset: # if we have an offset, add 16 to account for this function # generating a 16 byte header extoffset += 16 if packetlen > 1400: # For now, we aren't intending to support large SLP transmits # raise an exception to help identify if such a requirement emerges raise Exception("TODO: Transmit overflow packets") # We always do SLP v2, and only v2 header = bytearray([2, functionid]) # SLP uses 24 bit packed integers, so in such places we pack 32 then # discard the high byte header.extend(struct.pack('!IH', packetlen, flags)[1:]) # '2' below refers to the length of the language tag header.extend(struct.pack('!IHH', extoffset, xid, 2)[1:]) # we only do english (in SLP world, it's not like non-english appears...) header.extend(b'en') return header def _generate_attr_request(service, xid): service = service.encode('utf-8') payload = bytearray(struct.pack('!HH', 0, len(service)) + service) payload.extend(srvreqfooter) header = _generate_slp_header(payload, False, functionid=6, xid=xid) return header + payload def _generate_request_payload(srvtype, multicast, xid, prlist=''): prlist = prlist.encode('utf-8') payload = bytearray(struct.pack('!H', len(prlist)) + prlist) srvtype = srvtype.encode('utf-8') payload.extend(struct.pack('!H', len(srvtype)) + srvtype) payload.extend(srvreqfooter) extoffset = len(payload) payload.extend(attrlistext) header = _generate_slp_header(payload, multicast, functionid=1, xid=xid, extoffset=extoffset) return header + payload def _find_srvtype(net, net4, srvtype, addresses, xid): """Internal function to find a single service type Helper to do singleton requests to srvtype :param net: Socket active :param srvtype: Service type to do now :param addresses: Pass through of addresses argument from find_targets :return: """ if addresses is None: data = _generate_request_payload(srvtype, True, xid) net4.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) v6addrs = [] v6hash = _v6mcasthash(srvtype) # do 'interface local' and 'link local' # it shouldn't make sense, but some configurations work with interface # local that do not work with link local v6addrs.append(('ff01::1:' + v6hash, 427, 0, 0)) v6addrs.append(('ff02::1:' + v6hash, 427, 0, 0)) for idx in list_interface_indexes(): # IPv6 multicast is by index, so lead with that net.setsockopt(IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, idx) for sa in v6addrs: try: net.sendto(data, sa) except socket.error: # if we hit an interface without ipv6 multicast, # this can cause an error, skip such an interface # case in point, 'lo' pass for i4 in list_ips(): if 'broadcast' not in i4: continue addr = i4['addr'] bcast = i4['broadcast'] net4.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(addr)) try: net4.sendto(data, ('239.255.255.253', 427)) except socket.error as se: # On occasion, multicasting may be disabled # tolerate this scenario and move on if se.errno != 101: raise net4.sendto(data, (bcast, 427)) def _grab_rsps(socks, rsps, interval, xidmap): r = None res = select.select(socks, (), (), interval) if res: r = res[0] while r: for s in r: (rsp, peer) = s.recvfrom(9000) refresh_neigh() _parse_slp_packet(rsp, peer, rsps, xidmap) res = select.select(socks, (), (), interval) if not res: r = None else: r = res[0] def _parse_attrlist(attrstr): attribs = {} previousattrlen = None attrstr = stringify(attrstr) while attrstr: if len(attrstr) == previousattrlen: raise Exception('Looping in attrstr parsing') previousattrlen = len(attrstr) if attrstr[0] == '(': if ')' not in attrstr: attribs['INCOMPLETE'] = True return attribs currattr = attrstr[1:attrstr.index(')')] if '=' not in currattr: # Not allegedly kosher, but still.. attribs[currattr] = None else: attrname, attrval = currattr.split('=', 1) attribs[attrname] = [] for val in attrval.split(','): if val[:3] == '\\FF': # we should make this bytes finalval = bytearray([]) for bnum in attrval[3:].split('\\'): if bnum == '': continue finalval.append(int(bnum, 16)) val = finalval if 'uuid' in attrname and len(val) == 16: lebytes = struct.unpack_from( 'HHI', memoryview(val[8:])) val = '{0:08X}-{1:04X}-{2:04X}-{3:04X}-' \ '{4:04X}{5:08X}'.format( lebytes[0], lebytes[1], lebytes[2], bebytes[0], bebytes[1], bebytes[2] ).lower() attribs[attrname].append(val) attrstr = attrstr[attrstr.index(')'):] elif attrstr[0] == ','[0]: attrstr = attrstr[1:] elif ',' in attrstr: currattr = attrstr[:attrstr.index(',')] attribs[currattr] = None attrstr = attrstr[attrstr.index(','):] else: currattr = attrstr attribs[currattr] = None attrstr = None return attribs def _parse_attrs(data, parsed, xid=None): headinfo = _parse_slp_header(data) if xid is None: xid = parsed['xid'] if headinfo['function'] != 7 or headinfo['xid'] != xid: return payload = headinfo['payload'] if struct.unpack('!H', bytes(payload[:2]))[0] != 0: return length = struct.unpack('!H', bytes(payload[2:4]))[0] attrstr = bytes(payload[4:4+length]) parsed['attributes'] = _parse_attrlist(attrstr) def fix_info(info, handler): if '_attempts' not in info: info['_attempts'] = 10 if info['_attempts'] == 0: return info['_attempts'] -= 1 _add_attributes(info) handler(info) def _add_attributes(parsed): xid = parsed.get('xid', 42) attrq = _generate_attr_request(parsed['services'][0], xid) target = None # prefer reaching out to an fe80 if present, to be highly robust # in face of network changes for addr in parsed['addresses']: if addr[0].startswith('fe80'): target = addr # however if no fe80 seen, roll with the first available address if not target: target = parsed['addresses'][0] if len(target) == 4: net = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) else: net = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: net.settimeout(2.0) net.connect(target) except socket.error: return try: net.sendall(attrq) rsp = net.recv(8192) net.close() _parse_attrs(rsp, parsed, xid) except Exception as e: # this can be a messy area, just degrade the quality of rsp # in a bad situation return def unicast_scan(address): pass def query_srvtypes(target): """Query the srvtypes advertised by the target :param target: A sockaddr tuple (if you get the peer info) """ payload = b'\x00\x00\xff\xff\x00\x07DEFAULT' header = _generate_slp_header(payload, False, functionid=9, xid=1) packet = header + payload if len(target) == 2: net = socket.socket(socket.AF_INET, socket.SOCK_STREAM) elif len(target) == 4: net = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) else: raise Exception('Unrecognized target {0}'.format(repr(target))) tries = 3 connected = False while tries and not connected: tries -= 1 try: net.settimeout(1.0) net.connect(target) connected = True except socket.error: pass if not connected: return [u''] net.sendall(packet) rs = net.recv(8192) net.close() parsed = _parse_slp_header(rs) if parsed: payload = parsed['payload'] if payload[:2] != '\x00\x00': return stypelen = struct.unpack('!H', bytes(payload[2:4]))[0] stypes = payload[4:4+stypelen].decode('utf-8') return stypes.split(',') def rescan(handler): known_peers = set([]) for scanned in scan(): for addr in scanned['addresses']: ip = addr[0].partition('%')[0] # discard scope if present if ip not in neightable: continue if addr in known_peers: break known_peers.add(addr) else: handler(scanned) def snoop(handler, protocol=None): """Watch for SLP activity handler will be called with a dictionary of relevant attributes :param handler: :return: """ try: active_scan(handler, protocol) except Exception as e: raise net = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) net.setsockopt(IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) slpg = socket.inet_pton(socket.AF_INET6, 'ff01::123') slpg2 = socket.inet_pton(socket.AF_INET6, 'ff02::123') for i6idx in list_interface_indexes(): mreq = slpg + struct.pack('=I', i6idx) net.setsockopt(IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq) mreq = slpg2 + struct.pack('=I', i6idx) net.setsockopt(IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq) net4 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) net.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) net4.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) for i4 in list_ips(): if 'broadcast' not in i4: continue slpmcast = socket.inet_aton('239.255.255.253') + \ socket.inet_aton(i4['addr']) try: net4.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, slpmcast) except socket.error as e: if e.errno != 98: raise # socket in use can occur when aliased ipv4 are encountered net.bind(('', 427)) net4.bind(('', 427)) while True: try: newmacs = set([]) r, _, _ = select.select((net, net4), (), (), 60) # clear known_peers and peerbymacaddress # to avoid stale info getting in... # rely upon the select(0.2) to catch rapid fire and aggregate ip # addresses that come close together # calling code needs to understand deeper context, as snoop # will now yield dupe info over time known_peers = set([]) peerbymacaddress = {} while r: for s in r: (rsp, peer) = s.recvfrom(9000) ip = peer[0].partition('%')[0] if peer in known_peers: continue if ip not in neightable: update_neigh() if ip not in neightable: continue known_peers.add(peer) mac = neightable[ip] if mac in peerbymacaddress: peerbymacaddress[mac]['addresses'].append(peer) else: q = query_srvtypes(peer) if not q or not q[0]: # SLP might have started and not ready yet # ignore for now known_peers.discard(peer) continue # we want to prioritize the very well known services svcs = [] for svc in q: if svc in _slp_services: svcs.insert(0, svc) else: svcs.append(svc) peerbymacaddress[mac] = { 'services': svcs, 'addresses': [peer], } newmacs.add(mac) r, _, _ = select.select((net, net4), (), (), 0.2) for mac in newmacs: peerbymacaddress[mac]['xid'] = 1 _add_attributes(peerbymacaddress[mac]) peerbymacaddress[mac]['hwaddr'] = mac peerbymacaddress[mac]['protocol'] = protocol for srvurl in peerbymacaddress[mac].get('urls', ()): if len(srvurl) > 4: srvurl = srvurl[:-3] if srvurl.endswith('://Athena:'): continue if 'service:ipmi' in peerbymacaddress[mac]['services']: continue if 'service:lightttpd' in peerbymacaddress[mac]['services']: currinf = peerbymacaddress[mac] curratt = currinf.get('attributes', {}) if curratt.get('System-Manufacturing', [None])[0] == 'Lenovo' and curratt.get('type', [None])[0] == 'LenovoThinkServer': peerbymacaddress[mac]['services'] = ['service:lenovo-tsm'] else: continue handler(peerbymacaddress[mac]) except Exception as e: raise def active_scan(handler, protocol=None): known_peers = set([]) for scanned in scan(): for addr in scanned['addresses']: ip = addr[0].partition('%')[0] # discard scope if present if ip not in neightable: continue if addr in known_peers: break known_peers.add(addr) else: scanned['protocol'] = protocol handler(scanned) def scan(srvtypes=_slp_services, addresses=None, localonly=False): """Find targets providing matching requested srvtypes This is a generator that will iterate over respondants to the SrvType requested. :param srvtypes: An iterable list of the service types to find :param addresses: An iterable of addresses/ranges. Default is to scan local network segment using multicast and broadcast. Each address can be a single address, hyphen-delimited range, or an IP/CIDR indication of a network. :return: Iterable set of results """ net = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) net4 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # increase RCVBUF to max, mitigate chance of # failure due to full buffer. net.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 16777216) net4.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 16777216) # SLP is very poor at scanning large counts and managing it, so we # must make the best of it # Some platforms/config default to IPV6ONLY, we are doing IPv4 # too, so force it #net.setsockopt(IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) # we are going to do broadcast, so allow that... initxid = random.randint(0, 32768) xididx = 0 xidmap = {} # First we give fast repsonders of each srvtype individual chances to be # processed, mitigating volume of response traffic rsps = {} for srvtype in srvtypes: xididx += 1 _find_srvtype(net, net4, srvtype, addresses, initxid + xididx) xidmap[initxid + xididx] = srvtype _grab_rsps((net, net4), rsps, 0.1, xidmap) # now do a more slow check to work to get stragglers, # but fortunately the above should have taken the brunt of volume, so # reduced chance of many responses overwhelming receive buffer. _grab_rsps((net, net4), rsps, 1, xidmap) # now to analyze and flesh out the responses for id in rsps: for srvurl in rsps[id].get('urls', ()): if len(srvurl) > 4: srvurl = srvurl[:-3] if srvurl.endswith('://Athena:'): continue if 'service:ipmi' in rsps[id]['services']: continue if localonly: for addr in rsps[id]['addresses']: if 'fe80' in addr[0]: break else: continue _add_attributes(rsps[id]) if 'service:lighttpd' in rsps[id]['services']: currinf = rsps[id] curratt = currinf.get('attributes', {}) if curratt.get('System-Manufacturing', [None])[0] == 'Lenovo' and curratt.get('type', [None])[0] == 'LenovoThinkServer': currinf['services'] = ['service:lenovo-tsm'] serialnumber = curratt.get('Product-Serial', curratt.get('SerialNumber', None)) if serialnumber: curratt['enclosure-serial-number'] = serialnumber mtm = curratt.get('Machine-Type', curratt.get('Product-Name', None)) if mtm: mtm[0] = mtm[0].rstrip() curratt['enclosure-machinetype-model'] = mtm else: continue del rsps[id]['payload'] del rsps[id]['function'] del rsps[id]['xid'] yield rsps[id] # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2016 Lenovo # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # A consolidated manage of neighbor table information management. # Ultimately, this should use AF_NETLINK, but in the interest of time, # use ip neigh for the moment import subprocess import os neightable = {} neightime = 0 import re _validmac = re.compile('..:..:..:..:..:..') def update_neigh(): global neightable global neightime neightable = {} if os.name == 'nt': return ipn = subprocess.Popen(['ip', 'neigh'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (neighdata, err) = ipn.communicate() neighdata = stringify(neighdata) for entry in neighdata.split('\n'): entry = entry.split(' ') if len(entry) < 5 or not entry[4]: continue if entry[0] in ('192.168.0.100', '192.168.70.100', '192.168.70.125'): # Note that these addresses are common static ip addresses # that are hopelessly ambiguous if there are many # so ignore such entries and move on # ideally the system network steers clear of this landmine of # a subnet, but just in case continue if not _validmac.match(entry[4]): continue neightable[entry[0]] = entry[4] neightime = os.times()[4] def refresh_neigh(): global neightime if os.name == 'nt': return if os.times()[4] > (neightime + 30): update_neigh() # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2014 IBM Corporation # Copyright 2015-2017 Lenovo # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Various utility functions that do not neatly fit into one category or another import base64 import hashlib import netifaces import os import re import socket import ssl import struct def stringify(instr): # Normalize unicode and bytes to 'str', correcting for # current python version if isinstance(instr, bytes) and not isinstance(instr, str): return instr.decode('utf-8', errors='replace') elif not isinstance(instr, bytes) and not isinstance(instr, str): return instr.encode('utf-8') return instr def list_interface_indexes(): # Getting the interface indexes in a portable manner # would be better, but there's difficulty from a python perspective. # For now be linux specific try: for iface in os.listdir('/sys/class/net/'): if not os.path.exists('/sys/class/net/{0}/ifindex'.format(iface)): continue ifile = open('/sys/class/net/{0}/ifindex'.format(iface), 'r') intidx = int(ifile.read()) ifile.close() yield intidx except (IOError, OSError): # Probably situation is non-Linux, just do limited support for # such platforms until other people come along for iface in netifaces.interfaces(): addrinfo = netifaces.ifaddresses(iface).get(socket.AF_INET6, []) for addr in addrinfo: v6addr = addr.get('addr', '').partition('%')[2] if v6addr: yield(int(v6addr)) break return def list_ips(): # Used for getting addresses to indicate the multicast address # as well as getting all the broadcast addresses for iface in netifaces.interfaces(): addrs = netifaces.ifaddresses(iface) if netifaces.AF_INET in addrs: for addr in addrs[netifaces.AF_INET]: yield addr def randomstring(length=20): """Generate a random string of requested length :param length: The number of characters to produce, defaults to 20 """ chunksize = length // 4 if length % 4 > 0: chunksize += 1 strval = base64.urlsafe_b64encode(os.urandom(chunksize * 3)) return stringify(strval[0:length]) def securerandomnumber(low=0, high=4294967295): """Return a random number within requested range Note that this function will not return smaller than 0 nor larger than 2^32-1 no matter what is requested. The python random number facility does not provide characteristics appropriate for secure rng, go to os.urandom :param low: Smallest number to return (defaults to 0) :param high: largest number to return (defaults to 2^32-1) """ number = -1 while number < low or number > high: number = struct.unpack("I", os.urandom(4))[0] return number def monotonic_time(): """Return a monotoc time value In scenarios like timeouts and such, monotonic timing is preferred. """ # for now, just support POSIX systems return os.times()[4] def get_certificate_from_file(certfile): cert = open(certfile, 'r').read() inpemcert = False prunedcert = '' for line in cert.split('\n'): if '-----BEGIN CERTIFICATE-----' in line: inpemcert = True if inpemcert: prunedcert += line if '-----END CERTIFICATE-----' in line: break return ssl.PEM_cert_to_DER_cert(prunedcert) def get_fingerprint(certificate, algo='sha512'): if algo == 'sha256': return 'sha256$' + hashlib.sha256(certificate).hexdigest() elif algo == 'sha512': return 'sha512$' + hashlib.sha512(certificate).hexdigest() raise Exception('Unsupported fingerprint algorithm ' + algo) def cert_matches(fingerprint, certificate): if not fingerprint or not certificate: return False algo, _, fp = fingerprint.partition('$') newfp = None if algo in ('sha512', 'sha256'): newfp = get_fingerprint(certificate, algo) return newfp and fingerprint == newfp numregex = re.compile('([0-9]+)') def naturalize_string(key): """Analyzes string in a human way to enable natural sort :param nodename: The node name to analyze :returns: A structure that can be consumed by 'sorted' """ return [int(text) if text.isdigit() else text.lower() for text in re.split(numregex, key)] def natural_sort(iterable): """Return a sort using natural sort if possible :param iterable: :return: """ try: return sorted(iterable, key=naturalize_string) except TypeError: # The natural sort attempt failed, fallback to ascii sort return sorted(iterable) if __name__ == '__main__': def testsnoop(a): print(repr(a)) snoop(testsnoop)