def __init__(self, id=None, udpPort=4000, dataStore=None, routingTable=None, networkProtocol=None): if id != None: self.id = id else: self.id = self._generateID() self.port = udpPort if networkProtocol == None: self._protocol = protocol.KademliaProtocol(self) else: self._protocol = networkProtocol self._joinDeferred = None self.contactsList = [] self.dataStore = DictDataStore()
def __init__(self, id, client_string='ssl:%s:%d'): """ Initialises the object representing the node with the given id. """ # The node's ID within the distributed hash table. self.id = id # The routing table stores information about other nodes on the DHT. self._routing_table = RoutingTable(id) # The local key/value store containing data held by this node. self._data_store = DictDataStore() # A dictionary of IDs for messages pending a response and associated # deferreds to be fired when a response is completed. self._pending = {} # The template string to use when initiating a connection to another # node on the network. self._client_string = client_string # The version of Drogulus that this node implements. self.version = get_version() log.msg('Initialised node with id: %r' % self.id)
class Node(object): """ This class represents a single local node in the DHT encapsulating its presence in the network. All interactions with the DHT network by a client application are performed via this class (or a subclass). """ def __init__(self, id, client_string='ssl:%s:%d'): """ Initialises the object representing the node with the given id. """ # The node's ID within the distributed hash table. self.id = id # The routing table stores information about other nodes on the DHT. self._routing_table = RoutingTable(id) # The local key/value store containing data held by this node. self._data_store = DictDataStore() # A dictionary of IDs for messages pending a response and associated # deferreds to be fired when a response is completed. self._pending = {} # The template string to use when initiating a connection to another # node on the network. self._client_string = client_string # The version of Drogulus that this node implements. self.version = get_version() log.msg('Initialised node with id: %r' % self.id) def join(self, seed_nodes=None): """ Causes the Node to join the DHT network. This should be called before any other DHT operations. The seed_nodes argument must be a list of already known contacts describing existing nodes on the network. """ if not seed_nodes: raise ValueError('Seed nodes required for node to join network') for contact in seed_nodes: self._routing_table.add_contact(contact) # Looking up the node's ID on the network will populate the routing # table with fresh nodes as well as tell us who our nearest neighbours # are. # TODO: Add callback to kick off refresh of k-buckets in future..? raise Exception('FIX ME!') # Ensure the refresh of k-buckets is set up properly. return NodeLookup(self.id, FindNode, self) def message_received(self, message, protocol): """ Handles incoming messages. """ # Update the routing table. peer = protocol.transport.getPeer() other_node = Contact(message.node, peer.host, peer.port, message.version, time.time()) log.msg('Message received from %s' % other_node) log.msg(message) self._routing_table.add_contact(other_node) # Sort on message type and pass to handler method. Explicit > implicit. if isinstance(message, Ping): self.handle_ping(message, protocol) elif isinstance(message, Pong): self.handle_pong(message) elif isinstance(message, Store): self.handle_store(message, protocol, other_node) elif isinstance(message, FindNode): self.handle_find_node(message, protocol) elif isinstance(message, FindValue): self.handle_find_value(message, protocol) elif isinstance(message, Error): self.handle_error(message, protocol, other_node) elif isinstance(message, Value): self.handle_value(message, other_node) elif isinstance(message, Nodes): self.handle_nodes(message) def send_message(self, contact, message): """ Sends a message to the specified contact, adds it to the _pending dictionary and ensures it times-out after the correct period. If an error occurs the deferred's errback is called. """ d = defer.Deferred() # open network call. client_string = self._client_string % (contact.address, contact.port) client = clientFromString(reactor, client_string) connection = client.connect(DHTFactory(self)) # Ensure the connection will potentially time out. connection_timeout = reactor.callLater(constants.RPC_TIMEOUT, connection.cancel) def on_connect(protocol): # Cancel pending connection_timeout if it's still active. if connection_timeout.active(): connection_timeout.cancel() # Send the message and add a timeout for the response. protocol.sendMessage(message) self._pending[message.uuid] = d reactor.callLater(constants.RESPONSE_TIMEOUT, response_timeout, message, protocol, self) def on_error(error): log.msg('***** ERROR ***** interacting with %s' % contact) log.msg(error) self._routing_table.remove_contact(message.node) if message.uuid in self._pending: del self._pending[message.uuid] d.errback(error) connection.addCallback(on_connect) connection.addErrback(on_error) return d def trigger_deferred(self, message, error=False): """ Given a message, will attempt to retrieve the deferred and trigger it with the appropriate callback or errback. """ if message.uuid in self._pending: deferred = self._pending[message.uuid] if error: error.message = message deferred.errback(error) else: deferred.callback(message) # Remove the called deferred from the _pending dictionary. del self._pending[message.uuid] def handle_ping(self, message, protocol): """ Handles an incoming Ping message. Returns a Pong message using the referenced protocol object. """ pong = Pong(message.uuid, self.id, self.version) protocol.sendMessage(pong, True) def handle_pong(self, message): """ Handles an incoming Pong message. """ self.trigger_deferred(message) def handle_store(self, message, protocol, sender): """ Handles an incoming Store message. Checks the provenance and timeliness of the message before storing locally. If there is a problem, removes the untrustworthy peer from the routing table. Otherwise, at REPLICATE_INTERVAL minutes in the future, the local node will attempt to replicate the Store message elsewhere in the DHT if such time is <= the message's expiry time. Sends a Pong message if successful otherwise replies with an appropriate Error. """ # Check provenance is_valid, err_code = validate_message(message) if is_valid: # Ensure the node doesn't already have a more up-to-date version # of the value. current = self._data_store.get(message.key, False) if current and (message.timestamp < current.timestamp): # The node already has a later version of the value so # return an error. details = { 'new_timestamp': '%d' % current.timestamp } raise ValueError(8, constants.ERRORS[8], details, message.uuid) # Good to go, so store value. self._data_store.set_item(message.key, message) # Reply with a pong so the other end updates its routing table. pong = Pong(message.uuid, self.id, self.version) protocol.sendMessage(pong, True) # At some future time attempt to replicate the Store message # around the network IF it is within the message's expiry time. raise Exception("FIX ME!") # Need to check that callLater is called as part of the tests. reactor.callLater(constants.REPLICATE_INTERVAL, self.republish, message) else: # Remove from the routing table. log.msg('Problem with Store command: %d - %s' % (err_code, constants.ERRORS[err_code])) self._routing_table.blacklist(sender) # Return an error. details = { 'message': 'You have been blacklisted.' } raise ValueError(err_code, constants.ERRORS[err_code], details, message.uuid) def handle_find_node(self, message, protocol): """ Handles an incoming FindNode message. Finds the details of up to K other nodes closer to the target key that *this* node knows about. Responds with a "Nodes" message containing the list of matching nodes. """ target_key = message.key # List containing tuples of information about the matching contacts. other_nodes = [(n.id, n.address, n.port, n.version) for n in self._routing_table.find_close_nodes(target_key)] result = Nodes(message.uuid, self.id, other_nodes, self.version) protocol.sendMessage(result, True) def handle_find_value(self, message, protocol): """ Handles an incoming FindValue message. If the local node contains the value associated with the requested key replies with an appropriate "Value" message. Otherwise, responds with details of up to K other nodes closer to the target key that the local node knows about. In this case a "Nodes" message containing the list of matching nodes is sent to the caller. """ match = self._data_store.get(message.key, False) if match: result = Value(message.uuid, self.id, match.key, match.value, match.timestamp, match.expires, match.public_key, match.name, match.meta, match.sig, match.version) protocol.sendMessage(result, True) else: self.handle_find_node(message, protocol) def handle_error(self, message, protocol, sender): """ Handles an incoming Error message. Currently, this simply logs the error and closes the connection. In future this *may* remove the sender from the routing table (depending on the error). """ # TODO: Handle error 8 (out of date data) log.msg('***** ERROR ***** from %s' % sender) log.msg(message) def handle_value(self, message, sender): """ Handles an incoming Value message containing a value retrieved from another node on the DHT. Ensures the message is valid and calls the referenced deferred to signal the arrival of the value. TODO: How to handle invalid messages and errback the deferred. """ # Check provenance is_valid, err_code = validate_message(message) if is_valid: self.trigger_deferred(message) else: log.msg('Problem with incoming Value: %d - %s' % (err_code, constants.ERRORS[err_code])) log.msg(message) # Remove the remote node from the routing table. self._routing_table.remove_contact(sender.id, True) error = ValueError(constants.ERRORS[err_code]) self.trigger_deferred(message, error) def handle_nodes(self, message): """ Handles an incoming Nodes message containing information about other nodes on the network that are close to a requested key. """ self.trigger_deferred(message) def send_ping(self, contact): """ Sends a ping request to the given contact and returns a deferred that is fired when the reply arrives or an error occurs. """ new_uuid = str(uuid4()) ping = Ping(new_uuid, self.id, self.version) return self.send_message(contact, ping) def send_store(self, contact, public_key, name, value, timestamp, expires, meta, signature): """ Sends a Store message to the given contact. The value contained within the message is stored against a key derived from the public_key and name. Furthermore, the message is cryptographically signed using the value, timestamp, expires, name and meta values. """ uuid = str(uuid4()) compound_key = construct_key(public_key, name) store = Store(uuid, self.id, compound_key, value, timestamp, expires, public_key, name, meta, signature, self.version) return self.send_message(contact, store) def send_find(self, contact, target, message_type): """ Sends a Find[Node|Value] message to the given contact with the intention of obtaining information at the given target key. The type of find message is specified by message_type. """ new_uuid = str(uuid4()) find_message = message_type(new_uuid, self.id, target, self.version) deferred = self.send_message(contact, find_message) return (new_uuid, deferred) def _process_lookup_result(self, nearest_nodes, public_key, name, value, timestamp, expires, meta, signature, length): """ Given a list of nearest nodes will return a list of send_store based deferreds for the item to be stored in the DHT. The list will contain up to "length" number of deferreds. """ list_of_deferreds = [] for contact in nearest_nodes[:length]: deferred = self.send_store(contact, public_key, name, value, timestamp, expires, meta, signature) list_of_deferreds.append(deferred) return list_of_deferreds def replicate(self, public_key, name, value, timestamp, expires, meta, signature, duplicate): """ Will replicate args to "duplicate" number of nodes in the distributed hash table. Returns a deferred that will fire with a list of send_store deferreds when "duplicate" number of closest nodes have been identified. Obviously, the list can be turned in to a deferred_list to fire when the store commands have completed. Even if "duplicate" is > K no more than K items will be contained within the list result. """ if duplicate < 1: # Guard to ensure meaningful duplication count. raise ValueError('Duplication count may not be less than 1') result = defer.Deferred() compound_key = construct_key(public_key, name) lookup = NodeLookup(compound_key, FindNode, self) def on_success(nodes): """ A list of close nodes have been found so send store messages to the "duplicate" closest number of them and fire the "result" deferred with the resulting DeferredList of pending deferreds. """ deferreds = self._process_lookup_result(nodes, public_key, name, value, timestamp, expires, meta, signature, duplicate) result.callback(deferreds) def on_error(error): """ Catch all for errors during the lookup phase. Simply pass them on via the "result" deferred. """ result.errback(error) lookup.addCallback(on_success) lookup.addErrback(on_error) return result def retrieve(self, key): """ Given a key, will try to retrieve associated value from the distributed hash table. Returns a deferred that will fire when the operation is complete or failed. As the original Kademlia explains: "For caching purposes, once a lookup succeeds, the requesting node stores the <key, value> pair at the closest node it observed to the key that did not return the value." This method adds a callback to the NodeLookup to achieve this end. """ lookup = NodeLookup(key, FindValue, self) def cache(result): """ Called once the lookup succeeds in order to store the item at the node closest to the key that did not return the value. """ caching_contact = None for candidate in lookup.shortlist: if candidate in lookup.contacted: caching_contact = candidate break if caching_contact: log.msg("Caching to %r" % caching_contact) self.send_store(caching_contact, result.public_key, result.name, result.value, result.timestamp, result.expires, result.meta, result.sig) return result lookup.addCallback(cache) return lookup def republish(self, message): """ Will check and republish a locally stored message to the wider network. From the original Kademlia paper: "To ensure the persistence of key-value pairs, nodes must periodically republish keys. Otherwise, two phenomena may cause lookups for valid keys to fail. First, some of the k nodes that initially get a key-value pair when it is published may leave the network. Second, new nodes may join the network with IDs closer to some published key than the nodes on which the key-value pair was originally published. In both cases, the nodes with a key-value pair must republish it so as once again to ensure it is available on the k nodes closest to the key. To compensate for nodes leaving the network, Kademlia republishes each key-value pair once an hour. A naive implementation of this strategy would require many messages - each of up to k nodes storing a key-value pair would perform a node lookup followed by k - 1 STORE RPCs every hour. Fortunately, the republish process can be heavily optimized. First, when a node receives a STORE RPC for a given key-value pair, it assumes the RPC was also issued to the other k - 1 closest nodes, and thus the recipient will not republish the key-value pair in the next hour. This ensures that as long as republication intervals are not exactly synchronized, only one node will republish a given key-value pair every hour. A second optimization avoids performing node lookups before republishing keys. As described in Section 2.4, to handle unbalanced trees, nodes split k-buckets as required to ensure they have complete knowledge of a surrounding subtree with at least k nodes. If, before republishing key-value pairs, a node u refreshes all k-buckets in this subtree of k nodes, it will automatically be able to figure out the k closest nodes to a given key. These bucket refreshes can be amortized over the republication of many keys. To see why a node lookup is unnecessary after u refreshes buckets in the sub-tree of size >= k, it is necessary to consider two cases. If the key being republished falls in the ID range of the subtree, then since the subtree is of size at least k and u has complete knowledge of the subtree, clearly u must know the k closest nodes to the key. If, on the other hand, the key lies outside the subtree, yet u was one of the k closest nodes to the key, it must follow that u's k-buckets for intervals closer to the key than the subtree all have fewer than k entries. Hence, u will know all nodes in these k-buckets, which together with knowledge of the subtree will include the k closest nodes to the key. When a new node joins the system, it must store any key-value pair to which is is one of the k closest. Existing nodes, by similarly exploiting complete knowledge of their surrounding subtrees, will know which key-value pairs the new node should store. Any node learning of a new node therefore issues STORE RPCs to transfer relevant key-value pairs to the new node. To avoid redundant STORE RPCs, however, a node only transfers a key-value pair if it's [sic] own ID is closer to the key than are the IDs of other nodes." Messages are only republished if the following requirements are met: * They still exist in the local data store. * They have not expired. * They have not been updated for REPLICATE_INTERVAL seconds. """ pass
class Node(object): """ This class represents a single local node in the DHT encapsulating its presence in the network. All interactions with the DHT network by a client application are performed via this class (or a subclass). """ def __init__(self, id, client_string='ssl:%s:%d'): """ Initialises the object representing the node with the given id. """ # The node's ID within the distributed hash table. self.id = id # The routing table stores information about other nodes on the DHT. self._routing_table = RoutingTable(id) # The local key/value store containing data held by this node. self._data_store = DictDataStore() # A dictionary of IDs for messages pending a response and associated # deferreds to be fired when a response is completed. self._pending = {} # The template string to use when initiating a connection to another # node on the network. self._client_string = client_string # The version of Drogulus that this node implements. self.version = get_version() log.msg('Initialised node with id: %r' % self.id) def join(self, seed_nodes=None): """ Causes the Node to join the DHT network. This should be called before any other DHT operations. The seedNodes argument contains a list of tuples describing existing nodes on the network in the form of their IP address and port. """ pass def message_received(self, message, protocol): """ Handles incoming messages. """ # Update the routing table. peer = protocol.transport.getPeer() other_node = Contact(message.node, peer.host, peer.port, message.version, time.time()) log.msg('Message received from %s' % other_node) log.msg(message) self._routing_table.add_contact(other_node) # Sort on message type and pass to handler method. Explicit > implicit. if isinstance(message, Ping): self.handle_ping(message, protocol) elif isinstance(message, Pong): self.handle_pong(message) elif isinstance(message, Store): self.handle_store(message, protocol, other_node) elif isinstance(message, FindNode): self.handle_find_node(message, protocol) elif isinstance(message, FindValue): self.handle_find_value(message, protocol) elif isinstance(message, Error): self.handle_error(message, protocol, other_node) elif isinstance(message, Value): self.handle_value(message, other_node) elif isinstance(message, Nodes): self.handle_nodes(message) def send_message(self, contact, message): """ Sends a message to the specified contact, adds it to the _pending dictionary and ensures it times-out after the correct period. If an error occurs the deferred's errback is called. """ d = defer.Deferred() # open network call. client_string = self._client_string % (contact.address, contact.port) client = clientFromString(reactor, client_string) connection = client.connect(DHTFactory(self)) # Ensure the connection will potentially time out. connection_timeout = reactor.callLater(constants.RPC_TIMEOUT, connection.cancel) def on_connect(protocol): # Cancel pending connection_timeout if it's still active. if connection_timeout.active(): connection_timeout.cancel() # Send the message and add a timeout for the response. protocol.sendMessage(message) self._pending[message.uuid] = d reactor.callLater(constants.RESPONSE_TIMEOUT, response_timeout, message, protocol, self) def on_error(error): log.msg('***** ERROR ***** interacting with %s' % contact) log.msg(error) self._routing_table.remove_contact(message.node) if message.uuid in self._pending: del self._pending[message.uuid] d.errback(error) connection.addCallback(on_connect) connection.addErrback(on_error) return d def trigger_deferred(self, message, error=False): """ Given a message, will attempt to retrieve the deferred and trigger it with the appropriate callback or errback. """ if message.uuid in self._pending: deferred = self._pending[message.uuid] if error: error.message = message deferred.errback(error) else: deferred.callback(message) # Remove the called deferred from the _pending dictionary. del self._pending[message.uuid] def handle_ping(self, message, protocol): """ Handles an incoming Ping message. Returns a Pong message using the referenced protocol object. """ pong = Pong(message.uuid, self.id, self.version) protocol.sendMessage(pong, True) def handle_pong(self, message): """ Handles an incoming Pong message. """ self.trigger_deferred(message) def handle_store(self, message, protocol, sender): """ Handles an incoming Store message. Checks the provenance and timeliness of the message before storing locally. If there is a problem, removes the untrustworthy peer from the routing table. Otherwise, at REPLICATE_INTERVAL minutes in the future, the local node will attempt to replicate the Store message elsewhere in the DHT if such time is <= the message's expiry time. Sends a Pong message if successful otherwise replies with an appropriate Error. """ # Check provenance is_valid, err_code = validate_message(message) if is_valid: # Ensure the node doesn't already have a more up-to-date version # of the value. current = self._data_store.get(message.key, False) if current and (message.timestamp < current.timestamp): # The node already has a later version of the value so # return an error. details = { 'new_timestamp': '%d' % current.timestamp } raise ValueError(8, constants.ERRORS[8], details, message.uuid) # Good to go, so store value. self._data_store.set_item(message.key, message) # Reply with a pong so the other end updates its routing table. pong = Pong(message.uuid, self.id, self.version) protocol.sendMessage(pong, True) # At some future time attempt to replicate the Store message # around the network IF it is within the message's expiry time. reactor.callLater(constants.REPLICATE_INTERVAL, self.replicate, message) else: # Remove from the routing table. log.msg('Problem with Store command: %d - %s' % (err_code, constants.ERRORS[err_code])) self._routing_table.blacklist(sender) # Return an error. details = { 'message': 'You have been blacklisted.' } raise ValueError(err_code, constants.ERRORS[err_code], details, message.uuid) def handle_find_node(self, message, protocol): """ Handles an incoming FindNode message. Finds the details of up to K other nodes closer to the target key that *this* node knows about. Responds with a "Nodes" message containing the list of matching nodes. """ target_key = message.key # List containing tuples of information about the matching contacts. other_nodes = [(n.id, n.address, n.port, n.version) for n in self._routing_table.find_close_nodes(target_key)] result = Nodes(message.uuid, self.id, other_nodes, self.version) protocol.sendMessage(result, True) def handle_find_value(self, message, protocol): """ Handles an incoming FindValue message. If the local node contains the value associated with the requested key replies with an appropriate "Value" message. Otherwise, responds with details of up to K other nodes closer to the target key that the local node knows about. In this case a "Nodes" message containing the list of matching nodes is sent to the caller. """ match = self._data_store.get(message.key, False) if match: result = Value(message.uuid, self.id, match.key, match.value, match.timestamp, match.expires, match.public_key, match.name, match.meta, match.sig, match.version) protocol.sendMessage(result, True) else: self.handle_find_node(message, protocol) def handle_error(self, message, protocol, sender): """ Handles an incoming Error message. Currently, this simply logs the error and closes the connection. In future this *may* remove the sender from the routing table (depending on the error). """ # TODO: Handle error 8 (out of date data) log.msg('***** ERROR ***** from %s' % sender) log.msg(message) def handle_value(self, message, sender): """ Handles an incoming Value message containing a value retrieved from another node on the DHT. Ensures the message is valid and calls the referenced deferred to signal the arrival of the value. TODO: How to handle invalid messages and errback the deferred. """ # Check provenance is_valid, err_code = validate_message(message) if is_valid: self.trigger_deferred(message) else: log.msg('Problem with incoming Value: %d - %s' % (err_code, constants.ERRORS[err_code])) log.msg(message) # Remove the remote node from the routing table. self._routing_table.remove_contact(sender.id, True) error = ValueError(constants.ERRORS[err_code]) self.trigger_deferred(message, error) def handle_nodes(self, message): """ Handles an incoming Nodes message containing information about other nodes on the network that are close to a requested key. """ self.trigger_deferred(message) def send_ping(self, contact): """ Sends a ping request to the given contact and returns a deferred that is fired when the reply arrives or an error occurs. """ new_uuid = str(uuid4()) ping = Ping(new_uuid, self.id, self.version) return self.send_message(contact, ping) def send_store(self, contact, public_key, name, value, timestamp, expires, meta, signature): """ Sends a Store message to the given contact. The value contained within the message is stored against a key derived from the public_key and name. Furthermore, the message is cryptographically signed using the value, timestamp, expires, name and meta values. """ uuid = str(uuid4()) compound_key = construct_key(public_key, name) store = Store(uuid, self.id, compound_key, value, timestamp, expires, public_key, name, meta, signature, self.version) return self.send_message(contact, store) def send_find(self, contact, target, message_type): """ Sends a Find[Node|Value] message to the given contact with the intention of obtaining information at the given target key. The type of find message is specified by message_type. """ new_uuid = str(uuid4()) find_message = message_type(new_uuid, self.id, target, self.version) deferred = self.send_message(contact, find_message) return (new_uuid, deferred) def _process_lookup_result(self, nearest_nodes, public_key, name, value, timestamp, expires, meta, signature, length): """ Given a list of nearest nodes will return a list of send_store based deferreds for the item to be stored in the DHT. The list will contain up to "length" number of deferreds. """ list_of_deferreds = [] for contact in nearest_nodes[:length]: deferred = self.send_store(contact, public_key, name, value, timestamp, expires, meta, signature) list_of_deferreds.append(deferred) return list_of_deferreds def replicate(self, public_key, name, value, timestamp, expires, meta, signature, duplicate): """ Will replicate args to "duplicate" number of nodes in the distributed hash table. Returns a deferred that will fire with a list of send_store deferreds when "duplicate" number of closest nodes have been identified. Obviously, the list can be turned in to a deferred_list to fire when the store commands have completed. Even if "duplicate" is > K no more than K items will be contained within the list result. """ if duplicate < 1: # Guard to ensure meaningful duplication count. raise ValueError('Duplication count may not be less than 1') result = defer.Deferred() compound_key = construct_key(public_key, name) lookup = NodeLookup(compound_key, FindNode, self) def on_success(nodes): """ A list of close nodes have been found so send store messages to the "duplicate" closest number of them and fire the "result" deferred with the resulting DeferredList of pending deferreds. """ deferreds = self._process_lookup_result(nodes, public_key, name, value, timestamp, expires, meta, signature, duplicate) result.callback(deferreds) def on_error(error): """ Catch all for errors during the lookup phase. Simply pass them on via the "result" deferred. """ result.errback(error) lookup.addCallback(on_success) lookup.addErrback(on_error) return result def retrieve(self, key): """ Given a key, will try to retrieve associated value from the distributed hash table. Returns a deferred that will fire when the operation is complete or failed. As the original Kademlia explains: "For caching purposes, once a lookup succeeds, the requesting node stores the <key, value> pair at the closest node it observed to the key that did not return the value." This method adds a callback to the NodeLookup to achieve this end. """ lookup = NodeLookup(key, FindValue, self) def cache(result): """ Called once the lookup succeeds in order to store the item at the node closest to the key that did not return the value. """ caching_contact = None for candidate in lookup.shortlist: if candidate in lookup.contacted: caching_contact = candidate break if caching_contact: log.msg("Caching to %r" % caching_contact) self.send_store(caching_contact, result.public_key, result.name, result.value, result.timestamp, result.expires, result.meta, result.sig) return result lookup.addCallback(cache) return lookup
class Node(object): """ This class represents a single local node in the DHT encapsulating its presence in the network. All interactions with the DHT network by a client application are performed via this class (or a subclass). """ def __init__(self, id, client_string='ssl:%s:%d'): """ Initialises the object representing the node with the given id. """ # The node's ID within the distributed hash table. self.id = id # The routing table stores information about other nodes on the DHT. self._routing_table = RoutingTable(id) # The local key/value store containing data held by this node. self._data_store = DictDataStore() # A dictionary of IDs for messages pending a response and associated # deferreds to be fired when a response is completed. self._pending = {} # The template string to use when initiating a connection to another # node on the network. self._client_string = client_string # The version of Drogulus that this node implements. self.version = get_version() log.msg('Initialised node with id: %r' % self.id) def join(self, seed_nodes=None): """ Causes the Node to join the DHT network. This should be called before any other DHT operations. The seedNodes argument contains a list of tuples describing existing nodes on the network in the form of their IP address and port. """ pass def message_received(self, message, protocol): """ Handles incoming messages. """ # Update the routing table. peer = protocol.transport.getPeer() other_node = Contact(message.node, peer.host, peer.port, message.version, time.time()) log.msg('Message received from %s' % other_node) log.msg(message) self._routing_table.add_contact(other_node) # Sort on message type and pass to handler method. Explicit > implicit. if isinstance(message, Ping): self.handle_ping(message, protocol) elif isinstance(message, Pong): self.handle_pong(message) elif isinstance(message, Store): self.handle_store(message, protocol, other_node) elif isinstance(message, FindNode): self.handle_find_node(message, protocol) elif isinstance(message, FindValue): self.handle_find_value(message, protocol) elif isinstance(message, Error): self.handle_error(message, protocol, other_node) elif isinstance(message, Value): self.handle_value(message, other_node) elif isinstance(message, Nodes): self.handle_nodes(message) def send_message(self, contact, message): """ Sends a message to the specified contact, adds it to the _pending dictionary and ensures it times-out after the correct period. If an error occurs the deferred's errback is called. """ d = defer.Deferred() # open network call. client_string = self._client_string % (contact.address, contact.port) client = clientFromString(reactor, client_string) connection = client.connect(DHTFactory(self)) # Ensure the connection will potentially time out. connection_timeout = reactor.callLater(constants.RPC_TIMEOUT, connection.cancel) def on_connect(protocol): # Cancel pending connection_timeout if it's still active. if connection_timeout.active(): connection_timeout.cancel() # Send the message and add a timeout for the response. protocol.sendMessage(message) self._pending[message.uuid] = d reactor.callLater(constants.RESPONSE_TIMEOUT, response_timeout, message, protocol, self) def on_error(error): log.msg('***** ERROR ***** connecting to %s' % contact) log.msg(error) self._routing_table.remove_contact(message.node) d.errback(error) connection.addCallbacks(on_connect, on_error) return d def trigger_deferred(self, message, error=False): """ Given a message, will attempt to retrieve the deferred and trigger it with the appropriate callback or errback. """ if message.uuid in self._pending: deferred = self._pending[message.uuid] if error: error.message = message deferred.errback(error) else: deferred.callback(message) # Remove the called deferred from the _pending dictionary. del self._pending[message.uuid] def handle_ping(self, message, protocol): """ Handles an incoming Ping message. Returns a Pong message using the referenced protocol object. """ pong = Pong(message.uuid, self.id, self.version) protocol.sendMessage(pong, True) def handle_pong(self, message): """ Handles an incoming Pong message. """ self.trigger_deferred(message) def handle_store(self, message, protocol, sender): """ Handles an incoming Store message. Checks the provenance and timeliness of the message before storing locally. If there is a problem, removes the untrustworthy peer from the routing table. Otherwise, at REPLICATE_INTERVAL minutes in the future, the local node will attempt to replicate the Store message elsewhere in the DHT if such time is <= the message's expiry time. Sends a Pong message if successful otherwise replies with an appropriate Error. """ # Check provenance is_valid, err_code = validate_message(message) if is_valid: # Ensure the node doesn't already have a more up-to-date version # of the value. current = self._data_store.get(message.key, False) if current and (message.timestamp < current.timestamp): # The node already has a later version of the value so # return an error. details = { 'new_timestamp': '%d' % current.timestamp } raise ValueError(8, constants.ERRORS[8], details, message.uuid) # Good to go, so store value. self._data_store.set_item(message.key, message) # Reply with a pong so the other end updates its routing table. pong = Pong(message.uuid, self.id, self.version) protocol.sendMessage(pong, True) # At some future time attempt to replicate the Store message # around the network IF it is within the message's expiry time. reactor.callLater(constants.REPLICATE_INTERVAL, self.send_replicate, message) else: # Remove from the routing table. log.msg('Problem with Store command: %d - %s' % (err_code, constants.ERRORS[err_code])) self._routing_table.remove_contact(sender.id, True) # Return an error. details = { 'message': 'You have been removed from remote routing table.' } raise ValueError(err_code, constants.ERRORS[err_code], details, message.uuid) def handle_find_node(self, message, protocol): """ Handles an incoming FindNode message. Finds the details of up to K other nodes closer to the target key that *this* node knows about. Responds with a "Nodes" message containing the list of matching nodes. """ target_key = message.key # List containing tuples of information about the matching contacts. other_nodes = [(n.id, n.address, n.port, n.version) for n in self._routing_table.find_close_nodes(target_key)] result = Nodes(message.uuid, self.id, other_nodes, self.version) protocol.sendMessage(result, True) def handle_find_value(self, message, protocol): """ Handles an incoming FindValue message. If the local node contains the value associated with the requested key replies with an appropriate "Value" message. Otherwise, responds with details of up to K other nodes closer to the target key that the local node knows about. In this case a "Nodes" message containing the list of matching nodes is sent to the caller. """ match = self._data_store.get(message.key, False) if match: result = Value(message.uuid, self.id, match.key, match.value, match.timestamp, match.expires, match.public_key, match.name, match.meta, match.sig, match.version) protocol.sendMessage(result, True) else: self.handle_find_node(message, protocol) def handle_error(self, message, protocol, sender): """ Handles an incoming Error message. Currently, this simply logs the error and closes the connection. In future this *may* remove the sender from the routing table (depending on the error). """ # TODO: Handle error 8 (out of date data) log.msg('***** ERROR ***** from %s' % sender) log.msg(message) def handle_value(self, message, sender): """ Handles an incoming Value message containing a value retrieved from another node on the DHT. Ensures the message is valid and calls the referenced deferred to signal the arrival of the value. TODO: How to handle invalid messages and errback the deferred. """ # Check provenance is_valid, err_code = validate_message(message) if is_valid: self.trigger_deferred(message) else: log.msg('Problem with incoming Value: %d - %s' % (err_code, constants.ERRORS[err_code])) log.msg(message) # Remove the remote node from the routing table. self._routing_table.remove_contact(sender.id, True) error = ValueError(constants.ERRORS[err_code]) self.trigger_deferred(message, error) def handle_nodes(self, message): """ Handles an incoming Nodes message containing information about other nodes on the network that are close to a requested key. """ self.trigger_deferred(message) def iterative_lookup(self, key, message_class): """ A generic lookup function for finding nodes or values within the distributed hash table. Takes a key that either references a value or location in the hash-space. This function returns a deferred that will fire wth the found value or a set of peers in the DHT that are close to the key. The message class should be either FindNode or FindValue. """ pass def send_ping(self, contact): """ Sends a ping request to the given contact and returns a deferred that is fired when the reply arrives or an error occurs. """ new_uuid = str(uuid4()) ping = Ping(new_uuid, self.id, self.version) return self.send_message(contact, ping) def send_store(self, private_key, public_key, name, value, timestamp, expires, meta): """ Sends a Store message to the given contact. The value contained within the message is stored against a key derived from the public_key and name. Furthermore, the message is cryptographically signed using the value, timestamp, expires, name and meta values. """ new_uuid = str(uuid4()) signature = generate_signature(value, timestamp, expires, name, meta, private_key) compound_key = construct_key(public_key, name) new_store = Store(new_uuid, self.id, compound_key, value, timestamp, expires, public_key, name, meta, signature, self.version) return self.send_replicate(new_store) def send_replicate(self, store_message): """ Sends an existing valid Store message (that will probably have originated from a third party) to another peer on the network for the purposes of replication / spreading popular values. """ # Check for expiry time..? # Find closest node... """ new_uuid = str(uuid4()) store = Store(new_uuid, self.id, store_message.key, store_message.value, store_message.timestamp, store_message.expires, store_message.public_key, store_message.name, store_message.meta, store_message.sig, self.version) return self.send_message(contact, store)""" def send_find_node(self, contact, id): """ Sends a FindNode message to the given contact with the intention of obtaining contact information about the node with the specified id. """ pass def send_find_value(self, contact, key): """ Sends a FindValue message to the given contact with the intention of obtaining the value associated with the specified key. """ pass
class StaticTupleSpacePeer(): """ Enables tuples to be stored locally, and in turn allows non-local tuples to be located at static network locations provided as input at start-up """ def __init__(self, id=None, udpPort=4000, dataStore=None, routingTable=None, networkProtocol=None): if id != None: self.id = id else: self.id = self._generateID() self.port = udpPort if networkProtocol == None: self._protocol = protocol.KademliaProtocol(self) else: self._protocol = networkProtocol self._joinDeferred = None self.contactsList = [] self.dataStore = DictDataStore() def put(self, sTuple, originalPublisherID=None): """ Used to write a tuple or serialized (string) data into a tuple space @note: This method is generally called "out" in tuple space literature, but is renamed to "put" in this implementation to match the renamed "in"/"get" method (see the description for C{get()}). @param sTuple: The tuple to write into the static tuple space (it is named "sTuple" to avoid a conflict with the Python C{tuple} data type). @type sTuple: tuple @rtype: twisted.internet.defer.Deferred """ if isinstance(sTuple, tuple): # Parse the tuple sData = (sTuple[0], sTuple[1]) ownerID = sTuple[2] # Serialize the data tupleValue = cPickle.dumps(sData) elif isinstance(sTuple, str): tupleValue = sTuple ownerID = originalPublisherID else: raise DataFormatError("Error, expected a tuple or a serialized string as input") # TODO: may need to implement more advanced tuple space, based on data types # for now tuples need to match exactly (element value as well as element order) # Generate a hash of the data to be stored h = hashlib.sha1() h.update(tupleValue) mainKey = h.digest() originallyPublished = 0 now = int(time.time()) # print 'publishing :' # print 'key ' + str((mainKey,)) # print 'value' + tupleValue # TODO: Check if the tuple has already been stored, also check that other peers tuples don't replace # locally stored tuples self.dataStore.setItem(mainKey, tupleValue, now, originallyPublished, ownerID) df = defer.Deferred() # invoke call-back now df.callback(tupleValue) return df def get(self, template): """ Reads and removes (consumes) a tuple from the tuple space (blocking) @type template: tuple @note: This method is generally called "in" in tuple space literature, but is renamed to "get" in this implementation to avoid a conflict with the Python C{in} keyword. @return: a matching tuple, or None if no matching tuples were found """ # TODO: consider to implement blocking mechanism that waits until a tuple is found # TODO: consider to implement mechanism that removes the tuple (resource marshalling) return self.findTuple(template) def getIfExists(self, template, getListenerTuple=False): """ Reads and removes (consumes) a tuple from the tuple space (non-blocking) @type template: tuple @param getListenerTuple: If set to True, look for a I{listener tuple} for this template; this is typically used to remove event handlers. @type getListenerTuple: bool @return: a matching tuple, or None if no matching tuples were found @note: This method is generally called "in" in tuple space literature, but is renamed to "get" in this implementation to avoid a conflict with the Python C{in} keyword. """ return self.findTuple(template) def read(self, template, numberOfResults=1): """ Non-destructively reads a tuple in the tuple space (blocking) This operation is similar to "get" (or "in") in that the peer builds a template and waits for a matching tuple in the tuple space. Upon finding a matching tuple, however, it copies it, leaving the original tuple in the tuple space. @note: This method is named "rd" in some other implementations. @param numberOfResults: The maximum number of matching tuples to return. If set to 1 (default), return the tuple itself, otherwise return a list of tuples. If set to 0 or lower, return all results. @type numberOfResults: int @return: a matching tuple, or list of tuples (if C{numberOfResults} is not set to 1, or None if no matching tuples were found @rtype: twisted.internet.defer.Deferred """ returnedTuple = self.findTuple(template) if returnedTuple != None: #returnTuples = [] #returnTuples.append(returnedTuple) #return returnTuples return returnedTuple else: return None def readIfExists(self, template, numberOfResults=1): """ Non-destructively reads a tuple in the tuple space (non-blocking) This operation is similar to "get" (or "in") in that the peer builds a template and waits for a matching tuple in the tuple space. Upon finding a matching tuple, however, it copies it, leaving the original tuple in the tuple space. @note: This method is named "rd" in some other implementations. @param numberOfResults: The maximum number of matching tuples to return. If set to 1 (default), return the tuple itself, otherwise return a list of tuples. If set to 0 or lower, return all results. @type numberOfResults: int @return: a matching tuple, or list of tuples (if C{numberOfResults} is not set to 1, or None if no matching tuples were found @rtype: twisted.internet.defer.Deferred """ returnedTuple = self.findTuple(template) if returnedTuple != None: #returnTuples = [] #returnTuples.append(returnedTuple) #return returnTuples return returnedTuple else: return None @rpcmethod def findTuple(self, value): """ Used to search the dataStore for a tuple, if invoked locally it searches this peers datastore. If it is invoked via RPC it will search for the tuple at the remote peer @param value: The tuple to search for return: a matching tuple, or None if no matching tuples were found """ if isinstance(value, tuple): # parse the tuple dataTuple = (value[0], value[1]) # Serialize the tuple serialValue = cPickle.dumps(dataTuple) elif isinstance(value, str): serialValue = value else: raise DataFormatError("Error, expected a tuple or a serialized string as input") # print 'searching for ' + serialValue # Generate a hash of the value h = hashlib.sha1() h.update(serialValue) mainKey = h.digest() keys = self.dataStore.keys() try: dataStoreTuple = self.dataStore.__getitem__(mainKey) except Exception: # The data wasn't found, so return None return None # Note, returning value given as input since it was found in the data store # and it is in tuple format, not serialized as in the data store # TODO: Implement mechanism to handle tuple type (resource/handler) if len(value) == 5: extraArgs = [] if value[3] == None: extraArgs.append('') else: extraArgs.append(value[3]) if value[4] == None: extraArgs.append('') else: extraArgs.append(value[4]) returnTuple = (value[0], value[1], self.dataStore.originalPublisherID(mainKey), extraArgs[0], extraArgs[1]) else: returnTuple = (value[0], value[1], self.dataStore.originalPublisherID(mainKey)) return returnTuple @rpcmethod def getOwnedTuples(self): """ Used to obtain all of the tuples owned by this peer via RPC, @return: a list containing all of the tuples and their owner ID's in the following format [(ownerID, tuple1), ..., (ownerID, tuple n)] @rtype: list """ dataStoreKeys = self.dataStore.keys() tuples = [] for key in dataStoreKeys: if (self.dataStore.originalPublisherID(key) == self.id): idTuple = [] idTuple.append(self.id) idTuple.append(self.dataStore.__getitem__(key)) tuples.append(idTuple) return tuples @rpcmethod def getAllTuples(self): """ Used to obtain all of the tuples stored at a remote peer via RPC, @return: a list containing all of the tuples and their owner ID's in the following format [(ownerID, tuple1), ..., (ownerID, tuple n)] @rtype: list """ dataStoreKeys = self.dataStore.keys() tuples = [] for key in dataStoreKeys: idTuple = [] idTuple.append(self.dataStore.originalPublisherID(key)) idTuple.append(self.dataStore.__getitem__(key)) tuples.append(idTuple) return tuples def findContact(self, contactID): """ Used to search for a contact inside of this peers contactList @return: a contact if it was found """ #print 'searching for ' + str((contactID,)) for contact in self.contactsList: #print 'contact ' + str((contact.id,)) if contact.id == contactID: return contact def refreshDataStore(self): """ Refreshs the datastore, ensuring that the tuples obtained from remote peers are still valid and that those peers are still alive """ # TODO: Implement this def joinNetwork(self, knownNodeAddresses=None): """ Causes the peer to join the static network; A list of known contacts addresses is given as input. Each of these contacts are polled to see if they are alive. @param knownNodeAddresses: A sequence of tuples containing IP address information for existing nodes on the Kademlia network, in the format: C{(<ip address>, (udp port>)} @type knownNodeAddresses: tuple @return: Deferred, will call-back once join has completed @rtype: twisted.internet.defer.Deferred """ def addContact(responseTuple): """ adds a contact once it has responded to the remote procedure call """ responseMsg = responseTuple[0] originatingAddress = responseTuple[1] contactID = responseTuple[0].nodeID #print 'id ' + str((responseMsg.nodeID,)) #print 'response ' + str(responseMsg.response) #print 'address ' + str(originatingAddress) if contactID != None: activeContact = Contact(contactID, originatingAddress[0], originatingAddress[1], self._protocol) self.contactsList.append(activeContact) # Check for an errorMessage in the responseMsg if isinstance(responseMsg, ErrorMessage): self._joinDeferred.errback(failure.Failure(Exception('Error response from RPC call: ' + str(responseMsg.response)))) #print 'Error response from RPC call: ' + str(responseMsg.response) else: # Check if any tuples were returned by this contact response = responseMsg.response if isinstance(response, list): # obtain the tuples and their owner ID's for item in response: # Place this item into the local data store ownerID = item[0] tupleValue = item[1] self.put(tupleValue, ownerID) # print 'received tuples' # print response # # localTuples = self.getAllTuples() # print 'locally stored tuples' # for tuple in localTuples: # print str(tuple) else: self._joinDeferred.errback(failure.Failure(Exception('RPC response from contact invalid, expected a list'))) # Check if all the contacts have been reached if len(self.contactsList) == len(knownNodeAddresses): # invoke joinDeferred callback to signal that join has completed self._joinDeferred.callback(self.contactsList) def checkInitStatus(error): """ Invoked when RPC attempt to contact fails @type failure: twisted.python.failure.Failure """ error.trap(protocol.TimeoutError) deadContactID = error.getErrorMessage() # TODO: Log this error #print 'Error, communication with contact failed!' # Remove this contact from the tentativeContacts list badContacts = [] for cont in tentativeContacts: if cont.id == deadContactID: badContacts.append(cont) for cont in badContacts: # TODO: log this error #print 'dead contact removed!' tentativeContacts.remove(cont) # Check if all the other contacts have responded (Thus join completed) if (len(self.contactsList) > 0) and \ (len(self.contactsList) == (len(knownNodeAddresses) - (len(knownNodeAddresses) - len(tentativeContacts)))): # invoke joinDeferred errback to signal that join did not complete successfully self._joinDeferred.errback(failure.Failure(Exception('Not all contacts responded'))) # Check if all of the contacts did not respond elif len(tentativeContacts) == 0: # invoke joinDeferred errback to signal that join did not complete successfully self._joinDeferred.errback(failure.Failure(Exception('None of the contacts could be reached'))) # TODO: log this error # Prepare the underlying Kademlia protocol self._listeningPort = twisted.internet.reactor.listenUDP(self.port, self._protocol) #IGNORE:E1101 self._joinDeferred = defer.Deferred() tentativeContacts = [] # Create temporary contact information for the list of addresses of known nodes if knownNodeAddresses != None: bootstrapContacts = [] for address, port in knownNodeAddresses: contact = Contact(self._generateID(), address, port, self._protocol) tentativeContacts.append(contact) # Check that the contact exists, and obtain its actual id and a list of all the tuples stored # by this contact rpcMethod = getattr(contact, 'getOwnedTuples') df = rpcMethod(rawResponse=True) df.addCallback(addContact) df.addErrback(checkInitStatus) # if no known contacts, just call-back without trying to connect to peers else: self._joinDeferred.callback(None) #self.contactsList.append(contact) # TODO: schedule a call to a statusCheckMethod which ensures that contacts are active def _iterativeFind(self, contactID): """ Used in Distributed Hash Table, still accessed by current mobilIVR system, thus just here as a blank for interface purposes """ emptyList = [] return emptyList def addContact(self, contact): """ add a contact """ def removeContact(self, contact): """ remove the contact """ def _generateID(self): """ Generates a 160-bit pseudo-random identifier @return: A globally unique 160-bit pseudo-random identifier @rtype: str """ hash = hashlib.sha1() hash.update(str(random.getrandbits(255))) return hash.digest()