diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9415a30e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +MANIFEST +dist +build +*.pyc +*.pyo +*.e4? diff --git a/Browser.py b/Browser.py deleted file mode 100755 index 7347f39a..00000000 --- a/Browser.py +++ /dev/null @@ -1,30 +0,0 @@ -from Zeroconf import * -import socket - -class MyListener(object): - def __init__(self): - self.r = Zeroconf() - pass - - def removeService(self, zeroconf, type, name): - print "Service", name, "removed" - - def addService(self, zeroconf, type, name): - print "Service", name, "added" - print "Type is", type - info = self.r.getServiceInfo(type, name) - print "Address is", str(socket.inet_ntoa(info.getAddress())) - print "Port is", info.getPort() - print "Weight is", info.getWeight() - print "Priority is", info.getPriority() - print "Server is", info.getServer() - print "Text is", info.getText() - print "Properties are", info.getProperties() - -if __name__ == '__main__': - print "Multicast DNS Service Discovery for Python Browser test" - r = Zeroconf() - print "1. Testing browsing for a service..." - type = "_http._tcp.local." - listener = MyListener() - browser = ServiceBrowser(r, type, listener) diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..348c5377 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README +include lgpl.txt +include *.py +recursive-include samplecode *.py +recursive-include test *.py diff --git a/README b/README index 266b1c5b..074b2631 100644 --- a/README +++ b/README @@ -1,4 +1,8 @@ This is Multicast DNS Service Discovery for Python v0.12 by Paul Scott-Murphy. +The package zeroconf contains most of the functional code. The module +Zeroconf.py contains a threaded mDNS query/responder sub-class. The +samples/ directory contains various sample code. + Zeroconf.py is the implementation file, look at the end for examples of basic use. You can also view Browser.py to see how to browse for services. diff --git a/Zeroconf.py b/Zeroconf.py deleted file mode 100755 index 8efaf033..00000000 --- a/Zeroconf.py +++ /dev/null @@ -1,1557 +0,0 @@ -""" Multicast DNS Service Discovery for Python, v0.12 - Copyright (C) 2003, Paul Scott-Murphy - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. It has been tested against the JRendezvous - implementation from StrangeBerry, - and against the mDNSResponder from Mac OS X 10.3.8. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -""" - -"""0.12 update - allow selection of binding interface - typo fix - Thanks A. M. Kuchlingi - removed all use of word 'Rendezvous' - this is an API change""" - -"""0.11 update - correction to comments for addListener method - support for new record types seen from OS X - - IPv6 address - - hostinfo - ignore unknown DNS record types - fixes to name decoding - works alongside other processes using port 5353 (e.g. on Mac OS X) - tested against Mac OS X 10.3.2's mDNSResponder - corrections to removal of list entries for service browser""" - -"""0.10 update - Jonathon Paisley contributed these corrections: - always multicast replies, even when query is unicast - correct a pointer encoding problem - can now write records in any order - traceback shown on failure - better TXT record parsing - server is now separate from name - can cancel a service browser - - modified some unit tests to accommodate these changes""" - -"""0.09 update - remove all records on service unregistration - fix DOS security problem with readName""" - -"""0.08 update - changed licensing to LGPL""" - -"""0.07 update - faster shutdown on engine - pointer encoding of outgoing names - ServiceBrowser now works - new unit tests""" - -"""0.06 update - small improvements with unit tests - added defined exception types - new style objects - fixed hostname/interface problem - fixed socket timeout problem - fixed addServiceListener() typo bug - using select() for socket reads - tested on Debian unstable with Python 2.2.2""" - -"""0.05 update - ensure case insensitivty on domain names - support for unicast DNS queries""" - -"""0.04 update - added some unit tests - added __ne__ adjuncts where required - ensure names end in '.local.' - timeout on receiving socket for clean shutdown""" - -__author__ = "Paul Scott-Murphy" -__email__ = "paul at scott dash murphy dot com" -__version__ = "0.12" - -import string -import time -import struct -import socket -import threading -import select -import traceback - -__all__ = ["Zeroconf", "ServiceInfo", "ServiceBrowser"] - -# hook for threads - -globals()['_GLOBAL_DONE'] = 0 - -# Some timing constants - -_UNREGISTER_TIME = 125 -_CHECK_TIME = 175 -_REGISTER_TIME = 225 -_LISTENER_TIME = 200 -_BROWSER_TIME = 500 - -# Some DNS constants - -_MDNS_ADDR = '224.0.0.251' -_MDNS_PORT = 5353; -_DNS_PORT = 53; -_DNS_TTL = 60 * 60; # one hour default TTL - -_MAX_MSG_TYPICAL = 1460 # unused -_MAX_MSG_ABSOLUTE = 8972 - -_FLAGS_QR_MASK = 0x8000 # query response mask -_FLAGS_QR_QUERY = 0x0000 # query -_FLAGS_QR_RESPONSE = 0x8000 # response - -_FLAGS_AA = 0x0400 # Authorative answer -_FLAGS_TC = 0x0200 # Truncated -_FLAGS_RD = 0x0100 # Recursion desired -_FLAGS_RA = 0x8000 # Recursion available - -_FLAGS_Z = 0x0040 # Zero -_FLAGS_AD = 0x0020 # Authentic data -_FLAGS_CD = 0x0010 # Checking disabled - -_CLASS_IN = 1 -_CLASS_CS = 2 -_CLASS_CH = 3 -_CLASS_HS = 4 -_CLASS_NONE = 254 -_CLASS_ANY = 255 -_CLASS_MASK = 0x7FFF -_CLASS_UNIQUE = 0x8000 - -_TYPE_A = 1 -_TYPE_NS = 2 -_TYPE_MD = 3 -_TYPE_MF = 4 -_TYPE_CNAME = 5 -_TYPE_SOA = 6 -_TYPE_MB = 7 -_TYPE_MG = 8 -_TYPE_MR = 9 -_TYPE_NULL = 10 -_TYPE_WKS = 11 -_TYPE_PTR = 12 -_TYPE_HINFO = 13 -_TYPE_MINFO = 14 -_TYPE_MX = 15 -_TYPE_TXT = 16 -_TYPE_AAAA = 28 -_TYPE_SRV = 33 -_TYPE_ANY = 255 - -# Mapping constants to names - -_CLASSES = { _CLASS_IN : "in", - _CLASS_CS : "cs", - _CLASS_CH : "ch", - _CLASS_HS : "hs", - _CLASS_NONE : "none", - _CLASS_ANY : "any" } - -_TYPES = { _TYPE_A : "a", - _TYPE_NS : "ns", - _TYPE_MD : "md", - _TYPE_MF : "mf", - _TYPE_CNAME : "cname", - _TYPE_SOA : "soa", - _TYPE_MB : "mb", - _TYPE_MG : "mg", - _TYPE_MR : "mr", - _TYPE_NULL : "null", - _TYPE_WKS : "wks", - _TYPE_PTR : "ptr", - _TYPE_HINFO : "hinfo", - _TYPE_MINFO : "minfo", - _TYPE_MX : "mx", - _TYPE_TXT : "txt", - _TYPE_AAAA : "quada", - _TYPE_SRV : "srv", - _TYPE_ANY : "any" } - -# utility functions - -def currentTimeMillis(): - """Current system time in milliseconds""" - return time.time() * 1000 - -# Exceptions - -class NonLocalNameException(Exception): - pass - -class NonUniqueNameException(Exception): - pass - -class NamePartTooLongException(Exception): - pass - -class AbstractMethodException(Exception): - pass - -class BadTypeInNameException(Exception): - pass - -# implementation classes - -class DNSEntry(object): - """A DNS entry""" - - def __init__(self, name, type, clazz): - self.key = string.lower(name) - self.name = name - self.type = type - self.clazz = clazz & _CLASS_MASK - self.unique = (clazz & _CLASS_UNIQUE) != 0 - - def __eq__(self, other): - """Equality test on name, type, and class""" - if isinstance(other, DNSEntry): - return self.name == other.name and self.type == other.type and self.clazz == other.clazz - return 0 - - def __ne__(self, other): - """Non-equality test""" - return not self.__eq__(other) - - def getClazz(self, clazz): - """Class accessor""" - try: - return _CLASSES[clazz] - except: - return "?(%s)" % (clazz) - - def getType(self, type): - """Type accessor""" - try: - return _TYPES[type] - except: - return "?(%s)" % (type) - - def toString(self, hdr, other): - """String representation with additional information""" - result = "%s[%s,%s" % (hdr, self.getType(self.type), self.getClazz(self.clazz)) - if self.unique: - result += "-unique," - else: - result += "," - result += self.name - if other is not None: - result += ",%s]" % (other) - else: - result += "]" - return result - -class DNSQuestion(DNSEntry): - """A DNS question entry""" - - def __init__(self, name, type, clazz): - if not name.endswith(".local."): - raise NonLocalNameException - DNSEntry.__init__(self, name, type, clazz) - - def answeredBy(self, rec): - """Returns true if the question is answered by the record""" - return self.clazz == rec.clazz and (self.type == rec.type or self.type == _TYPE_ANY) and self.name == rec.name - - def __repr__(self): - """String representation""" - return DNSEntry.toString(self, "question", None) - - -class DNSRecord(DNSEntry): - """A DNS record - like a DNS entry, but has a TTL""" - - def __init__(self, name, type, clazz, ttl): - DNSEntry.__init__(self, name, type, clazz) - self.ttl = ttl - self.created = currentTimeMillis() - - def __eq__(self, other): - """Tests equality as per DNSRecord""" - if isinstance(other, DNSRecord): - return DNSEntry.__eq__(self, other) - return 0 - - def suppressedBy(self, msg): - """Returns true if any answer in a message can suffice for the - information held in this record.""" - for record in msg.answers: - if self.suppressedByAnswer(record): - return 1 - return 0 - - def suppressedByAnswer(self, other): - """Returns true if another record has same name, type and class, - and if its TTL is at least half of this record's.""" - if self == other and other.ttl > (self.ttl / 2): - return 1 - return 0 - - def getExpirationTime(self, percent): - """Returns the time at which this record will have expired - by a certain percentage.""" - return self.created + (percent * self.ttl * 10) - - def getRemainingTTL(self, now): - """Returns the remaining TTL in seconds.""" - return max(0, (self.getExpirationTime(100) - now) / 1000) - - def isExpired(self, now): - """Returns true if this record has expired.""" - return self.getExpirationTime(100) <= now - - def isStale(self, now): - """Returns true if this record is at least half way expired.""" - return self.getExpirationTime(50) <= now - - def resetTTL(self, other): - """Sets this record's TTL and created time to that of - another record.""" - self.created = other.created - self.ttl = other.ttl - - def write(self, out): - """Abstract method""" - raise AbstractMethodException - - def toString(self, other): - """String representation with addtional information""" - arg = "%s/%s,%s" % (self.ttl, self.getRemainingTTL(currentTimeMillis()), other) - return DNSEntry.toString(self, "record", arg) - -class DNSAddress(DNSRecord): - """A DNS address record""" - - def __init__(self, name, type, clazz, ttl, address): - DNSRecord.__init__(self, name, type, clazz, ttl) - self.address = address - - def write(self, out): - """Used in constructing an outgoing packet""" - out.writeString(self.address, len(self.address)) - - def __eq__(self, other): - """Tests equality on address""" - if isinstance(other, DNSAddress): - return self.address == other.address - return 0 - - def __repr__(self): - """String representation""" - try: - return socket.inet_ntoa(self.address) - except: - return self.address - -class DNSHinfo(DNSRecord): - """A DNS host information record""" - - def __init__(self, name, type, clazz, ttl, cpu, os): - DNSRecord.__init__(self, name, type, clazz, ttl) - self.cpu = cpu - self.os = os - - def write(self, out): - """Used in constructing an outgoing packet""" - out.writeString(self.cpu, len(self.cpu)) - out.writeString(self.os, len(self.os)) - - def __eq__(self, other): - """Tests equality on cpu and os""" - if isinstance(other, DNSHinfo): - return self.cpu == other.cpu and self.os == other.os - return 0 - - def __repr__(self): - """String representation""" - return self.cpu + " " + self.os - -class DNSPointer(DNSRecord): - """A DNS pointer record""" - - def __init__(self, name, type, clazz, ttl, alias): - DNSRecord.__init__(self, name, type, clazz, ttl) - self.alias = alias - - def write(self, out): - """Used in constructing an outgoing packet""" - out.writeName(self.alias) - - def __eq__(self, other): - """Tests equality on alias""" - if isinstance(other, DNSPointer): - return self.alias == other.alias - return 0 - - def __repr__(self): - """String representation""" - return self.toString(self.alias) - -class DNSText(DNSRecord): - """A DNS text record""" - - def __init__(self, name, type, clazz, ttl, text): - DNSRecord.__init__(self, name, type, clazz, ttl) - self.text = text - - def write(self, out): - """Used in constructing an outgoing packet""" - out.writeString(self.text, len(self.text)) - - def __eq__(self, other): - """Tests equality on text""" - if isinstance(other, DNSText): - return self.text == other.text - return 0 - - def __repr__(self): - """String representation""" - if len(self.text) > 10: - return self.toString(self.text[:7] + "...") - else: - return self.toString(self.text) - -class DNSService(DNSRecord): - """A DNS service record""" - - def __init__(self, name, type, clazz, ttl, priority, weight, port, server): - DNSRecord.__init__(self, name, type, clazz, ttl) - self.priority = priority - self.weight = weight - self.port = port - self.server = server - - def write(self, out): - """Used in constructing an outgoing packet""" - out.writeShort(self.priority) - out.writeShort(self.weight) - out.writeShort(self.port) - out.writeName(self.server) - - def __eq__(self, other): - """Tests equality on priority, weight, port and server""" - if isinstance(other, DNSService): - return self.priority == other.priority and self.weight == other.weight and self.port == other.port and self.server == other.server - return 0 - - def __repr__(self): - """String representation""" - return self.toString("%s:%s" % (self.server, self.port)) - -class DNSIncoming(object): - """Object representation of an incoming DNS packet""" - - def __init__(self, data): - """Constructor from string holding bytes of packet""" - self.offset = 0 - self.data = data - self.questions = [] - self.answers = [] - self.numQuestions = 0 - self.numAnswers = 0 - self.numAuthorities = 0 - self.numAdditionals = 0 - - self.readHeader() - self.readQuestions() - self.readOthers() - - def readHeader(self): - """Reads header portion of packet""" - format = '!HHHHHH' - length = struct.calcsize(format) - info = struct.unpack(format, self.data[self.offset:self.offset+length]) - self.offset += length - - self.id = info[0] - self.flags = info[1] - self.numQuestions = info[2] - self.numAnswers = info[3] - self.numAuthorities = info[4] - self.numAdditionals = info[5] - - def readQuestions(self): - """Reads questions section of packet""" - format = '!HH' - length = struct.calcsize(format) - for i in range(0, self.numQuestions): - name = self.readName() - info = struct.unpack(format, self.data[self.offset:self.offset+length]) - self.offset += length - - question = DNSQuestion(name, info[0], info[1]) - self.questions.append(question) - - def readInt(self): - """Reads an integer from the packet""" - format = '!I' - length = struct.calcsize(format) - info = struct.unpack(format, self.data[self.offset:self.offset+length]) - self.offset += length - return info[0] - - def readCharacterString(self): - """Reads a character string from the packet""" - length = ord(self.data[self.offset]) - self.offset += 1 - return self.readString(length) - - def readString(self, len): - """Reads a string of a given length from the packet""" - format = '!' + str(len) + 's' - length = struct.calcsize(format) - info = struct.unpack(format, self.data[self.offset:self.offset+length]) - self.offset += length - return info[0] - - def readUnsignedShort(self): - """Reads an unsigned short from the packet""" - format = '!H' - length = struct.calcsize(format) - info = struct.unpack(format, self.data[self.offset:self.offset+length]) - self.offset += length - return info[0] - - def readOthers(self): - """Reads the answers, authorities and additionals section of the packet""" - format = '!HHiH' - length = struct.calcsize(format) - n = self.numAnswers + self.numAuthorities + self.numAdditionals - for i in range(0, n): - domain = self.readName() - info = struct.unpack(format, self.data[self.offset:self.offset+length]) - self.offset += length - - rec = None - if info[0] == _TYPE_A: - rec = DNSAddress(domain, info[0], info[1], info[2], self.readString(4)) - elif info[0] == _TYPE_CNAME or info[0] == _TYPE_PTR: - rec = DNSPointer(domain, info[0], info[1], info[2], self.readName()) - elif info[0] == _TYPE_TXT: - rec = DNSText(domain, info[0], info[1], info[2], self.readString(info[3])) - elif info[0] == _TYPE_SRV: - rec = DNSService(domain, info[0], info[1], info[2], self.readUnsignedShort(), self.readUnsignedShort(), self.readUnsignedShort(), self.readName()) - elif info[0] == _TYPE_HINFO: - rec = DNSHinfo(domain, info[0], info[1], info[2], self.readCharacterString(), self.readCharacterString()) - elif info[0] == _TYPE_AAAA: - rec = DNSAddress(domain, info[0], info[1], info[2], self.readString(16)) - else: - # Try to ignore types we don't know about - # this may mean the rest of the name is - # unable to be parsed, and may show errors - # so this is left for debugging. New types - # encountered need to be parsed properly. - # - #print "UNKNOWN TYPE = " + str(info[0]) - #raise BadTypeInNameException - pass - - if rec is not None: - self.answers.append(rec) - - def isQuery(self): - """Returns true if this is a query""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY - - def isResponse(self): - """Returns true if this is a response""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE - - def readUTF(self, offset, len): - """Reads a UTF-8 string of a given length from the packet""" - result = self.data[offset:offset+len].decode('utf-8') - return result - - def readName(self): - """Reads a domain name from the packet""" - result = '' - off = self.offset - next = -1 - first = off - - while 1: - len = ord(self.data[off]) - off += 1 - if len == 0: - break - t = len & 0xC0 - if t == 0x00: - result = ''.join((result, self.readUTF(off, len) + '.')) - off += len - elif t == 0xC0: - if next < 0: - next = off + 1 - off = ((len & 0x3F) << 8) | ord(self.data[off]) - if off >= first: - raise "Bad domain name (circular) at " + str(off) - first = off - else: - raise "Bad domain name at " + str(off) - - if next >= 0: - self.offset = next - else: - self.offset = off - - return result - - -class DNSOutgoing(object): - """Object representation of an outgoing packet""" - - def __init__(self, flags, multicast = 1): - self.finished = 0 - self.id = 0 - self.multicast = multicast - self.flags = flags - self.names = {} - self.data = [] - self.size = 12 - - self.questions = [] - self.answers = [] - self.authorities = [] - self.additionals = [] - - def addQuestion(self, record): - """Adds a question""" - self.questions.append(record) - - def addAnswer(self, inp, record): - """Adds an answer""" - if not record.suppressedBy(inp): - self.addAnswerAtTime(record, 0) - - def addAnswerAtTime(self, record, now): - """Adds an answer if if does not expire by a certain time""" - if record is not None: - if now == 0 or not record.isExpired(now): - self.answers.append((record, now)) - - def addAuthorativeAnswer(self, record): - """Adds an authoritative answer""" - self.authorities.append(record) - - def addAdditionalAnswer(self, record): - """Adds an additional answer""" - self.additionals.append(record) - - def writeByte(self, value): - """Writes a single byte to the packet""" - format = '!c' - self.data.append(struct.pack(format, chr(value))) - self.size += 1 - - def insertShort(self, index, value): - """Inserts an unsigned short in a certain position in the packet""" - format = '!H' - self.data.insert(index, struct.pack(format, value)) - self.size += 2 - - def writeShort(self, value): - """Writes an unsigned short to the packet""" - format = '!H' - self.data.append(struct.pack(format, value)) - self.size += 2 - - def writeInt(self, value): - """Writes an unsigned integer to the packet""" - format = '!I' - self.data.append(struct.pack(format, value)) - self.size += 4 - - def writeString(self, value, length): - """Writes a string to the packet""" - format = '!' + str(length) + 's' - self.data.append(struct.pack(format, value)) - self.size += length - - def writeUTF(self, s): - """Writes a UTF-8 string of a given length to the packet""" - utfstr = s.encode('utf-8') - length = len(utfstr) - if length > 64: - raise NamePartTooLongException - self.writeByte(length) - self.writeString(utfstr, length) - - def writeName(self, name): - """Writes a domain name to the packet""" - - try: - # Find existing instance of this name in packet - # - index = self.names[name] - except KeyError: - # 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.writeUTF(part) - self.writeByte(0) - return - - # An index was found, so write a pointer to it - # - self.writeByte((index >> 8) | 0xC0) - self.writeByte(index) - - def writeQuestion(self, question): - """Writes a question to the packet""" - self.writeName(question.name) - self.writeShort(question.type) - self.writeShort(question.clazz) - - def writeRecord(self, record, now): - """Writes a record (answer, authoritative answer, additional) to - the packet""" - self.writeName(record.name) - self.writeShort(record.type) - if record.unique and self.multicast: - self.writeShort(record.clazz | _CLASS_UNIQUE) - else: - self.writeShort(record.clazz) - if now == 0: - self.writeInt(record.ttl) - else: - self.writeInt(record.getRemainingTTL(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(''.join(self.data[index:])) - self.insertShort(index, length) # Here is the short we adjusted for - - 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 = 1 - for question in self.questions: - self.writeQuestion(question) - for answer, time in self.answers: - self.writeRecord(answer, time) - for authority in self.authorities: - self.writeRecord(authority, 0) - for additional in self.additionals: - self.writeRecord(additional, 0) - - self.insertShort(0, len(self.additionals)) - self.insertShort(0, len(self.authorities)) - self.insertShort(0, len(self.answers)) - self.insertShort(0, len(self.questions)) - self.insertShort(0, self.flags) - if self.multicast: - self.insertShort(0, 0) - else: - self.insertShort(0, self.id) - return ''.join(self.data) - - -class DNSCache(object): - """A cache of DNS entries""" - - def __init__(self): - self.cache = {} - - def add(self, entry): - """Adds an entry""" - try: - list = self.cache[entry.key] - except: - list = self.cache[entry.key] = [] - list.append(entry) - - def remove(self, entry): - """Removes an entry""" - try: - list = self.cache[entry.key] - list.remove(entry) - except: - pass - - def get(self, entry): - """Gets an entry by key. Will return None if there is no - matching entry.""" - try: - list = self.cache[entry.key] - return list[list.index(entry)] - except: - return None - - def getByDetails(self, name, type, clazz): - """Gets an entry by details. Will return None if there is - no matching entry.""" - entry = DNSEntry(name, type, clazz) - return self.get(entry) - - def entriesWithName(self, name): - """Returns a list of entries whose key matches the name.""" - try: - return self.cache[name] - except: - return [] - - def entries(self): - """Returns a list of all entries""" - def add(x, y): return x+y - try: - return reduce(add, self.cache.values()) - except: - return [] - - -class Engine(threading.Thread): - """An engine wraps read access to sockets, allowing objects that - need to receive data from sockets to be called back when the - sockets are ready. - - A reader needs a handle_read() method, which is called when the socket - it is interested in is ready for reading. - - Writers are not implemented here, because we only send short - packets. - """ - - def __init__(self, zeroconf): - threading.Thread.__init__(self) - self.zeroconf = zeroconf - self.readers = {} # maps socket to reader - self.timeout = 5 - self.condition = threading.Condition() - self.start() - - def run(self): - while not globals()['_GLOBAL_DONE']: - rs = self.getReaders() - if len(rs) == 0: - # No sockets to manage, but we wait for the timeout - # or addition of a socket - # - self.condition.acquire() - self.condition.wait(self.timeout) - self.condition.release() - else: - try: - rr, wr, er = select.select(rs, [], [], self.timeout) - for socket in rr: - try: - self.readers[socket].handle_read() - except: - # Ignore errors that occur on shutdown - pass - except: - pass - - def getReaders(self): - result = [] - self.condition.acquire() - result = self.readers.keys() - self.condition.release() - return result - - def addReader(self, reader, socket): - self.condition.acquire() - self.readers[socket] = reader - self.condition.notify() - self.condition.release() - - def delReader(self, socket): - self.condition.acquire() - del(self.readers[socket]) - self.condition.notify() - self.condition.release() - - def notify(self): - self.condition.acquire() - self.condition.notify() - self.condition.release() - -class Listener(object): - """A Listener is used by this module to listen on the multicast - group to which DNS messages are sent, allowing the implementation - to cache information as it arrives. - - It requires registration with an Engine object in order to have - the read() method called when a socket is availble for reading.""" - - def __init__(self, zeroconf): - self.zeroconf = zeroconf - self.zeroconf.engine.addReader(self, self.zeroconf.socket) - - def handle_read(self): - data, (addr, port) = self.zeroconf.socket.recvfrom(_MAX_MSG_ABSOLUTE) - self.data = data - msg = DNSIncoming(data) - if msg.isQuery(): - # Always multicast responses - # - if port == _MDNS_PORT: - self.zeroconf.handleQuery(msg, _MDNS_ADDR, _MDNS_PORT) - # If it's not a multicast query, reply via unicast - # and multicast - # - elif port == _DNS_PORT: - self.zeroconf.handleQuery(msg, addr, port) - self.zeroconf.handleQuery(msg, _MDNS_ADDR, _MDNS_PORT) - else: - self.zeroconf.handleResponse(msg) - - -class Reaper(threading.Thread): - """A Reaper is used by this module to remove cache entries that - have expired.""" - - def __init__(self, zeroconf): - threading.Thread.__init__(self) - self.zeroconf = zeroconf - self.start() - - def run(self): - while 1: - self.zeroconf.wait(10 * 1000) - if globals()['_GLOBAL_DONE']: - return - now = currentTimeMillis() - for record in self.zeroconf.cache.entries(): - if record.isExpired(now): - self.zeroconf.updateRecord(now, record) - self.zeroconf.cache.remove(record) - - -class ServiceBrowser(threading.Thread): - """Used to browse for a service of a specific type. - - The listener object will have its addService() and - removeService() methods called when this browser - discovers changes in the services availability.""" - - def __init__(self, zeroconf, type, listener): - """Creates a browser for a specific type""" - threading.Thread.__init__(self) - self.zeroconf = zeroconf - self.type = type - self.listener = listener - self.services = {} - self.nextTime = currentTimeMillis() - self.delay = _BROWSER_TIME - self.list = [] - - self.done = 0 - - self.zeroconf.addListener(self, DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) - self.start() - - def updateRecord(self, zeroconf, now, record): - """Callback invoked by Zeroconf when new information arrives. - - Updates information required by browser in the Zeroconf cache.""" - if record.type == _TYPE_PTR and record.name == self.type: - expired = record.isExpired(now) - try: - oldrecord = self.services[record.alias.lower()] - if not expired: - oldrecord.resetTTL(record) - else: - del(self.services[record.alias.lower()]) - callback = lambda x: self.listener.removeService(x, self.type, record.alias) - self.list.append(callback) - return - except: - if not expired: - self.services[record.alias.lower()] = record - callback = lambda x: self.listener.addService(x, self.type, record.alias) - self.list.append(callback) - - expires = record.getExpirationTime(75) - if expires < self.nextTime: - self.nextTime = expires - - def cancel(self): - self.done = 1 - self.zeroconf.notifyAll() - - def run(self): - while 1: - event = None - now = currentTimeMillis() - if len(self.list) == 0 and self.nextTime > now: - self.zeroconf.wait(self.nextTime - now) - if globals()['_GLOBAL_DONE'] or self.done: - return - now = currentTimeMillis() - - if self.nextTime <= now: - out = DNSOutgoing(_FLAGS_QR_QUERY) - out.addQuestion(DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) - for record in self.services.values(): - if not record.isExpired(now): - out.addAnswerAtTime(record, now) - self.zeroconf.send(out) - self.nextTime = now + self.delay - self.delay = min(20 * 1000, self.delay * 2) - - if len(self.list) > 0: - event = self.list.pop(0) - - if event is not None: - event(self.zeroconf) - - -class ServiceInfo(object): - """Service information""" - - def __init__(self, type, name, address=None, port=None, weight=0, priority=0, properties=None, server=None): - """Create a service description. - - type: fully qualified service type name - name: fully qualified service name - address: IP address as unsigned short, network byte order - port: port that the service runs on - weight: weight of the service - priority: priority of the service - properties: dictionary of properties (or a string holding the bytes for the text field) - server: fully qualified name for service host (defaults to name)""" - - if not name.endswith(type): - raise BadTypeInNameException - self.type = type - self.name = name - self.address = address - self.port = port - self.weight = weight - self.priority = priority - if server: - self.server = server - else: - self.server = name - self.setProperties(properties) - - def setProperties(self, properties): - """Sets properties and text of this info from a dictionary""" - if isinstance(properties, dict): - self.properties = properties - list = [] - result = '' - for key in properties: - value = properties[key] - if value is None: - suffix = ''.encode('utf-8') - elif isinstance(value, str): - suffix = value.encode('utf-8') - elif isinstance(value, int): - if value: - suffix = 'true' - else: - suffix = 'false' - else: - suffix = ''.encode('utf-8') - list.append('='.join((key, suffix))) - for item in list: - result = ''.join((result, struct.pack('!c', chr(len(item))), item)) - self.text = result - else: - self.text = properties - - def setText(self, text): - """Sets properties and text given a text field""" - self.text = text - try: - result = {} - end = len(text) - index = 0 - strs = [] - while index < end: - length = ord(text[index]) - index += 1 - strs.append(text[index:index+length]) - index += length - - for s in strs: - eindex = s.find('=') - if eindex == -1: - # No equals sign at all - key = s - value = 0 - else: - key = s[:eindex] - value = s[eindex+1:] - if value == 'true': - value = 1 - elif value == 'false' or not value: - value = 0 - - # Only update non-existent properties - if key and result.get(key) == None: - result[key] = value - - self.properties = result - except: - traceback.print_exc() - self.properties = None - - def getType(self): - """Type accessor""" - return self.type - - def getName(self): - """Name accessor""" - if self.type is not None and self.name.endswith("." + self.type): - return self.name[:len(self.name) - len(self.type) - 1] - return self.name - - def getAddress(self): - """Address accessor""" - return self.address - - def getPort(self): - """Port accessor""" - return self.port - - def getPriority(self): - """Pirority accessor""" - return self.priority - - def getWeight(self): - """Weight accessor""" - return self.weight - - def getProperties(self): - """Properties accessor""" - return self.properties - - def getText(self): - """Text accessor""" - return self.text - - def getServer(self): - """Server accessor""" - return self.server - - def updateRecord(self, zeroconf, now, record): - """Updates service information from a DNS record""" - if record is not None and not record.isExpired(now): - if record.type == _TYPE_A: - if record.name == self.name: - self.address = record.address - elif record.type == _TYPE_SRV: - if record.name == self.name: - self.server = record.server - self.port = record.port - self.weight = record.weight - self.priority = record.priority - self.address = None - self.updateRecord(zeroconf, now, zeroconf.cache.getByDetails(self.server, _TYPE_A, _CLASS_IN)) - elif record.type == _TYPE_TXT: - if record.name == self.name: - self.setText(record.text) - - def request(self, zeroconf, timeout): - """Returns true if the service could be discovered on the - network, and updates this object with details discovered. - """ - now = currentTimeMillis() - delay = _LISTENER_TIME - next = now + delay - last = now + timeout - result = 0 - try: - zeroconf.addListener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) - while self.server is None or self.address is None or self.text is None: - if last <= now: - return 0 - if next <= now: - out = DNSOutgoing(_FLAGS_QR_QUERY) - out.addQuestion(DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) - out.addAnswerAtTime(zeroconf.cache.getByDetails(self.name, _TYPE_SRV, _CLASS_IN), now) - out.addQuestion(DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) - out.addAnswerAtTime(zeroconf.cache.getByDetails(self.name, _TYPE_TXT, _CLASS_IN), now) - if self.server is not None: - out.addQuestion(DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) - out.addAnswerAtTime(zeroconf.cache.getByDetails(self.server, _TYPE_A, _CLASS_IN), now) - zeroconf.send(out) - next = now + delay - delay = delay * 2 - - zeroconf.wait(min(next, last) - now) - now = currentTimeMillis() - result = 1 - finally: - zeroconf.removeListener(self) - - return result - - def __eq__(self, other): - """Tests equality of service name""" - if isinstance(other, ServiceInfo): - return other.name == self.name - return 0 - - def __ne__(self, other): - """Non-equality test""" - return not self.__eq__(other) - - def __repr__(self): - """String representation""" - result = "service[%s,%s:%s," % (self.name, socket.inet_ntoa(self.getAddress()), self.port) - if self.text is None: - result += "None" - else: - if len(self.text) < 20: - result += self.text - else: - result += self.text[:17] + "..." - result += "]" - return result - - -class Zeroconf(object): - """Implementation of Zeroconf Multicast DNS Service Discovery - - Supports registration, unregistration, queries and browsing. - """ - def __init__(self, bindaddress=None): - """Creates an instance of the Zeroconf class, establishing - multicast communications, listening and reaping threads.""" - globals()['_GLOBAL_DONE'] = 0 - if bindaddress is None: - self.intf = socket.gethostbyname(socket.gethostname()) - else: - self.intf = bindaddress - self.group = ('', _MDNS_PORT) - self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) - except: - # SO_REUSEADDR should be equivalent to SO_REUSEPORT for - # multicast UDP sockets (p 731, "TCP/IP Illustrated, - # Volume 2"), but some BSD-derived systems require - # SO_REUSEPORT to be specified explicity. Also, not all - # versions of Python have SO_REUSEPORT available. So - # if you're on a BSD-based system, and haven't upgraded - # to Python 2.3 yet, you may find this library doesn't - # work as expected. - # - pass - self.socket.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_TTL, 255) - self.socket.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_LOOP, 1) - try: - self.socket.bind(self.group) - except: - # Some versions of linux raise an exception even though - # the SO_REUSE* options have been set, so ignore it - # - pass - self.socket.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(self.intf) + socket.inet_aton('0.0.0.0')) - self.socket.setsockopt(socket.SOL_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(_MDNS_ADDR) + socket.inet_aton('0.0.0.0')) - - self.listeners = [] - self.browsers = [] - self.services = {} - - self.cache = DNSCache() - - self.condition = threading.Condition() - - self.engine = Engine(self) - self.listener = Listener(self) - self.reaper = Reaper(self) - - def isLoopback(self): - return self.intf.startswith("127.0.0.1") - - def isLinklocal(self): - return self.intf.startswith("169.254.") - - def wait(self, timeout): - """Calling thread waits for a given number of milliseconds or - until notified.""" - self.condition.acquire() - self.condition.wait(timeout/1000) - self.condition.release() - - def notifyAll(self): - """Notifies all waiting threads""" - self.condition.acquire() - self.condition.notifyAll() - self.condition.release() - - def getServiceInfo(self, type, name, timeout=3000): - """Returns network's service information for a particular - name and type, or None if no service matches by the timeout, - which defaults to 3 seconds.""" - info = ServiceInfo(type, name) - if info.request(self, timeout): - return info - return None - - def addServiceListener(self, type, listener): - """Adds a listener for a particular service type. This object - will then have its updateRecord method called when information - arrives for that type.""" - self.removeServiceListener(listener) - self.browsers.append(ServiceBrowser(self, type, listener)) - - def removeServiceListener(self, listener): - """Removes a listener from the set that is currently listening.""" - for browser in self.browsers: - if browser.listener == listener: - browser.cancel() - del(browser) - - def registerService(self, info, ttl=_DNS_TTL): - """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.checkService(info) - self.services[info.name.lower()] = info - now = currentTimeMillis() - nextTime = now - i = 0 - while i < 3: - if now < nextTime: - self.wait(nextTime - now) - now = currentTimeMillis() - continue - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.addAnswerAtTime(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, ttl, info.name), 0) - out.addAnswerAtTime(DNSService(info.name, _TYPE_SRV, _CLASS_IN, ttl, info.priority, info.weight, info.port, info.server), 0) - out.addAnswerAtTime(DNSText(info.name, _TYPE_TXT, _CLASS_IN, ttl, info.text), 0) - if info.address: - out.addAnswerAtTime(DNSAddress(info.server, _TYPE_A, _CLASS_IN, ttl, info.address), 0) - self.send(out) - i += 1 - nextTime += _REGISTER_TIME - - def unregisterService(self, info): - """Unregister a service.""" - try: - del(self.services[info.name.lower()]) - except: - pass - now = currentTimeMillis() - nextTime = now - i = 0 - while i < 3: - if now < nextTime: - self.wait(nextTime - now) - now = currentTimeMillis() - continue - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.addAnswerAtTime(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) - out.addAnswerAtTime(DNSService(info.name, _TYPE_SRV, _CLASS_IN, 0, info.priority, info.weight, info.port, info.name), 0) - out.addAnswerAtTime(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) - if info.address: - out.addAnswerAtTime(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0) - self.send(out) - i += 1 - nextTime += _UNREGISTER_TIME - - def unregisterAllServices(self): - """Unregister all registered services.""" - if len(self.services) > 0: - now = currentTimeMillis() - nextTime = now - i = 0 - while i < 3: - if now < nextTime: - self.wait(nextTime - now) - now = currentTimeMillis() - continue - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - for info in self.services.values(): - out.addAnswerAtTime(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) - out.addAnswerAtTime(DNSService(info.name, _TYPE_SRV, _CLASS_IN, 0, info.priority, info.weight, info.port, info.server), 0) - out.addAnswerAtTime(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) - if info.address: - out.addAnswerAtTime(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0) - self.send(out) - i += 1 - nextTime += _UNREGISTER_TIME - - def checkService(self, info): - """Checks the network for a unique service name, modifying the - ServiceInfo passed in if it is not unique.""" - now = currentTimeMillis() - nextTime = now - i = 0 - while i < 3: - for record in self.cache.entriesWithName(info.type): - if record.type == _TYPE_PTR and not record.isExpired(now) and record.alias == info.name: - if (info.name.find('.') < 0): - info.name = info.name + ".[" + info.address + ":" + info.port + "]." + info.type - self.checkService(info) - return - raise NonUniqueNameException - if now < nextTime: - self.wait(nextTime - now) - now = currentTimeMillis() - continue - out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) - self.debug = out - out.addQuestion(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) - out.addAuthorativeAnswer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, _DNS_TTL, info.name)) - self.send(out) - i += 1 - nextTime += _CHECK_TIME - - def addListener(self, listener, question): - """Adds a listener for a given question. The listener will have - its updateRecord method called when information is available to - answer the question.""" - now = currentTimeMillis() - self.listeners.append(listener) - if question is not None: - for record in self.cache.entriesWithName(question.name): - if question.answeredBy(record) and not record.isExpired(now): - listener.updateRecord(self, now, record) - self.notifyAll() - - def removeListener(self, listener): - """Removes a listener.""" - try: - self.listeners.remove(listener) - self.notifyAll() - except: - pass - - def updateRecord(self, now, rec): - """Used to notify listeners of new information that has updated - a record.""" - for listener in self.listeners: - listener.updateRecord(self, now, rec) - self.notifyAll() - - def handleResponse(self, msg): - """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified.""" - now = currentTimeMillis() - for record in msg.answers: - expired = record.isExpired(now) - if record in self.cache.entries(): - if expired: - self.cache.remove(record) - else: - entry = self.cache.get(record) - if entry is not None: - entry.resetTTL(record) - record = entry - else: - self.cache.add(record) - - self.updateRecord(now, record) - - def handleQuery(self, msg, addr, port): - """Deal with incoming query packets. Provides a response if - possible.""" - out = None - - # Support unicast client responses - # - if port != _MDNS_PORT: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, 0) - for question in msg.questions: - out.addQuestion(question) - - for question in msg.questions: - if question.type == _TYPE_PTR: - for service in self.services.values(): - if question.name == service.type: - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.addAnswer(msg, DNSPointer(service.type, _TYPE_PTR, _CLASS_IN, _DNS_TTL, service.name)) - else: - try: - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - - # Answer A record queries for any service addresses we know - if question.type == _TYPE_A or question.type == _TYPE_ANY: - for service in self.services.values(): - if service.server == question.name.lower(): - out.addAnswer(msg, DNSAddress(question.name, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.address)) - - service = self.services.get(question.name.lower(), None) - if not service: continue - - if question.type == _TYPE_SRV or question.type == _TYPE_ANY: - out.addAnswer(msg, DNSService(question.name, _TYPE_SRV, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.priority, service.weight, service.port, service.server)) - if question.type == _TYPE_TXT or question.type == _TYPE_ANY: - out.addAnswer(msg, DNSText(question.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.text)) - if question.type == _TYPE_SRV: - out.addAdditionalAnswer(DNSAddress(service.server, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.address)) - except: - traceback.print_exc() - - if out is not None and out.answers: - out.id = msg.id - self.send(out, addr, port) - - def send(self, out, addr = _MDNS_ADDR, port = _MDNS_PORT): - """Sends an outgoing packet.""" - # This is a quick test to see if we can parse the packets we generate - #temp = DNSIncoming(out.packet()) - try: - bytes_sent = self.socket.sendto(out.packet(), 0, (addr, port)) - except: - # Ignore this, it may be a temporary loss of network connection - pass - - def close(self): - """Ends the background threads, and prevent this instance from - servicing further queries.""" - if globals()['_GLOBAL_DONE'] == 0: - globals()['_GLOBAL_DONE'] = 1 - self.notifyAll() - self.engine.notify() - self.unregisterAllServices() - self.socket.setsockopt(socket.SOL_IP, socket.IP_DROP_MEMBERSHIP, socket.inet_aton(_MDNS_ADDR) + socket.inet_aton('0.0.0.0')) - self.socket.close() - -# Test a few module features, including service registration, service -# query (for Zoe), and service unregistration. - -if __name__ == '__main__': - print "Multicast DNS Service Discovery for Python, version", __version__ - r = Zeroconf() - print "1. Testing registration of a service..." - desc = {'version':'0.10','a':'test value', 'b':'another value'} - info = ServiceInfo("_http._tcp.local.", "My Service Name._http._tcp.local.", socket.inet_aton("127.0.0.1"), 1234, 0, 0, desc) - print " Registering service..." - r.registerService(info) - print " Registration done." - print "2. Testing query of service information..." - print " Getting ZOE service:", str(r.getServiceInfo("_http._tcp.local.", "ZOE._http._tcp.local.")) - print " Query done." - print "3. Testing query of own service..." - print " Getting self:", str(r.getServiceInfo("_http._tcp.local.", "My Service Name._http._tcp.local.")) - print " Query done." - print "4. Testing unregister of service information..." - r.unregisterService(info) - print " Unregister done." - r.close() diff --git a/samplecode/Browser.py b/samplecode/Browser.py new file mode 100755 index 00000000..c5ced322 --- /dev/null +++ b/samplecode/Browser.py @@ -0,0 +1,48 @@ +from zeroconf.mdns import * +import socket + +class MyListener(object): + def __init__(self): + self.r = Zeroconf('') + pass + + def removeService(self, zeroconf, type_, name): + print "Service", name, "removed" + + def addService(self, zeroconf, type_, name): + print "Service", name, "added" + print "Type is", type_ + info = None + retries = 0 + while not info and retries < 10: + info = self.r.getServiceInfo(type_, name) + if not info: + print " (timeout)" + retries += 1 + print "Address is", str(socket.inet_ntoa(info.getAddress())) + print "Port is", info.getPort() + print "Weight is", info.getWeight() + print "Priority is", info.getPriority() + print "Server is", info.getServer() + print "Text is", repr(info.getText()) + print "Properties are", info.getProperties() + +if __name__ == '__main__': + import logging, sys + logging.basicConfig(level=logging.WARNING) + print "Multicast DNS Service Discovery for Python Browser test" + if sys.argv[1:]: + type_ = sys.argv[1] + else: + type_ = "_http._tcp.local." + r = Zeroconf() + try: + print "1. Testing browsing for a service (ctrl-c to stop)..." + try: + listener = MyListener() + browser = ServiceBrowser(r, type_, listener) + raw_input( 'Press to exit' ) + except KeyboardInterrupt, err: + print 'Exiting' + finally: + r.close() diff --git a/samplecode/nameprobe.py b/samplecode/nameprobe.py new file mode 100644 index 00000000..30b04be6 --- /dev/null +++ b/samplecode/nameprobe.py @@ -0,0 +1,38 @@ +#! /usr/bin/env python +from zeroconf import mdns, mcastsocket, dns + +fake_type = '_test-server.local.' + +def main( base_name='coolserver.local.'): + z = mdns.Zeroconf( '0.0.0.0' ) + try: + name = '%s.%s'%( base_name.split('.')[0], fake_type ) + s = dns.ServiceInfo( + fake_type, + name, + server = base_name, + address = '127.0.0.1', + port = 8080, + properties = {}, + ) + z.registerService( s ) + name = z.probeName( 'coolserver.local.' ) + z.unregisterService( s ) + print 'Negotiated name:', name + s.server = name + z.checkService( s ) + z.registerService( s ) + raw_input( 'Press to release name > ' ) + finally: + z.close() + +if __name__ == "__main__": + import logging, sys + logging.basicConfig( + #level = logging.DEBUG + ) + if sys.argv[1:]: + name = sys.argv[1] + else: + name = 'coolserver.local.' + main(name) diff --git a/samplecode/testmdnssd.py b/samplecode/testmdnssd.py new file mode 100755 index 00000000..e882a21b --- /dev/null +++ b/samplecode/testmdnssd.py @@ -0,0 +1,38 @@ +#! /usr/bin/env python +import logging,socket,sys,os +from zeroconf import mdns as Zeroconf + +# Test a few module features, including service registration, service +# query (for Zoe), and service unregistration. + +def main(ip=None): + print "Multicast DNS Service Discovery for Python, version", Zeroconf.__version__ + r = Zeroconf.Zeroconf(ip or '') + host_ip = socket.gethostbyname( socket.gethostname()) + try: + print "1. Testing registration of a service..." + desc = {'version':'0.10','a':'test value', 'b':'another value'} + info = Zeroconf.ServiceInfo( + "_http._tcp.local.", "My Service Name._http._tcp.local.", + socket.inet_aton(host_ip), 1234, 0, 0, desc + ) + print " Registering service..." + r.registerService(info) + print " Registration done." + print "2. Testing query of service information..." + print " Getting ZOE service:", str(r.getServiceInfo("_http._tcp.local.", "ZOE._http._tcp.local.")) + print " Query done." + print "3. Testing query of own service..." + my_service = r.getServiceInfo("_http._tcp.local.", "My Service Name._http._tcp.local.") + print " Getting self:", str(my_service) + print " Query done." + print "4. Testing unregister of service information..." + r.unregisterService(info) + print " Unregister done." + finally: + r.close() + +if __name__ == '__main__': + logging.basicConfig( level = logging.INFO ) + usage = 'testmdnssd.py [ip.address]' + sys.exit( main(*sys.argv[1:]) ) diff --git a/samplecode/testmulticast.py b/samplecode/testmulticast.py new file mode 100755 index 00000000..2dc980f2 --- /dev/null +++ b/samplecode/testmulticast.py @@ -0,0 +1,39 @@ +#! /usr/bin/env python +"""This script simply tests that the multicast setup works on your machine + +We create socket that listens on the Zeroconf mDNS port/address and then +join the mDNS multicast group and send a (malformed) message to the group, +our socket should receive that packet (because we have enabled multicast +loopback on the socket). +""" +import socket,os,sys,select,logging +from zeroconf import dns,mcastsocket,mdns + +def main(ip): + """Create a multicast socket, send a message, check it comes back""" + sock = mcastsocket.create_socket( (ip,dns._MDNS_PORT), loop=True ) + mcastsocket.join_group( sock, dns._MDNS_ADDR ) + try: + payload = 'hello world' + for i in range( 5 ): + sock.sendto( payload, 0, (dns._MDNS_ADDR, dns._MDNS_PORT)) + print 'Waiting for looped message receipt' + rs,wr,xs = select.select( [sock],[],[], 1.0 ) + data,(addr,port) = sock.recvfrom( 200 ) + if data == payload: + print 'Success: looped message received from address %s port %s'%( + addr,port, + ) + return 0 + print 'Failure: Looped message not received' + return 1 + finally: + mcastsocket.leave_group( sock, dns._MDNS_ADDR ) + +if __name__ == "__main__": + logging.basicConfig( level = logging.DEBUG ) + usage = 'testmulticast.py ip.address' + if not sys.argv[1:]: + print usage + sys.exit( 1 ) + sys.exit( main(*sys.argv[1:]) ) diff --git a/samplecode/testupnpigd.py b/samplecode/testupnpigd.py new file mode 100755 index 00000000..8c8da681 --- /dev/null +++ b/samplecode/testupnpigd.py @@ -0,0 +1,81 @@ +#! /usr/bin/env python +"""Trivial script to handle IGD port-opening... +""" +import socket,os,sys,select,logging +from zeroconf import mcastsocket +try: + from lxml import etree +except ImportError: + try: + # Python 2.5 + import xml.etree.cElementTree as etree + except ImportError: + try: + # Python 2.5 + import xml.etree.ElementTree as etree + except ImportError: + try: + # normal cElementTree install + import cElementTree as etree + except ImportError: + import elementtree.ElementTree as etree + +GROUP = '239.255.255.250' +PORT = 1900 + +query = """M-SEARCH * HTTP/1.1 +HOST: %(ip)s:%(port)s +MAN: ssdp:discover +MX: 10 +ST: ssdp:all""" +query = """M-SEARCH * HTTP/1.1 +HOST: %(ip)s:%(port)s +MAN: ssdp:discover +MX: 10 +ST: upnp:rootdevice""" + +def describe_device( record, indent = '' ): + print 'Found: ', record.find( 'friendlyName' ).text + for service in record.find( 'serviceList' ): + print indent, 'Service:', service.find( 'serviceType' ).text + if record.find( 'deviceList' ): + for device in record.find( 'deviceList' ): + describe_device( device, indent + ' ' ) + + +def parse( result ): + root = etree.fromstring( result ) + describe_device( root.find( 'device' ) ) + +def handle( sock, data, address ): + """Handle incoming message about service""" + print 'received from %s: '%(address,) + print data + +# :schemas-upnp-org:device:InternetGatewayDevice:1 + +def main(ip): + """Create a multicast socket, send a message, check it comes back""" + port = PORT + sock = mcastsocket.create_socket( (ip,port), loop=False ) + mcastsocket.join_group( sock, GROUP ) + try: + payload = query % locals() + while True: + sock.sendto( payload, 0, (GROUP,PORT)) + print 'Waiting for responses' + rs,wr,xs = select.select( [sock],[],[], 20.0 ) + if rs: + data, addr = sock.recvfrom( 2000 ) + handle( sock, data, addr ) + return 1 + finally: + mcastsocket.leave_group( sock, GROUP ) + +if __name__ == "__main__": + logging.basicConfig( level = logging.DEBUG ) + usage = 'testupnpigd.py ip.address' + if not sys.argv[1:]: + print usage + sys.exit( 1 ) + sys.exit( main(*sys.argv[1:]) ) diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..be6351d0 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +#! /usr/bin/env python +"""Install Zeroconf.py using distutils""" +try: + from setuptools import setup +except ImportError, err: + from distutils.core import setup +import os +info = {} +keys = [('__author__','author'),('__email__','author_email'),('__version__','version')] +for line in open( os.path.join('zeroconf','__init__.py') ): + for key,inf in keys: + if line.startswith( key ): + info[inf] = line.strip().split('=')[1].strip().strip('"').strip("'") + keys.remove( (key,inf)) + if not keys: + break +if __name__ == "__main__": + setup( + name='pyzeroconf', + description='Python Zeroconf (mDNS) Library', + url='http://digitaltorque.ca', + packages=['zeroconf'], + #scripts=['Browser.py'], + classifiers=[ + 'Development Status :: Production', + 'License :: OSI Approved :: LGPL2', + 'Topic :: Networking', + 'Intended Audience :: Developers', + 'Operating System :: Any', + 'Environment :: Console', + ], + **info + ) diff --git a/ZeroconfTest.py b/test/ZeroconfTest.py similarity index 78% rename from ZeroconfTest.py rename to test/ZeroconfTest.py index 4a435bd9..b95de148 100755 --- a/ZeroconfTest.py +++ b/test/ZeroconfTest.py @@ -1,165 +1,181 @@ -""" Multicast DNS Service Discovery for Python, v0.12 - Copyright (C) 2003, Paul Scott-Murphy - - This module provides a unit test suite for the Multicast DNS - Service Discovery for Python module. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -""" - -__author__ = "Paul Scott-Murphy" -__email__ = "paul at scott dash murphy dot com" -__version__ = "0.12" - -import Zeroconf as r -import unittest - - -class PacketGeneration(unittest.TestCase): - - def testParseOwnPacketSimple(self): - generated = r.DNSOutgoing(0) - parsed = r.DNSIncoming(generated.packet()) - - def testParseOwnPacketSimpleUnicast(self): - generated = r.DNSOutgoing(0, 0) - parsed = r.DNSIncoming(generated.packet()) - - def testParseOwnPacketFlags(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - parsed = r.DNSIncoming(generated.packet()) - - def testParseOwnPacketQuestion(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - generated.addQuestion(r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN)) - parsed = r.DNSIncoming(generated.packet()) - - def testMatchQuestion(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - generated.addQuestion(question) - parsed = r.DNSIncoming(generated.packet()) - self.assertEqual(len(generated.questions), 1) - self.assertEqual(len(generated.questions), len(parsed.questions)) - self.assertEqual(question, parsed.questions[0]) - - -class PacketForm(unittest.TestCase): - - def testTransactionID(self): - """ID must be zero in a DNS-SD packet""" - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - bytes = generated.packet() - id = ord(bytes[0]) << 8 | ord(bytes[1]) - self.assertEqual(id, 0) - - def testQueryHeaderBits(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - bytes = generated.packet() - flags = ord(bytes[2]) << 8 | ord(bytes[3]) - self.assertEqual(flags, 0x0) - - def testResponseHeaderBits(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - bytes = generated.packet() - flags = ord(bytes[2]) << 8 | ord(bytes[3]) - self.assertEqual(flags, 0x8000) - - def testNumbers(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - bytes = generated.packet() - numQuestions = ord(bytes[4]) << 8 | ord(bytes[5]) - numAnswers = ord(bytes[6]) << 8 | ord(bytes[7]) - numAuthorities = ord(bytes[8]) << 8 | ord(bytes[9]) - numAddtionals = ord(bytes[10]) << 8 | ord(bytes[11]) - self.assertEqual(numQuestions, 0) - self.assertEqual(numAnswers, 0) - self.assertEqual(numAuthorities, 0) - self.assertEqual(numAddtionals, 0) - - def testNumbersQuestions(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - for i in range(0, 10): - generated.addQuestion(question) - bytes = generated.packet() - numQuestions = ord(bytes[4]) << 8 | ord(bytes[5]) - numAnswers = ord(bytes[6]) << 8 | ord(bytes[7]) - numAuthorities = ord(bytes[8]) << 8 | ord(bytes[9]) - numAddtionals = ord(bytes[10]) << 8 | ord(bytes[11]) - self.assertEqual(numQuestions, 10) - self.assertEqual(numAnswers, 0) - self.assertEqual(numAuthorities, 0) - self.assertEqual(numAddtionals, 0) - - def testNumbersAnswers(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - for i in range(0, 10): - generated.addQuestion(question) - bytes = generated.packet() - numQuestions = ord(bytes[4]) << 8 | ord(bytes[5]) - numAnswers = ord(bytes[6]) << 8 | ord(bytes[7]) - numAuthorities = ord(bytes[8]) << 8 | ord(bytes[9]) - numAddtionals = ord(bytes[10]) << 8 | ord(bytes[11]) - self.assertEqual(numQuestions, 10) - self.assertEqual(numAnswers, 0) - self.assertEqual(numAuthorities, 0) - self.assertEqual(numAddtionals, 0) - - -class Names(unittest.TestCase): - - def testNonLocalName(self): - self.assertRaises(r.NonLocalNameException, r.DNSQuestion, "this.wont.work.com.", r._TYPE_SRV, r._CLASS_IN) - - def testLongName(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion("this.is.a.very.long.name.with.lots.of.parts.in.it.local.", r._TYPE_SRV, r._CLASS_IN) - generated.addQuestion(question) - parsed = r.DNSIncoming(generated.packet()) - - def testExceedinglyLongName(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - name = "%slocal." % ("part." * 1000) - question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) - generated.addQuestion(question) - parsed = r.DNSIncoming(generated.packet()) - - def testExceedinglyLongNamePart(self): - name = "%s.local." % ("a" * 1000) - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) - generated.addQuestion(question) - self.assertRaises(r.NamePartTooLongException, generated.packet) - - def testSameName(self): - name = "paired.local." - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) - generated.addQuestion(question) - generated.addQuestion(question) - parsed = r.DNSIncoming(generated.packet()) - - -class Framework(unittest.TestCase): - - def testLaunchAndClose(self): - rv = r.Zeroconf() - rv.close() - -if __name__ == '__main__': - unittest.main() +""" Multicast DNS Service Discovery for Python, v0.12 + Copyright (C) 2003, Paul Scott-Murphy + + This module provides a unit test suite for the Multicast DNS + Service Discovery for Python module. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" + +__author__ = "Paul Scott-Murphy" +__email__ = "paul at scott dash murphy dot com" +__version__ = "0.12" + +from zeroconf import dns as r +from zeroconf import mdns +import unittest,socket + + +class PacketGeneration(unittest.TestCase): + + def testParseOwnPacketSimple(self): + generated = r.DNSOutgoing(0) + parsed = r.DNSIncoming(generated.packet()) + + def testParseOwnPacketSimpleUnicast(self): + generated = r.DNSOutgoing(0, 0) + parsed = r.DNSIncoming(generated.packet()) + + def testParseOwnPacketFlags(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + parsed = r.DNSIncoming(generated.packet()) + + def testParseOwnPacketQuestion(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + generated.addQuestion(r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN)) + parsed = r.DNSIncoming(generated.packet()) + + def testMatchQuestion(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + generated.addQuestion(question) + parsed = r.DNSIncoming(generated.packet()) + self.assertEqual(len(generated.questions), 1) + self.assertEqual(len(generated.questions), len(parsed.questions)) + self.assertEqual(question, parsed.questions[0]) + + +class PacketForm(unittest.TestCase): + + def testTransactionID(self): + """ID must be zero in a DNS-SD packet""" + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + bytes = generated.packet() + id = ord(bytes[0]) << 8 | ord(bytes[1]) + self.assertEqual(id, 0) + + def testQueryHeaderBits(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + bytes = generated.packet() + flags = ord(bytes[2]) << 8 | ord(bytes[3]) + self.assertEqual(flags, 0x0) + + def testResponseHeaderBits(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + bytes = generated.packet() + flags = ord(bytes[2]) << 8 | ord(bytes[3]) + self.assertEqual(flags, 0x8000) + + def testNumbers(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + bytes = generated.packet() + numQuestions = ord(bytes[4]) << 8 | ord(bytes[5]) + numAnswers = ord(bytes[6]) << 8 | ord(bytes[7]) + numAuthorities = ord(bytes[8]) << 8 | ord(bytes[9]) + numAddtionals = ord(bytes[10]) << 8 | ord(bytes[11]) + self.assertEqual(numQuestions, 0) + self.assertEqual(numAnswers, 0) + self.assertEqual(numAuthorities, 0) + self.assertEqual(numAddtionals, 0) + + def testNumbersQuestions(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + for i in range(0, 10): + generated.addQuestion(question) + bytes = generated.packet() + numQuestions = ord(bytes[4]) << 8 | ord(bytes[5]) + numAnswers = ord(bytes[6]) << 8 | ord(bytes[7]) + numAuthorities = ord(bytes[8]) << 8 | ord(bytes[9]) + numAddtionals = ord(bytes[10]) << 8 | ord(bytes[11]) + self.assertEqual(numQuestions, 10) + self.assertEqual(numAnswers, 0) + self.assertEqual(numAuthorities, 0) + self.assertEqual(numAddtionals, 0) + + def testNumbersAnswers(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + for i in range(0, 10): + generated.addQuestion(question) + bytes = generated.packet() + numQuestions = ord(bytes[4]) << 8 | ord(bytes[5]) + numAnswers = ord(bytes[6]) << 8 | ord(bytes[7]) + numAuthorities = ord(bytes[8]) << 8 | ord(bytes[9]) + numAddtionals = ord(bytes[10]) << 8 | ord(bytes[11]) + self.assertEqual(numQuestions, 10) + self.assertEqual(numAnswers, 0) + self.assertEqual(numAuthorities, 0) + self.assertEqual(numAddtionals, 0) + + +class Names(unittest.TestCase): + + def testNonLocalName(self): + self.assertRaises(r.NonLocalNameException, r.DNSQuestion, "this.wont.work.com.", r._TYPE_SRV, r._CLASS_IN) + + def testLongName(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + question = r.DNSQuestion("this.is.a.very.long.name.with.lots.of.parts.in.it.local.", r._TYPE_SRV, r._CLASS_IN) + generated.addQuestion(question) + parsed = r.DNSIncoming(generated.packet()) + + def testExceedinglyLongName(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + name = "%slocal." % ("part." * 1000) + question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + generated.addQuestion(question) + parsed = r.DNSIncoming(generated.packet()) + + def testExceedinglyLongNamePart(self): + name = "%s.local." % ("a" * 1000) + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + generated.addQuestion(question) + self.assertRaises(r.NamePartTooLongException, generated.packet) + + def testSameName(self): + name = "paired.local." + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + generated.addQuestion(question) + generated.addQuestion(question) + parsed = r.DNSIncoming(generated.packet()) + + def testServiceDiscoverMessage(self): + info = mdns.ServiceInfo( + '_test._tcp.local.', + 'blue._test._tcp.local.', + socket.inet_aton('127.0.0.1'), + 80, + 0, + 0, + {}, + server = 'myhost.local', + ) + out = mdns.Zeroconf.serviceAnnouncement( info ) + temp = r.DNSIncoming(out.packet()) + +class Framework(unittest.TestCase): + + def testLaunchAndClose(self): + rv = mdns.Zeroconf() + rv.close() + +if __name__ == '__main__': + import logging + logging.basicConfig( level=logging.DEBUG ) + unittest.main() diff --git a/ZeroconfTest2.py b/test/ZeroconfTest2.py similarity index 95% rename from ZeroconfTest2.py rename to test/ZeroconfTest2.py index 2071e3d6..7f760e1b 100755 --- a/ZeroconfTest2.py +++ b/test/ZeroconfTest2.py @@ -24,7 +24,7 @@ __email__ = "paul at scott dash murphy dot com" __version__ = "0.12" -from Zeroconf import * +from zeroconf.mdns import * import socket desc = {'path':'/~paulsm/'} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py new file mode 100644 index 00000000..fd8227f2 --- /dev/null +++ b/zeroconf/__init__.py @@ -0,0 +1,92 @@ +""" Multicast DNS Service Discovery for Python, v0.12 + Copyright (C) 2003, Paul Scott-Murphy + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. It has been tested against the JRendezvous + implementation from StrangeBerry, + and against the mDNSResponder from Mac OS X 10.3.8. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +"""0.12.3 update - fix name matching being case-sensitive for certain queries""" + +"""0.12.1 update - support for certain broken clients (e.g. aastra phones), + partial support for negotiating host-names (probe operation).""" + +"""0.12 update - allow selection of binding interface + typo fix - Thanks A. M. Kuchling + removed all use of word 'Rendezvous' - this is an API change""" + +"""0.11 update - correction to comments for addListener method + support for new record types seen from OS X + - IPv6 address + - hostinfo + ignore unknown DNS record types + fixes to name decoding + works alongside other processes using port 5353 (e.g. on Mac OS X) + tested against Mac OS X 10.3.2's mDNSResponder + corrections to removal of list entries for service browser""" + +"""0.10 update - Jonathon Paisley contributed these corrections: + always multicast replies, even when query is unicast + correct a pointer encoding problem + can now write records in any order + traceback shown on failure + better TXT record parsing + server is now separate from name + can cancel a service browser + + modified some unit tests to accommodate these changes""" + +"""0.09 update - remove all records on service unregistration + fix DOS security problem with readName""" + +"""0.08 update - changed licensing to LGPL""" + +"""0.07 update - faster shutdown on engine + pointer encoding of outgoing names + ServiceBrowser now works + new unit tests""" + +"""0.06 update - small improvements with unit tests + added defined exception types + new style objects + fixed hostname/interface problem + fixed socket timeout problem + fixed addServiceListener() typo bug + using select() for socket reads + tested on Debian unstable with Python 2.2.2""" + +"""0.05 update - ensure case insensitivty on domain names + support for unicast DNS queries""" + +"""0.04 update - added some unit tests + added __ne__ adjuncts where required + ensure names end in '.local.' + timeout on receiving socket for clean shutdown""" + +__author__ = "Paul Scott-Murphy" +__email__ = "paul at scott dash murphy dot com" +__version__ = "0.12.3" + +#from Zeroconf import dns +#from Zeroconf import mcastsocket +#from Zeroconf import mdns +# +#ServiceInfo = dns.ServiceInfo +#ServiceBrowser = mdns.ServiceBrowser +#Zeroconf = mdns.Zeroconf +#__all__ = ["Zeroconf", "ServiceInfo", "ServiceBrowser"] diff --git a/zeroconf/dns.py b/zeroconf/dns.py new file mode 100644 index 00000000..6f3bb126 --- /dev/null +++ b/zeroconf/dns.py @@ -0,0 +1,1122 @@ +""" Multicast DNS Service Discovery for Python, v0.12 + Copyright (C) 2003, Paul Scott-Murphy + + This module provides a DNS/mDNS encoding/decoding facility which + is used by the package to communicate with mDNS servers/clients. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" +import string +import time +import struct +import socket +import traceback +import logging +log = logging.getLogger(__name__) +__all__ = [ + 'ServiceInfo', + 'DNSAddress', 'DNSCache', 'DNSEntry', 'DNSHinfo', 'DNSIncoming', + 'DNSOutgoing', 'DNSPointer', 'DNSQuestion', 'DNSRecord', 'DNSService', + 'DNSText', + 'AbstractMethodException', 'BadTypeInNameException', + 'NamePartTooLongException', 'NonLocalNameException', 'NonUniqueNameException', + 'currentTimeMillis', +] + +# Some timing constants + +_UNREGISTER_TIME = 125 +_CHECK_TIME = 175 +_REGISTER_TIME = 225 +_LISTENER_TIME = 200 +_BROWSER_TIME = 500 + +_PROBE_TIME = 250 +_PROBE_THROTTLED_TIME = 5000 +_PROBE_TIMEOUT = 800 + +# Some DNS constants + +_MDNS_ADDR = '224.0.0.251' +_MDNS_PORT = 5353; +_DNS_PORT = 53; +_DNS_TTL = 60 * 60; # one hour default TTL + +_MAX_MSG_TYPICAL = 1460 # unused +_MAX_MSG_ABSOLUTE = 8972 + +_FLAGS_QR_MASK = 0x8000 # query response mask +_FLAGS_QR_QUERY = 0x0000 # query +_FLAGS_QR_RESPONSE = 0x8000 # response + +_FLAGS_AA = 0x0400 # Authorative answer +_FLAGS_TC = 0x0200 # Truncated +_FLAGS_RD = 0x0100 # Recursion desired +_FLAGS_RA = 0x8000 # Recursion available + +_FLAGS_Z = 0x0040 # Zero +_FLAGS_AD = 0x0020 # Authentic data +_FLAGS_CD = 0x0010 # Checking disabled + +_CLASS_IN = 1 +_CLASS_CS = 2 +_CLASS_CH = 3 +_CLASS_HS = 4 +_CLASS_NONE = 254 +_CLASS_ANY = 255 +_CLASS_MASK = 0x7FFF +_CLASS_UNIQUE = 0x8000 + +_TYPE_A = 1 +_TYPE_NS = 2 +_TYPE_MD = 3 +_TYPE_MF = 4 +_TYPE_CNAME = 5 +_TYPE_SOA = 6 +_TYPE_MB = 7 +_TYPE_MG = 8 +_TYPE_MR = 9 +_TYPE_NULL = 10 +_TYPE_WKS = 11 +_TYPE_PTR = 12 +_TYPE_HINFO = 13 +_TYPE_MINFO = 14 +_TYPE_MX = 15 +_TYPE_TXT = 16 +_TYPE_AAAA = 28 +_TYPE_SRV = 33 +_TYPE_NSEC = 47 +_TYPE_ANY = 255 + +# Mapping constants to names + +_CLASSES = { _CLASS_IN : "in", + _CLASS_CS : "cs", + _CLASS_CH : "ch", + _CLASS_HS : "hs", + _CLASS_NONE : "none", + _CLASS_ANY : "any" } + +_TYPES = { _TYPE_A : "a", + _TYPE_NS : "ns", + _TYPE_MD : "md", + _TYPE_MF : "mf", + _TYPE_CNAME : "cname", + _TYPE_SOA : "soa", + _TYPE_MB : "mb", + _TYPE_MG : "mg", + _TYPE_MR : "mr", + _TYPE_NULL : "null", + _TYPE_WKS : "wks", + _TYPE_PTR : "ptr", + _TYPE_HINFO : "hinfo", + _TYPE_MINFO : "minfo", + _TYPE_MX : "mx", + _TYPE_TXT : "txt", + _TYPE_AAAA : "quada", + _TYPE_SRV : "srv", + _TYPE_NSEC : "nsec", + _TYPE_ANY : "any" } + +_IGNORED_TYPES = { + 0: "Ignoring Invalid DNS query type (0)", + _TYPE_NS: "Ignoring Name Server (NS) query", + _TYPE_NSEC: "Ignoring Next Secure Record (NSEC) query", + _TYPE_MF: "Ignoring Obsoleted Mail Forwarding (MF) query", +} + +# utility functions + +def currentTimeMillis(): + """Current system time in milliseconds""" + return time.time() * 1000 + +# Exceptions + +class DNSError( Exception ): + """Base class for all DNS errors""" + +class DNSNameError( DNSError ): + """Name/type related errors""" +class NonLocalNameException(DNSNameError): + pass +class NonUniqueNameException(DNSNameError): + pass +class NamePartTooLongException(DNSNameError): + pass +class BadTypeInNameException(DNSNameError): + pass + +class AbstractMethodException(DNSError): + pass + + +# implementation classes + +class DNSEntry(object): + """A DNS entry""" + + def __init__(self, name, type_, clazz): + self.key = string.lower(name) + self.name = name + self.type = type_ + self.clazz = clazz & _CLASS_MASK + self.unique = (clazz & _CLASS_UNIQUE) != 0 + + def __eq__(self, other): + """Equality test on name, type_, and class""" + if isinstance(other, DNSEntry): + return self.name == other.name and self.type == other.type and self.clazz == other.clazz + return 0 + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def getClazz(self, clazz): + """Class accessor""" + try: + return _CLASSES[clazz] + except: + return "?(%s)" % (clazz) + + def getType(self, type_): + """Type accessor""" + try: + return _TYPES[type_] + except: + return "?(%s)" % (type_) + + def toString(self, hdr, other): + """String representation with additional information""" + result = "%s[%s,%s" % (hdr, self.getType(self.type), self.getClazz(self.clazz)) + if self.unique: + result += "-unique," + else: + result += "," + result += self.name + if other is not None: + result += ",%s]" % (other) + else: + result += "]" + return result + +class DNSQuestion(DNSEntry): + """A DNS question entry""" + + def __init__(self, name, type_, clazz): + if not name.endswith(".local."): + raise NonLocalNameException( 'No .local. suffix in %r'%(name,) ) + DNSEntry.__init__(self, name, type_, clazz) + + def answeredBy(self, rec): + """Returns true if the question is answered by the record""" + return self.clazz == rec.clazz and (self.type == rec.type or self.type == _TYPE_ANY) and self.name == rec.name + + def __repr__(self): + """String representation""" + return DNSEntry.toString(self, "question", None) + __str__ = __repr__ + +class DNSRecord(DNSEntry): + """A DNS record - like a DNS entry, but has a TTL""" + + def __init__(self, name, type_, clazz, ttl): + DNSEntry.__init__(self, name, type_, clazz) + self.ttl = ttl + self.created = currentTimeMillis() + if '..' in name: + raise DNSNameError( '.. not allowed in dns names: %r'%( name )) + if name.startswith( '.' ): + raise DNSNameError( 'dns names cannot start with .: %r'%( name )) + if not type_ in _TYPES: + raise RuntimeError( type_ ) + + def __eq__(self, other): + """Tests equality as per DNSRecord""" + if isinstance(other, DNSRecord): + return DNSEntry.__eq__(self, other) + return 0 + + def suppressedBy(self, msg): + """Returns true if any answer in a message can suffice for the + information held in this record.""" + for record in msg.answers: + if self.suppressedByAnswer(record): + log.debug( 'Suppressing %s due to query record %s', self, record ) + return 1 + return 0 + + def suppressedByAnswer(self, other): + """Returns true if another record has same name, type_ and class, + and if its TTL is at least half of this record's.""" + if self == other and other.ttl > (self.ttl / 2): + return 1 + return 0 + + def getExpirationTime(self, percent): + """Returns the time at which this record will have expired + by a certain percentage.""" + return self.created + (percent * self.ttl * 10) + + def getRemainingTTL(self, now): + """Returns the remaining TTL in seconds.""" + return max(0, (self.getExpirationTime(100) - now) / 1000) + + def isExpired(self, now): + """Returns true if this record has expired.""" + return self.getExpirationTime(100) <= now + + def isStale(self, now): + """Returns true if this record is at least half way expired.""" + return self.getExpirationTime(50) <= now + + def resetTTL(self, other): + """Sets this record's TTL and created time to that of + another record.""" + self.created = other.created + self.ttl = other.ttl + + def write(self, out): + """Abstract method""" + raise AbstractMethodException( 'write' ) + + def toString(self, other): + """String representation with addtional information""" + arg = "%s/%s,%s" % (self.ttl, self.getRemainingTTL(currentTimeMillis()), other) + return DNSEntry.toString(self, "record", arg) + +class DNSAddress(DNSRecord): + """A DNS address record""" + + def __init__(self, name, type_, clazz, ttl, address): + DNSRecord.__init__(self, name, type_, clazz, ttl) + self.address = address + + def write(self, out): + """Used in constructing an outgoing packet""" + out.writeString(self.address, len(self.address)) + + def __eq__(self, other): + """Tests equality on address""" + if isinstance(other, DNSAddress): + return self.address == other.address + return 0 + + def __repr__(self): + """String representation""" + try: + return self.toString(socket.inet_ntoa(self.address)) + except: + try: + return self.toString(socket.inet_ntop(socket.AF_INET6, self.address)) + except: + return self.address + +class DNSHinfo(DNSRecord): + """A DNS host information record""" + + def __init__(self, name, type_, clazz, ttl, cpu, os): + DNSRecord.__init__(self, name, type_, clazz, ttl) + self.cpu = cpu + self.os = os + + def write(self, out): + """Used in constructing an outgoing packet""" + out.writeString(self.cpu, len(self.cpu)) + out.writeString(self.os, len(self.os)) + + def __eq__(self, other): + """Tests equality on cpu and os""" + if isinstance(other, DNSHinfo): + return self.cpu == other.cpu and self.os == other.os + return 0 + + def __repr__(self): + """String representation""" + return self.cpu + " " + self.os + +class DNSPointer(DNSRecord): + """A DNS pointer record""" + + def __init__(self, name, type_, clazz, ttl, alias): + DNSRecord.__init__(self, name, type_, clazz, ttl) + self.alias = alias + + def write(self, out): + """Used in constructing an outgoing packet""" + out.writeName(self.alias) + + def __eq__(self, other): + """Tests equality on alias""" + if isinstance(other, DNSPointer): + return self.alias == other.alias + return 0 + + def __repr__(self): + """String representation""" + return self.toString(self.alias) + +class DNSText(DNSRecord): + """A DNS text record""" + + def __init__(self, name, type_, clazz, ttl, text): + DNSRecord.__init__(self, name, type_, clazz, ttl) + self.text = text + + def write(self, out): + """Used in constructing an outgoing packet""" + out.writeString(self.text, len(self.text)) + + def __eq__(self, other): + """Tests equality on text""" + if isinstance(other, DNSText): + return self.text == other.text + return 0 + + def __repr__(self): + """String representation""" + if len(self.text) > 10: + return self.toString(self.text[:7] + "...") + else: + return self.toString(self.text) + +class DNSService(DNSRecord): + """A DNS service record""" + + def __init__(self, name, type_, clazz, ttl, priority, weight, port, server): + DNSRecord.__init__(self, name, type_, clazz, ttl) + self.priority = priority + self.weight = weight + self.port = port + self.server = server + + def write(self, out): + """Used in constructing an outgoing packet""" + out.writeShort(self.priority) + out.writeShort(self.weight) + out.writeShort(self.port) + out.writeName(self.server) + + def __eq__(self, other): + """Tests equality on priority, weight, port and server""" + if isinstance(other, DNSService): + return self.priority == other.priority and self.weight == other.weight and self.port == other.port and self.server == other.server + return 0 + + def __repr__(self): + """String representation""" + return self.toString("%s:%s" % (self.server, self.port)) + +class DNSIncoming(object): + """Object representation of an incoming DNS packet""" + + def __init__(self, data): + """Constructor from string holding bytes of packet""" + self.offset = 0 + self.data = data + self.questions = [] + self.answers = [] + self.numQuestions = 0 + self.numAnswers = 0 + self.numAuthorities = 0 + self.numAdditionals = 0 + + self.readHeader() + self.readQuestions() + self.readOthers() + + def readHeader(self): + """Reads header portion of packet""" + format = '!HHHHHH' + length = struct.calcsize(format) + info = struct.unpack(format, self.data[self.offset:self.offset+length]) + self.offset += length + + self.id = info[0] + self.flags = info[1] + self.numQuestions = info[2] + self.numAnswers = info[3] + self.numAuthorities = info[4] + self.numAdditionals = info[5] + + def readQuestions(self): + """Reads questions section of packet""" + format = '!HH' + length = struct.calcsize(format) + for i in range(0, self.numQuestions): + name = self.readName() + info = struct.unpack(format, self.data[self.offset:self.offset+length]) + self.offset += length + + question = DNSQuestion(name, info[0], info[1]) + self.questions.append(question) + + def readInt(self): + """Reads an integer from the packet""" + format = '!I' + length = struct.calcsize(format) + info = struct.unpack(format, self.data[self.offset:self.offset+length]) + self.offset += length + return info[0] + + def readCharacterString(self): + """Reads a character string from the packet""" + length = ord(self.data[self.offset]) + self.offset += 1 + return self.readString(length) + + def readString(self, len_): + """Reads a string of a given length from the packet""" + format = '!' + str(len_) + 's' + length = struct.calcsize(format) + info = struct.unpack(format, self.data[self.offset:self.offset+length]) + self.offset += length + return info[0] + + def readUnsignedShort(self): + """Reads an unsigned short from the packet""" + format = '!H' + length = struct.calcsize(format) + info = struct.unpack(format, self.data[self.offset:self.offset+length]) + self.offset += length + return info[0] + + def readOthers(self): + """Reads the answers, authorities and additionals section of the packet""" + format = '!HHiH' + length = struct.calcsize(format) + n = self.numAnswers + self.numAuthorities + self.numAdditionals + for i in range(0, n): + domain = self.readName() + info = struct.unpack(format, self.data[self.offset:self.offset+length]) + self.offset += length + + try: + rec = None + if info[0] == _TYPE_A: + rec = DNSAddress(domain, info[0], info[1], info[2], self.readString(4)) + elif info[0] == _TYPE_CNAME: + rec = DNSPointer(domain, info[0], info[1], info[2], domain) + elif info[0] == _TYPE_PTR: + rec = DNSPointer(domain, info[0], info[1], info[2], self.readName()) + elif info[0] == _TYPE_TXT: + rec = DNSText(domain, info[0], info[1], info[2], self.readString(info[3])) + elif info[0] == _TYPE_SRV: + rec = DNSService(domain, info[0], info[1], info[2], self.readUnsignedShort(), self.readUnsignedShort(), self.readUnsignedShort(), self.readName()) + elif info[0] == _TYPE_HINFO: + rec = DNSHinfo(domain, info[0], info[1], info[2], self.readCharacterString(), self.readCharacterString()) + elif info[0] == _TYPE_AAAA: + rec = DNSAddress(domain, info[0], info[1], info[2], self.readString(16)) + elif info[0] in _IGNORED_TYPES: + log.debug( "%s", _IGNORED_TYPES[info[0]]) + else: + # Try to ignore types we don't know about + # this may mean the rest of the name is + # unable to be parsed, and may show errors + # so this is left for debugging. New types + # encountered need to be parsed properly. + # + log.info( + "Unknown DNS query type: %s %r", info, self.data + ) + if rec is not None: + self.answers.append(rec) + except Exception, err: + log.debug( "Failure on record type_ %s, ignoring: %s", info[0], err ) + log.debug( "%s", traceback.format_exc()) + + def isQuery(self): + """Returns true if this is a query""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def isResponse(self): + """Returns true if this is a response""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + + def readUTF(self, offset, len_): + """Reads a UTF-8 string of a given length from the packet + + TODO: there are cases were non-utf-8 data comes through, + we need to decide how to properly handle these. + """ + return self.data[offset:offset+len_].decode('utf-8','ignore') + + def readName(self): + """Reads a domain name from the packet""" + result = '' + off = self.offset + next = -1 + first = off + + while 1: + len_ = ord(self.data[off]) + off += 1 + if len_ == 0: + break + t = len_ & 0xC0 + if t == 0x00: + result = ''.join((result, self.readUTF(off, len_) + '.')) + off += len_ + elif t == 0xC0: + if next < 0: + next = off + 1 + off = ((len_ & 0x3F) << 8) | ord(self.data[off]) + if off >= first: + raise DNSNameError( "Bad domain name (circular) at char %s", off ) + first = off + else: + raise DNSNameError( "Bad domain name (unknown encoding type %r) at %s"%( t,str(off) )) + + if next >= 0: + self.offset = next + else: + self.offset = off + + return result + + +class DNSOutgoing(object): + """Object representation of an outgoing packet""" + + def __init__(self, flags, multicast = 1): + self.finished = 0 + self.id = 0 + self.multicast = multicast + self.flags = flags + self.names = {} + self.data = [] + self.size = 12 + + self.questions = [] + self.answers = [] + self.authorities = [] + self.additionals = [] + + def addQuestion(self, record): + """Adds a question""" + self.questions.append(record) + + def addAnswer(self, inp, record): + """Adds an answer""" + if not record.suppressedBy(inp): + self.addAnswerAtTime(record, 0) + + def addAnswerAtTime(self, record, now): + """Adds an answer if if does not expire by a certain time""" + if record is not None: + if now == 0 or not record.isExpired(now): + self.answers.append((record, now)) + + def addAuthorativeAnswer(self, record): + """Adds an authoritative answer""" + self.authorities.append(record) + + def addAdditionalAnswer(self, record): + """Adds an additional answer""" + self.additionals.append(record) + + def writeByte(self, value): + """Writes a single byte to the packet""" + format = '!c' + self.data.append(struct.pack(format, chr(value))) + self.size += 1 + + def insertShort(self, index, value): + """Inserts an unsigned short in a certain position in the packet""" + format = '!H' + self.data.insert(index, struct.pack(format, value)) + self.size += 2 + + def writeShort(self, value): + """Writes an unsigned short to the packet""" + format = '!H' + self.data.append(struct.pack(format, value)) + self.size += 2 + + def writeInt(self, value): + """Writes an unsigned integer to the packet""" + format = '!I' + self.data.append(struct.pack(format, long(value))) + self.size += 4 + + def writeString(self, value, length): + """Writes a string to the packet""" + format = '!' + str(length) + 's' + self.data.append(struct.pack(format, value)) + self.size += length + + def writeUTF(self, s): + """Writes a UTF-8 string of a given length to the packet""" + utfstr = s.encode('utf-8') + length = len(utfstr) + if length > 64: + raise NamePartTooLongException( utfstr ) + self.writeByte(length) + self.writeString(utfstr, length) + + def writeName(self, name): + """Writes a domain name to the packet""" + + try: + # Find existing instance of this name in packet + # + index = self.names[name] + except KeyError: + # 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.writeUTF(part) + self.writeByte(0) + return + + # An index was found, so write a pointer to it + # + self.writeByte((index >> 8) | 0xC0) + self.writeByte(index) + + def writeQuestion(self, question): + """Writes a question to the packet""" + self.writeName(question.name) + self.writeShort(question.type) + self.writeShort(question.clazz) + + def writeRecord(self, record, now): + """Writes a record (answer, authoritative answer, additional) to + the packet""" + self.writeName(record.name) + self.writeShort(record.type) + if record.unique and self.multicast: + self.writeShort(record.clazz | _CLASS_UNIQUE) + else: + self.writeShort(record.clazz) + if now == 0: + self.writeInt(record.ttl) + else: + self.writeInt(record.getRemainingTTL(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(''.join(self.data[index:])) + self.insertShort(index, length) # Here is the short we adjusted for + + 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 = 1 + for question in self.questions: + self.writeQuestion(question) + for answer, time in self.answers: + self.writeRecord(answer, time) + for authority in self.authorities: + self.writeRecord(authority, 0) + for additional in self.additionals: + self.writeRecord(additional, 0) + + self.insertShort(0, len(self.additionals)) + self.insertShort(0, len(self.authorities)) + self.insertShort(0, len(self.answers)) + self.insertShort(0, len(self.questions)) + self.insertShort(0, self.flags) + if self.multicast: + self.insertShort(0, 0) + else: + self.insertShort(0, self.id) + return ''.join(self.data) + + +class DNSCache(object): + """A cache of DNS entries""" + + def __init__(self): + self.cache = {} + + def add(self, entry): + """Adds an entry""" + try: + list = self.cache[entry.key] + except: + list = self.cache[entry.key] = [] + list.append(entry) + + def remove(self, entry): + """Removes an entry""" + try: + list = self.cache[entry.key] + list.remove(entry) + except: + pass + + def get(self, entry): + """Gets an entry by key. Will return None if there is no + matching entry.""" + try: + list = self.cache[entry.key] + return list[list.index(entry)] + except: + return None + + def getByDetails(self, name, type_, clazz): + """Gets an entry by details. Will return None if there is + no matching entry.""" + entry = DNSEntry(name, type_, clazz) + return self.get(entry) + + def entriesWithName(self, name): + """Returns a list of entries whose key matches the name.""" + try: + return self.cache[name] + except: + return [] + + def entries(self): + """Returns a list of all entries""" + def add(x, y): return x+y + try: + return reduce(add, self.cache.values()) + except: + return [] + + def clear( self ): + """Clear our cache of entries""" + self.cache.clear() + +class ServiceInfo(object): + """Service information""" + + def __init__(self, type_, name, address=None, port=None, weight=0, priority=0, properties=None, server=None): + """Create a service description. + + type_: fully qualified service type_ name + name: fully qualified service name + address: IP address as unsigned short, network byte order + port: port that the service runs on + weight: weight of the service + priority: priority of the service + properties: dictionary of properties (or a string holding the bytes for the text field) + server: fully qualified name for service host (defaults to name)""" + + if not name.endswith(type_): + raise BadTypeInNameException( 'Name: %r does not end with type %r', name, type_ ) + if type_.startswith( '.' ): + raise DNSNameError( 'Types cannot start with the . character %r'%( type_ )) + if '..' in type_: + raise DNSNameError( 'Types cannot contain .. %r'%( type_ )) + self.type = type_ + self.name = name + self.address = address + self.port = port + self.weight = weight + self.priority = priority + if server: + self.server = server + else: + self.server = name #'.'.join([x for x in name.split('.') if not x.startswith('_')]) + self.setProperties(properties) + + def setProperties(self, properties): + """Sets properties and text of this info from a dictionary""" + if isinstance(properties, dict): + self.properties = properties + list = [] + result = '' + for key in properties: + value = properties[key] + if value is None: + suffix = ''.encode('utf-8') + elif isinstance(value, str): + suffix = value.encode('utf-8') + elif isinstance(value, int): + if value: + suffix = 'true' + else: + suffix = 'false' + else: + suffix = ''.encode('utf-8') + list.append('='.join((key, suffix))) + for item in list: + result = ''.join((result, struct.pack('!c', chr(len(item))), item)) + self.text = result + else: + self.text = properties + + def setText(self, text): + """Sets properties and text given a text field""" + self.text = text + try: + result = {} + end = len(text) + index = 0 + strs = [] + while index < end: + length = ord(text[index]) + index += 1 + strs.append(text[index:index+length]) + index += length + + for s in strs: + eindex = s.find('=') + if eindex == -1: + # No equals sign at all + key = s + value = 0 + else: + key = s[:eindex] + value = s[eindex+1:] + if value == 'true': + value = 1 + elif value == 'false' or not value: + value = 0 + + # Only update non-existent properties + if key and result.get(key) == None: + result[key] = value + + self.properties = result + except Exception, err: + log.error( "Failure composing text: %s", traceback.format_exc() ) + self.properties = None + + def getType(self): + """Type accessor""" + return self.type + + def getName(self): + """Name accessor""" + if self.type is not None and self.name.endswith("." + self.type): + return self.name[:len(self.name) - len(self.type) - 1] + return self.name + + def getAddress(self): + """Address accessor""" + return self.address + + def getPort(self): + """Port accessor""" + return self.port + + def getPriority(self): + """Pirority accessor""" + return self.priority + + def getWeight(self): + """Weight accessor""" + return self.weight + + def getProperties(self): + """Properties accessor""" + return self.properties + + def getText(self): + """Text accessor""" + return self.text + + def getServer(self): + """Server accessor""" + return self.server + + def updateRecord(self, zeroconf, now, record): + """Updates service information from a DNS record""" + if record is not None and not record.isExpired(now): + if record.type == _TYPE_A: + if record.name.lower() in (self.name.lower(),self.server.lower()): + log.debug( 'Got A record for %s', record.name ) + self.address = record.address + else: + log.debug( 'Got A record for %s, wanted %s', record.name, self.name ) + elif record.type == _TYPE_SRV: + if record.name.lower() == self.name.lower(): + log.debug( 'Got SRV record for %s', record.name ) + self.server = record.server + self.port = record.port + self.weight = record.weight + self.priority = record.priority + #self.address = None + self.updateRecord(zeroconf, now, zeroconf.cache.getByDetails(self.server, _TYPE_A, _CLASS_IN)) + + elif record.type == _TYPE_TXT: + if record.name.lower() == self.name.lower(): + log.debug( 'Got TXT record for %s', record.name ) + self.setText(record.text) + + def request(self, zeroconf, timeout): + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + now = currentTimeMillis() + delay = _LISTENER_TIME + next = now + delay + last = now + timeout + result = 0 + try: + zeroconf.addListener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) + while self.server is None or self.address is None or self.text is None: + if last <= now: + return 0 + if next <= now: + out = DNSOutgoing(_FLAGS_QR_QUERY) + out.addQuestion(DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) + out.addAnswerAtTime(zeroconf.cache.getByDetails(self.name, _TYPE_SRV, _CLASS_IN), now) + out.addQuestion(DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) + out.addAnswerAtTime(zeroconf.cache.getByDetails(self.name, _TYPE_TXT, _CLASS_IN), now) + if self.server is not None: + out.addQuestion(DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) + out.addAnswerAtTime(zeroconf.cache.getByDetails(self.server, _TYPE_A, _CLASS_IN), now) + zeroconf.send(out) + next = now + delay + delay = delay * 2 + + zeroconf.wait(min(next, last) - now) + now = currentTimeMillis() + result = 1 + finally: + zeroconf.removeListener(self) + + return result + + def __eq__(self, other): + """Tests equality of service name""" + if isinstance(other, ServiceInfo): + return other.name == self.name + return 0 + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def __repr__(self): + """String representation""" + result = "service[%s,%s:%s," % (self.name, socket.inet_ntoa(self.getAddress()), self.port) + if self.text is None: + result += "None" + else: + if len(self.text) < 20: + result += self.text + else: + result += self.text[:17] + "..." + result += "]" + return result + +class ProbeWatcher( object ): + """Watches for response to a probe (query) + + + """ + def __init__( self, query ): + """Probe for any conflict in query (DNSOutgoing)""" + assert hasattr( query, 'questions' ), query + self.query = query + self.found = False + self.records = [] + def request( self, zeroconf, timeout=_PROBE_TIMEOUT, delay=_PROBE_TIME ): + """Perform probe operations for our query""" + now = currentTimeMillis() + next = now + delay + last = now + timeout + result = 0 + try: + for question in self.query.questions: + zeroconf.addListener(self) + while not self.found: + if last <= now: + break + if next <= now: + out = DNSOutgoing(_FLAGS_QR_QUERY) + for question in self.query.questions: + out.addQuestion( question ) + out.addAnswerAtTime( + zeroconf.cache.getByDetails(question.name, question.type, question.clazz), + now + ) + zeroconf.send(out) + next = now + delay + # Note: non-standard, should here delay by delay without expanding for 3 queries *only* + delay = delay * 2 + zeroconf.wait(min(next, last) - now) + now = currentTimeMillis() + finally: + zeroconf.removeListener( self ) + return self.records + def updateRecord(self, zeroconf, now, record): + """Updates service information from a DNS record""" + if record is not None and not record.isExpired(now): + for question in self.query.questions: + if question.answeredBy( record ): + self.found = True + self.records.append( record ) + +class ServerNameWatcher( object ): + def __init__(self, name, ignore=None ): + self.name = name + self.address = None + self.ignore = ignore + if ignore: + self.ignore = [socket.inet_aton( a ) for a in ignore] + def request( self, zeroconf, timeout=_PROBE_TIMEOUT, delay=_PROBE_TIME ): + now = currentTimeMillis() + next = now + delay + last = now + timeout + result = 0 + try: + zeroconf.addListener(self, DNSQuestion( self.name.lower(), _TYPE_ANY, _CLASS_IN )) + while self.address is None: + if last <= now: + return 0 + if next <= now: + out = DNSOutgoing(_FLAGS_QR_QUERY) + out.addQuestion(DNSQuestion(self.name.lower(), _TYPE_A, _CLASS_IN)) + out.addAnswerAtTime(zeroconf.cache.getByDetails(self.name, _TYPE_A, _CLASS_IN), now) + zeroconf.send(out) + next = now + delay + delay = delay * 2 + + zeroconf.wait(min(next, last) - now) + now = currentTimeMillis() + result = 1 + finally: + zeroconf.removeListener(self) + return result + def updateRecord(self, zeroconf, now, record): + """Updates service information from a DNS record""" + if record is not None and not record.isExpired(now): + if record.name == self.name: + if ( + self.ignore and + (getattr(record,'address',None) not in self.ignore) + ) or (not self.ignore): + # something is using this name, whether for a server-name or not... + if self.address in (True,None): + if getattr( record, 'address', None ): + self.address = record.address + else: + self.address = True + else: + log.debug( + """Ignoring own-response""" + ) + diff --git a/zeroconf/mcastsocket.py b/zeroconf/mcastsocket.py new file mode 100644 index 00000000..9999373b --- /dev/null +++ b/zeroconf/mcastsocket.py @@ -0,0 +1,132 @@ +"""Multicast socket setup code + +This is refactored from the Zeroconf.py main module to allow for reuse within +multiple environments (e.g. multicast SIP configuration, multicast paging +groups and the like). + + Multicast DNS Service Discovery for Python, v0.12 + Copyright (C) 2003, Paul Scott-Murphy + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. It has been tested against the JRendezvous + implementation from StrangeBerry, + and against the mDNSResponder from Mac OS X 10.3.8. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" +import socket,logging +log = logging.getLogger( __name__ ) + +def create_socket( address, TTL=1, loop=True, reuse=True ): + """Create our multicast socket for mDNS usage + + Creates a multicast UDP socket with multicast address configured for the + ip in address[0], and bound on all interfaces with port address[1]. + Configures TTL and loop-back operation + + address -- IP address family address ('ip',port) on which to listen/broadcast, + the port is always bound to all interfaces, but the use of an ip will cause + the IP_MULTICAST_IF option to be set in order to direct messages solely to + a given port. + TTL -- multicast TTL to set on the socket + loop -- whether to reflect our sent messages to our listening port + reuse -- whether to set up socket reuse parameters before binding + + returns socket.socket instance configured as specified + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, TTL) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, int(bool(loop))) + allow_reuse( sock, reuse ) + limit_to_interface( sock, address[0] ) + try: + # Note: multicast is *not* working if we don't bind on all interfaces, most likely + # because the 224.* isn't getting mapped (routed) to the address of the interface... + # to debug that case, see if {{{ip route add 224.0.0.0/4 dev br0}}} (or whatever your + # interface is) makes the route suddenly start working... +# if address[0]: +# sock.bind( address ) +# else: + sock.bind(('',address[1])) + except Exception, err: + # Some versions of linux raise an exception even though + # the SO_REUSE* options have been set, so ignore it + log.error('Failure binding: %s', err) + return sock + +def limit_to_interface( sock, interface_ip ): + """Restrict multicast operation to the given interface/ip (instead of using routing) + + Sets the IP_MULTICAST_IF option on the socket to restrict multicast + operations to a particular interface. This is done without reference + to the system routing tables, so you do not need to set up a 224.0.0.0/4 + route on the system to receive multicast on the interface. + """ + if interface_ip: + # listen/send on a single interface... + log.debug( 'Limiting multicast to use interface of %s', interface_ip ) + sock.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_IF, + socket.inet_aton( interface_ip) # + socket.inet_aton( '0.0.0.0' ) + ) + return True + return False + +def allow_reuse( sock, reuse=True ): + """Setup reuse parameters on the given socket + + The common case where e.g. the host system has avahi or mdnsresponder + installed will mean that our mDNS or uPNP port is likely already bound. + This operation sets reuse options so that we can re-bind to the port. + + """ + if reuse: + log.debug( 'Setting address/port reuse on mcast socket' ) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError, err: + # ignore common case where SO_REUSEPORT isn't provided on Linux + if err.args[0].find('SO_REUSEPORT') > -1: + pass + else: + raise + except Exception, err: + # SO_REUSEADDR should be equivalent to SO_REUSEPORT for + # multicast UDP sockets (p 731, "TCP/IP Illustrated, + # Volume 2"), but some BSD-derived systems require + # SO_REUSEPORT to be specified explicity. Also, not all + # versions of Python have SO_REUSEPORT available. So + # if you're on a BSD-based system, and haven't upgraded + # to Python 2.3 yet, you may find this library doesn't + # work as expected. + log.debug( 'Ignoring likely spurious error on setting reuse options: %s', err ) + return True + return False + +def join_group( sock, group ): + """Add our socket to this multicast group""" + log.info( 'Joining multicast group: %s', group ) + sock.setsockopt( + socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, + socket.inet_aton(group) + socket.inet_aton('0.0.0.0') + ) +def leave_group( sock, group ): + """Remove our socket from this multicast group""" + log.info( 'Leaving multicast group: %s', group ) + sock.setsockopt( + socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, + socket.inet_aton(group) + socket.inet_aton('0.0.0.0') + ) diff --git a/zeroconf/mdns.py b/zeroconf/mdns.py new file mode 100644 index 00000000..75b587f9 --- /dev/null +++ b/zeroconf/mdns.py @@ -0,0 +1,713 @@ +""" Multicast DNS Service Discovery for Python, v0.12 + Copyright (C) 2003, Paul Scott-Murphy + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. It has been tested against the JRendezvous + implementation from StrangeBerry, + and against the mDNSResponder from Mac OS X 10.3.8. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +This is the threaded mDNS responder/query-er implementation +""" +import string +import time +import struct +import socket +import errno +import threading +import select +import traceback +import logging +log = logging.getLogger(__name__) +from zeroconf import dns,mcastsocket,__version__ + +ServiceInfo = dns.ServiceInfo +__all__ = ["Zeroconf", "ServiceInfo", "ServiceBrowser"] + +# hook for threads +globals()['_GLOBAL_DONE'] = 0 + +# Some timing constants +_UNREGISTER_TIME = 125 +_CHECK_TIME = 175 +_REGISTER_TIME = 225 +_LISTENER_TIME = 200 +_BROWSER_TIME = 500 +# Minimum time before sending duplicate message... +_MINIMUM_REPEAT_TIME = 200 + +class Engine(threading.Thread): + """An engine wraps read access to sockets, allowing objects that + need to receive data from sockets to be called back when the + sockets are ready. + + A reader needs a handle_read() method, which is called when the socket + it is interested in is ready for reading. + + Writers are not implemented here, because we only send short + packets. + """ + + def __init__(self, zeroconf): + threading.Thread.__init__(self) + self.zeroconf = zeroconf + self.readers = {} # maps socket to reader + self.timeout = 5 + self.condition = threading.Condition() + self.start() + + def run(self): + while not globals()['_GLOBAL_DONE']: + rs = self.getReaders() + if len(rs) == 0: + # No sockets to manage, but we wait for the timeout + # or addition of a socket + # + log.debug( 'No sockets, waiting %s', self.timeout ) + self.condition.acquire() + self.condition.wait(self.timeout/25.) + self.condition.release() + else: + try: + rr, wr, er = select.select(rs, [], [], self.timeout) + except (socket.error,select.error), err: + if err[0] in (errno.EWOULDBLOCK,errno.EINTR,errno.EAGAIN): + pass + else: + log.info( 'Failure on select, ignoring: %s', err ) + except Exception, err: + log.info( 'Select failure, ignored: %s', err ) + else: + for sock in rr: + try: + self.readers[sock].handle_read() + except Exception, err: + # Ignore errors that occur on shutdown + log.info( 'Error handling read: %s', err ) + log.debug( 'Traceback: %s', traceback.format_exc()) + + def getReaders(self): + result = [] + self.condition.acquire() + result = self.readers.keys() + self.condition.release() + return result + + def addReader(self, reader, socket): + self.condition.acquire() + self.readers[socket] = reader + self.condition.notify() + self.condition.release() + + def delReader(self, socket): + self.condition.acquire() + del(self.readers[socket]) + self.condition.notify() + self.condition.release() + + def notify(self): + self.condition.acquire() + self.condition.notify() + self.condition.release() + +class Listener(object): + """A Listener is used by this module to listen on the multicast + group to which DNS messages are sent, allowing the implementation + to cache information as it arrives. + + It requires registration with an Engine object in order to have + the read() method called when a socket is availble for reading.""" + def __init__(self, zeroconf): + self.zeroconf = zeroconf + self.zeroconf.engine.addReader(self, self.zeroconf.socket) + + def handle_read(self): + try: + data, (addr, port) = self.zeroconf.socket.recvfrom(dns._MAX_MSG_ABSOLUTE) + except Exception, err: + if getattr( err, 'errno', None ) == 9: # 'Bad file descriptor' during shutdown... + pass + else: + log.info( 'Error on recvfrom: %s', err ) + return None + self.data = data + try: + msg = dns.DNSIncoming(data) + except dns.NonLocalNameException, err: + """We ignore mdns queries for non-local addresses, such as in-addr.arpa.""" + except dns.DNSError, err: + log.error( + "Malformed packet from %s (%s), ignored: %r", + addr, err, data + ) + else: + if msg.isQuery(): + # Always multicast responses + # + if port == dns._MDNS_PORT: + self.zeroconf.handleQuery(msg, dns._MDNS_ADDR, dns._MDNS_PORT) + # If it's not a multicast query, reply via unicast + # + # and multicast + elif port == dns._DNS_PORT: + self.zeroconf.handleQuery(msg, addr, port) + self.zeroconf.handleQuery(msg, dns._MDNS_ADDR, dns._MDNS_PORT) + else: + log.error( + "Unknown port: %s", port + ) + else: + self.zeroconf.handleResponse(msg) + +class Reaper(threading.Thread): + """A Reaper is used by this module to remove cache entries that + have expired.""" + + def __init__(self, zeroconf): + threading.Thread.__init__(self) + self.zeroconf = zeroconf + self.daemon = True + self.start() + + def run(self): + while 1: + if globals()['_GLOBAL_DONE']: + return + try: + self.zeroconf.wait(10 * 1000) + except ValueError, err: + break + if globals()['_GLOBAL_DONE']: + return + now = dns.currentTimeMillis() + for record in self.zeroconf.cache.entries(): + if record.isExpired(now): + self.zeroconf.updateRecord(now, record) + self.zeroconf.cache.remove(record) + + +class ServiceBrowser(threading.Thread): + """Used to browse for a service of a specific type_. + + The listener object will have its addService() and + removeService() methods called when this browser + discovers changes in the services availability.""" + + def __init__(self, zeroconf, type_, listener): + """Creates a browser for a specific type_""" + threading.Thread.__init__(self) + self.zeroconf = zeroconf + self.type = type_ + self.listener = listener + self.daemon = True + self.services = {} + self.nextTime = dns.currentTimeMillis() + self.delay = _BROWSER_TIME + self.list = [] + + self.done = 0 + + self.zeroconf.addListener(self, dns.DNSQuestion(self.type, dns._TYPE_PTR, dns._CLASS_IN)) + self.start() + + def updateRecord(self, zeroconf, now, record): + """Callback invoked by Zeroconf when new information arrives. + + Updates information required by browser in the Zeroconf cache.""" + if record.type == dns._TYPE_PTR and record.name == self.type: + expired = record.isExpired(now) + try: + oldrecord = self.services[record.alias.lower()] + if not expired: + oldrecord.resetTTL(record) + else: + del(self.services[record.alias.lower()]) + callback = lambda x: self.listener.removeService(x, self.type, record.alias) + self.list.append(callback) + return + except: + if not expired: + self.services[record.alias.lower()] = record + callback = lambda x: self.listener.addService(x, self.type, record.alias) + self.list.append(callback) + + expires = record.getExpirationTime(75) + if expires < self.nextTime: + self.nextTime = expires + + def cancel(self): + self.done = 1 + self.zeroconf.notifyAll() + + def run(self): + while 1: + event = None + now = dns.currentTimeMillis() + if len(self.list) == 0 and self.nextTime > now: + self.zeroconf.wait(self.nextTime - now) + if globals()['_GLOBAL_DONE'] or self.done: + return + now = dns.currentTimeMillis() + + if self.nextTime <= now: + out = dns.DNSOutgoing(dns._FLAGS_QR_QUERY) + out.addQuestion(dns.DNSQuestion(self.type, dns._TYPE_PTR, dns._CLASS_IN)) + for record in self.services.values(): + if not record.isExpired(now): + out.addAnswerAtTime(record, now) + self.zeroconf.send(out) + self.nextTime = now + self.delay + self.delay = min(20 * 1000, self.delay * 2) + + if len(self.list) > 0: + event = self.list.pop(0) + + if event is not None: + event(self.zeroconf) + +class Zeroconf(object): + """Implementation of Zeroconf Multicast DNS Service Discovery + + Supports registration, unregistration, queries and browsing. + """ + def __init__(self, bindaddress=None): + """Creates an instance of the Zeroconf class, establishing + multicast communications, listening and reaping threads.""" + globals()['_GLOBAL_DONE'] = 0 + if bindaddress is None: + try: + """Try to find the internet-routing interface so we don't get 127.0.0.1""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('www.google.com',80)) + self.intf = s.getsockname()[0] + s.close() + except: + self.intf = socket.gethostbyname(socket.gethostname()) + bindaddress = self.intf + else: + self.intf = bindaddress + self.socket = mcastsocket.create_socket( (bindaddress, dns._MDNS_PORT) ) + mcastsocket.join_group( self.socket, dns._MDNS_ADDR ) + + self.listeners = [] + self.suppression_queue = [] + self.browsers = [] + self.services = {} + + self.cache = dns.DNSCache() + + self.condition = threading.Condition() + + self.engine = Engine(self) + self.listener = Listener(self) + self.reaper = Reaper(self) + + def isLoopback(self): + return self.intf.startswith("127.0.0.1") + + def isLinklocal(self): + return self.intf.startswith("169.254.") + + def wait(self, timeout): + """Calling thread waits for a given number of milliseconds or + until notified.""" + self.condition.acquire() + self.condition.wait(timeout/1000) + self.condition.release() + + def notifyAll(self): + """Notifies all waiting threads""" + self.condition.acquire() + self.condition.notifyAll() + self.condition.release() + + def probeName( self, name, timeout=dns._PROBE_TIMEOUT ): + """Probe host-names until we find an unoccupied one + + name -- name from which to construct the host-name, note that this + must be a .local. suffix'd name, names will be constructed as + prefix%s.suffix. where %s is a numeric suffix, with '' as the + first attempted name + + TODO: maybe make this part of checkService instead? Same basic + operation, it's just looking for unique server name instead of unique + service name. The spec explicitly seems to expect that, but the + current checkService isn't doing most of the probe operations as + defined. + + http://files.multicastdns.org/draft-cheshire-dnsext-multicastdns.txt Section 8 + + Note: this is not yet a full implementation of the protocol, + as it is missing support for pre-registering services, handling + conflicts and race conditions, etc. + + Note: you *must* advertise your services *before* you issue this + query, as race conditions will occur if two machines booted at + the same time try to resolve the same name. + + Steps: + + wait( random.randint( 0,250 ) ) # msg + probe( question ) # 3 queries at 0, 250, 500 then wait another 250 for response + if response: # conflict + mutate( question ) + if conflict_count > 15: # within ten seconds + wait( 5000 ) + else: + wait( random.randint( 0, 1000)) + else: + return query + + """ + def names( name ): + yield name + prefix,suffix = name.split('.',1) + count = 1 + while True: + yield '%s%s.%s'%( prefix,count,suffix ) + count += 1 + if count > 2**16: + raise RuntimeError( """Unable to find an unused name %s"""%( name, )) + delay = dns._PROBE_TIME + for failures,name in enumerate(names(name)): + address = self.getServerAddress( + name, timeout, + delay = delay, + ignore=[self.intf] # our own address is *not* to be considered + ) + if failures > 5: + # mDNS requires huge slowdown after 15 failures + # to prevent flooding network... + log.info( + "Throttling host-name configuration to prevent network flood" + ) + delay = dns._PROBE_THROTTLED_TIME + timeout = 2.5 * delay + if not address: + return name + def getServerAddress(self, name, timeout=dns._PROBE_TIMEOUT, ignore=None, delay=dns._PROBE_TIME): + """Returns given server-name record or None on timeout""" + info = dns.ServerNameWatcher( name, ignore=ignore ) + if info.request( self, timeout, delay=delay ): + return info.address + return None + + def getServiceInfo(self, type_, name, timeout=3000): + """Returns network's service information for a particular + name and type_, or None if no service matches by the timeout, + which defaults to 3 seconds.""" + info = dns.ServiceInfo(type_, name) + if info.request(self, timeout): + return info + return None + + def addServiceListener(self, type_, listener): + """Adds a listener for a particular service type_. This object + will then have its updateRecord method called when information + arrives for that type_.""" + self.removeServiceListener(listener) + self.browsers.append(ServiceBrowser(self, type_, listener)) + + def removeServiceListener(self, listener): + """Removes a listener from the set that is currently listening.""" + for browser in self.browsers: + if browser.listener == listener: + browser.cancel() + del(browser) + + def registerService(self, info, ttl=dns._DNS_TTL): + """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.checkService(info) + self.services[info.name.lower()] = info + now = dns.currentTimeMillis() + nextTime = now + i = 0 + while i < 3: + if now < nextTime: + self.wait(nextTime - now) + now = dns.currentTimeMillis() + continue + out = self.serviceAnnouncement( info, ttl ) + self.send(out) + i += 1 + nextTime += _REGISTER_TIME + + @classmethod + def serviceAnnouncement( cls, info, ttl=dns._DNS_TTL ): + out = dns.DNSOutgoing(dns._FLAGS_QR_RESPONSE | dns._FLAGS_AA) + out.addAnswerAtTime(dns.DNSPointer(info.type, dns._TYPE_PTR, dns._CLASS_IN, ttl, info.name), 0) + out.addAnswerAtTime(dns.DNSService(info.name, dns._TYPE_SRV, dns._CLASS_IN, ttl, info.priority, info.weight, info.port, info.server), 0) + out.addAnswerAtTime(dns.DNSText(info.name, dns._TYPE_TXT, dns._CLASS_IN, ttl, info.text), 0) + if info.address: + out.addAnswerAtTime(dns.DNSAddress(info.server, dns._TYPE_A, dns._CLASS_IN, ttl, info.address), 0) + return out + + def unregisterService(self, info): + """Unregister a service.""" + try: + del(self.services[info.name.lower()]) + except: + pass + now = dns.currentTimeMillis() + nextTime = now + i = 0 + while i < 3: + if now < nextTime: + self.wait(nextTime - now) + now = dns.currentTimeMillis() + continue + out = dns.DNSOutgoing(dns._FLAGS_QR_RESPONSE | dns._FLAGS_AA) + out.addAnswerAtTime(dns.DNSPointer(info.type, dns._TYPE_PTR, dns._CLASS_IN, 0, info.name), 0) + out.addAnswerAtTime(dns.DNSService(info.name, dns._TYPE_SRV, dns._CLASS_IN, 0, info.priority, info.weight, info.port, info.name), 0) + out.addAnswerAtTime(dns.DNSText(info.name, dns._TYPE_TXT, dns._CLASS_IN, 0, info.text), 0) + if info.address: + out.addAnswerAtTime(dns.DNSAddress(info.server, dns._TYPE_A, dns._CLASS_IN, 0, info.address), 0) + self.send(out) + i += 1 + nextTime += _UNREGISTER_TIME + + def unregisterAllServices(self): + """Unregister all registered services.""" + if len(self.services) > 0: + now = dns.currentTimeMillis() + nextTime = now + i = 0 + while i < 3: + if now < nextTime: + self.wait(nextTime - now) + now = dns.currentTimeMillis() + continue + out = dns.DNSOutgoing(dns._FLAGS_QR_RESPONSE | dns._FLAGS_AA) + for info in self.services.values(): + out.addAnswerAtTime(dns.DNSPointer(info.type, dns._TYPE_PTR, dns._CLASS_IN, 0, info.name), 0) + out.addAnswerAtTime(dns.DNSService(info.name, dns._TYPE_SRV, dns._CLASS_IN, 0, info.priority, info.weight, info.port, info.server), 0) + out.addAnswerAtTime(dns.DNSText(info.name, dns._TYPE_TXT, dns._CLASS_IN, 0, info.text), 0) + if info.address: + out.addAnswerAtTime(dns.DNSAddress(info.server, dns._TYPE_A, dns._CLASS_IN, 0, info.address), 0) + self.send(out) + i += 1 + nextTime += _UNREGISTER_TIME + + def checkService(self, info): + """Checks the network for a unique service name, modifying the + ServiceInfo passed in if it is not unique.""" + now = dns.currentTimeMillis() + nextTime = now + i = 0 + while i < 3: + for record in self.cache.entriesWithName(info.type): + if record.type == dns._TYPE_PTR and not record.isExpired(now) and record.alias == info.name: + if (info.name.find('.') < 0): + info.name = info.name + ".[" + info.address + ":" + info.port + "]." + info.type + self.checkService(info) + return + raise dns.NonUniqueNameException( info.name ) + if now < nextTime: + self.wait(nextTime - now) + now = dns.currentTimeMillis() + continue + out = dns.DNSOutgoing(dns._FLAGS_QR_QUERY | dns._FLAGS_AA) + self.debug = out + out.addQuestion(dns.DNSQuestion(info.type, dns._TYPE_PTR, dns._CLASS_IN)) + out.addAuthorativeAnswer(dns.DNSPointer(info.type, dns._TYPE_PTR, dns._CLASS_IN, dns._DNS_TTL, info.name)) + self.send(out) + i += 1 + nextTime += _CHECK_TIME + + def addListener(self, listener, question): + """Adds a listener for a given question. The listener will have + its updateRecord method called when information is available to + answer the question.""" + now = dns.currentTimeMillis() + self.listeners.append(listener) + if question is not None: + for record in self.cache.entriesWithName(question.name): + if question.answeredBy(record) and not record.isExpired(now): + listener.updateRecord(self, now, record) + self.notifyAll() + + def removeListener(self, listener): + """Removes a listener.""" + try: + self.listeners.remove(listener) + self.notifyAll() + except: + pass + + def updateRecord(self, now, rec): + """Used to notify listeners of new information that has updated + a record.""" + for listener in self.listeners: + listener.updateRecord(self, now, rec) + self.notifyAll() + + def handleResponse(self, msg): + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified.""" + now = dns.currentTimeMillis() + for record in msg.answers: + expired = record.isExpired(now) + if record in self.cache.entries(): + if expired: + self.cache.remove(record) + else: + entry = self.cache.get(record) + if entry is not None: + entry.resetTTL(record) + record = entry + else: + self.cache.add(record) + + self.updateRecord(now, record) + + def handleQuery(self, msg, addr, port): + """Deal with incoming query packets. Provides a response if + possible.""" + out = None + + # Support unicast client responses + # + if port != dns._MDNS_PORT: + out = dns.DNSOutgoing(dns._FLAGS_QR_RESPONSE | dns._FLAGS_AA, 0) + for question in msg.questions: + out.addQuestion(question) + log.debug( 'Questions...') + for question in msg.questions: + log.debug( 'Question: %s', question ) + if out is None: + out = dns.DNSOutgoing(dns._FLAGS_QR_RESPONSE | dns._FLAGS_AA) + try: + self.responses( question, msg, out ) + except Exception, err: + log.error( + 'Error handling query: %s',traceback.format_exc() + ) + if out is not None and out.answers: + out.id = msg.id + self.send(out, addr, port) + log.debug( 'Sent response: %s', out.answers ) + else: + log.debug( 'No (newer) answer for %s', [q for q in msg.questions] ) + + def responses( self, question, msg, out ): + """Adds all responses to out which match the given question + + Note that the incoming query may suppress our responses + by having cache times higher than our records. That is, + out.answers may be null even if we have the records that + match the query. + """ + log.debug( 'Question: %s', question ) + for service in self.services.values(): + if question.type == dns._TYPE_PTR: + if question.name.lower() in (service.type.lower(),service.name.lower()): + log.debug( 'Service query found %s', service.name ) + out.addAnswer(msg, dns.DNSPointer(question.name, dns._TYPE_PTR, dns._CLASS_IN, dns._DNS_TTL, service.name)) + # devices such as AAstra phones will not re-query to + # resolve the pointer, they expect the final IP to show up + # in the response + out.addAdditionalAnswer(dns.DNSText( + service.name, dns._TYPE_TXT, + dns._CLASS_IN | dns._CLASS_UNIQUE, + dns._DNS_TTL, service.text + )) + out.addAdditionalAnswer(dns.DNSService( + service.name, dns._TYPE_SRV, + dns._CLASS_IN | dns._CLASS_UNIQUE, dns._DNS_TTL, + service.priority, service.weight, service.port, service.server + )) + out.addAdditionalAnswer(dns.DNSAddress( + service.server, dns._TYPE_A, + dns._CLASS_IN | dns._CLASS_UNIQUE, + dns._DNS_TTL, service.address + )) + else: + if question.type in (dns._TYPE_A, ): + if service.server.lower() == question.name.lower(): + out.addAnswer(msg, dns.DNSAddress( + question.name, dns._TYPE_A, + dns._CLASS_IN | dns._CLASS_UNIQUE, + dns._DNS_TTL, service.address + )) + if question.type in (dns._TYPE_SRV, dns._TYPE_ANY): + if question.name.lower() in (service.name.lower(),service.server.lower(),service.type.lower()): + out.addAnswer(msg, dns.DNSService( + question.name, dns._TYPE_SRV, + dns._CLASS_IN | dns._CLASS_UNIQUE, + dns._DNS_TTL, service.priority, + service.weight, service.port, service.server + )) + if question.type in (dns._TYPE_TXT, dns._TYPE_ANY): + if question.name.lower() in (service.name.lower(),service.server.lower(),service.type.lower()): + out.addAnswer(msg, dns.DNSText( + question.name, dns._TYPE_TXT, + dns._CLASS_IN | dns._CLASS_UNIQUE, + dns._DNS_TTL, service.text + )) + if question.type in (dns._TYPE_SRV,dns._TYPE_ANY ): + # srv queries need the address for aastra-style single query + if question.name.lower() in (service.name.lower(),service.server.lower(),service.type.lower()): + out.addAdditionalAnswer(dns.DNSAddress( + service.server, dns._TYPE_A, + dns._CLASS_IN | dns._CLASS_UNIQUE, + dns._DNS_TTL, service.address + )) + + def send(self, out, addr = dns._MDNS_ADDR, port = dns._MDNS_PORT): + """Sends an outgoing packet. + + Note: this method is instrumented to provide low-level + prevention of packet floods by throttling same-message + sending to once per _MINIMUM_REPEAT_TIME ms. That will + fail for a "regular" DNS server, which should also use + the addr/port combo... + """ + current = dns.currentTimeMillis() + log.info( '%s messages in suppression_queue', len(self.suppression_queue)) + while self.suppression_queue and self.suppression_queue[0][0] < current: + log.debug( 'Removing...' ) + self.suppression_queue.pop(0) + packet = out.packet() + sent = False + for i,(expire,old_packet) in enumerate(self.suppression_queue[:]): + if old_packet == packet: + log.debug( 'Dropping to prevent flood' ) + sent = True + if not sent: + try: + sent = self.socket.sendto(packet, 0, (addr, port)) + except: + # Ignore this, it may be a temporary loss of network connection + pass + self.suppression_queue.append( + (current + _MINIMUM_REPEAT_TIME, packet ) + ) + return sent + + def close(self): + """Ends the background threads, and prevent this instance from + servicing further queries.""" + if globals()['_GLOBAL_DONE'] == 0: + globals()['_GLOBAL_DONE'] = 1 + self.notifyAll() + self.engine.notify() + self.unregisterAllServices() + mcastsocket.leave_group( self.socket, dns._MDNS_ADDR ) + self.socket.close() +