From 4bdf03a09cee84fe4d274b96e92bb86c7044458f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20H=C3=B6=C3=9F?= Date: Sat, 7 Nov 2020 19:27:46 +0100 Subject: [PATCH] Add WSDD / support files --- gentoo/wsdd/README.md | 26 + gentoo/wsdd/wsdd.conf | 7 + gentoo/wsdd/wsdd.init | 81 ++ gentoo/wsdd/wsdd.py | 1688 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1802 insertions(+) create mode 100644 gentoo/wsdd/README.md create mode 100644 gentoo/wsdd/wsdd.conf create mode 100755 gentoo/wsdd/wsdd.init create mode 100755 gentoo/wsdd/wsdd.py diff --git a/gentoo/wsdd/README.md b/gentoo/wsdd/README.md new file mode 100644 index 0000000..d4d9568 --- /dev/null +++ b/gentoo/wsdd/README.md @@ -0,0 +1,26 @@ +My configs/init-script for WSDD. + +Original source see https://github.com/christgau/wsdd + +Install: + +Check if newer wsdd.py is available at source! + +``` +cp wsdd.py /opt/wsdd/wsdd.py +chown -R daemon.daemon /opt/wsdd/wsdd.py +cp wsdd.init /etc/init.d/wsdd +cp wsdd.conf /etc/conf.d/wsdd +``` + +# Running: +- Be sure to update /etc/conf.d/wsdd! +- If using single instance all fields of /etc/conf.d/wsdd should be commented out! +- If using multiple instances (wsdd.INSTANCE...) be sure to set all fields in /etc/conf.d/wsdd in ALL instances! +- UUIDs can be generated with `uuidgen -N test.mh3000.net -n @dns -s` + + + + + + diff --git a/gentoo/wsdd/wsdd.conf b/gentoo/wsdd/wsdd.conf new file mode 100644 index 0000000..ada9be1 --- /dev/null +++ b/gentoo/wsdd/wsdd.conf @@ -0,0 +1,7 @@ +# !! If adding instance (wsdd.INSTANCE) be aware that all fields are inherited from this file, +# !! and therefore must be redefined +WSDD_INTERFACE="192.168.61.1" +WSDD_HOST="ZEUS" +# Generate with uuidgen -N [FQDN] -n @dns -s +WSDD_UUID="9044b09f-1dd5-5309-b453-1e3408673d56" + diff --git a/gentoo/wsdd/wsdd.init b/gentoo/wsdd/wsdd.init new file mode 100755 index 0000000..dc69a07 --- /dev/null +++ b/gentoo/wsdd/wsdd.init @@ -0,0 +1,81 @@ +#!/sbin/openrc-run +# Copyright 2017 Steffen Christgau +# Distributed under the terms of the MIT licence + +depend() { + need net + need samba +} + +SMB_CONFIG_FILE="/etc/samba/smb.conf" +LOG_FILE="${WSDD_LOG_FILE:-/var/log/wsdd.log}" +WSDD_EXEC="/opt/wsdd/wsdd.py" +RUN_AS_USER="${WSDD_USER:-daemon:daemon}" + +SVCPREFIX="${SVCNAME%.*}" +INSTNAME="${SVCNAME:${#SVCPREFIX}+1}" + + +start() { + ebegin "Starting ${RC_SVCNAME} daemon" + + OPTS="${WSDD_OPTS}" + + if [ -z "$WSDD_WORKGROUP" ]; then + # try to extract workgroup with Samba's testparm + if which testparm >/dev/null 2>/dev/null; then + GROUP="$(testparm -s --parameter-name workgroup 2>/dev/null)" + fi + + # fallback to poor man's approach if testparm is unavailable or failed for some reason + if [ -z "$GROUP" ] && [ -r "${SMB_CONFIG_FILE}" ]; then + GROUP=`grep -i '^\s*workgroup\s*=' ${SMB_CONFIG_FILE} | cut -f2 -d= | tr -d '[:blank:]'` + fi + + if [ -n "${GROUP}" ]; then + OPTS="-w ${GROUP} ${OPTS}" + fi + else + OPTS="-w ${WSDD_WORKGROUP} ${OPTS}" + fi + + if [ -z "${WSDD_HOST}" ]; then + WSDD_HOST="${INSTNAME}" + fi + + if [ -n "${WSDD_HOST}" ]; then + OPTS="-n ${WSDD_HOST} ${OPTS}" + fi + + if [ -n "${WSDD_INTERFACE}" ]; then + OPTS="-i ${WSDD_INTERFACE} ${OPTS}" + fi + + if [ -n "${WSDD_UUID}" ]; then + OPTS="-U ${WSDD_UUID} ${OPTS}" + fi + + if [ -n "${INSTNAME}" ]; then + if ! [[ "${OPTS}" =~ -i ]] && ! [[ "${OPTS}" =~ --interface ]]; then + ewarn "If running multiple service-instances set WSDD_INTERFACE or parameter -i" + fi + + if ! [[ "${OPTS}" =~ -U ]] && ! [[ "${OPTS}" =~ --uuid ]]; then + ewarn "If running multiple service-instances set WSDD_UUID or parameter -u" + fi + fi + + if [ ! -r "${LOG_FILE}" ]; then + touch "${LOG_FILE}" + fi + chown ${RUN_AS_USER} "${LOG_FILE}" + + start-stop-daemon --start --background --user ${RUN_AS_USER} --make-pidfile --pidfile /var/run/${RC_SVCNAME}.pid --stdout "${LOG_FILE}" --stderr "${LOG_FILE}" --exec ${WSDD_EXEC} -- ${OPTS} + eend $? +} + +stop() { + ebegin "Stopping ${RC_SVCNAME} daemon" + start-stop-daemon --stop --retry 2 --pidfile /var/run/${RC_SVCNAME}.pid --exec ${WSDD_EXEC} + eend $? +} diff --git a/gentoo/wsdd/wsdd.py b/gentoo/wsdd/wsdd.py new file mode 100755 index 0000000..154a0e7 --- /dev/null +++ b/gentoo/wsdd/wsdd.py @@ -0,0 +1,1688 @@ +#!/usr/bin/env python3 + +# Implements a target service according to the Web Service Discovery +# specification. +# +# The purpose is to enable non-Windows devices to be found by the 'Network +# (Neighborhood)' from Windows machines. +# +# see http://specs.xmlsoap.org/ws/2005/04/discovery/ws-discovery.pdf and +# related documents for details (look at README for more references) +# +# (c) Steffen Christgau, 2017-2020 + +import sys +import signal +import socket +import selectors +import struct +import argparse +import uuid +import time +import random +import logging +import platform +import ctypes.util +import collections +import xml.etree.ElementTree as ElementTree +import http +import http.server +import urllib.request +import urllib.parse +import socketserver +import os +import pwd +import grp +import datetime + + +class MulticastHandler: + """ + A class for handling multicast traffic on a given interface for a + given address family. It provides multicast sender and receiver sockets + """ + # TODO: this one needs some cleanup + def __init__(self, family, address, interface, selector): + self.address = address + self.family = family + self.interface = interface + self.recv_socket = socket.socket(self.family, socket.SOCK_DGRAM) + self.recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.send_socket = socket.socket(self.family, socket.SOCK_DGRAM) + self.transport_address = address + self.multicast_address = None + self.listen_address = None + + self.message_handlers = {} + self.selector = selector + + if family == socket.AF_INET: + self.init_v4() + elif family == socket.AF_INET6: + self.init_v6() + + logger.info('joined multicast group {0} on {2}%{1}'.format( + self.multicast_address, self.interface.name, self.address)) + logger.debug('transport address on {0} is {1}'.format( + self.interface.name, self.transport_address)) + logger.debug('will listen for HTTP traffic on address {0}'.format( + self.listen_address)) + + self.selector.register(self.recv_socket, selectors.EVENT_READ, self) + self.selector.register(self.send_socket, selectors.EVENT_READ, self) + + def cleanup(self): + self.selector.unregister(self.recv_socket) + self.selector.unregister(self.send_socket) + + self.recv_socket.close() + self.send_socket.close() + + def handles(self, family, addr, interface): + return (self.family == family and self.address == addr and + self.interface.name == interface.name) + + def init_v6(self): + idx = socket.if_nametoindex(self.interface.name) + self.multicast_address = (WSD_MCAST_GRP_V6, WSD_UDP_PORT, 0x575C, idx) + + # v6: member_request = { multicast_addr, intf_idx } + mreq = ( + socket.inet_pton(self.family, WSD_MCAST_GRP_V6) + + struct.pack('@I', idx)) + self.recv_socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq) + self.recv_socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + + # Could anyone ask the Linux folks for the rationale for this!? + if platform.system() == 'Linux': + try: + # supported starting from Linux 4.20 + IPV6_MULTICAST_ALL = 29 + self.recv_socket.setsockopt( + socket.IPPROTO_IPV6, IPV6_MULTICAST_ALL, 0) + except OSError as e: + logger.warning('cannot unset all_multicast: {}'.format(e)) + + # bind to network interface, i.e. scope and handle OS differences, + # see Stevens: Unix Network Programming, Section 21.6, last paragraph + try: + self.recv_socket.bind((WSD_MCAST_GRP_V6, WSD_UDP_PORT, 0, idx)) + except OSError: + self.recv_socket.bind(('::', 0, 0, idx)) + + self.send_socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, 0) + self.send_socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, args.hoplimit) + self.send_socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, idx) + + self.transport_address = '[{0}]'.format(self.address) + self.listen_address = (self.address, WSD_HTTP_PORT, 0, idx) + + def init_v4(self): + idx = socket.if_nametoindex(self.interface.name) + self.multicast_address = (WSD_MCAST_GRP_V4, WSD_UDP_PORT) + + # v4: member_request (ip_mreqn) = { multicast_addr, intf_addr, idx } + mreq = ( + socket.inet_pton(self.family, WSD_MCAST_GRP_V4) + + socket.inet_pton(self.family, self.address) + + struct.pack('@I', idx)) + self.recv_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + if platform.system() == 'Linux': + IP_MULTICAST_ALL = 49 + self.recv_socket.setsockopt(socket.IPPROTO_IP, IP_MULTICAST_ALL, 0) + + try: + self.recv_socket.bind((WSD_MCAST_GRP_V4, WSD_UDP_PORT)) + except OSError: + self.recv_socket.bind(('', WSD_UDP_PORT)) + + self.send_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_IF, mreq) + self.send_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 0) + self.send_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, args.hoplimit) + + self.listen_address = (self.address, WSD_HTTP_PORT) + + def add_handler(self, socket, handler): + try: + self.selector.register(socket, selectors.EVENT_READ, self) + except KeyError: + # accept attempts of multiple registrations + pass + + if socket in self.message_handlers: + self.message_handlers[socket].append(handler) + else: + self.message_handlers[socket] = [handler] + + def remove_handler(self, socket, handler): + if socket in self.message_handlers: + if handler in self.message_handlers[socket]: + self.message_handlers[socket].remove(handler) + + def handle_request(self, key): + s = None + if key.fileobj == self.send_socket: + s = self.send_socket + elif key.fileobj == self.recv_socket: + s = self.recv_socket + else: + return + + msg, address = s.recvfrom(WSD_MAX_LEN) + if s in self.message_handlers: + for handler in self.message_handlers[s]: + handler.handle_request(msg, address) + + +# constants for WSD XML/SOAP parsing +WSA_URI = 'http://schemas.xmlsoap.org/ws/2004/08/addressing' +WSD_URI = 'http://schemas.xmlsoap.org/ws/2005/04/discovery' +WSDP_URI = 'http://schemas.xmlsoap.org/ws/2006/02/devprof' + +namespaces = { + 'soap': 'http://www.w3.org/2003/05/soap-envelope', + 'wsa': WSA_URI, + 'wsd': WSD_URI, + 'wsx': 'http://schemas.xmlsoap.org/ws/2004/09/mex', + 'wsdp': WSDP_URI, + 'pnpx': 'http://schemas.microsoft.com/windows/pnpx/2005/10', + 'pub': 'http://schemas.microsoft.com/windows/pub/2005/07' +} + +WSD_MAX_KNOWN_MESSAGES = 10 + +WSD_PROBE = WSD_URI + '/Probe' +WSD_PROBE_MATCH = WSD_URI + '/ProbeMatches' +WSD_RESOLVE = WSD_URI + '/Resolve' +WSD_RESOLVE_MATCH = WSD_URI + '/ResolveMatches' +WSD_HELLO = WSD_URI + '/Hello' +WSD_BYE = WSD_URI + '/Bye' +WSD_GET = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Get' +WSD_GET_RESPONSE = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/GetResponse' + +WSD_TYPE_DEVICE = 'wsdp:Device' +PUB_COMPUTER = 'pub:Computer' +WSD_TYPE_DEVICE_COMPUTER = '{0} {1}'.format(WSD_TYPE_DEVICE, PUB_COMPUTER) + +WSD_MCAST_GRP_V4 = '239.255.255.250' +WSD_MCAST_GRP_V6 = 'ff02::c' # link-local + +WSA_ANON = WSA_URI + '/role/anonymous' +WSA_DISCOVERY = 'urn:schemas-xmlsoap-org:ws:2005:04:discovery' + +MIME_TYPE_SOAP_XML = 'application/soap+xml' + +# protocol assignments (WSD spec/Section 2.4) +WSD_UDP_PORT = 3702 +WSD_HTTP_PORT = 5357 +WSD_MAX_LEN = 32767 + +WSDD_LISTEN_PORT = 5359 + +# SOAP/UDP transmission constants +MULTICAST_UDP_REPEAT = 4 +UNICAST_UDP_REPEAT = 2 +UDP_MIN_DELAY = 50 +UDP_MAX_DELAY = 250 +UDP_UPPER_DELAY = 500 + +# servers must recond in 4 seconds after probe arrives +PROBE_TIMEOUT = 4 +MAX_STARTUP_PROBE_DELAY = 3 + +# some globals +wsd_instance_id = int(time.time()) +send_queue = [] + +args = None +logger = None + + +class WSDMessageHandler(object): + known_messages = collections.deque([], WSD_MAX_KNOWN_MESSAGES) + + def __init__(self): + self.handlers = {} + + def cleanup(self): + pass + + # shortcuts for building WSD responses + def add_endpoint_reference(self, parent, endpoint=None): + epr = ElementTree.SubElement(parent, 'wsa:EndpointReference') + address = ElementTree.SubElement(epr, 'wsa:Address') + if endpoint is None: + address.text = args.uuid.urn + else: + address.text = endpoint + + def add_metadata_version(self, parent): + meta_data = ElementTree.SubElement(parent, 'wsd:MetadataVersion') + meta_data.text = '1' + + def add_types(self, parent): + dev_type = ElementTree.SubElement(parent, 'wsd:Types') + dev_type.text = WSD_TYPE_DEVICE_COMPUTER + + def add_xaddr(self, parent, transport_addr): + if transport_addr: + item = ElementTree.SubElement(parent, 'wsd:XAddrs') + item.text = 'http://{0}:{1}/{2}'.format( + transport_addr, WSD_HTTP_PORT, args.uuid) + + def build_message(self, to_addr, action_str, request_header, response): + retval = self.xml_to_buffer(self.build_message_tree( + to_addr, action_str, request_header, response)[0]) + + logger.debug('constructed xml for WSD message: {0}'.format(retval)) + + return retval + + def build_message_tree(self, to_addr, action_str, request_header, body): + """ + Build a WSD message with a given action string including SOAP header. + + The message can be constructed based on a response to another + message (given by its header) and with a optional response that + serves as the message's body + """ + root = ElementTree.Element('soap:Envelope') + header = ElementTree.SubElement(root, 'soap:Header') + + to = ElementTree.SubElement(header, 'wsa:To') + to.text = to_addr + + action = ElementTree.SubElement(header, 'wsa:Action') + action.text = action_str + + msg_id = ElementTree.SubElement(header, 'wsa:MessageID') + msg_id.text = uuid.uuid1().urn + + if request_header: + req_msg_id = request_header.find('./wsa:MessageID', namespaces) + if req_msg_id is not None: + relates_to = ElementTree.SubElement(header, 'wsa:RelatesTo') + relates_to.text = req_msg_id.text + + self.add_header_elements(header, action_str) + + body_root = ElementTree.SubElement(root, 'soap:Body') + if body is not None: + body_root.append(body) + + for prefix, uri in namespaces.items(): + root.attrib['xmlns:' + prefix] = uri + + return root, msg_id.text + + def add_header_elements(self, header, extra): + pass + + def handle_message(self, msg, mch, src_address): + """ + handle a WSD message that might be received by a MulticastHandler + """ + tree = ElementTree.fromstring(msg) + header = tree.find('./soap:Header', namespaces) + msg_id_tag = header.find('./wsa:MessageID', namespaces) + if msg_id_tag is None: + return None + + msg_id = msg_id_tag.text + + # if message came over a MulticastHandler, check for duplicates + if mch and self.is_duplicated_msg(msg_id): + logger.debug('known message ({0}): dropping it'.format(msg_id)) + return None + + action_tag = header.find('./wsa:Action', namespaces) + if action_tag is None: + return None + + action = action_tag.text + _, _, action_method = action.rpartition('/') + + if mch: + logger.info('{}:{}({}) - - "{} {} UDP" - -'.format( + src_address[0], src_address[1], mch.interface.name, + action_method, msg_id + )) + else: + # http logging is already done by according server + logger.debug('processing WSD {} message ({})'.format( + action_method, msg_id)) + + body = tree.find('./soap:Body', namespaces) + if body is None: + return None + + logger.debug('incoming message content is {0}'.format(msg)) + if action in self.handlers: + handler = self.handlers[action] + retval = handler(header, body) + if retval is not None: + response, response_type = retval + return self.build_message( + WSA_ANON, response_type, header, response) + else: + logger.debug('unhandled action {0}/{1}'.format(action, msg_id)) + + return None + + def is_duplicated_msg(self, msg_id): + """ + Check for a duplicated message. + + Implements SOAP-over-UDP Appendix II Item 2 + """ + if msg_id in type(self).known_messages: + return True + + type(self).known_messages.append(msg_id) + + return False + + def xml_to_buffer(self, xml): + retval = b'' + retval = retval + ElementTree.tostring(xml, encoding='utf-8') + + return retval + + +class WSDUDPMessageHandler(WSDMessageHandler): + """ + A message handler that handles traffic received via MutlicastHandler. + """ + + def __init__(self, mch): + super().__init__() + + self.mch = mch + + def teardown(self): + pass + + def enqueue_datagram(self, msg, address=None, msg_type=None): + """ + Add an outgoing WSD (SOAP) message to the queue of outstanding messages + + Implements SOAP over UDP, Appendix I. + """ + if not address: + address = self.mch.multicast_address + + if msg_type: + logger.debug('scheduling {0} message via {1} to {2}'.format( + msg_type, self.mch.interface.name, address)) + + msg_count = ( + MULTICAST_UDP_REPEAT + if address == self.mch.multicast_address + else UNICAST_UDP_REPEAT) + + due_time = time.time() + t = random.randint(UDP_MIN_DELAY, UDP_MAX_DELAY) + for i in range(msg_count): + send_queue.append([due_time, self.mch, address, msg]) + due_time += t / 1000 + t = min(t * 2, UDP_UPPER_DELAY) + + +class WSDDiscoveredDevice(object): + + def __init__(self, xml_str, xaddr, interface): + self.last_seen = None + self.addresses = {} + self.props = {} + self.display_name = '' + + self.update(xml_str, xaddr, interface) + + def update(self, xml_str, xaddr, interface): + tree = ElementTree.fromstring(xml_str) + mds_path = 'soap:Body/wsx:Metadata/wsx:MetadataSection' + sections = tree.findall(mds_path, namespaces) + for section in sections: + dialect = section.attrib['Dialect'] + if dialect == WSDP_URI + '/ThisDevice': + self.extract_wsdp_props(section, dialect) + elif dialect == WSDP_URI + '/ThisModel': + self.extract_wsdp_props(section, dialect) + elif dialect == WSDP_URI + '/Relationship': + host_xpath = ('wsdp:Relationship[@Type="{}/host"]/wsdp:Host' + .format(WSDP_URI)) + host_sec = section.find(host_xpath, namespaces) + if (host_sec): + self.extract_host_props(host_sec) + else: + logger.debug('unknown metadata dialect ({})'.format(dialect)) + + url = urllib.parse.urlparse(xaddr) + addr, _, _ = url.netloc.rpartition(':') + if interface not in self.addresses: + self.addresses[interface] = set([addr]) + else: + self.addresses[interface].add(addr) + + self.last_seen = time.time() + if ('DisplayName' in self.props) and ('BelongsTo' in self.props): + self.display_name = self.props['DisplayName'] + logger.info('discovered {} in {} on {}%{}'.format( + self.display_name, self.props['BelongsTo'], addr, + interface.interface.name)) + elif 'FriendlyName' in self.props: + self.display_name = self.props['FriendlyName'] + logger.info('discovered {} on {}%{}'.format( + self.display_name, addr, interface.interface.name)) + + logger.debug(str(self.props)) + + def extract_wsdp_props(self, root, dialect): + _, _, propsRoot = dialect.rpartition('/') + # XPath support is limited, so filter by namespace on our own + nodes = root.findall('./wsdp:{0}/*'.format(propsRoot), namespaces) + ns_prefix = '{{{}}}'.format(WSDP_URI) + prop_nodes = [n for n in nodes if n.tag.startswith(ns_prefix)] + for node in prop_nodes: + tag_name = node.tag[len(ns_prefix):] + self.props[tag_name] = node.text + + def extract_host_props(self, root): + types = root.findtext('wsdp:Types', '', namespaces) + self.props['types'] = types.split(' ') + if types != PUB_COMPUTER: + return + + comp = root.findtext(PUB_COMPUTER, '', namespaces) + self.props['DisplayName'], _, self.props['BelongsTo'] = ( + comp.partition('/')) + + +class WSDClient(WSDUDPMessageHandler): + + def __init__(self, mch, known_devices): + super().__init__(mch) + + self.mch.add_handler(self.mch.send_socket, self) + self.mch.add_handler(self.mch.recv_socket, self) + + self.probes = {} + self.known_devices = known_devices + + self.handlers[WSD_HELLO] = self.handle_hello + self.handlers[WSD_BYE] = self.handle_bye + self.handlers[WSD_PROBE_MATCH] = self.handle_probe_match + self.handlers[WSD_RESOLVE_MATCH] = self.handle_resolve_match + + # avoid packet storm when hosts come up by delaying initial probe + time.sleep(random.randint(0, MAX_STARTUP_PROBE_DELAY)) + self.send_probe() + + def cleanup(self): + self.mch.remove_handler(self.mch.send_socket, self) + self.mch.remove_handler(self.mch.recv_socket, self) + + def send_probe(self): + """WS-Discovery, Section 4.3, Probe message""" + self.remove_outdated_probes() + + probe = ElementTree.Element('wsd:Probe') + ElementTree.SubElement(probe, 'wsd:Types').text = WSD_TYPE_DEVICE + + xml, i = self.build_message_tree(WSA_DISCOVERY, WSD_PROBE, None, probe) + self.enqueue_datagram(self.xml_to_buffer(xml), msg_type='Probe') + self.probes[i] = time.time() + + def teardown(self): + self.remove_outdated_probes() + + def handle_request(self, msg, address): + self.handle_message(msg, self.mch, address) + + def handle_hello(self, header, body): + pm_path = 'wsd:Hello' + endpoint, xaddrs = self.extract_endpoint_metadata(body, pm_path) + if not xaddrs: + logger.info('Hello without XAddrs, sending resolve') + msg = self.build_resolve_message(endpoint) + self.enqueue_datagram(msg) + return + + xaddr = xaddrs.strip() + logger.info('Hello from {} on {}'.format(endpoint, xaddr)) + self.perform_metadata_exchange(endpoint, xaddr) + + def handle_bye(self, header, body): + bye_path = 'wsd:Bye' + endpoint, _ = self.extract_endpoint_metadata(body, bye_path) + device_uuid = str(uuid.UUID(endpoint)) + if device_uuid in self.known_devices: + del(self.known_devices[device_uuid]) + + def handle_probe_match(self, header, body): + # do not handle to probematches issued not sent by ourself + rel_msg = header.findtext('wsa:RelatesTo', None, namespaces) + if rel_msg not in self.probes: + logger.debug("unknown probe {}".format(rel_msg)) + return None + + # if XAddrs are missing, issue resolve request + pm_path = 'wsd:ProbeMatches/wsd:ProbeMatch' + endpoint, xaddrs = self.extract_endpoint_metadata(body, pm_path) + if not xaddrs: + logger.debug('probe match without XAddrs, sending resolve') + msg = self.build_resolve_message(endpoint) + self.enqueue_datagram(msg) + return + + xaddr = xaddrs.strip() + logger.debug('probe match for {} on {}'.format(endpoint, xaddr)) + self.perform_metadata_exchange(endpoint, xaddr) + + def build_resolve_message(self, endpoint): + resolve = ElementTree.Element('wsd:Resolve') + self.add_endpoint_reference(resolve, endpoint) + + return self.build_message(WSA_DISCOVERY, WSD_RESOLVE, None, resolve) + + def handle_resolve_match(self, header, body): + rm_path = 'wsd:ResolveMatches/wsd:ResolveMatch' + endpoint, xaddrs = self.extract_endpoint_metadata(body, rm_path) + if not endpoint or not xaddrs: + logger.debug('resolve match without endpoint/xaddr') + return + + xaddr = xaddrs.strip() + logger.debug('resolve match for {} on {}'.format(endpoint, xaddr)) + self.perform_metadata_exchange(endpoint, xaddr) + + def extract_endpoint_metadata(self, body, prefix): + prefix = prefix + '/' + addr_path = 'wsa:EndpointReference/wsa:Address' + + endpoint = body.findtext(prefix + addr_path, namespaces=namespaces) + xaddrs = body.findtext(prefix + 'wsd:XAddrs', namespaces=namespaces) + + return endpoint, xaddrs + + def perform_metadata_exchange(self, endpoint, xaddr): + if not (xaddr.startswith('http://') or xaddr.startswith('https://')): + logger.debug('invalid XAddr: {}'.format(xaddr)) + return + + host = None + url = xaddr + if self.mch.family == socket.AF_INET6: + host = '[{}]'.format(url.partition('[')[2].partition(']')[0]) + url = url.replace(']', '%{}]'.format(self.mch.interface.name)) + + body = self.build_getmetadata_message(endpoint) + request = urllib.request.Request(url, data=body, method='POST') + request.add_header('Content-Type', 'application/soap+xml') + request.add_header('User-Agent', 'wsdd') + if host is not None: + request.add_header('Host', host) + + try: + with urllib.request.urlopen(request, None, 2.0) as stream: + self.handle_metadata(stream.read(), endpoint, xaddr) + except urllib.error.URLError as e: + logger.warning('could not fetch metadata from: {} {}'.format( + url, e)) + + def build_getmetadata_message(self, endpoint): + tree, _ = self.build_message_tree(endpoint, WSD_GET, None, None) + return self.xml_to_buffer(tree) + + def handle_metadata(self, meta, endpoint, xaddr): + device_uuid = str(uuid.UUID(endpoint)) + if device_uuid in self.known_devices: + self.known_devices[device_uuid].update(meta, xaddr, self.mch) + else: + self.known_devices[device_uuid] = WSDDiscoveredDevice( + meta, xaddr, self.mch) + + def remove_outdated_probes(self): + cut = time.time() - PROBE_TIMEOUT * 2 + self.probes = dict(filter(lambda x: x[1] > cut, self.probes.items())) + + def add_header_elements(self, header, extra): + action_str = extra + if action_str == WSD_GET: + reply_to = ElementTree.SubElement(header, 'wsa:ReplyTo') + addr = ElementTree.SubElement(reply_to, 'wsa:Address') + addr.text = WSA_ANON + + wsa_from = ElementTree.SubElement(header, 'wsa:From') + addr = ElementTree.SubElement(wsa_from, 'wsa:Address') + addr.text = args.uuid.urn + + +class WSDHost(WSDUDPMessageHandler): + """Class for handling WSD requests coming from UDP datagrams.""" + + message_number = 0 + + def __init__(self, mch): + super().__init__(mch) + + self.mch.add_handler(self.mch.recv_socket, self) + + self.handlers[WSD_PROBE] = self.handle_probe + self.handlers[WSD_RESOLVE] = self.handle_resolve + + self.send_hello() + + def teardown(self): + self.send_bye() + + def handle_request(self, msg, address): + reply = self.handle_message(msg, self.mch, address) + if reply: + self.enqueue_datagram(reply, address=address) + + def send_hello(self): + """WS-Discovery, Section 4.1, Hello message""" + hello = ElementTree.Element('wsd:Hello') + self.add_endpoint_reference(hello) + # THINK: Microsoft does not send the transport address here due + # to privacy reasons. Could make this optional. + self.add_xaddr(hello, self.mch.transport_address) + self.add_metadata_version(hello) + + msg = self.build_message(WSA_DISCOVERY, WSD_HELLO, None, hello) + self.enqueue_datagram(msg, msg_type='Hello') + + def send_bye(self): + """WS-Discovery, Section 4.2, Bye message""" + bye = ElementTree.Element('wsd:Bye') + self.add_endpoint_reference(bye) + + msg = self.build_message(WSA_DISCOVERY, WSD_BYE, None, bye) + self.enqueue_datagram(msg, msg_type='Bye') + + def handle_probe(self, header, body): + probe = body.find('./wsd:Probe', namespaces) + scopes = probe.find('./wsd:Scopes', namespaces) + + if scopes: + # THINK: send fault message (see p. 21 in WSD) + logger.debug('scopes ({}) unsupported but probed'.format(scopes)) + return None + + types_elem = probe.find('./wsd:Types', namespaces) + if types_elem is None: + logger.debug('Probe message lacks wsd:Types element. Ignored.') + return None + + types = types_elem.text + if not types == WSD_TYPE_DEVICE: + logger.debug('unknown discovery type ({}) for probe'.format(types)) + return None + + matches = ElementTree.Element('wsd:ProbeMatches') + match = ElementTree.SubElement(matches, 'wsd:ProbeMatch') + self.add_endpoint_reference(match) + self.add_types(match) + self.add_metadata_version(match) + + return matches, WSD_PROBE_MATCH + + def handle_resolve(self, header, body): + resolve = body.find('./wsd:Resolve', namespaces) + addr = resolve.find('./wsa:EndpointReference/wsa:Address', namespaces) + if addr is None: + logger.debug('invalid resolve request: missing endpoint address') + return None + + if not addr.text == args.uuid.urn: + logger.debug( + 'invalid resolve request: address ({}) does not ' + 'match own one ({})'.format(addr.text, args.uuid.urn)) + return None + + matches = ElementTree.Element('wsd:ResolveMatches') + match = ElementTree.SubElement(matches, 'wsd:ResolveMatch') + self.add_endpoint_reference(match) + self.add_types(match) + self.add_xaddr(match, self.mch.transport_address) + self.add_metadata_version(match) + + return matches, WSD_RESOLVE_MATCH + + def add_header_elements(self, header, extra): + ElementTree.SubElement(header, 'wsd:AppSequence', { + 'InstanceId': str(wsd_instance_id), + 'SequenceId': uuid.uuid1().urn, + 'MessageNumber': str(type(self).message_number)}) + + type(self).message_number += 1 + + +class WSDHttpMessageHandler(WSDMessageHandler): + + def __init__(self): + super().__init__() + + self.handlers[WSD_GET] = self.handle_get + + def handle_get(self, header, body): + # see https://msdn.microsoft.com/en-us/library/hh441784.aspx for an + # example. Some of the properties below might be made configurable + # in future releases. + metadata = ElementTree.Element('wsx:Metadata') + section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', { + 'Dialect': WSDP_URI + '/ThisDevice'}) + device = ElementTree.SubElement(section, 'wsdp:ThisDevice') + ElementTree.SubElement(device, 'wsdp:FriendlyName').text = ( + 'WSD Device {0}'.format(args.hostname)) + ElementTree.SubElement(device, 'wsdp:FirmwareVersion').text = '1.0' + ElementTree.SubElement(device, 'wsdp:SerialNumber').text = '1' + + section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', { + 'Dialect': WSDP_URI + '/ThisModel'}) + model = ElementTree.SubElement(section, 'wsdp:ThisModel') + ElementTree.SubElement(model, 'wsdp:Manufacturer').text = 'wsdd' + ElementTree.SubElement(model, 'wsdp:ModelName').text = 'wsdd' + ElementTree.SubElement(model, 'pnpx:DeviceCategory').text = 'Computers' + + section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', { + 'Dialect': WSDP_URI + '/Relationship'}) + rel = ElementTree.SubElement(section, 'wsdp:Relationship', { + 'Type': WSDP_URI + '/host'}) + host = ElementTree.SubElement(rel, 'wsdp:Host') + self.add_endpoint_reference(host) + ElementTree.SubElement(host, 'wsdp:Types').text = PUB_COMPUTER + ElementTree.SubElement(host, 'wsdp:ServiceId').text = args.uuid.urn + + fmt = '{0}/Domain:{1}' if args.domain else '{0}/Workgroup:{1}' + value = args.domain if args.domain else args.workgroup.upper() + if args.domain: + dh = args.hostname if args.preserve_case else args.hostname.lower() + else: + dh = args.hostname if args.preserve_case else args.hostname.upper() + + ElementTree.SubElement(host, PUB_COMPUTER).text = fmt.format(dh, value) + + return metadata, WSD_GET_RESPONSE + + +class WSDHttpServer(http.server.HTTPServer): + """ HTTP server both with IPv6 support and WSD handling """ + + def __init__(self, mch, RequestHandlerClass, addr_family, sel): + # hacky way to convince HTTP/SocketServer of the address family + type(self).address_family = addr_family + + # remember actual address family used by the server instance + self.addr_family = addr_family + self.mch = mch + self.selector = sel + self.wsd_handler = WSDHttpMessageHandler() + self.registered = False + + super().__init__(mch.listen_address, RequestHandlerClass) + + def server_bind(self): + if self.address_family == socket.AF_INET6: + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + + super().server_bind() + + def server_activate(self): + super().server_activate() + self.selector.register(self.fileno(), selectors.EVENT_READ, self) + self.registered = True + + def server_close(self): + if self.registered: + self.selector.unregister(self.fileno()) + super().server_close() + + +class WSDHttpRequestHandler(http.server.BaseHTTPRequestHandler): + """Class for handling WSD requests coming over HTTP""" + + def log_message(self, fmt, *args): + logger.info("{} - - ".format(self.address_string()) + fmt % args) + + def do_POST(self): + if self.path != '/' + str(args.uuid): + self.send_error() + + ct = self.headers['Content-Type'] + if ct is None or not ct.startswith(MIME_TYPE_SOAP_XML): + self.send_error(http.HTTPStatus.NOT_FOUND, 'Invalid Content-Type') + + content_length = int(self.headers['Content-Length']) + body = self.rfile.read(content_length) + + response = self.server.wsd_handler.handle_message(body, None, None) + if response: + self.send_response(http.HTTPStatus.OK) + self.send_header('Content-Type', MIME_TYPE_SOAP_XML) + self.end_headers() + self.wfile.write(response) + else: + self.send_error(http.HTTPStatus.BAD_REQUEST) + + +class ApiRequestHandler(socketserver.StreamRequestHandler): + + def handle(self): + line = str(self.rfile.readline().strip(), 'utf-8') + if not line: + return + + words = line.split() + command = words[0] + args = words[1:] + if command == 'probe': + intf = args[0] if args else None + logger.debug('probing devices on {} upon request'.format(intf)) + for client in self.get_clients_by_interface(intf): + client.send_probe() + elif command == 'clear': + logger.debug('clearing list of known devices') + self.server.wsd_known_devices.clear() + elif command == 'list': + self.wfile.write(bytes(self.get_list_reply(), 'utf-8')) + else: + logger.debug('could not handle API request: {}'.format(line)) + + def get_clients_by_interface(self, interface): + return [c for c in self.server.wsd_clients if + c.mch.interface.name == interface or not interface] + + def get_list_reply(self): + retval = '' + for dev_uuid in self.server.wsd_known_devices: + dev = self.server.wsd_known_devices[dev_uuid] + addrs_str = [] + for mci, addrs in dev.addresses.items(): + addrs_str.append(', '.join(['{}%{}'.format( + a, mci.interface.name) for a in addrs])) + + retval = retval + '{}\t{}\t{}\t{}\t{}\n'.format( + dev_uuid, + dev.display_name, + dev.props['BelongsTo'] if 'BelongsTo' in dev.props else '', + datetime.datetime.fromtimestamp(dev.last_seen).isoformat( + 'T', 'seconds'), + ','.join(addrs_str)) + + return retval + + +class ApiServer(object): + + def __init__(self, selector, listen_address, clients, known_devices): + self.clients = clients + + if isinstance(listen_address, int) or listen_address.isnumeric(): + s_addr = ('localhost', int(listen_address)) + socketserver.TCPServer.allow_reuse_address = True + s = socketserver.TCPServer(s_addr, ApiRequestHandler) + else: + s = socketserver.UnixStreamServer( + listen_address, ApiRequestHandler) + + # quiet hacky + s.wsd_clients = clients + s.wsd_known_devices = known_devices + + selector.register(s.fileno(), selectors.EVENT_READ, s) + + +class NetworkInterface(object): + + def __init__(self, name, scope): + self.name = name + self.scope = scope + + +class MetaEnumAfterInit(type): + + def __call__(cls, *args, **kwargs): + obj = super().__call__(*args, **kwargs) + obj.enumerate() + return obj + + +class NetworkAddressMonitor(object, metaclass=MetaEnumAfterInit): + """ + Observes changes of network addresses, handles addition and removal of + network addresses, and filters for addresses/interfaces that are or are not + handled. The actual OS-specific implementation that detects the changes is + done in subclasses. + """ + + def __init__(self, selector): + self.interfaces = {} + self.selector = selector + + self.clients = [] + self.hosts = [] + self.mchs = [] + self.http_servers = [] + self.known_devices = {} + + def enumerate(self): + """ + Performs an initial enumeration of addresses and sets up everything + for observing future changes. + """ + pass + + def handle_request(self): + """ handle network change message """ + pass + + def add_interface(self, name, idx, scope): + if idx in self.interfaces: + self.interfaces[idx].name = name + else: + self.interfaces[idx] = NetworkInterface(name, scope) + + return self.interfaces[idx] + + def is_address_handled(self, addr, addr_family, interface): + """ + Check if we should handle that address. + Address must be provided as raw address, i.e. byte array + """ + if args.ipv4only and addr_family != socket.AF_INET: + return False + if args.ipv6only and addr_family != socket.AF_INET6: + return False + + # Nah, this check is not optimal but there are no local flags for + # addresses, but it should be safe for IPv4 anyways + # (https://tools.ietf.org/html/rfc5735#page-3) + if (addr_family == socket.AF_INET) and (addr[0] == 127): + return False + if (addr_family == socket.AF_INET6) and (addr[0:2] != b'\xfe\x80'): + return False + + addr_str = socket.inet_ntop(addr_family, addr) + + if (args.interface) and (interface.name not in args.interface) and ( + addr_str not in args.interface): + return False + + return True + + def handle_new_address(self, raw_addr, addr_family, interface): + addr = socket.inet_ntop(addr_family, raw_addr) + logger.debug('new address {} on {}'.format(addr, interface.name)) + + if not self.is_address_handled(raw_addr, addr_family, interface): + logger.debug('ignoring that address on {}'.format(interface.name)) + return + + # filter out what is not wanted + # Ignore addresses or interfaces we already handle. There can only be + # one multicast handler per address family and network interface + # However, multiple link-local addresses can be + for mch in self.mchs: + if mch.handles(addr_family, addr, interface): + return + + logger.debug('handling traffic for {} on {}'.format( + addr, interface.name)) + mch = MulticastHandler(addr_family, addr, interface, self.selector) + self.mchs.append(mch) + + if not args.no_host: + h = WSDHost(mch) + self.hosts.append(h) + if not args.no_http: + self.http_servers.append(WSDHttpServer( + mch, WSDHttpRequestHandler, mch.family, self.selector)) + + if args.discovery: + client = WSDClient(mch, self.known_devices) + self.clients.append(client) + + def handle_deleted_address(self, raw_addr, addr_family, interface): + addr = socket.inet_ntop(addr_family, raw_addr) + logger.info('deleted address {} on {}'.format(addr, interface.name)) + + if not self.is_address_handled(raw_addr, addr_family, interface): + return + + mch = self.get_mch_by_address(addr_family, addr, interface) + if mch is None: + return + + # Do not tear the client/hosts down. Saying goodbye does not work + # because the address is already gone (at least on Linux). + for c in self.clients: + if c.mch == mch: + c.cleanup() + self.clients.remove(c) + break + for h in self.hosts: + if h.mch == mch: + h.cleanup() + self.hosts.remove(h) + break + for s in self.http_servers: + if s.mch == mch: + s.server_close() + self.http_servers.remove(s) + + mch.cleanup() + self.mchs.remove(mch) + + def cleanup(self): + + for h in self.hosts: + h.teardown() + h.cleanup() + + for c in self.clients: + c.teardown() + c.cleanup() + + def get_mch_by_address(self, family, address, interface): + """ + Get the MCI for the address, its family and the interface. + adress must be given as a string. + """ + for retval in self.mchs: + if retval.handles(family, address, interface): + return retval + + return None + + +# from rtnetlink.h +RTMGRP_LINK = 1 +RTMGRP_IPV4_IFADDR = 0x10 +RTMGRP_IPV6_IFADDR = 0x100 + +# from netlink.h +NLM_HDR_LEN = 16 + +NLM_F_REQUEST = 0x01 +NLM_F_ROOT = 0x100 +NLM_F_MATCH = 0x200 +NLM_F_DUMP = NLM_F_ROOT | NLM_F_MATCH + +# self defines +NLM_HDR_LEN = 16 +NLM_HDR_ALIGNTO = 4 + +# ifa flags +IFA_F_DADFAILED = 0x08 +IFA_F_HOMEADDRESS = 0x10 +IFA_F_DEPRECATED = 0x20 +IFA_F_TENTATIVE = 0x40 + +# from if_addr.h +IFA_ADDRESS = 1 +IFA_LOCAL = 2 +IFA_LABEL = 3 +IFA_FLAGS = 8 +IFA_MSG_LEN = 8 + +RTA_ALIGNTO = 4 +RTA_LEN = 4 + + +class NetlinkAddressMonitor(NetworkAddressMonitor): + """ + Implementation of the AddressMonitor for Netlink sockets, i.e. Linux + """ + + RTM_NEWADDR = 20 + RTM_DELADDR = 21 + RTM_GETADDR = 22 + + def __init__(self, selector): + super().__init__(selector) + + rtm_groups = RTMGRP_LINK + if not args.ipv4only: + rtm_groups = rtm_groups | RTMGRP_IPV6_IFADDR + if not args.ipv6only: + rtm_groups = rtm_groups | RTMGRP_IPV4_IFADDR + + self.socket = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, + socket.NETLINK_ROUTE) + self.socket.bind((0, rtm_groups)) + self.selector.register(self.socket, selectors.EVENT_READ, self) + + def enumerate(self): + super().enumerate() + + kernel = (0, 0) + req = struct.pack('@IHHIIB', NLM_HDR_LEN + 1, self.RTM_GETADDR, + NLM_F_REQUEST | NLM_F_DUMP, 1, 0, socket.AF_PACKET) + self.socket.sendto(req, kernel) + + def handle_request(self): + super().handle_request() + + buf, src = self.socket.recvfrom(4096) + logger.debug('netlink message with {} bytes'.format(len(buf))) + + offset = 0 + while offset < len(buf): + h_len, h_type, _, _, _ = struct.unpack_from('@IHHII', buf, offset) + offset += NLM_HDR_LEN + + msg_len = h_len - NLM_HDR_LEN + if msg_len < 0: + break + + if h_type != self.RTM_NEWADDR and h_type != self.RTM_DELADDR: + logger.debug('invalid rtm_message type {}'.format(h_type)) + offset += ((msg_len + 1) // NLM_HDR_ALIGNTO) * NLM_HDR_ALIGNTO + continue + + # decode ifaddrmsg as in rtnetlink.h + ifa_family, _, ifa_flags, ifa_scope, ifa_idx = struct.unpack_from( + '@BBBBI', buf, offset) + if (ifa_flags & IFA_F_DADFAILED or ifa_flags & IFA_F_HOMEADDRESS or + ifa_flags & IFA_F_DEPRECATED or ifa_flags & IFA_F_TENTATIVE): + logger.debug('ignore address with invalid state {}'.format( + hex(ifa_flags))) + offset += ((msg_len + 1) // NLM_HDR_ALIGNTO) * NLM_HDR_ALIGNTO + continue + + addr = None + i = offset + IFA_MSG_LEN + while i - offset < msg_len: + attr_len, attr_type = struct.unpack_from('HH', buf, i) + + if attr_len < RTA_LEN: + break + + if attr_type == IFA_LABEL: + name, = struct.unpack_from(str(attr_len - 4 - 1) + 's', + buf, i + 4) + self.add_interface(name.decode(), ifa_idx, ifa_scope) + elif attr_type == IFA_LOCAL and ifa_family == socket.AF_INET: + addr = buf[i + 4:i + 4 + 4] + elif (attr_type == IFA_ADDRESS and + ifa_family == socket.AF_INET6): + addr = buf[i + 4:i + 4 + 16] + elif attr_type == IFA_FLAGS: + _, ifa_flags = struct.unpack_from('HI', buf, i) + i += ((attr_len + 1) // RTA_ALIGNTO) * RTA_ALIGNTO + logger.debug('rt_attr {} {}'.format(attr_len, attr_type)) + + if addr is None: + logger.debug('not address in RTM message') + offset += ((msg_len + 1) // NLM_HDR_ALIGNTO) * NLM_HDR_ALIGNTO + continue + + if ifa_idx in self.interfaces: + iface = self.interfaces[ifa_idx] + if h_type == self.RTM_NEWADDR: + self.handle_new_address(addr, ifa_family, iface) + elif h_type == self.RTM_DELADDR: + self.handle_deleted_address(addr, ifa_family, iface) + else: + logger.debug('unknown interface index: {}'.format(ifa_idx)) + + offset += ((msg_len + 1) // NLM_HDR_ALIGNTO) * NLM_HDR_ALIGNTO + + def cleanup(self): + self.selector.unregister(self.socket) + self.socket.close() + super().cleanup() + + +# from sys/net/route.h +RTA_IFA = 0x20 + +# from sys/socket.h +CTL_NET = 4 +NET_RT_IFLIST = 3 + +# from sys/net/if.h +IFF_LOOPBACK = 0x8 +IFF_MULTICAST = 0x800 + +# sys/netinet6/in6_var.h +IN6_IFF_TENTATIVE = 0x02 +IN6_IFF_DUPLICATED = 0x04 +IN6_IFF_NOTREADY = IN6_IFF_TENTATIVE | IN6_IFF_DUPLICATED + +SA_ALIGNTO = ctypes.sizeof(ctypes.c_long) + + +class RouteSocketAddressMonitor(NetworkAddressMonitor): + """ + Implementation of the AddressMonitor for FreeBSD using route sockets + """ + + # from sys/net/route.h + RTM_NEWADDR = 0xC + RTM_DELADDR = 0xD + RTM_IFINFO = 0xE + + def __init__(self, selector): + super().__init__(selector) + self.intf_blacklist = [] + + # Create routing socket to get notified about future changes. + # Do this before fetching the current routing information to avoid + # race condition. + self.socket = socket.socket(socket.AF_ROUTE, socket.SOCK_RAW, + socket.AF_UNSPEC) + self.selector.register(self.socket, selectors.EVENT_READ, self) + + def enumerate(self): + super().enumerate() + mib = [CTL_NET, socket.AF_ROUTE, 0, 0, NET_RT_IFLIST, 0] + rt_mib = (ctypes.c_int * len(mib))() + rt_mib[:] = mib[:] + + libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True) + + # Ask kernel for routing table size first. + rt_size = ctypes.c_size_t() + if libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), 0, + ctypes.byref(rt_size), 0, 0): + raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) + + # Get the initial routing (interface list) data. + rt_buf = ctypes.create_string_buffer(rt_size.value) + if libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), + rt_buf, ctypes.byref(rt_size), 0, 0): + raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) + + self.parse_route_socket_response(rt_buf.raw, True) + + def handle_request(self): + super().handle_request() + + self.parse_route_socket_response(self.socket.recv(4096), False) + + def parse_route_socket_response(self, buf, keep_intf): + offset = 0 + + intf = None + intf_flags = 0 + while offset < len(buf): + rtm_len, _, rtm_type = struct.unpack_from('@HBB', buf, offset) + # addr_mask has same offset in if_msghdr and ifs_msghdr + addr_mask, flags = struct.unpack_from('ii', buf, offset + 4) + + msg_types = [self.RTM_NEWADDR, self.RTM_DELADDR, self.RTM_IFINFO] + if rtm_type not in msg_types: + offset += rtm_len + continue + + if rtm_type == self.RTM_IFINFO: + intf_flags = flags + + # those offset may unfortunately be architecture dependent + sa_offset = offset + ((16 + 152) if rtm_type == self.RTM_IFINFO + else 20) + + # For a route socket message, and different to a sysctl response, + # the link info is stored inside the same rtm message, so it has to + # survive multiple rtm messages in such cases + if not keep_intf: + intf = None + + new_intf = self.parse_addrs(buf, sa_offset, offset + rtm_len, + intf, addr_mask, rtm_type, intf_flags) + intf = new_intf if new_intf else intf + + offset += rtm_len + + def parse_addrs(self, buf, offset, limit, intf, addr_mask, rtm_type, + flags): + addr_type_idx = 1 + addr = None + addr_family = None + while offset < limit: + while (not (addr_type_idx & addr_mask) and + (addr_type_idx <= addr_mask)): + addr_type_idx = addr_type_idx << 1 + + sa_len, sa_fam = struct.unpack_from('@BB', buf, offset) + if (sa_fam in [socket.AF_INET, socket.AF_INET6] and + addr_type_idx == RTA_IFA): + addr_family = sa_fam + addr_offset = 4 if sa_fam == socket.AF_INET else 8 + addr_length = 16 if sa_fam == socket.AF_INET6 else 4 + addr_start = offset + addr_offset + addr = buf[addr_start:addr_start + addr_length] + elif sa_fam == socket.AF_LINK: + idx, _, name_len = struct.unpack_from('@HBB', buf, offset + 2) + if idx > 0: + off_name = offset + 8 + if_name = (buf[off_name:off_name + name_len]).decode() + intf = self.add_interface(if_name, idx, idx) + + offset += (((sa_len + SA_ALIGNTO - 1) // SA_ALIGNTO) * SA_ALIGNTO + if sa_len > 0 else SA_ALIGNTO) + addr_type_idx = addr_type_idx << 1 + + if rtm_type == self.RTM_IFINFO and intf is not None: + if flags & IFF_LOOPBACK or not flags & IFF_MULTICAST: + self.intf_blacklist.append(intf.name) + elif intf in self.intf_blacklist: + self.intf_blacklist.remove(intf.name) + + if intf is None or intf.name in self.intf_blacklist or addr is None: + return intf + + if rtm_type == self.RTM_DELADDR: + self.handle_deleted_address(addr, addr_family, intf) + else: + # Too bad, the address may be unuseable (tentative, e.g.) here + # but we won't get any further notifcation about the address being + # available for use. Thus, we try and may fail here + self.handle_new_address(addr, addr_family, intf) + + return intf + + def cleanup(self): + self.selector.unregister(self.socket) + self.socket.close() + super().cleanup() + + +def sigterm_handler(signum, frame): + if signum == signal.SIGTERM: + logger.info('received SIGTERM, tearing down') + # implictely raise SystemExit to cleanup properly + sys.exit(0) + + +def parse_args(): + global args, logger + + parser = argparse.ArgumentParser() + + parser.add_argument( + '-i', '--interface', + help='interface or address to use', + action='append', default=[]) + parser.add_argument( + '-H', '--hoplimit', + help='hop limit for multicast packets (default = 1)', type=int, + default=1) + parser.add_argument( + '-U', '--uuid', + help='UUID for the target device', + default=None) + parser.add_argument( + '-v', '--verbose', + help='increase verbosity', + action='count', default=0) + parser.add_argument( + '-d', '--domain', + help='set domain name (disables workgroup)', + default=None) + parser.add_argument( + '-n', '--hostname', + help='override (NetBIOS) hostname to be used (default hostname)', + # use only the local part of a possible FQDN + default=socket.gethostname().partition('.')[0]) + parser.add_argument( + '-w', '--workgroup', + help='set workgroup name (default WORKGROUP)', + default='WORKGROUP') + parser.add_argument( + '-t', '--no-http', + help='disable http service (for debugging, e.g.)', + action='store_true') + parser.add_argument( + '-4', '--ipv4only', + help='use only IPv4 (default = off)', + action='store_true') + parser.add_argument( + '-6', '--ipv6only', + help='use IPv6 (default = off)', + action='store_true') + parser.add_argument( + '-s', '--shortlog', + help='log only level and message', + action='store_true') + parser.add_argument( + '-p', '--preserve-case', + help='preserve case of the provided/detected hostname', + action='store_true') + parser.add_argument( + '-c', '--chroot', + help='directory to chroot into', + default=None) + parser.add_argument( + '-u', '--user', + help='drop privileges to user:group', + default=None) + parser.add_argument( + '-D', '--discovery', + help='enable discovery operation mode', + action='store_true') + parser.add_argument( + '-l', '--listen', + help='listen on path or localhost port in discovery mode', + default=None) + parser.add_argument( + '-o', '--no-host', + help='disable server mode operation (host will be undiscoverable)', + action='store_true') + + args = parser.parse_args(sys.argv[1:]) + + if args.verbose == 1: + log_level = logging.INFO + elif args.verbose > 1: + log_level = logging.DEBUG + else: + log_level = logging.WARNING + + if args.shortlog: + fmt = '%(levelname)s: %(message)s' + else: + fmt = ('%(asctime)s:%(name)s %(levelname)s(pid %(process)d): ' + '%(message)s') + + logging.basicConfig(level=log_level, format=fmt) + logger = logging.getLogger('wsdd') + + if not args.interface: + logger.warning('no interface given, using all interfaces') + + if not args.uuid: + if args.hostname: + hn = '{0}.{1}'.format(args.hostname, args.workgroup) + if not hn: + hn=socket.gethostname() + args.uuid = uuid.uuid5(uuid.NAMESPACE_DNS, hn) + logger.info('using pre-defined UUID {0}'.format(str(args.uuid))) + else: + args.uuid = uuid.UUID(args.uuid) + logger.info('user-supplied device UUID is {0}'.format(str(args.uuid))) + + for prefix, uri in namespaces.items(): + ElementTree.register_namespace(prefix, uri) + + +def send_outstanding_messages(block=False): + """ + Send all queued datagrams for which the timeout has been reached. If block + is true then all queued messages will be sent but with their according + delay. + """ + if len(send_queue) == 0: + return None + + # reverse ordering for faster removal of the last element + send_queue.sort(key=lambda x: x[0], reverse=True) + + # Emit every message that is "too late". Note that in case the system + # time jumps forward, multiple outstanding message which have a + # delay between them are sent out without that delay. + now = time.time() + while len(send_queue) > 0 and (send_queue[-1][0] <= now or block): + mch = send_queue[-1][1] + addr = send_queue[-1][2] + msg = send_queue[-1][3] + try: + mch.send_socket.sendto(msg, addr) + except Exception as e: + logger.error('error while sending packet on {}: {}'.format( + mch.interface.name, e)) + + del send_queue[-1] + if block and len(send_queue) > 0: + delay = send_queue[-1][0] - now + if delay > 0: + time.sleep(delay) + now = time.time() + + if len(send_queue) > 0 and not block: + return send_queue[-1][0] - now + + return None + + +def chroot(root): + """ + Chroot into a separate directory to isolate ourself for increased security. + """ + # preload for socket.gethostbyaddr() + import encodings.idna + + try: + os.chroot(root) + os.chdir('/') + logger.info('chrooted successfully to {}'.format(root)) + except Exception as e: + logger.error('could not chroot to {}: {}'.format(root, e)) + return False + + return True + + +def get_ids_from_userspec(user_spec): + uid = None + gid = None + try: + user, _, group = user_spec.partition(':') + + if user: + uid = pwd.getpwnam(user).pw_uid + + if group: + gid = grp.getgrnam(group).gr_gid + except Exception as e: + logger.error('could not get uid/gid for {}: {}'.format(user_spec, e)) + return False + + return (uid, gid) + + +def drop_privileges(uid, gid): + try: + if gid is not None: + os.setgid(gid) + os.setegid(gid) + logger.debug('switched uid to {}'.format(uid)) + + if uid is not None: + os.setuid(uid) + os.seteuid(uid) + logger.debug('switched gid to {}'.format(gid)) + + logger.info('running as {} ({}:{})'.format(args.user, uid, gid)) + except Exception as e: + logger.error('dropping privileges failed: {}'.format(e)) + return False + + return True + + +def main(): + parse_args() + + if not args.discovery and args.listen: + logger.warning('Listen option ignored since discovery is disabled.') + elif args.discovery and not args.listen: + logger.warning('Discovery enabled but no listen option provided. ' + 'Falling back to port {}'.format(WSDD_LISTEN_PORT)) + args.listen = WSDD_LISTEN_PORT + + if args.ipv4only and args.ipv6only: + logger.error('Listening to no IP address family.') + return 4 + + s = selectors.DefaultSelector() + if platform.system() == 'Linux': + nm = NetlinkAddressMonitor(s) + elif platform.system() == 'FreeBSD': + nm = RouteSocketAddressMonitor(s) + else: + raise NotImplementedError('unsupported OS') + + if args.listen: + ApiServer(s, args.listen, nm.clients, nm.known_devices) + + # get uid:gid before potential chroot'ing + if args.user is not None: + ids = get_ids_from_userspec(args.user) + if not ids: + return 3 + + if args.chroot is not None: + if not chroot(args.chroot): + return 2 + + if args.user is not None: + if not drop_privileges(ids[0], ids[1]): + return 3 + + if args.chroot and (os.getuid() == 0 or os.getgid() == 0): + logger.warning('chrooted but running as root, consider -u option') + + # main loop, serve requests coming from any outbound socket + signal.signal(signal.SIGTERM, sigterm_handler) + try: + while True: + try: + timeout = send_outstanding_messages() + events = s.select(timeout) + for key, mask in events: + if isinstance(key.data, MulticastHandler): + key.data.handle_request(key) + else: + key.data.handle_request() + except (SystemExit, KeyboardInterrupt): + # silently exit the loop + logger.debug('got termination signal') + break + except Exception: + logger.exception('error in main loop') + finally: + logger.info('shutting down gracefully...') + + nm.cleanup() + + send_outstanding_messages(True) + logger.info('Done.') + return 0 + + +if __name__ == '__main__': + sys.exit(main())