1689 lines
57 KiB
Python
Executable File
1689 lines
57 KiB
Python
Executable File
#!/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'<?xml version="1.0" encoding="utf-8"?>'
|
|
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())
|