diff -Nru python-zeroconf-0.17.6/debian/changelog python-zeroconf-0.19.1/debian/changelog --- python-zeroconf-0.17.6/debian/changelog 2016-10-06 17:05:02.000000000 +0000 +++ python-zeroconf-0.19.1/debian/changelog 2017-07-13 19:56:09.000000000 +0000 @@ -1,3 +1,17 @@ +python-zeroconf (0.19.1-1) unstable; urgency=low + + * New upstream release + * Upload to unstable + * debian/control: New standards version 4.0.0 - no changes + + -- Ruben Undheim Thu, 13 Jul 2017 19:42:34 +0000 + +python-zeroconf (0.18.0-1~exp1) experimental; urgency=low + + * New upstream release + + -- Ruben Undheim Fri, 17 Mar 2017 15:27:43 +0100 + python-zeroconf (0.17.6-1) unstable; urgency=low * New upstream release diff -Nru python-zeroconf-0.17.6/debian/control python-zeroconf-0.19.1/debian/control --- python-zeroconf-0.17.6/debian/control 2016-10-06 17:05:02.000000000 +0000 +++ python-zeroconf-0.19.1/debian/control 2017-07-13 19:56:09.000000000 +0000 @@ -9,7 +9,7 @@ python-setuptools, python3-all, python3-setuptools -Standards-Version: 3.9.8 +Standards-Version: 4.0.0 X-Python-Version: all X-Python3-Version: >= 3.1 Homepage: https://github.com/jstasiak/python-zeroconf diff -Nru python-zeroconf-0.17.6/debian/.git-dpm python-zeroconf-0.19.1/debian/.git-dpm --- python-zeroconf-0.17.6/debian/.git-dpm 2016-10-06 17:02:45.000000000 +0000 +++ python-zeroconf-0.19.1/debian/.git-dpm 2017-07-13 19:56:09.000000000 +0000 @@ -1,11 +1,11 @@ # see git-dpm(1) from git-dpm package -41fb1b4da7a4581010d2c7dad5342945f7db3cae -41fb1b4da7a4581010d2c7dad5342945f7db3cae -e694928afacaf099988e8b2c6f40df196df18245 -e694928afacaf099988e8b2c6f40df196df18245 -python-zeroconf_0.17.6.orig.tar.gz -2aaa079ff2eecd8ab4f7d8f9f16ebeb76a18fc72 -31309 +7021e671cb7b2113188419b36b0b823d0e2e8004 +7021e671cb7b2113188419b36b0b823d0e2e8004 +ca5286c3284c6e62d42b24d715ba5d63e76ab81e +ca5286c3284c6e62d42b24d715ba5d63e76ab81e +python-zeroconf_0.19.1.orig.tar.gz +eac776422f3dd8c50b8c525998854ac7a9183ba4 +35655 debianTag="debian/%e%v" patchedTag="patched/%e%v" upstreamTag="upstream/%e%u" diff -Nru python-zeroconf-0.17.6/debian/patches/0001-Set-install_requires-to-enum34-instead-of-enum-compa.patch python-zeroconf-0.19.1/debian/patches/0001-Set-install_requires-to-enum34-instead-of-enum-compa.patch --- python-zeroconf-0.17.6/debian/patches/0001-Set-install_requires-to-enum34-instead-of-enum-compa.patch 2016-10-06 17:02:45.000000000 +0000 +++ python-zeroconf-0.19.1/debian/patches/0001-Set-install_requires-to-enum34-instead-of-enum-compa.patch 2017-07-13 19:56:09.000000000 +0000 @@ -1,22 +1,28 @@ -From 41fb1b4da7a4581010d2c7dad5342945f7db3cae Mon Sep 17 00:00:00 2001 +From 7021e671cb7b2113188419b36b0b823d0e2e8004 Mon Sep 17 00:00:00 2001 From: Ruben Undheim Date: Sun, 6 Mar 2016 10:26:23 +0100 Subject: Set install_requires to enum34 instead of enum-compat --- - setup.py | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) + setup.py | 8 ++------ + 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py -index 75fdf2d..2a8db1b 100755 +index ddda07c..a742454 100755 --- a/setup.py +++ b/setup.py -@@ -53,7 +53,7 @@ setup( +@@ -55,12 +55,8 @@ setup( 'mDNS', ], install_requires=[ - 'enum-compat', +- # netifaces 0.10.5 has a bug that results in all interfaces' netmasks +- # to be 255.255.255.255 on Windows which breaks things. See: +- # * https://github.com/jstasiak/python-zeroconf/issues/84 +- # * https://bitbucket.org/al45tair/netifaces/issues/39/netmask-is-always-255255255255 +- 'netifaces!=0.10.5', + 'enum34', - 'netifaces', ++ 'netifaces', 'six', ], + ) diff -Nru python-zeroconf-0.17.6/examples/browser.py python-zeroconf-0.19.1/examples/browser.py --- python-zeroconf-0.17.6/examples/browser.py 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/examples/browser.py 2017-06-13 06:34:17.000000000 +0000 @@ -30,6 +30,7 @@ print(" No info") print('\n') + if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) if len(sys.argv) > 1: diff -Nru python-zeroconf-0.17.6/examples/old_browser.py python-zeroconf-0.19.1/examples/old_browser.py --- python-zeroconf-0.17.6/examples/old_browser.py 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/examples/old_browser.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,54 +0,0 @@ -#!/usr/bin/env python -from __future__ import absolute_import, division, print_function, unicode_literals - -""" Example of browsing for a service (in this case, HTTP) """ - -import logging -import socket -import sys -from time import sleep - -from zeroconf import ServiceBrowser, Zeroconf - - -class MyListener(object): - - def remove_service(self, zeroconf, type, name): - print("Service %s removed" % (name,)) - print('\n') - - def add_service(self, zeroconf, type, name): - print("Service %s added" % (name,)) - print(" Type is %s" % (type,)) - info = zeroconf.get_service_info(type, name) - if info: - print(" Address is %s:%d" % (socket.inet_ntoa(info.address), - info.port)) - print(" Weight is %d, Priority is %d" % (info.weight, - info.priority)) - print(" Server is", info.server) - if info.properties: - print(" Properties are") - for key, value in info.properties.items(): - print(" %s: %s" % (key, value)) - else: - print(" No info") - print('\n') - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - if len(sys.argv) > 1: - assert sys.argv[1:] == ['--debug'] - logging.getLogger('zeroconf').setLevel(logging.DEBUG) - - zeroconf = Zeroconf() - print("\nBrowsing services, press Ctrl-C to exit...\n") - listener = MyListener() - browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener) - try: - while True: - sleep(0.1) - except KeyboardInterrupt: - pass - finally: - zeroconf.close() diff -Nru python-zeroconf-0.17.6/README.rst python-zeroconf-0.19.1/README.rst --- python-zeroconf-0.17.6/README.rst 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/README.rst 2017-06-13 06:34:17.000000000 +0000 @@ -43,7 +43,7 @@ Python compatibility -------------------- -* CPython 2.6, 2.7, 3.3+ +* CPython 2.7, 3.3+ * PyPy 2.2+ (possibly 1.9-2.1 as well) * PyPy3 2.4+ @@ -78,7 +78,7 @@ How do I use it? ================ -Here's an example: +Here's an example of browsing for a service: .. code-block:: python @@ -122,6 +122,41 @@ Changelog ========= +0.19.1 +------ + +* Allowed installation with netifaces >= 0.10.6 (a bug that was concerning us + got fixed) + +0.19.0 +------ + +* Technically backwards incompatible - restricted netifaces dependency version to + work around a bug, see https://github.com/jstasiak/python-zeroconf/issues/84 for + details + +0.18.0 +------ + +* Dropped Python 2.6 support +* Improved error handling inside code executed when Zeroconf object is being closed + +0.17.7 +------ + +* Better Handling of DNS Incoming Packets parsing exceptions +* Many exceptions will now log a warning the first time they are seen +* Catch and log sendto() errors +* Fix/Implement duplicate name change +* Fix overly strict name validation introduced in 0.17.6 +* Greatly improve handling of oversized packets including: + + - Implement name compression per RFC1035 + - Limit size of generated packets to 9000 bytes as per RFC6762 + - Better handle over sized incoming packets + +* Increased test coverage to 95% + 0.17.6 ------ diff -Nru python-zeroconf-0.17.6/requirements-dev.txt python-zeroconf-0.19.1/requirements-dev.txt --- python-zeroconf-0.17.6/requirements-dev.txt 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/requirements-dev.txt 2017-06-13 06:34:17.000000000 +0000 @@ -2,12 +2,14 @@ coveralls coverage enum34 -flake8 +# Upper bound because of https://github.com/PyCQA/flake8-import-order/issues/79 +flake8<3 flake8-blind-except # Upper bound because of https://github.com/public/flake8-import-order/issues/42 flake8-import-order>=0.4.0, <0.6.0 mock -netifaces +# See setup.py comment for why this version is restricted +netifaces<=0.10.4 nose pep8==1.5.7 pep8-naming diff -Nru python-zeroconf-0.17.6/setup.py python-zeroconf-0.19.1/setup.py --- python-zeroconf-0.17.6/setup.py 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/setup.py 2017-06-13 06:34:17.000000000 +0000 @@ -45,6 +45,8 @@ 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], @@ -54,7 +56,11 @@ ], install_requires=[ 'enum-compat', - 'netifaces', + # netifaces 0.10.5 has a bug that results in all interfaces' netmasks + # to be 255.255.255.255 on Windows which breaks things. See: + # * https://github.com/jstasiak/python-zeroconf/issues/84 + # * https://bitbucket.org/al45tair/netifaces/issues/39/netmask-is-always-255255255255 + 'netifaces!=0.10.5', 'six', ], ) diff -Nru python-zeroconf-0.17.6/test_zeroconf.py python-zeroconf-0.19.1/test_zeroconf.py --- python-zeroconf-0.17.6/test_zeroconf.py 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/test_zeroconf.py 2017-06-13 06:34:17.000000000 +0000 @@ -38,6 +38,64 @@ log.setLevel(original_logging_level[0]) +class TestDunder(unittest.TestCase): + + def test_dns_text_repr(self): + # There was an issue on Python 3 that prevented DNSText's repr + # from working when the text was longer than 10 bytes + text = DNSText('irrelevant', None, 0, 0, b'12345678901') + repr(text) + + text = DNSText('irrelevant', None, 0, 0, b'123') + repr(text) + + def test_dns_hinfo_repr_eq(self): + hinfo = DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os') + assert hinfo == hinfo + repr(hinfo) + + def test_dns_pointer_repr(self): + pointer = r.DNSPointer( + 'irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_TTL, '123') + repr(pointer) + + def test_dns_address_repr(self): + address = r.DNSAddress('irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + repr(address) + + def test_dns_question_repr(self): + question = r.DNSQuestion( + 'irrelevant', r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE) + repr(question) + assert not question != question + + def test_dns_service_repr(self): + service = r.DNSService( + 'irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL, 0, 0, 80, b'a') + repr(service) + + def test_dns_record_abc(self): + record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL) + self.assertRaises(r.AbstractMethodException, record.__eq__, record) + self.assertRaises(r.AbstractMethodException, record.write, None) + + def test_service_info_dunder(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + info = ServiceInfo( + type_, registration_name, + socket.inet_aton("10.0.1.2"), 80, 0, 0, + None, "ash-2.local.") + + assert not info != info + repr(info) + + def test_dns_outgoing_repr(self): + dns_outgoing = r.DNSOutgoing(r._FLAGS_QR_QUERY) + repr(dns_outgoing) + + class PacketGeneration(unittest.TestCase): def test_parse_own_packet_simple(self): @@ -157,11 +215,177 @@ generated.add_question(question) r.DNSIncoming(generated.packet()) + def test_lots_of_names(self): + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # create a bunch of servers + type_ = "_my-service._tcp.local." + name = 'a wonderful service' + server_count = 300 + self.generate_many_hosts(zc, type_, name, server_count) + + # verify that name changing works + self.verify_name_change(zc, type_, name, server_count) + + # we are going to monkey patch the zeroconf send to check packet sizes + old_send = zc.send + + # needs to be a list so that we can modify it in our phony send + longest_packet = [0, None] + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + packet = out.packet() + if longest_packet[0] < len(packet): + longest_packet[0] = len(packet) + longest_packet[1] = out + old_send(out, addr=addr, port=port) + + # monkey patch the zeroconf send + zc.send = send + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + # start a browser + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + # wait until the browse request packet has maxed out in size + sleep_count = 0 + while sleep_count < 100 and \ + longest_packet[0] < r._MAX_MSG_ABSOLUTE - 100: + sleep_count += 1 + time.sleep(0.1) + + browser.cancel() + time.sleep(0.5) + + import zeroconf + zeroconf.log.debug('sleep_count %d, sized %d', + sleep_count, longest_packet[0]) + + # now the browser has sent at least one request, verify the size + assert longest_packet[0] <= r._MAX_MSG_ABSOLUTE + assert longest_packet[0] >= r._MAX_MSG_ABSOLUTE - 100 + + # mock zeroconf's logger warning() and debug() + from mock import patch + patch_warn = patch('zeroconf.log.warning') + patch_debug = patch('zeroconf.log.debug') + mocked_log_warn = patch_warn.start() + mocked_log_debug = patch_debug.start() + + # now that we have a long packet in our possession, let's verify the + # exception handling. + out = longest_packet[1] + out.data.append(b'\0' * 1000) + + # mock the zeroconf logger and check for the correct logging backoff + call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count + # try to send an oversized packet + zc.send(out) + assert mocked_log_warn.call_count == call_counts[0] + 1 + assert mocked_log_debug.call_count == call_counts[0] + zc.send(out) + assert mocked_log_warn.call_count == call_counts[0] + 1 + assert mocked_log_debug.call_count == call_counts[0] + 1 + + # force a receive of an oversized packet + packet = out.packet() + s = zc._respond_sockets[0] + + # mock the zeroconf logger and check for the correct logging backoff + call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count + # force receive on oversized packet + s.sendto(packet, 0, (r._MDNS_ADDR, r._MDNS_PORT)) + s.sendto(packet, 0, (r._MDNS_ADDR, r._MDNS_PORT)) + time.sleep(2.0) + zeroconf.log.debug('warn %d debug %d was %s', + mocked_log_warn.call_count, + mocked_log_debug.call_count, + call_counts) + assert mocked_log_debug.call_count > call_counts[0] + + # close our zeroconf which will close the sockets + zc.close() + + # pop the big chunk off the end of the data and send on a closed socket + out.data.pop() + zc._GLOBAL_DONE = False + + # mock the zeroconf logger and check for the correct logging backoff + call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count + # send on a closed socket (force a socket error) + zc.send(out) + zeroconf.log.debug('warn %d debug %d was %s', + mocked_log_warn.call_count, + mocked_log_debug.call_count, + call_counts) + assert mocked_log_warn.call_count > call_counts[0] + assert mocked_log_debug.call_count > call_counts[0] + zc.send(out) + zeroconf.log.debug('warn %d debug %d was %s', + mocked_log_warn.call_count, + mocked_log_debug.call_count, + call_counts) + assert mocked_log_debug.call_count > call_counts[0] + 2 + + mocked_log_warn.stop() + mocked_log_debug.stop() + + def verify_name_change(self, zc, type_, name, number_hosts): + desc = {'path': '/~paulsm/'} + info_service = ServiceInfo( + type_, '%s.%s' % (name, type_), socket.inet_aton("10.0.1.2"), + 80, 0, 0, desc, "ash-2.local.") + + # verify name conflict + self.assertRaises( + r.NonUniqueNameException, + zc.register_service, info_service) + + zc.register_service(info_service, allow_name_change=True) + assert info_service.name.split('.')[0] == '%s-%d' % ( + name, number_hosts + 1) + + def generate_many_hosts(self, zc, type_, name, number_hosts): + records_per_server = 2 + block_size = 25 + number_hosts = int(((number_hosts - 1) / block_size + 1)) * block_size + for i in range(1, number_hosts + 1): + next_name = name if i == 1 else '%s-%d' % (name, i) + self.generate_host(zc, next_name, type_) + if i % block_size == 0: + sleep_count = 0 + while sleep_count < 40 and \ + i * records_per_server > len( + zc.cache.entries_with_name(type_)): + sleep_count += 1 + time.sleep(0.05) + + @staticmethod + def generate_host(zc, host_name, type_): + name = '.'.join((host_name, type_)) + out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + out.add_answer_at_time( + r.DNSPointer(type_, r._TYPE_PTR, r._CLASS_IN, + r._DNS_TTL, name), 0) + out.add_answer_at_time( + r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN, + r._DNS_TTL, 0, 0, 80, + name), 0) + zc.send(out) + class Framework(unittest.TestCase): def test_launch_and_close(self): - rv = r.Zeroconf() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) rv.close() @@ -171,7 +395,7 @@ @classmethod def setUpClass(cls): - cls.browser = Zeroconf() + cls.browser = Zeroconf(interfaces=['127.0.0.1']) @classmethod def tearDownClass(cls): @@ -198,17 +422,25 @@ '_22._udp.local.', '_2-2._tcp.local.', '_1234567890-abcde._udp.local.', - '._x._udp.local.', + '\x00._x._udp.local.', ) for name in bad_names_to_try: self.assertRaises( r.BadTypeInNameException, self.browser.get_service_info, name, 'x.' + name) - def test_bad_sub_types(self): - bad_names_to_try = ( - '_sub._http._tcp.local.', + def test_good_instance_names(self): + good_names_to_try = ( + '.._x._tcp.local.', 'x.sub._http._tcp.local.', + '6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.' + ) + for name in good_names_to_try: + r.service_type_name(name) + + def test_bad_types(self): + bad_names_to_try = ( + '._x._tcp.local.', 'a' * 64 + '._sub._http._tcp.local.', 'a' * 62 + u'รข._sub._http._tcp.local.', ) @@ -216,6 +448,17 @@ self.assertRaises( r.BadTypeInNameException, r.service_type_name, name) + def test_bad_sub_types(self): + bad_names_to_try = ( + '_sub._http._tcp.local.', + '._sub._http._tcp.local.', + '\x7f._sub._http._tcp.local.', + '\x1f._sub._http._tcp.local.', + ) + for name in bad_names_to_try: + self.assertRaises( + r.BadTypeInNameException, r.service_type_name, name) + def test_good_service_names(self): good_names_to_try = ( '_x._tcp.local.', @@ -229,6 +472,31 @@ r.service_type_name(name) +class TestDnsIncoming(unittest.TestCase): + + def test_incoming_exception_handling(self): + generated = r.DNSOutgoing(0) + packet = generated.packet() + packet = packet[:8] + b'deadbeef' + packet[8:] + parsed = r.DNSIncoming(packet) + parsed = r.DNSIncoming(packet) + assert parsed.valid is False + + def test_incoming_unknown_type(self): + generated = r.DNSOutgoing(0) + answer = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + generated.add_additional_answer(answer) + packet = generated.packet() + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 0 + assert parsed.is_query() != parsed.is_response() + + def test_incoming_ipv6(self): + # ::TODO:: could use a test here if we add IPV6 record handling + # ie: _TYPE_AAAA + pass + + class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): @@ -246,7 +514,8 @@ zeroconf_registrar.register_service(info) try: - service_types = ZeroconfServiceTypes.find(timeout=0.5) + service_types = ZeroconfServiceTypes.find( + interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types service_types = ZeroconfServiceTypes.find( zc=zeroconf_registrar, timeout=0.5) @@ -272,8 +541,8 @@ zeroconf_registrar.register_service(info) try: - service_types = ZeroconfServiceTypes.find(timeout=0.5) - print(service_types) + service_types = ZeroconfServiceTypes.find( + interfaces=['127.0.0.1'], timeout=0.5) assert discovery_type in service_types service_types = ZeroconfServiceTypes.find( zc=zeroconf_registrar, timeout=0.5) @@ -304,8 +573,9 @@ def remove_service(self, zeroconf, type, name): service_removed.set() - zeroconf_browser = Zeroconf() - zeroconf_browser.add_service_listener(subtype, MyListener()) + listener = MyListener() + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + zeroconf_browser.add_service_listener(subtype, listener) properties = dict( prop_none=None, @@ -316,7 +586,7 @@ prop_false=0, ) - zeroconf_registrar = Zeroconf() + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} desc.update(properties) info_service = ServiceInfo( @@ -354,6 +624,7 @@ assert service_removed.is_set() finally: zeroconf_registrar.close() + zeroconf_browser.remove_service_listener(listener) zeroconf_browser.close() @@ -371,10 +642,10 @@ elif state_change is ServiceStateChange.Removed: service_removed.set() - zeroconf_browser = Zeroconf() + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - zeroconf_registrar = Zeroconf() + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} info = ServiceInfo( type_, registration_name, @@ -391,10 +662,3 @@ zeroconf_registrar.close() browser.cancel() zeroconf_browser.close() - - -def test_dnstext_repr_works(): - # There was an issue on Python 3 that prevented DNSText's repr - # from working when the text was longer than 10 bytes - text = DNSText('irrelevant', None, 0, 0, b'12345678901') - repr(text) diff -Nru python-zeroconf-0.17.6/.travis.yml python-zeroconf-0.19.1/.travis.yml --- python-zeroconf-0.17.6/.travis.yml 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/.travis.yml 2017-06-13 06:34:17.000000000 +0000 @@ -1,12 +1,12 @@ language: python python: - - "2.6" - "2.7" - "3.3" - "3.4" - "3.5" + - "3.6" - "pypy" - - "pypy3" + - "pypy3.3-5.2-alpha1" matrix: fast_finish: true install: diff -Nru python-zeroconf-0.17.6/zeroconf.py python-zeroconf-0.19.1/zeroconf.py --- python-zeroconf-0.17.6/zeroconf.py 2016-07-04 04:01:20.000000000 +0000 +++ python-zeroconf-0.19.1/zeroconf.py 2017-06-13 06:34:17.000000000 +0000 @@ -30,6 +30,7 @@ import select import socket import struct +import sys import threading import time from functools import reduce @@ -40,19 +41,10 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.17.6' +__version__ = '0.19.1' __license__ = 'LGPL' -try: - NullHandler = logging.NullHandler -except AttributeError: - # Python 2.6 fallback - class NullHandler(logging.Handler): - - def emit(self, record): - pass - __all__ = [ "__version__", "Zeroconf", "ServiceInfo", "ServiceBrowser", @@ -61,7 +53,7 @@ log = logging.getLogger(__name__) -log.addHandler(NullHandler()) +log.addHandler(logging.NullHandler()) if log.level == logging.NOTSET: log.setLevel(logging.WARN) @@ -82,13 +74,13 @@ _DNS_TTL = 60 * 60 # one hour default TTL _MAX_MSG_TYPICAL = 1460 # unused -_MAX_MSG_ABSOLUTE = 8972 +_MAX_MSG_ABSOLUTE = 8966 _FLAGS_QR_MASK = 0x8000 # query response mask _FLAGS_QR_QUERY = 0x0000 # query _FLAGS_QR_RESPONSE = 0x8000 # response -_FLAGS_AA = 0x0400 # Authorative answer +_FLAGS_AA = 0x0400 # Authoritative answer _FLAGS_TC = 0x0200 # Truncated _FLAGS_RD = 0x0100 # Recursion desired _FLAGS_RA = 0x8000 # Recursion available @@ -157,6 +149,23 @@ _HAS_A_TO_Z = re.compile(r'[A-Za-z]') _HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-]+$') +_HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') + + +@enum.unique +class InterfaceChoice(enum.Enum): + Default = 1 + All = 2 + + +@enum.unique +class ServiceStateChange(enum.Enum): + Added = 1 + Removed = 2 + + +HOST_ONLY_NETWORK_MASK = '255.255.255.255' + # utility functions @@ -195,61 +204,79 @@ The instance name and sub type may be up to 63 bytes. + The portion of the Service Instance Name is a user- + friendly name consisting of arbitrary Net-Unicode text [RFC5198]. It + MUST NOT contain ASCII control characters (byte values 0x00-0x1F and + 0x7F) [RFC20] but otherwise is allowed to contain any characters, + without restriction, including spaces, uppercase, lowercase, + punctuation -- including dots -- accented characters, non-Roman text, + and anything else that may be represented using Net-Unicode. + :param type_: Type, SubType or service name to validate :return: fully qualified service name (eg: _http._tcp.local.) """ if not (type_.endswith('._tcp.local.') or type_.endswith('._udp.local.')): raise BadTypeInNameException( - "Type must end with '._tcp.local.' or '._udp.local.'") - - if type_.startswith('.'): - raise BadTypeInNameException("Type must not start with '.'") + "Type '%s' must end with '._tcp.local.' or '._udp.local.'" % + type_) remaining = type_[:-len('._tcp.local.')].split('.') name = remaining.pop() if not name: raise BadTypeInNameException("No Service name found") + if len(remaining) == 1 and len(remaining[0]) == 0: + raise BadTypeInNameException( + "Type '%s' must not start with '.'" % type_) + if name[0] != '_': - raise BadTypeInNameException("Service name must start with '_'") + raise BadTypeInNameException( + "Service name (%s) must start with '_'" % name) # remove leading underscore name = name[1:] if len(name) > 15: - raise BadTypeInNameException("Service name must be <= 15 bytes") + raise BadTypeInNameException( + "Service name (%s) must be <= 15 bytes" % name) if '--' in name: - raise BadTypeInNameException("Service name must not contain '--'") + raise BadTypeInNameException( + "Service name (%s) must not contain '--'" % name) if '-' in (name[0], name[-1]): raise BadTypeInNameException( - "Service name may not start or end with '-'") + "Service name (%s) may not start or end with '-'" % name) if not _HAS_A_TO_Z.search(name): raise BadTypeInNameException( - "Service name must contain at least one letter (eg: 'A-Z')") + "Service name (%s) must contain at least one letter (eg: 'A-Z')" % + name) if not _HAS_ONLY_A_TO_Z_NUM_HYPHEN.search(name): raise BadTypeInNameException( - "Service name must contain only these characters: " - "A-Z, a-z, 0-9, hyphen ('-')") + "Service name (%s) must contain only these characters: " + "A-Z, a-z, 0-9, hyphen ('-')" % name) if remaining and remaining[-1] == '_sub': remaining.pop() - if len(remaining) == 0: + if len(remaining) == 0 or len(remaining[0]) == 0: raise BadTypeInNameException( "_sub requires a subtype name") if len(remaining) > 1: - raise BadTypeInNameException( - "Unexpected characters '%s.'" % '.'.join(remaining[1:])) + remaining = ['.'.join(remaining)] if remaining: length = len(remaining[0].encode('utf-8')) if length > 63: raise BadTypeInNameException("Too long: '%s'" % remaining[0]) + if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): + raise BadTypeInNameException( + "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % + remaining[0]) + return '_' + name + type_[-len('._tcp.local.'):] @@ -260,28 +287,57 @@ pass -class NonLocalNameException(Exception): +class IncomingDecodeError(Error): pass -class NonUniqueNameException(Exception): +class NonUniqueNameException(Error): pass -class NamePartTooLongException(Exception): +class NamePartTooLongException(Error): pass -class AbstractMethodException(Exception): +class AbstractMethodException(Error): pass -class BadTypeInNameException(Exception): +class BadTypeInNameException(Error): pass # implementation classes +class QuietLogger(object): + _seen_logs = {} + + @classmethod + def log_exception_warning(cls, logger_data=None): + exc_info = sys.exc_info() + exc_str = str(exc_info[1]) + if exc_str not in cls._seen_logs: + # log at warning level the first time this is seen + cls._seen_logs[exc_str] = exc_info + logger = log.warning + else: + logger = log.debug + if logger_data is not None: + logger(*logger_data) + logger('Exception occurred:', exc_info=exc_info) + + @classmethod + def log_warning_once(cls, *args): + msg_str = args[0] + if msg_str not in cls._seen_logs: + cls._seen_logs[msg_str] = 0 + logger = log.warning + else: + logger = log.debug + cls._seen_logs[msg_str] += 1 + logger(*args) + + class DNSEntry(object): """A DNS entry""" @@ -358,8 +414,8 @@ self.created = current_time_millis() def __eq__(self, other): - """Tests equality as per DNSRecord""" - return isinstance(other, DNSRecord) and DNSEntry.__eq__(self, other) + """Abstract method""" + raise AbstractMethodException def suppressed_by(self, msg): """Returns true if any answer in a message can suffice for the @@ -427,10 +483,9 @@ def __repr__(self): """String representation""" try: - return socket.inet_ntoa(self.address) - except Exception as e: # TODO stop catching all Exceptions - log.exception('Unknown error, possibly benign: %r', e) - return self.address + return str(socket.inet_ntoa(self.address)) + except Exception: # TODO stop catching all Exceptions + return str(self.address) class DNSHinfo(DNSRecord): @@ -541,7 +596,7 @@ return self.to_string("%s:%s" % (self.server, self.port)) -class DNSIncoming(object): +class DNSIncoming(QuietLogger): """Object representation of an incoming DNS packet""" @@ -557,10 +612,17 @@ self.num_answers = 0 self.num_authorities = 0 self.num_additionals = 0 + self.valid = False - self.read_header() - self.read_questions() - self.read_others() + try: + self.read_header() + self.read_questions() + self.read_others() + self.valid = True + + except (IndexError, struct.error, IncomingDecodeError): + self.log_exception_warning(( + 'Choked at offset %d while unpacking %r', self.offset, data)) def unpack(self, format_): length = struct.calcsize(format_) @@ -583,9 +645,9 @@ question = DNSQuestion(name, type_, class_) self.questions.append(question) - def read_int(self): - """Reads an integer from the packet""" - return self.unpack(b'!I')[0] + # def read_int(self): + # """Reads an integer from the packet""" + # return self.unpack(b'!I')[0] def read_character_string(self): """Reads a character string from the packet""" @@ -675,12 +737,11 @@ next_ = off + 1 off = ((length & 0x3F) << 8) | indexbytes(self.data, off) if off >= first: - # TODO raise more specific exception - raise Exception("Bad domain name (circular) at %s" % (off,)) + raise IncomingDecodeError( + "Bad domain name (circular) at %s" % (off,)) first = off else: - # TODO raise more specific exception - raise Exception("Bad domain name at %s" % (off,)) + raise IncomingDecodeError("Bad domain name at %s" % (off,)) if next_ >= 0: self.offset = next_ @@ -702,12 +763,27 @@ self.names = {} self.data = [] self.size = 12 + self.state = self.State.init self.questions = [] self.answers = [] self.authorities = [] self.additionals = [] + def __repr__(self): + return '' % ', '.join([ + 'multicast=%s' % self.multicast, + 'flags=%s' % self.flags, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + 'authorities=%s' % self.authorities, + 'additionals=%s' % self.additionals, + ]) + + class State(enum.Enum): + init = 0 + finished = 1 + def add_question(self, record): """Adds a question""" self.questions.append(record) @@ -718,7 +794,7 @@ self.add_answer_at_time(record, 0) def add_answer_at_time(self, record, now): - """Adds an answer if if does not expire by a certain time""" + """Adds an answer if it does not expire by a certain time""" if record is not None: if now == 0 or not record.is_expired(now): self.answers.append((record, now)) @@ -728,7 +804,41 @@ self.authorities.append(record) def add_additional_answer(self, record): - """Adds an additional answer""" + """ Adds an additional answer + + From: RFC 6763, DNS-Based Service Discovery, February 2013 + + 12. DNS Additional Record Generation + + DNS has an efficiency feature whereby a DNS server may place + additional records in the additional section of the DNS message. + These additional records are records that the client did not + explicitly request, but the server has reasonable grounds to expect + that the client might request them shortly, so including them can + save the client from having to issue additional queries. + + This section recommends which additional records SHOULD be generated + to improve network efficiency, for both Unicast and Multicast DNS-SD + responses. + + 12.1. PTR Records + + When including a DNS-SD Service Instance Enumeration or Selective + Instance Enumeration (subtype) PTR record in a response packet, the + server/responder SHOULD include the following additional records: + + o The SRV record(s) named in the PTR rdata. + o The TXT record(s) named in the PTR rdata. + o All address records (type "A" and "AAAA") named in the SRV rdata. + + 12.2. SRV Records + + When including an SRV record in a response packet, the + server/responder SHOULD include the following additional records: + + o All address records (type "A" and "AAAA") named in the SRV rdata. + + """ self.additionals.append(record) def pack(self, format_, value): @@ -776,28 +886,49 @@ self.write_string(value) def write_name(self, name): - """Writes a domain name to the packet""" + """ + Write names to packet + + 18.14. Name Compression - if name in self.names: - # Find existing instance of this name in packet - # - index = self.names[name] + When generating Multicast DNS messages, implementations SHOULD use + name compression wherever possible to compress the names of resource + records, by replacing some or all of the resource record name with a + compact two-byte reference to an appearance of that data somewhere + earlier in the message [RFC1035]. + """ - # An index was found, so write a pointer to it - # + # split name into each label + parts = name.split('.') + if not parts[-1]: + parts.pop() + + # construct each suffix + name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] + + # look for an existing name or suffix + for count, sub_name in enumerate(name_suffices): + if sub_name in self.names: + break + else: + count += 1 + + # note the new names we are saving into the packet + for suffix in name_suffices[:count]: + self.names[suffix] = self.size + len(name) - len(suffix) - 1 + + # write the new names out. + for part in parts[:count]: + self.write_utf(part) + + # if we wrote part of the name, create a pointer to the rest + if count != len(name_suffices): + # Found substring in packet, create pointer + index = self.names[name_suffices[count]] self.write_byte((index >> 8) | 0xC0) self.write_byte(index & 0xFF) else: - # No record of this name already, so write it - # out as normal, recording the location of the name - # for future pointers to it. - # - self.names[name] = self.size - parts = name.split('.') - if parts[-1] == '': - parts = parts[:-1] - for part in parts: - self.write_utf(part) + # this is the end of a name self.write_byte(0) def write_question(self, question): @@ -809,6 +940,10 @@ def write_record(self, record, now): """Writes a record (answer, authoritative answer, additional) to the packet""" + if self.state == self.State.finished: + return 1 + + start_data_length, start_size = len(self.data), self.size self.write_name(record.name) self.write_short(record.type) if record.unique and self.multicast: @@ -820,34 +955,47 @@ else: self.write_int(record.get_remaining_ttl(now)) index = len(self.data) + # Adjust size for the short we will write before this record - # self.size += 2 record.write(self) self.size -= 2 - length = len(b''.join(self.data[index:])) - self.insert_short(index, length) # Here is the short we adjusted for + length = sum((len(d) for d in self.data[index:])) + # Here is the short we adjusted for + self.insert_short(index, length) + + # if we go over, then rollback and quit + if self.size > _MAX_MSG_ABSOLUTE: + while len(self.data) > start_data_length: + self.data.pop() + self.size = start_size + self.state = self.State.finished + return 1 + return 0 def packet(self): """Returns a string containing the packet's bytes No further parts should be added to the packet once this is done.""" - if not self.finished: - self.finished = True + + overrun_answers, overrun_authorities, overrun_additionals = 0, 0, 0 + + if self.state != self.State.finished: for question in self.questions: self.write_question(question) for answer, time_ in self.answers: - self.write_record(answer, time_) + overrun_answers += self.write_record(answer, time_) for authority in self.authorities: - self.write_record(authority, 0) + overrun_authorities += self.write_record(authority, 0) for additional in self.additionals: - self.write_record(additional, 0) + overrun_additionals += self.write_record(additional, 0) + self.state = self.State.finished - self.insert_short(0, len(self.additionals)) - self.insert_short(0, len(self.authorities)) - self.insert_short(0, len(self.answers)) + self.insert_short(0, len(self.additionals) - overrun_additionals) + self.insert_short(0, len(self.authorities) - overrun_authorities) + self.insert_short(0, len(self.answers) - overrun_answers) self.insert_short(0, len(self.questions)) self.insert_short(0, self.flags) if self.multicast: @@ -896,10 +1044,18 @@ def entries_with_name(self, name): """Returns a list of entries whose key matches the name.""" try: - return self.cache[name] + return self.cache[name.lower()] except KeyError: return [] + def current_entry_with_name_and_alias(self, name, alias): + now = current_time_millis() + for record in self.entries_with_name(name): + if (record.type == _TYPE_PTR and + not record.is_expired(now) and + record.alias == alias): + return record + def entries(self): """Returns a list of all entries""" if not self.cache: @@ -950,10 +1106,10 @@ if reader: reader.handle_read(socket_) - except socket.error as e: + except (select.error, socket.error) as e: # If the socket was closed by another thread, during # shutdown, ignore it and exit - if e.errno != socket.EBADF or not self.zc.done: + if e.args[0] != socket.EBADF or not self.zc.done: raise def add_reader(self, reader, socket_): @@ -967,7 +1123,7 @@ self.condition.notify() -class Listener(object): +class Listener(QuietLogger): """A Listener is used by this module to listen on the multicast group to which DNS messages are sent, allowing the implementation @@ -981,22 +1137,30 @@ self.data = None def handle_read(self, socket_): - data, (addr, port) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) - log.debug('Received %r from %r:%r', data, addr, port) + try: + data, (addr, port) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) + except Exception: + self.log_exception_warning() + return + + log.debug('Received from %r:%r: %r ', addr, port, data) self.data = data msg = DNSIncoming(data) - if msg.is_query(): + if not msg.valid: + pass + + elif msg.is_query(): # Always multicast responses - # if port == _MDNS_PORT: self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) + # If it's not a multicast query, reply via unicast # and multicast - # elif port == _DNS_PORT: self.zc.handle_query(msg, addr, port) self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) + else: self.zc.handle_response(msg) @@ -1154,13 +1318,13 @@ if self.zc.done or self.done: return now = current_time_millis() - if self.next_time <= now: out = DNSOutgoing(_FLAGS_QR_QUERY) out.add_question(DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) for record in self.services.values(): if not record.is_expired(now): out.add_answer_at_time(record, now) + self.zc.send(out) self.next_time = now + self.delay self.delay = min(20 * 1000, self.delay * 2) @@ -1358,9 +1522,7 @@ def __eq__(self, other): """Tests equality of service name""" - if isinstance(other, ServiceInfo): - return other.name == self.name - return False + return isinstance(other, ServiceInfo) and other.name == self.name def __ne__(self, other): """Non-equality test""" @@ -1394,7 +1556,7 @@ pass @classmethod - def find(cls, zc=None, timeout=5): + def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All): """ Return all of the advertised services on any local networks. @@ -1403,7 +1565,7 @@ :param timeout: seconds to wait for any responses :return: tuple of service type strings """ - local_zc = zc or Zeroconf() + local_zc = zc or Zeroconf(interfaces=interfaces) listener = cls() browser = ServiceBrowser( local_zc, '_services._dns-sd._udp.local.', listener=listener) @@ -1420,21 +1582,6 @@ return tuple(sorted(listener.found_services)) -@enum.unique -class InterfaceChoice(enum.Enum): - Default = 1 - All = 2 - - -@enum.unique -class ServiceStateChange(enum.Enum): - Added = 1 - Removed = 2 - - -HOST_ONLY_NETWORK_MASK = '255.255.255.255' - - def get_all_addresses(address_family): return list(set( addr['addr'] @@ -1491,7 +1638,7 @@ return e.args[0] -class Zeroconf(object): +class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery @@ -1580,7 +1727,6 @@ info = ServiceInfo(type_, name) if info.request(self, timeout): return info - return None def add_service_listener(self, type_, listener): """Adds a listener for a particular service type. This object @@ -1600,12 +1746,12 @@ for listener in [k for k in self.browsers]: self.remove_service_listener(listener) - def register_service(self, info, ttl=_DNS_TTL): + def register_service(self, info, ttl=_DNS_TTL, allow_name_change=False): """Registers service information to the network with a default TTL of 60 seconds. Zeroconf will then respond to requests for information for that service. The name of the service may be changed if needed to make it unique on the network.""" - self.check_service(info) + self.check_service(info, allow_name_change) self.services[info.name.lower()] = info if info.type in self.servicetypes: self.servicetypes[info.type] += 1 @@ -1700,28 +1846,42 @@ i += 1 next_time += _UNREGISTER_TIME - def check_service(self, info): + def check_service(self, info, allow_name_change): """Checks the network for a unique service name, modifying the ServiceInfo passed in if it is not unique.""" + + # This is kind of funky because of the subtype based tests + # need to make subtypes a first class citizen + service_name = service_type_name(info.name) + if not info.type.endswith(service_name): + raise BadTypeInNameException + + instance_name = info.name[:-len(service_name) - 1] + next_instance_number = 2 + now = current_time_millis() next_time = now i = 0 while i < 3: - for record in self.cache.entries_with_name(info.type): - if (record.type == _TYPE_PTR and - not record.is_expired(now) and - record.alias == info.name): - if info.name.find('.') < 0: - info.name = '%s.[%s:%s].%s' % ( - info.name, info.address, info.port, info.type) - - self.check_service(info) - return + # check for a name conflict + while self.cache.current_entry_with_name_and_alias( + info.type, info.name): + if not allow_name_change: raise NonUniqueNameException + + # change the name and look for a conflict + info.name = '%s-%s.%s' % ( + instance_name, next_instance_number, info.type) + next_instance_number += 1 + service_type_name(info.name) + next_time = now + i = 0 + if now < next_time: self.wait(next_time - now) now = current_time_millis() continue + out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) self.debug = out out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) @@ -1785,7 +1945,7 @@ # Support unicast client responses # if port != _MDNS_PORT: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, False) + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) for question in msg.questions: out.add_question(question) @@ -1836,8 +1996,8 @@ out.add_additional_answer(DNSAddress( service.server, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.address)) - except Exception as e: # TODO stop catching all Exceptions - log.exception('Unknown error, possibly benign: %r', e) + except Exception: # TODO stop catching all Exceptions + self.log_exception_warning() if out is not None and out.answers: out.id = msg.id @@ -1846,15 +2006,24 @@ def send(self, out, addr=_MDNS_ADDR, port=_MDNS_PORT): """Sends an outgoing packet.""" packet = out.packet() - log.debug('Sending %r as %r...', out, packet) + if len(packet) > _MAX_MSG_ABSOLUTE: + self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", + out, len(packet), packet) + return + log.debug('Sending %r (%d bytes) as %r...', out, len(packet), packet) for s in self._respond_sockets: if self._GLOBAL_DONE: return - bytes_sent = s.sendto(packet, 0, (addr, port)) - if bytes_sent != len(packet): - raise Error( - 'Should not happen, sent %d out of %d bytes' % ( - bytes_sent, len(packet))) + try: + bytes_sent = s.sendto(packet, 0, (addr, port)) + except Exception: # TODO stop catching all Exceptions + # on send errors, log the exception and keep going + self.log_exception_warning() + else: + if bytes_sent != len(packet): + self.log_warning_once( + '!!! sent %d out of %d bytes to %r' % ( + bytes_sent, len(packet)), s) def close(self): """Ends the background threads, and prevent this instance from