diff --git a/jmdaemon/jmdaemon/onionmc.py b/jmdaemon/jmdaemon/onionmc.py index 8e6f63f..1265939 100644 --- a/jmdaemon/jmdaemon/onionmc.py +++ b/jmdaemon/jmdaemon/onionmc.py @@ -700,8 +700,8 @@ class OnionMessageChannel(MessageChannel): self.wait_for_directories_loop = None # this dict plays the same role as `active_channels` in `MessageChannelCollection`. - # it has structure {nick: set(),..} where set() has elements that are dicts: - # {OnionPeer: bool}. + # it has structure {nick1: {}, nick2: {}, ...} where the inner dicts are: + # {OnionDirectoryPeer1: bool, OnionDirectoryPeer2: bool, ...}. # Entries get updated with changing connection status of directories, # allowing us to decide where to send each message we want to send when we have no # direct connection. @@ -1057,6 +1057,33 @@ class OnionMessageChannel(MessageChannel): return self.send_peers(peer, peer_filter=[peer_from]) + def on_nick_leave_directory(self, nick: str, dir_peer: OnionPeer) -> None: + """ This is called in response to a disconnection control + message from a directory, telling us that a certain nick has left. + We update this connection status in the active_directories map, + and fire the MessageChannel.on_nick_leave when we see all the + connections are lost. + Note that `on_nick_leave` can be triggered in two ways; both here, + and also via `self.register_disconnection`, which occurs for peers + to whom we are directly connected. Calling it multiple times is not + harmful, but remember that the on_nick_leave event only bubbles up + above the message channel layer once *all* message channels trigger + on_nick_leave (in case we are using another message channel as well + as this one, like IRC). + """ + if not nick in self.active_directories: + return + if not dir_peer in self.active_directories[nick]: + log.info("Directory {} is telling us that {} has left, but we " + "didn't know about them. Ignoring.".format( + dir_peer.peer_location(), nick)) + return + log.debug("Directory {} has lost connection to: {}".format( + dir_peer.peer_location(), nick)) + self.active_directories[nick][dir_peer] = False + if not any(self.active_directories[nick].values()): + self.on_nick_leave(nick, self) + def process_control_message(self, peerid: str, msgtype: int, msgval: str) -> bool: """ Triggered by a directory node feeding us @@ -1084,10 +1111,24 @@ class OnionMessageChannel(MessageChannel): return True try: peerlist = msgval.split(",") - for peer in peerlist: + for peer_in_list in peerlist: + # directories should send us peerstrings that include + # nick;host:port;D where "D" indicates that the directory + # is signalling this peer as having left. Otherwise, without + # the third field, we treat it as a "join" event. + try: + nick, hostport, disconnect_code = peer_in_list.split( + NICK_PEERLOCATOR_SEPARATOR) + if disconnect_code != "D": + continue + self.on_nick_leave_directory(nick, peer) + continue + except ValueError: + # just means this message is not of the 'disconnect' type + pass # defaults mean we just add the peer, not # add or alter its connection status: - self.add_peer(peer, with_nick=True) + self.add_peer(peer_in_list, with_nick=True) except Exception as e: log.debug("Incorrectly formatted peer list: {}, " "ignoring, {}".format(msgval, e)) @@ -1118,6 +1159,13 @@ class OnionMessageChannel(MessageChannel): msgval = self.get_peer_by_id(msgval).peer_location() self.add_peer(msgval, connection=False, overwrite_connection=True) + if self.self_as_peer.directory: + # We propagate the control message as a "peerlist" with + # the "D" flag: + disconnected_peer = self.get_peer_by_id(msgval) + for p in self.get_connected_nondirectory_peers(): + self.send_peers(p, peer_filter=[disconnected_peer], + disconnect=True) # bubble up the disconnection event to the abstract # message channel logic: if self.on_nick_leave: @@ -1310,8 +1358,12 @@ class OnionMessageChannel(MessageChannel): try: nick, peer = peerdata.split(NICK_PEERLOCATOR_SEPARATOR) except Exception as e: - # TODO: as of now, this is not an error, but expected. - # Don't log? Do something else? + # old code does not recognize messages with "D" as a third + # field; they will swallow the message here, ignoring + # the message as invalid because it has three fields + # instead of two. + # (We still use the catch-all `Exception`, for the usual reason + # of not wanting to make assumptions about external input). log.debug("Received invalid peer identifier string: {}, {}".format( peerdata, e)) return @@ -1361,7 +1413,7 @@ class OnionMessageChannel(MessageChannel): return [p for p in self.peers if p.directory and p.status() == \ PEER_STATUS_HANDSHAKED] - def get_connected_nondirectory_peers(self) -> list: + def get_connected_nondirectory_peers(self) -> List[OnionPeer]: return [p for p in self.peers if (not p.directory) and p.status() == \ PEER_STATUS_HANDSHAKED] @@ -1397,41 +1449,51 @@ class OnionMessageChannel(MessageChannel): """ CONTROL MESSAGES SENT BY US """ def send_peers(self, requesting_peer: OnionPeer, - peer_filter: List[OnionPeer]) -> None: + peer_filter: List[OnionPeer], disconnect: bool=False) -> None: """ This message is sent by directory peers, currently - only when a privmsg has to be forwarded to them. It - could also be sent by directories to non-directory peers - according to some other algorithm. - If peer_filter is specified, only those peers will be sent. + only when a privmsg has to be forwarded to them, or a peer has + disconnected. It could also be sent by directories to non-directory + peers according to some other algorithm. + The message is sent *to* `requesting_peer`. + If `peer_filter` is specified, only those peers will be sent. + If `disconnect` is True, we append "D" to every entry, which + indicates to the receiver that the peer being sent has left, + not that that peer is available. The peerlist message should have this format: (1) entries comma separated - (2) each entry is serialized nick then the NICK_PEERLOCATOR_SEPARATOR - then host:port - (3) Peers that do not have a reachable location are not sent. + (2) each entry a two- or three- element list, separated by NICK_PEERLOCATOR_SEPARATOR, + [nick, host:port] or same with ["D"] added at the end. + For the case disconnect=False, peers that do not have a reachable location are not sent. """ if not requesting_peer.status() == PEER_STATUS_HANDSHAKED: raise OnionPeerConnectionError( "Cannot send peer list to unhandshaked peer") peerlist = set() peer_filter_exists = len(peer_filter) > 0 - for p in self.get_connected_nondirectory_peers(): - # don't send a peer to itself - if p == requesting_peer: - continue - if peer_filter_exists and p not in peer_filter: - continue - if p.status() != PEER_STATUS_HANDSHAKED: - # don't advertise what is not online. - continue - # peers that haven't sent their nick yet are not - # privmsg-reachable; don't send them - if p.nick == "": - continue - if p.peer_location() == NOT_SERVING_ONION_HOSTNAME: - # if a connection has no reachable destination, - # don't forward it - continue - peerlist.add(p.get_nick_peerlocation_ser()) + if disconnect is False: + for p in self.get_connected_nondirectory_peers(): + # don't send a peer to itself + if p == requesting_peer: + continue + if peer_filter_exists and p not in peer_filter: + continue + if p.status() != PEER_STATUS_HANDSHAKED: + # don't advertise what is not online. + continue + # peers that haven't sent their nick yet are not + # privmsg-reachable; don't send them + if p.nick == "": + continue + if p.peer_location() == NOT_SERVING_ONION_HOSTNAME: + # if a connection has no reachable destination, + # don't forward it + continue + peerlist.add(p.get_nick_peerlocation_ser()) + else: + # since the peer may already be removed from self.peers, + # we don't limit except by filter: + for p in peer_filter: + peerlist.add(p.get_nick_peerlocation_ser() + NICK_PEERLOCATOR_SEPARATOR + "D") # For testing: dns won't usually participate: peerlist.add(self.self_as_peer.get_nick_peerlocation_ser()) # don't send an empty set (will not be possible unless