| # Copyright (c) 2013 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import collections |
| import dpkt |
| import logging |
| import socket |
| import time |
| |
| |
| DnsRecord = collections.namedtuple('DnsResult', ['rrname', 'rrtype', 'data', 'ts']) |
| |
| MDNS_IP_ADDR = '224.0.0.251' |
| MDNS_PORT = 5353 |
| |
| # Value to | to a class value to signal cache flush. |
| DNS_CACHE_FLUSH = 0x8000 |
| |
| # When considering SRV records, clients are supposed to unilaterally prefer |
| # numerically lower priorities, then pick probabilistically by weight. |
| # See RFC2782. |
| # An arbitrary number that will fit in 16 bits. |
| DEFAULT_PRIORITY = 500 |
| # An arbitrary number that will fit in 16 bits. |
| DEFAULT_WEIGHT = 500 |
| |
| def _RR_equals(rra, rrb): |
| """Returns whether the two dpkt.dns.DNS.RR objects are equal.""" |
| # Compare all the members present in either object and on any RR object. |
| keys = set(rra.__dict__.keys() + rrb.__dict__.keys() + |
| dpkt.dns.DNS.RR.__slots__) |
| # On RR objects, rdata is packed based on the other members and the final |
| # packed string depends on other RR and Q elements on the same DNS/mDNS |
| # packet. |
| keys.discard('rdata') |
| for key in keys: |
| if hasattr(rra, key) != hasattr(rrb, key): |
| return False |
| if not hasattr(rra, key): |
| continue |
| if key == 'cls': |
| # cls attribute should be masked for the cache flush bit. |
| if (getattr(rra, key) & ~DNS_CACHE_FLUSH != |
| getattr(rrb, key) & ~DNS_CACHE_FLUSH): |
| return False |
| else: |
| if getattr(rra, key) != getattr(rrb, key): |
| return False |
| return True |
| |
| |
| class ZeroconfDaemon(object): |
| """Implements a simulated Zeroconf daemon running on the given host. |
| |
| This class implements part of the Multicast DNS RFC 6762 able to simulate |
| a host exposing services or consuming services over mDNS. |
| """ |
| def __init__(self, host, hostname, domain='local'): |
| """Initializes the ZeroconfDameon running on the given host. |
| |
| For the purposes of the Zeroconf implementation, a host must have a |
| hostname and a domain that defaults to 'local'. The ZeroconfDaemon will |
| by default advertise the host address it is running on, which is |
| required by some services. |
| |
| @param host: The Host instance where this daemon runs on. |
| @param hostname: A string representing the hostname |
| """ |
| self._host = host |
| self._hostname = hostname |
| self._domain = domain |
| self._response_ttl = 60 # Default TTL in seconds. |
| |
| self._a_records = {} # Local A records. |
| self._srv_records = {} # Local SRV records. |
| self._ptr_records = {} # Local PTR records. |
| self._txt_records = {} # Local TXT records. |
| |
| # dict() of name --> (dict() of type --> (dict() of data --> timeout)) |
| # For example: _peer_records['somehost.local'][dpkt.dns.DNS_A] \ |
| # ['192.168.0.1'] = time.time() + 3600 |
| self._peer_records = {} |
| |
| # Register the host address locally. |
| self.register_A(self.full_hostname, host.ip_addr) |
| |
| # Attend all the traffic to the mDNS port (unicast, multicast or |
| # broadcast). |
| self._sock = host.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| self._sock.listen(MDNS_IP_ADDR, MDNS_PORT, self._mdns_request) |
| |
| # Observer list for new responses. |
| self._answer_callbacks = [] |
| |
| |
| def __del__(self): |
| self._sock.close() |
| |
| |
| @property |
| def host(self): |
| """The Host object where this daemon is running.""" |
| return self._host |
| |
| |
| @property |
| def hostname(self): |
| """The hostname part within a domain.""" |
| return self._hostname |
| |
| |
| @property |
| def domain(self): |
| """The domain where the given hostname is running.""" |
| return self._domain |
| |
| |
| @property |
| def full_hostname(self): |
| """The full hostname designation including host and domain name.""" |
| return self._hostname + '.' + self._domain |
| |
| |
| def _mdns_request(self, data, addr, port): |
| """Handles a mDNS multicast packet. |
| |
| This method will generate and send a mDNS response to any query |
| for which it has new authoritative information. Called by the Simulator |
| as a callback for every mDNS received packet. |
| |
| @param data: The string contained on the UDP message. |
| @param addr: The address where the message comes from. |
| @param port: The port number where the message comes from. |
| """ |
| # Parse the mDNS request using dpkt's DNS module. |
| mdns = dpkt.dns.DNS(data) |
| if mdns.op == 0x0000: # Query |
| QUERY_HANDLERS = { |
| dpkt.dns.DNS_A: self._process_A, |
| dpkt.dns.DNS_PTR: self._process_PTR, |
| dpkt.dns.DNS_TXT: self._process_TXT, |
| dpkt.dns.DNS_SRV: self._process_SRV, |
| } |
| |
| answers = [] |
| for q in mdns.qd: # Query entries |
| if q.type in QUERY_HANDLERS: |
| answers += QUERY_HANDLERS[q.type](q) |
| elif q.type == dpkt.dns.DNS_ANY: |
| # Special type matching any known type. |
| for _, handler in QUERY_HANDLERS.iteritems(): |
| answers += handler(q) |
| # Remove all the already known answers from the list. |
| answers = [ans for ans in answers if not any(True |
| for known_ans in mdns.an if _RR_equals(known_ans, ans))] |
| |
| self._send_answers(answers) |
| |
| # Always process the received authoritative answers. |
| answers = mdns.ns |
| |
| # Process the answers for response packets. |
| if mdns.op == 0x8400: # Standard response |
| answers.extend(mdns.an) |
| |
| if answers: |
| cur_time = time.time() |
| new_answers = [] |
| for rr in answers: # Answers RRs |
| # dpkt decodes the information on different fields depending on |
| # the response type. |
| if rr.type == dpkt.dns.DNS_A: |
| data = socket.inet_ntoa(rr.ip) |
| elif rr.type == dpkt.dns.DNS_PTR: |
| data = rr.ptrname |
| elif rr.type == dpkt.dns.DNS_TXT: |
| data = tuple(rr.text) # Convert the list to a hashable tuple |
| elif rr.type == dpkt.dns.DNS_SRV: |
| data = rr.srvname, rr.priority, rr.weight, rr.port |
| else: |
| continue # Ignore unsupported records. |
| if not rr.name in self._peer_records: |
| self._peer_records[rr.name] = {} |
| # Start a new cache or clear the existing if required. |
| if not rr.type in self._peer_records[rr.name] or ( |
| rr.cls & DNS_CACHE_FLUSH): |
| self._peer_records[rr.name][rr.type] = {} |
| |
| new_answers.append((rr.type, rr.name, data)) |
| cached_ans = self._peer_records[rr.name][rr.type] |
| rr_timeout = cur_time + rr.ttl |
| # Update the answer timeout if already cached. |
| if data in cached_ans: |
| cached_ans[data] = max(cached_ans[data], rr_timeout) |
| else: |
| cached_ans[data] = rr_timeout |
| if new_answers: |
| for cbk in self._answer_callbacks: |
| cbk(new_answers) |
| |
| |
| def clear_cache(self): |
| """Discards all the cached records.""" |
| self._peer_records = {} |
| |
| |
| def _send_answers(self, answers): |
| """Send a mDNS reply with the provided answers. |
| |
| This method uses the undelying Host to send an IP packet with a mDNS |
| response containing the list of answers of the type dpkt.dns.DNS.RR. |
| If the list is empty, no packet is sent. |
| |
| @param answers: The list of answers to send. |
| """ |
| if not answers: |
| return |
| logging.debug('Sending response with answers: %r.', answers) |
| resp_dns = dpkt.dns.DNS( |
| op = dpkt.dns.DNS_AA, # Authoritative Answer. |
| rcode = dpkt.dns.DNS_RCODE_NOERR, |
| an = answers) |
| # This property modifies the "op" field: |
| resp_dns.qr = dpkt.dns.DNS_R, # Response. |
| self._sock.send(str(resp_dns), MDNS_IP_ADDR, MDNS_PORT) |
| |
| |
| ### RFC 2782 - RR for specifying the location of services (DNS SRV). |
| def register_SRV(self, service, proto, priority, weight, port): |
| """Publishes the SRV specified record. |
| |
| A SRV record defines a service on a port of a host with given properties |
| like priority and weight. The service has a name of the form |
| "service.proto.domain". The target host, this is, the host where the |
| announced service is running on is set to the host where this zeroconf |
| daemon is running, "hostname.domain". |
| |
| @param service: A string with the service name. |
| @param proto: A string with the protocol name, for example "_tcp". |
| @param priority: The service priority number as defined by RFC2782. |
| @param weight: The service weight number as defined by RFC2782. |
| @param port: The port number where the service is running on. |
| """ |
| srvname = service + '.' + proto + '.' + self._domain |
| self._srv_records[srvname] = priority, weight, port |
| |
| |
| def _process_SRV(self, q): |
| """Process a SRV query provided in |q|. |
| |
| @param q: The dns.DNS.Q query object with type dpkt.dns.DNS_SRV. |
| @return: A list of dns.DNS.RR responses to the provided query that can |
| be empty. |
| """ |
| if not q.name in self._srv_records: |
| return [] |
| priority, weight, port = self._srv_records[q.name] |
| full_hostname = self._hostname + '.' + self._domain |
| ans = dpkt.dns.DNS.RR( |
| type = dpkt.dns.DNS_SRV, |
| cls = dpkt.dns.DNS_IN | DNS_CACHE_FLUSH, |
| ttl = self._response_ttl, |
| name = q.name, |
| srvname = full_hostname, |
| priority = priority, |
| weight = weight, |
| port = port) |
| # The target host (srvname) requires to send an A record with its IP |
| # address. We do this as if a query for it was sent. |
| a_qry = dpkt.dns.DNS.Q(name=full_hostname, type=dpkt.dns.DNS_A) |
| return [ans] + self._process_A(a_qry) |
| |
| |
| ### RFC 1035 - 3.4.1, Domains Names - A (IPv4 address). |
| def register_A(self, hostname, ip_addr): |
| """Registers an Address record (A) pointing to the given IP addres. |
| |
| Records registered with method are assumed authoritative. |
| |
| @param hostname: The full host name, for example, "somehost.local". |
| @param ip_addr: The IPv4 address of the host, for example, "192.0.1.1". |
| """ |
| if not hostname in self._a_records: |
| self._a_records[hostname] = [] |
| self._a_records[hostname].append(socket.inet_aton(ip_addr)) |
| |
| |
| def _process_A(self, q): |
| """Process an A query provided in |q|. |
| |
| @param q: The dns.DNS.Q query object with type dpkt.dns.DNS_A. |
| @return: A list of dns.DNS.RR responses to the provided query that can |
| be empty. |
| """ |
| if not q.name in self._a_records: |
| return [] |
| answers = [] |
| for ip_addr in self._a_records[q.name]: |
| answers.append(dpkt.dns.DNS.RR( |
| type = dpkt.dns.DNS_A, |
| cls = dpkt.dns.DNS_IN | DNS_CACHE_FLUSH, |
| ttl = self._response_ttl, |
| name = q.name, |
| ip = ip_addr)) |
| return answers |
| |
| |
| ### RFC 1035 - 3.3.12, Domain names - PTR (domain name pointer). |
| def register_PTR(self, domain, destination): |
| """Register a domain pointer record. |
| |
| A domain pointer record is simply a pointer to a hostname on the domain. |
| |
| @param domain: A domain name that can include a proto name, for |
| example, "_workstation._tcp.local". |
| @param destination: The hostname inside the given domain, for example, |
| "my-desktop". |
| """ |
| if not domain in self._ptr_records: |
| self._ptr_records[domain] = [] |
| self._ptr_records[domain].append(destination) |
| |
| |
| def _process_PTR(self, q): |
| """Process a PTR query provided in |q|. |
| |
| @param q: The dns.DNS.Q query object with type dpkt.dns.DNS_PTR. |
| @return: A list of dns.DNS.RR responses to the provided query that can |
| be empty. |
| """ |
| if not q.name in self._ptr_records: |
| return [] |
| answers = [] |
| for dest in self._ptr_records[q.name]: |
| answers.append(dpkt.dns.DNS.RR( |
| type = dpkt.dns.DNS_PTR, |
| cls = dpkt.dns.DNS_IN, # Don't cache flush for PTR records. |
| ttl = self._response_ttl, |
| name = q.name, |
| ptrname = dest + '.' + q.name)) |
| return answers |
| |
| |
| ### RFC 1035 - 3.3.14, Domain names - TXT (descriptive text). |
| def register_TXT(self, domain, txt_list, announce=False): |
| """Register a TXT record on a domain with given list of strings. |
| |
| A TXT record can hold any list of text entries whos format depends on |
| the domain. This method replaces any previous TXT record previously |
| registered for the given domain. |
| |
| @param domain: A domain name that normally can include a proto name and |
| a service or host name. |
| @param txt_list: A list of strings. |
| @param announce: If True, the method will also announce the changes |
| on the network. |
| """ |
| self._txt_records[domain] = txt_list |
| if announce: |
| self._send_answers(self._process_TXT(dpkt.dns.DNS.Q(name=domain))) |
| |
| |
| def _process_TXT(self, q): |
| """Process a TXT query provided in |q|. |
| |
| @param q: The dns.DNS.Q query object with type dpkt.dns.DNS_TXT. |
| @return: A list of dns.DNS.RR responses to the provided query that can |
| be empty. |
| """ |
| if not q.name in self._txt_records: |
| return [] |
| text_list = self._txt_records[q.name] |
| answer = dpkt.dns.DNS.RR( |
| type = dpkt.dns.DNS_TXT, |
| cls = dpkt.dns.DNS_IN | DNS_CACHE_FLUSH, |
| ttl = self._response_ttl, |
| name = q.name, |
| text = text_list) |
| return [answer] |
| |
| |
| def register_service(self, unique_prefix, service_type, |
| protocol, port, txt_list): |
| """Register a service in the Avahi style. |
| |
| Avahi exposes a convenient set of methods for manipulating "services" |
| which are a trio of PTR, SRV, and TXT records. This is a similar |
| helper method for our daemon. |
| |
| @param unique_prefix: string unique prefix of service (part of the |
| canonical name). |
| @param service_type: string type of service (e.g. '_privet'). |
| @param protocol: string protocol to use for service (e.g. '_tcp'). |
| @param port: IP port of service (e.g. 53). |
| @param txt_list: list of txt records (e.g. ['vers=1.0', 'foo']). |
| """ |
| service_name = '.'.join([unique_prefix, service_type]) |
| fq_service_name = '.'.join([service_name, protocol, self._domain]) |
| logging.debug('Registering service=%s on port=%d with txt records=%r', |
| fq_service_name, port, txt_list) |
| self.register_SRV( |
| service_name, protocol, DEFAULT_PRIORITY, DEFAULT_WEIGHT, port) |
| self.register_PTR('.'.join([service_type, protocol, self._domain]), |
| unique_prefix) |
| self.register_TXT(fq_service_name, txt_list) |
| |
| |
| def cached_results(self, rrname, rrtype, timestamp=None): |
| """Return all the cached results for the requested rrname and rrtype. |
| |
| This method is used to request all the received mDNS answers present |
| on the cache that were valid at the provided timestamp or later. |
| Answers received before this timestamp whose TTL isn't long enough to |
| make them valid at the timestamp aren't returned. On the other hand, |
| answers received *after* the provided timestamp will always be |
| considered, even if they weren't known at the provided timestamp point. |
| A timestamp of None will return them all. |
| |
| This method allows to retrieve "volatile" answers with a TTL of zero. |
| According to the RFC, these answers should be only considered for the |
| "ongoing" request. To do this, call this method after a few seconds (the |
| request timeout) after calling the send_request() method, passing to |
| this method the returned timestamp. |
| |
| @param rrname: The requested domain name. |
| @param rrtype: The DNS record type. For example, dpkt.dns.DNS_TXT. |
| @param timestamp: The request timestamp. See description. |
| @return: The list of matching records of the form (rrname, rrtype, data, |
| timeout). |
| """ |
| if timestamp is None: |
| timestamp = 0 |
| if not rrname in self._peer_records: |
| return [] |
| if not rrtype in self._peer_records[rrname]: |
| return [] |
| res = [] |
| for data, data_ts in self._peer_records[rrname][rrtype].iteritems(): |
| if data_ts >= timestamp: |
| res.append(DnsRecord(rrname, rrtype, data, data_ts)) |
| return res |
| |
| |
| def send_request(self, queries): |
| """Sends a request for the provided rrname and rrtype. |
| |
| All the known and valid answers for this request will be included in the |
| non authoritative list of known answers together with the request. This |
| is recommended by the RFC and avoid unnecessary responses. |
| |
| @param queries: A list of pairs (rrname, rrtype) where rrname is the |
| domain name you are requesting for and the rrtype is the DNS record |
| type. For example, ('somehost.local', dpkt.dns.DNS_ANY). |
| @return: The timestamp where this request is sent. See cached_results(). |
| """ |
| queries = [dpkt.dns.DNS.Q(name=rrname, type=rrtype) |
| for rrname, rrtype in queries] |
| # TODO(deymo): Inlcude the already known answers on the request. |
| answers = [] |
| mdns = dpkt.dns.DNS( |
| op = dpkt.dns.DNS_QUERY, |
| qd = queries, |
| an = answers) |
| self._sock.send(str(mdns), MDNS_IP_ADDR, MDNS_PORT) |
| return time.time() |
| |
| |
| def add_answer_observer(self, callback): |
| """Adds the callback to the list of observers for new answers. |
| |
| @param callback: A callable object accepting a list of tuples (rrname, |
| rrtype, data) where rrname is the domain name, rrtype the DNS record |
| type and data is the information associated with the answers, similar to |
| what cached_results() returns. |
| """ |
| self._answer_callbacks.append(callback) |