# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
#   its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
#   an artificial intelligence, machine learning or language model training
#   dataset, including but not limited to any use that contributes to the
#   training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
#   all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import os
import gc
import RNS
import time
import math
import struct
import inspect
import threading
from time import sleep
from threading import Lock
from collections import deque
from .vendor import umsgpack as umsgpack
from RNS.Interfaces.BackboneInterface import BackboneInterface

class Transport:
    """
    Through static methods of this class you can interact with the
    Transport system of Reticulum.
    """
    # Constants
    BROADCAST                   = 0x00;
    TRANSPORT                   = 0x01;
    RELAY                       = 0x02;
    TUNNEL                      = 0x03;
    types                       = [BROADCAST, TRANSPORT, RELAY, TUNNEL]

    REACHABILITY_UNREACHABLE    = 0x00
    REACHABILITY_DIRECT         = 0x01
    REACHABILITY_TRANSPORT      = 0x02

    APP_NAME = "rnstransport"

    PATHFINDER_M                = 128          # Max hops
    """
    Maximum amount of hops that Reticulum will transport a packet.
    """
    
    PATHFINDER_R                = 1            # Retransmit retries
    PATHFINDER_G                = 5            # Retry grace period
    PATHFINDER_RW               = 0.5          # Random window for announce rebroadcast
    PATHFINDER_E                = 60*60*24*7   # Path expiration of one week
    AP_PATH_TIME                = 60*60*24     # Path expiration of one day for Access Point paths
    ROAMING_PATH_TIME           = 60*60*6      # Path expiration of 6 hours for Roaming paths

    # TODO: Calculate an optimal number for this in
    # various situations
    LOCAL_REBROADCASTS_MAX      = 2            # How many local rebroadcasts of an announce is allowed

    PATH_REQUEST_TIMEOUT        = 15           # Default timeout for client path requests in seconds
    PATH_REQUEST_GATE_TIMEOUT   = 120          # Default timeout for client path request gate control in seconds
    PATH_REQUEST_GRACE          = 0.4          # Grace time before a path announcement is made, allows directly reachable peers to respond first
    PATH_REQUEST_RG             = 1.5          # Extra grace time for roaming-mode interfaces to allow more suitable peers to respond first
    PATH_REQUEST_MI             = 20           # Minimum interval in seconds for automated path requests

    STATE_UNKNOWN               = 0x00
    STATE_UNRESPONSIVE          = 0x01
    STATE_RESPONSIVE            = 0x02

    LINK_TIMEOUT                = RNS.Link.STALE_TIME * 1.25
    REVERSE_TIMEOUT             = 8*60         # Reverse table entries are removed after 8 minutes
    DESTINATION_TIMEOUT         = 60*60*24*7   # Destination table entries are removed if unused for one week
    UNUSED_DESTINATION_LINGER   = 6*60         # Linger time for pathless and never used destinations
    TUNNEL_TIMEOUT              = 60*60*8      # Tunnel table entries are removed if unused for eight hours
    TUNNEL_PATH_TIMEOUT         = 60*60*8      # Tunnel path table entries are removed if unused for eight hours
    MAX_RECEIPTS                = 1024         # Maximum number of receipts to keep track of
    MAX_RATE_TIMESTAMPS         = 16           # Maximum number of announce timestamps to keep per destination
    PERSIST_RANDOM_BLOBS        = 32           # Maximum number of random blobs per destination to persist to disk
    MAX_RANDOM_BLOBS            = 64           # Maximum number of random blobs per destination to keep in memory
    READY_WAIT                  = 60           # Maximum wait time for inbound packets received before transport core was ready

    interfaces                  = []           # All active interfaces
    destinations                = []           # All active destinations
    destinations_map            = {}           # Destination hash map of active destinations
    pending_links               = []           # Links that are being established
    active_links                = []           # Links that are active
    packet_hashlist             = set()        # A list of packet hashes for duplicate detection
    packet_hashlist_prev        = set()
    receipts                    = []           # Receipts of all outgoing packets for proof processing

    # Notes on memory usage: 1 megabyte of memory can store approximately
    # 55.100 path table entries or approximately 22.300 link table entries.

    announce_table              = {}           # A table for storing announces currently waiting to be retransmitted
    path_table                  = {}           # A lookup table containing the next hop to a given destination
    reverse_table               = {}           # A lookup table for storing packet hashes used to return proofs and replies
    link_table                  = {}           # A lookup table containing hops for links
    held_announces              = {}           # A table containing temporarily held announce-table entries
    announce_handlers           = []           # A table storing externally registered announce handlers
    tunnels                     = {}           # A table storing tunnels to other transport instances
    announce_rate_table         = {}           # A table for keeping track of announce rates
    path_requests               = {}           # A table for storing path request timestamps
    path_states                 = {}           # A table for keeping track of path states
    blackholed_identities       = {}           # A table for keeping track of blackholed identities
    
    discovery_path_requests     = {}           # A table for keeping track of path requests on behalf of other nodes
    discovery_pr_tags           = []           # A table for keeping track of tagged path requests
    max_pr_tags                 = 32000        # Maximum amount of unique path request tags to remember
    max_queued_discovery_prs    = 32           # Maximum amount of queued discovery path requests

    interfaces_lock             = Lock()
    destinations_lock           = Lock()
    destinations_map_lock       = Lock()
    inbound_announce_lock       = Lock()
    announce_table_lock         = Lock()
    announce_rate_table_lock    = Lock()
    announce_handler_lock       = Lock()
    path_table_lock             = Lock()
    reverse_table_lock          = Lock()
    link_table_lock             = Lock()
    active_links_lock           = Lock()
    pending_links_lock          = Lock()
    tunnels_lock                = Lock()
    receipts_lock               = Lock()
    discovery_pr_lock           = Lock()
    discovery_pr_tags_lock      = Lock()
    path_requests_lock          = Lock()
    pending_local_prs_lock      = Lock()
    path_states_lock            = Lock()
    jobs_lock                   = Lock()
    cache_clean_lock            = Lock()
    persist_lock                = Lock()

    # Transport control destinations are used
    # for control purposes like path requests
    control_destinations        = []
    control_hashes              = []
    mgmt_destinations           = []
    mgmt_hashes                 = []
    remote_management_allowed   = []

    # Interfaces for communicating with
    # local clients connected to a shared
    # Reticulum instance
    local_client_interfaces     = []

    local_client_rssi_cache     = []
    local_client_snr_cache      = []
    local_client_q_cache        = []
    LOCAL_CLIENT_CACHE_MAXSIZE  = 512

    pending_local_path_requests = {}

    ready                       = False
    start_time                  = None
    hashlist_maxsize            = 1000000
    job_interval                = 0.250
    links_last_checked          = 0.0
    links_check_interval        = 1.0
    receipts_last_checked       = 0.0
    receipts_check_interval     = 1.0
    announces_last_checked      = 0.0
    announces_check_interval    = 1.0
    pending_prs_last_checked    = 0.0
    pending_prs_check_interval  = 30.0
    cache_last_cleaned          = 0.0
    cache_clean_interval        = 5*60
    destinations_last_cleaned   = 0.0
    known_destinations_interval = 5*60
    tables_last_culled          = 0.0
    tables_cull_interval        = 5.0
    interface_last_jobs         = 0.0
    interface_jobs_interval     = 5.0
    last_mgmt_announce          = 0
    mgmt_announce_interval      = 2*60*60
    blackhole_last_checked      = 0
    blackhole_check_interval    = 60
    interface_announcer         = None
    discovery_handler           = None
    blackhole_updater           = None

    traffic_rxb                 = 0
    traffic_txb                 = 0
    speed_rx                    = 0
    speed_tx                    = 0
    traffic_captured            = None

    identity                    = None
    network_identity            = None

    _should_run                 = True

    @staticmethod
    def start(reticulum_instance):
        Transport.owner = reticulum_instance

        if Transport.identity == None:
            transport_identity_path = RNS.Reticulum.storagepath+"/transport_identity"
            if os.path.isfile(transport_identity_path):
                Transport.identity = RNS.Identity.from_file(transport_identity_path)                

            if Transport.identity == None:
                RNS.log("No valid Transport Identity in storage, creating...", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None
                Transport.identity = RNS.Identity()
                Transport.identity.to_file(transport_identity_path)
            else:
                RNS.log("Loaded Transport Identity from storage", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None

        packet_hashlist_path = RNS.Reticulum.storagepath+"/packet_hashlist"
        if not Transport.owner.is_connected_to_shared_instance:
            if os.path.isfile(packet_hashlist_path):
                try:
                    file = open(packet_hashlist_path, "rb")
                    hashlist_data = umsgpack.unpackb(file.read())
                    Transport.packet_hashlist = set(hashlist_data)
                    file.close()
                except Exception as e:
                    RNS.log("Could not load packet hashlist from storage, the contained exception was: "+str(e), RNS.LOG_ERROR)

        Transport.reload_blackhole()

        # Create transport-specific destinations
        Transport.path_request_destination = RNS.Destination(None, RNS.Destination.IN, RNS.Destination.PLAIN, Transport.APP_NAME, "path", "request")
        Transport.path_request_destination.set_packet_callback(Transport.path_request_handler)
        Transport.control_destinations.append(Transport.path_request_destination)
        Transport.control_hashes.append(Transport.path_request_destination.hash)

        Transport.tunnel_synthesize_destination = RNS.Destination(None, RNS.Destination.IN, RNS.Destination.PLAIN, Transport.APP_NAME, "tunnel", "synthesize")
        Transport.tunnel_synthesize_destination.set_packet_callback(Transport.tunnel_synthesize_handler)
        Transport.control_destinations.append(Transport.tunnel_synthesize_handler)
        Transport.control_hashes.append(Transport.tunnel_synthesize_destination.hash)

        if RNS.Reticulum.remote_management_enabled() and not Transport.owner.is_connected_to_shared_instance:
            Transport.remote_management_destination = RNS.Destination(Transport.identity, RNS.Destination.IN, RNS.Destination.SINGLE, Transport.APP_NAME, "remote", "management")
            Transport.remote_management_destination.register_request_handler("/status", response_generator = Transport.remote_status_handler, allow = RNS.Destination.ALLOW_LIST, allowed_list=Transport.remote_management_allowed)
            Transport.remote_management_destination.register_request_handler("/path", response_generator = Transport.remote_path_handler, allow = RNS.Destination.ALLOW_LIST, allowed_list=Transport.remote_management_allowed)
            Transport.mgmt_destinations.append(Transport.remote_management_destination)
            Transport.mgmt_hashes.append(Transport.remote_management_destination.hash)
            RNS.log("Enabled remote management on "+str(Transport.remote_management_destination), RNS.LOG_NOTICE)

        if RNS.Reticulum.publish_blackhole_enabled() and not Transport.owner.is_connected_to_shared_instance:
            Transport.blackhole_destination = RNS.Destination(Transport.identity, RNS.Destination.IN, RNS.Destination.SINGLE, Transport.APP_NAME, "info", "blackhole")
            Transport.blackhole_destination.register_request_handler("/list", response_generator = Transport.blackhole_list_handler, allow=RNS.Destination.ALLOW_ALL)
            Transport.mgmt_destinations.append(Transport.blackhole_destination)
            Transport.mgmt_hashes.append(Transport.blackhole_destination.hash)
            RNS.log(f"Enabled blackhole list publishing for transport identity {RNS.prettyhexrep(Transport.identity.hash)}", RNS.LOG_NOTICE)

        if Transport.network_identity and not Transport.owner.is_connected_to_shared_instance:
            Transport.instance_destination = RNS.Destination(Transport.network_identity, RNS.Destination.IN, RNS.Destination.SINGLE, Transport.APP_NAME, "network", "instance", RNS.hexrep(Transport.network_identity.hash, delimit=False))
            Transport.network_destination  = RNS.Destination(Transport.network_identity, RNS.Destination.IN, RNS.Destination.SINGLE, Transport.APP_NAME, "network")
            Transport.mgmt_destinations.append(Transport.instance_destination)
            Transport.mgmt_destinations.append(Transport.network_destination)
            Transport.mgmt_hashes.append(Transport.instance_destination)
            Transport.mgmt_hashes.append(Transport.network_destination)

        # Defer cleaning packet cache for 60 seconds
        Transport.cache_last_cleaned = time.time() + 60
        
        # Defer cleaning known destinations
        Transport.destinations_last_cleaned = time.time()

        # Defer sending management announces for 15 seconds
        Transport.last_mgmt_announce = time.time() - Transport.mgmt_announce_interval + 15
        
        # Start job loops
        threading.Thread(target=Transport.jobloop, daemon=True).start()
        threading.Thread(target=Transport.count_traffic_loop, daemon=True).start()

        # Load transport-related data
        if RNS.Reticulum.transport_enabled():
            path_table_path = RNS.Reticulum.storagepath+"/destination_table"
            tunnel_table_path = RNS.Reticulum.storagepath+"/tunnels"

            if os.path.isfile(path_table_path) and not Transport.owner.is_connected_to_shared_instance:
                serialised_destinations = []
                try:
                    file = open(path_table_path, "rb")
                    serialised_destinations = umsgpack.unpackb(file.read())
                    file.close()

                    for serialised_entry in serialised_destinations:
                        destination_hash = serialised_entry[0]

                        if len(destination_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8:
                            timestamp = serialised_entry[1]
                            received_from = serialised_entry[2]
                            hops = serialised_entry[3]
                            expires = serialised_entry[4]
                            random_blobs = serialised_entry[5]
                            receiving_interface = Transport.find_interface_from_hash(serialised_entry[6])
                            announce_packet = Transport.get_cached_packet(serialised_entry[7], packet_type="announce")
                            blackholed = False

                            if len(Transport.blackholed_identities) > 0:
                                path_identity = RNS.Identity.recall(destination_hash, _no_use=True)
                                if path_identity in Transport.blackholed_identities: blackholed = True
                                del path_identity

                            if announce_packet != None and receiving_interface != None and blackholed == False:
                                announce_packet.unpack()
                                # We increase the hops, since reading a packet
                                # from cache is equivalent to receiving it again
                                # over an interface. It is cached with it's non-
                                # increased hop-count.
                                announce_packet.hops += 1
                                with Transport.path_table_lock:
                                    Transport.path_table[destination_hash] = [timestamp, received_from, hops, expires, random_blobs, receiving_interface, announce_packet.packet_hash]
                                RNS.log("Loaded path table entry for "+RNS.prettyhexrep(destination_hash)+" from storage", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                            else:
                                RNS.log("Could not reconstruct path table entry from storage for "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                if announce_packet == None:
                                    RNS.log("The announce packet could not be loaded from cache", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                if receiving_interface == None:
                                    RNS.log("The interface is no longer available", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                if blackholed:
                                    RNS.log("The associated identity is blackholed", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                    if len(Transport.path_table) == 1: specifier = "entry"
                    else:                              specifier = "entries"

                    RNS.log("Loaded "+str(len(Transport.path_table))+" path table "+specifier+" from storage", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None
                    gc.collect()

                except Exception as e:
                    RNS.log("Could not load destination table from storage, the contained exception was: "+str(e), RNS.LOG_ERROR)
                    gc.collect()

            if os.path.isfile(tunnel_table_path) and not Transport.owner.is_connected_to_shared_instance:
                serialised_tunnels = []
                try:
                    file = open(tunnel_table_path, "rb")
                    serialised_tunnels = umsgpack.unpackb(file.read())
                    file.close()

                    for serialised_tunnel in serialised_tunnels:
                        tunnel_id = serialised_tunnel[IDX_TT_TUNNEL_ID]
                        interface_hash = serialised_tunnel[IDX_TT_IF]
                        serialised_paths = serialised_tunnel[IDX_TT_PATHS]
                        expires = serialised_tunnel[IDX_TT_EXPIRES]

                        tunnel_paths = {}
                        for serialised_entry in serialised_paths:
                            destination_hash = serialised_entry[0]
                            timestamp = serialised_entry[1]
                            received_from = serialised_entry[2]
                            hops = serialised_entry[3]
                            expires = serialised_entry[4]
                            random_blobs = list(set(serialised_entry[5]))
                            receiving_interface = Transport.find_interface_from_hash(serialised_entry[6])
                            announce_packet = Transport.get_cached_packet(serialised_entry[7], packet_type="announce")

                            if announce_packet != None:
                                announce_packet.unpack()
                                # We increase the hops, since reading a packet
                                # from cache is equivalent to receiving it again
                                # over an interface. It is cached with it's non-
                                # increased hop-count.
                                announce_packet.hops += 1

                                tunnel_path = [timestamp, received_from, hops, expires, random_blobs, receiving_interface, announce_packet.packet_hash]
                                tunnel_paths[destination_hash] = tunnel_path

                        if len(tunnel_paths) > 0:
                            tunnel = [tunnel_id, None, tunnel_paths, expires]
                            with Transport.tunnels_lock: Transport.tunnels[tunnel_id] = tunnel

                    if len(Transport.tunnels) == 1: specifier = "entry"
                    else:                           specifier = "entries"

                    RNS.log("Loaded "+str(len(Transport.tunnels))+" tunnel table "+specifier+" from storage", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None
                    gc.collect()

                except Exception as e:
                    RNS.log("Could not load tunnel table from storage, the contained exception was: "+str(e), RNS.LOG_ERROR)
                    gc.collect()

            if RNS.Reticulum.probe_destination_enabled():
                Transport.probe_destination = RNS.Destination(Transport.identity, RNS.Destination.IN, RNS.Destination.SINGLE, Transport.APP_NAME, "probe")
                Transport.probe_destination.accepts_links(False)
                Transport.probe_destination.set_proof_strategy(RNS.Destination.PROVE_ALL)
                Transport.mgmt_destinations.append(Transport.probe_destination)
                RNS.log("Transport Instance will respond to probe requests on "+str(Transport.probe_destination), RNS.LOG_NOTICE)
            else:
                Transport.probe_destination = None

            RNS.log("Transport instance "+str(Transport.identity)+" started", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None
            Transport.start_time = time.time()

        # Sort interfaces according to bitrate
        Transport.prioritize_interfaces()
        Transport.ready = True

        # Synthesize tunnels for any interfaces wanting it
        for interface in Transport.interfaces:
            interface.tunnel_id = None
            if hasattr(interface, "wants_tunnel") and interface.wants_tunnel:
                Transport.synthesize_tunnel(interface)

        gc.collect()

    @staticmethod
    def add_interface(interface):
        with Transport.interfaces_lock:
            if not interface in Transport.interfaces:
                Transport.interfaces.append(interface)

    @staticmethod
    def remove_interface(interface):
        with Transport.interfaces_lock:
            if interface in Transport.interfaces:
                Transport.interfaces.remove(interface)

    @staticmethod
    def set_network_identity(identity):
        if not Transport.network_identity:
            Transport.network_identity = identity

    @staticmethod
    def has_network_identity():
        if Transport.network_identity: return True
        else:                          return False

    @staticmethod
    def prioritize_interfaces():
        with Transport.interfaces_lock:
            try: Transport.interfaces.sort(key=lambda interface: interface.bitrate, reverse=True)
            except Exception as e: RNS.log(f"Could not prioritize interfaces according to bitrate. The contained exception was: {e}", RNS.LOG_ERROR)

    @staticmethod
    def enable_discovery():
        if not Transport.interface_announcer:
            Transport.interface_announcer = RNS.Discovery.InterfaceAnnouncer(Transport)
            Transport.interface_announcer.start()

    @staticmethod
    def discover_interfaces():
        if not Transport.discovery_handler:
            Transport.discovery_handler = RNS.Discovery.InterfaceDiscovery(required_value=RNS.Reticulum.required_discovery_value(), discover_interfaces=True)

    @staticmethod
    def enable_blackhole_updater():
        if not Transport.blackhole_updater:
            Transport.blackhole_updater = RNS.Discovery.BlackholeUpdater()
            Transport.blackhole_updater.start()

    @staticmethod
    def count_traffic_loop():
        while True:
            time.sleep(1)
            try:
                rxb = 0; txb = 0;
                rxs = 0; txs = 0;
                for interface in Transport.interfaces:
                    if not hasattr(interface, "parent_interface") or interface.parent_interface == None:
                        if hasattr(interface, "transport_traffic_counter"):
                            now = time.time(); irxb = interface.rxb; itxb = interface.txb
                            tc = interface.transport_traffic_counter
                            rx_diff = irxb - tc["rxb"]
                            tx_diff = itxb - tc["txb"]
                            ts_diff = now  - tc["ts"]
                            rxb    += rx_diff; crxs = (rx_diff*8)/ts_diff
                            txb    += tx_diff; ctxs = (tx_diff*8)/ts_diff
                            interface.current_rx_speed = crxs; rxs += crxs
                            interface.current_tx_speed = ctxs; txs += ctxs
                            tc["rxb"] = irxb;
                            tc["txb"] = itxb;
                            tc["ts"] = now;

                        else:
                            interface.transport_traffic_counter = {"ts": time.time(), "rxb": interface.rxb, "txb": interface.txb}

                Transport.traffic_rxb += rxb
                Transport.traffic_txb += txb
                Transport.speed_rx     = rxs
                Transport.speed_tx     = txs
            
            except Exception as e:
                RNS.log(f"An error occurred while counting interface traffic: {e}", RNS.LOG_ERROR)

    @staticmethod
    def jobloop():
        while (Transport._should_run):
            Transport.jobs()
            sleep(Transport.job_interval)

    @staticmethod
    def jobs():
        outgoing = []
        path_requests = {}
        blocked_if = None

        try:
            with Transport.jobs_lock:
                should_collect = False

                # Process active and pending link lists
                if time.time() > Transport.links_last_checked+Transport.links_check_interval:

                    with Transport.pending_links_lock:
                        for link in Transport.pending_links:
                            if link.status == RNS.Link.CLOSED:
                                # If we are not a Transport Instance, finding a pending link
                                # that was never activated will trigger an expiry of the path
                                # to the destination, and an attempt to rediscover the path.
                                if not RNS.Reticulum.transport_enabled():
                                    Transport.expire_path(link.destination.hash)

                                    # If we are connected to a shared instance, it will take
                                    # care of sending out a new path request. If not, we will
                                    # send one directly.
                                    if not Transport.owner.is_connected_to_shared_instance:
                                        last_path_request = 0
                                        with Transport.path_requests_lock:
                                            if link.destination.hash in Transport.path_requests:
                                                last_path_request = Transport.path_requests[link.destination.hash]

                                        if time.time() - last_path_request > Transport.PATH_REQUEST_MI:
                                            RNS.log("Trying to rediscover path for "+RNS.prettyhexrep(link.destination.hash)+" since an attempted link was never established", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                            if not link.destination.hash in path_requests:
                                                blocked_if = None
                                                path_requests[link.destination.hash] = blocked_if

                                Transport.pending_links.remove(link)

                    with Transport.active_links_lock:
                        closed_links = []
                        for link in Transport.active_links:
                            if link.status == RNS.Link.CLOSED: closed_links.append(link)

                        for closed_link in closed_links: Transport.active_links.remove(closed_link)

                    Transport.links_last_checked = time.time()

                # Process receipts list for timed-out packets
                if time.time() > Transport.receipts_last_checked+Transport.receipts_check_interval:
                    with Transport.receipts_lock:
                        while len(Transport.receipts) > Transport.MAX_RECEIPTS:
                            culled_receipt = Transport.receipts.pop(0)
                            culled_receipt.timeout = -1
                            culled_receipt.check_timeout()
                            should_collect = True

                    with Transport.receipts_lock:
                        expired_receipts = []
                        for receipt in Transport.receipts:
                            receipt.check_timeout()
                            if receipt.status != RNS.PacketReceipt.SENT: expired_receipts.append(receipt)

                        for expired_receipt in expired_receipts:
                            if expired_receipt in Transport.receipts: Transport.receipts.remove(expired_receipt)

                    Transport.receipts_last_checked = time.time()

                # Process announces needing retransmission
                if time.time() > Transport.announces_last_checked+Transport.announces_check_interval:
                    completed_announces = []
                    
                    with Transport.announce_table_lock:
                        for destination_hash in Transport.announce_table:
                            announce_entry = Transport.announce_table[destination_hash]
                            if announce_entry[IDX_AT_RETRIES] > 0 and announce_entry[IDX_AT_RETRIES] >= Transport.LOCAL_REBROADCASTS_MAX:
                                RNS.log("Completed announce processing for "+RNS.prettyhexrep(destination_hash)+", local rebroadcast limit reached", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                completed_announces.append(destination_hash)
                            elif announce_entry[IDX_AT_RETRIES] > Transport.PATHFINDER_R:
                                RNS.log("Completed announce processing for "+RNS.prettyhexrep(destination_hash)+", retry limit reached", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                completed_announces.append(destination_hash)
                            else:
                                if time.time() > announce_entry[IDX_AT_RTRNS_TMO]:
                                    announce_entry[IDX_AT_RTRNS_TMO] = time.time() + Transport.PATHFINDER_G + Transport.PATHFINDER_RW
                                    announce_entry[IDX_AT_RETRIES] += 1
                                    packet = announce_entry[IDX_AT_PACKET]
                                    block_rebroadcasts = announce_entry[IDX_AT_BLCK_RBRD]
                                    attached_interface = announce_entry[IDX_AT_ATTCHD_IF]
                                    announce_context = RNS.Packet.NONE
                                    if block_rebroadcasts: announce_context = RNS.Packet.PATH_RESPONSE
                                    announce_data = packet.data
                                    announce_identity = RNS.Identity.recall(packet.destination_hash, _no_use=True)
                                    if not announce_identity:
                                        RNS.log("Completed announce processing for "+RNS.prettyhexrep(destination_hash)+", the path was cleaned while waiting for announce rebroadcast", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        completed_announces.append(destination_hash)

                                    else:
                                        announce_destination = RNS.Destination(announce_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "unknown", "unknown");
                                        announce_destination.hash = packet.destination_hash
                                        announce_destination.hexhash = announce_destination.hash.hex()
                                        
                                        new_packet = RNS.Packet(announce_destination,
                                                                announce_data,
                                                                RNS.Packet.ANNOUNCE,
                                                                context = announce_context,
                                                                header_type = RNS.Packet.HEADER_2,
                                                                transport_type = Transport.TRANSPORT,
                                                                transport_id = Transport.identity.hash,
                                                                attached_interface = attached_interface,
                                                                context_flag = packet.context_flag)

                                        new_packet.hops = announce_entry[4]
                                        if block_rebroadcasts: RNS.log("Rebroadcasting announce as path response for "+RNS.prettyhexrep(announce_destination.hash)+" with hop count "+str(new_packet.hops), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        else: RNS.log("Rebroadcasting announce for "+RNS.prettyhexrep(announce_destination.hash)+" with hop count "+str(new_packet.hops), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        
                                        outgoing.append(new_packet)

                                        # This handles an edge case where a peer sends a past
                                        # request for a destination just after an announce for
                                        # said destination has arrived, but before it has been
                                        # rebroadcast locally. In such a case the actual announce
                                        # is temporarily held, and then reinserted when the path
                                        # request has been served to the peer.
                                        if destination_hash in Transport.held_announces:
                                            held_entry = Transport.held_announces.pop(destination_hash)
                                            Transport.announce_table[destination_hash] = held_entry
                                            RNS.log("Reinserting held announce into table", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                        for destination_hash in completed_announces:
                            if destination_hash in Transport.announce_table: Transport.announce_table.pop(destination_hash)

                    Transport.announces_last_checked = time.time()


                # Cull the packet hashlist if it has reached its max size
                if len(Transport.packet_hashlist) > Transport.hashlist_maxsize//2:
                    Transport.packet_hashlist_prev = Transport.packet_hashlist
                    Transport.packet_hashlist = set()

                # Cull invalidated path requests
                if time.time() > Transport.pending_prs_last_checked+Transport.pending_prs_check_interval:
                    stale_local_prs = []
                    with Transport.pending_local_prs_lock:
                        for destination_hash in Transport.pending_local_path_requests:
                            if not Transport.pending_local_path_requests[destination_hash] in Transport.interfaces:
                                stale_local_prs.append(destination_hash)
                        
                        for destination_hash in stale_local_prs:
                            Transport.pending_local_path_requests.pop(destination_hash)

                    Transport.pending_prs_last_checked = time.time()

                # Cull the path request tags list if it has reached its max size
                if len(Transport.discovery_pr_tags) > Transport.max_pr_tags:
                    with Transport.discovery_pr_tags_lock:
                        Transport.discovery_pr_tags = Transport.discovery_pr_tags[len(Transport.discovery_pr_tags)-Transport.max_pr_tags:len(Transport.discovery_pr_tags)-1]

                if time.time() > Transport.tables_last_culled + Transport.tables_cull_interval:
                    # Remove unneeded path state entries
                    stale_path_states = []
                    with Transport.path_states_lock:
                        for destination_hash in Transport.path_states:
                            if not destination_hash in Transport.path_table:
                                stale_path_states.append(destination_hash)

                    # Cull the reverse table according to timeout
                    stale_reverse_entries = []
                    with Transport.reverse_table_lock:
                        for truncated_packet_hash in Transport.reverse_table:
                            reverse_entry = Transport.reverse_table[truncated_packet_hash]
                            if time.time() > reverse_entry[IDX_RT_TIMESTAMP] + Transport.REVERSE_TIMEOUT: stale_reverse_entries.append(truncated_packet_hash)
                            elif not reverse_entry[IDX_RT_OUTB_IF] in Transport.interfaces:               stale_reverse_entries.append(truncated_packet_hash)
                            elif not reverse_entry[IDX_RT_RCVD_IF] in Transport.interfaces:               stale_reverse_entries.append(truncated_packet_hash)

                    # Cull the link table according to timeout
                    stale_links = []
                    with Transport.link_table_lock:
                        for link_id in Transport.link_table:
                            link_entry = Transport.link_table[link_id]

                            if link_entry[IDX_LT_VALIDATED] == True:
                                if time.time() > link_entry[IDX_LT_TIMESTAMP] + Transport.LINK_TIMEOUT: stale_links.append(link_id)
                                elif not link_entry[IDX_LT_NH_IF] in Transport.interfaces:              stale_links.append(link_id)
                                elif not link_entry[IDX_LT_RCVD_IF] in Transport.interfaces:            stale_links.append(link_id)
                            
                            else:
                                if time.time() > link_entry[IDX_LT_PROOF_TMO]:
                                    stale_links.append(link_id)

                                    last_path_request = 0
                                    with Transport.path_requests_lock:
                                        if link_entry[IDX_LT_DSTHASH] in Transport.path_requests:
                                            last_path_request = Transport.path_requests[link_entry[IDX_LT_DSTHASH]]

                                    lr_taken_hops = link_entry[IDX_LT_HOPS]

                                    path_request_throttle = time.time() - last_path_request < Transport.PATH_REQUEST_MI
                                    path_request_conditions = False
                                    
                                    # If the path has been invalidated between the time of
                                    # making the link request and now, try to rediscover it
                                    if not Transport.has_path(link_entry[IDX_LT_DSTHASH]):
                                        RNS.log("Trying to rediscover path for "+RNS.prettyhexrep(link_entry[IDX_LT_DSTHASH])+" since an attempted link was never established, and path is now missing", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        path_request_conditions = True

                                    # If this link request was originated from a local client
                                    # attempt to rediscover a path to the destination, if this
                                    # has not already happened recently.
                                    elif not path_request_throttle and lr_taken_hops == 0:
                                        RNS.log("Trying to rediscover path for "+RNS.prettyhexrep(link_entry[IDX_LT_DSTHASH])+" since an attempted local client link was never established", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        path_request_conditions = True

                                    # If the link destination was previously only 1 hop
                                    # away, this likely means that it was local to one
                                    # of our interfaces, and that it roamed somewhere else.
                                    # In that case, try to discover a new path, and mark
                                    # the old one as unresponsive.
                                    elif not path_request_throttle and Transport.hops_to(link_entry[IDX_LT_DSTHASH]) == 1:
                                        RNS.log("Trying to rediscover path for "+RNS.prettyhexrep(link_entry[IDX_LT_DSTHASH])+" since an attempted link was never established, and destination was previously local to an interface on this instance", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        path_request_conditions = True
                                        blocked_if = link_entry[IDX_LT_RCVD_IF]

                                        # TODO: This might result in the path re-resolution
                                        # only being able to happen once, since new path found
                                        # after allowing update from higher hop-count path, after
                                        # marking old path unresponsive, might be more than 1 hop away,
                                        # thus dealocking us into waiting for a new announce all-together.
                                        # Is this problematic, or does it actually not matter?
                                        # Best would be to have full support for alternative paths,
                                        # and score them according to number of unsuccessful tries or
                                        # similar.
                                        if RNS.Reticulum.transport_enabled():
                                            if hasattr(link_entry[IDX_LT_RCVD_IF], "mode") and link_entry[IDX_LT_RCVD_IF].mode != RNS.Interfaces.Interface.Interface.MODE_BOUNDARY:
                                                Transport.mark_path_unresponsive(link_entry[IDX_LT_DSTHASH])

                                    # If the link initiator is only 1 hop away,
                                    # this likely means that network topology has
                                    # changed. In that case, we try to discover a new path,
                                    # and mark the old one as potentially unresponsive.
                                    elif not path_request_throttle and lr_taken_hops == 1:
                                        RNS.log("Trying to rediscover path for "+RNS.prettyhexrep(link_entry[IDX_LT_DSTHASH])+" since an attempted link was never established, and link initiator is local to an interface on this instance", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        path_request_conditions = True
                                        blocked_if = link_entry[IDX_LT_RCVD_IF]

                                        if RNS.Reticulum.transport_enabled():
                                            if hasattr(link_entry[IDX_LT_RCVD_IF], "mode") and link_entry[IDX_LT_RCVD_IF].mode != RNS.Interfaces.Interface.Interface.MODE_BOUNDARY:
                                                Transport.mark_path_unresponsive(link_entry[IDX_LT_DSTHASH])

                                    if path_request_conditions:
                                        with Transport.path_requests_lock:
                                            if not link_entry[IDX_LT_DSTHASH] in path_requests:
                                                path_requests[link_entry[IDX_LT_DSTHASH]] = blocked_if

                                        if not RNS.Reticulum.transport_enabled():
                                            # Drop current path if we are not a transport instance, to
                                            # allow using higher-hop count paths or reused announces
                                            # from newly adjacent transport instances.
                                            Transport.expire_path(link_entry[IDX_LT_DSTHASH])

                    # Cull the path table
                    stale_paths = []
                    with Transport.path_table_lock:
                        for destination_hash in Transport.path_table:
                            destination_entry = Transport.path_table[destination_hash]
                            attached_interface = destination_entry[IDX_PT_RVCD_IF]

                            if attached_interface != None and hasattr(attached_interface, "mode") and attached_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT:
                                destination_expiry = destination_entry[IDX_PT_TIMESTAMP] + Transport.AP_PATH_TIME
                            elif attached_interface != None and hasattr(attached_interface, "mode") and attached_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ROAMING:
                                destination_expiry = destination_entry[IDX_PT_TIMESTAMP] + Transport.ROAMING_PATH_TIME
                            else:
                                destination_expiry = destination_entry[IDX_PT_TIMESTAMP] + Transport.DESTINATION_TIMEOUT

                            if time.time() > destination_expiry:
                                stale_paths.append(destination_hash)
                                should_collect = True
                                RNS.log("Path to "+RNS.prettyhexrep(destination_hash)+" timed out and was removed", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                            elif not attached_interface in Transport.interfaces:
                                stale_paths.append(destination_hash)
                                should_collect = True
                                RNS.log("Path to "+RNS.prettyhexrep(destination_hash)+" was removed since the attached interface no longer exists", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                    # Cull the pending path requests table
                    stale_path_requests = []
                    with Transport.path_requests_lock:
                        try:
                            for destination_hash in Transport.path_requests:
                                if time.time() > Transport.path_requests[destination_hash] + Transport.PATH_REQUEST_GATE_TIMEOUT:
                                    stale_path_requests.append(destination_hash)
                                    RNS.log("Path request entry for "+RNS.prettyhexrep(destination_hash)+" timed out and was removed", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                        except Exception as e:
                            RNS.log(f"Could not complete stale path request enumeration in this job round, retrying later: {e}", RNS.LOG_WARNING)

                    # Cull the pending discovery path requests table
                    stale_discovery_path_requests = []
                    with Transport.discovery_pr_lock:
                        for destination_hash in Transport.discovery_path_requests:
                            entry = Transport.discovery_path_requests[destination_hash]

                            if time.time() > entry["timeout"]:
                                stale_discovery_path_requests.append(destination_hash)
                                should_collect = True
                                RNS.log("Waiting path request for "+RNS.prettyhexrep(destination_hash)+" timed out and was removed", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                    # Cull the tunnel table
                    stale_tunnels = []; ti = 0
                    with Transport.tunnels_lock:
                        for tunnel_id in Transport.tunnels:
                            tunnel_entry = Transport.tunnels[tunnel_id]

                            expires = tunnel_entry[IDX_TT_EXPIRES]
                            if expires > time.time() + Transport.TUNNEL_TIMEOUT*2:
                                stale_tunnels.append(tunnel_id); should_collect = True
                                RNS.log("Tunnel "+RNS.prettyhexrep(tunnel_id)+" with excessive expiry was removed", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                            elif time.time() > expires:
                                stale_tunnels.append(tunnel_id); should_collect = True
                                RNS.log("Tunnel "+RNS.prettyhexrep(tunnel_id)+" timed out and was removed", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                            
                            else:
                                if tunnel_entry[IDX_TT_IF] and not tunnel_entry[IDX_TT_IF] in Transport.interfaces:
                                    RNS.log(f"Removing non-existent tunnel interface {tunnel_entry[IDX_TT_IF]}", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                    tunnel_entry[IDX_TT_IF] = None

                                stale_tunnel_paths = []
                                tunnel_paths = tunnel_entry[IDX_TT_PATHS]
                                for tunnel_path in tunnel_paths:
                                    tunnel_path_entry = tunnel_paths[tunnel_path]

                                    if time.time() > tunnel_path_entry[0] + Transport.TUNNEL_PATH_TIMEOUT:
                                        stale_tunnel_paths.append(tunnel_path); should_collect = True
                                        RNS.log("Tunnel path to "+RNS.prettyhexrep(tunnel_path)+" timed out and was removed", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                                    else:
                                        active_path = None
                                        with Transport.path_table_lock:
                                            if tunnel_path in Transport.path_table: active_path = Transport.path_table[tunnel_path]
                                        
                                        if active_path:
                                            random_blobs             = tunnel_path_entry[4]
                                            current_random_blobs     = active_path[IDX_PT_RANDBLOBS]
                                            current_path_timebase    = Transport.timebase_from_random_blobs(current_random_blobs)
                                            tunnel_announce_timebase = Transport.timebase_from_random_blobs(random_blobs)
                                            
                                            if current_path_timebase > tunnel_announce_timebase:
                                                stale_tunnel_paths.append(tunnel_path); should_collect = True
                                                RNS.log("Tunnel path to "+RNS.prettyhexrep(tunnel_path)+" was removed due to more recent active path", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                                for tunnel_path in stale_tunnel_paths:
                                    tunnel_paths.pop(tunnel_path)
                                    ti += 1

                    if ti > 0:
                        if ti == 1: RNS.log("Removed "+str(ti)+" tunnel path", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Removed "+str(ti)+" tunnel paths", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    i = 0
                    with Transport.reverse_table_lock:
                        for truncated_packet_hash in stale_reverse_entries:
                            if truncated_packet_hash in Transport.reverse_table: Transport.reverse_table.pop(truncated_packet_hash)
                            i += 1

                    if i > 0:
                        if i == 1: RNS.log("Released "+str(i)+" reverse table entry", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Released "+str(i)+" reverse table entries", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    i = 0
                    with Transport.link_table_lock:
                        for link_id in stale_links:
                            Transport.link_table.pop(link_id)
                            i += 1

                    if i > 0:
                        if i == 1: RNS.log("Released "+str(i)+" link", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Released "+str(i)+" links", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    i = 0
                    with Transport.path_table_lock:
                        for destination_hash in stale_paths:
                            if destination_hash in Transport.path_table: Transport.path_table.pop(destination_hash)
                            i += 1

                    if i > 0:
                        if i == 1: RNS.log("Removed "+str(i)+" path", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Removed "+str(i)+" paths", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    i = 0
                    with Transport.path_requests_lock:
                        for destination_hash in stale_path_requests:
                            Transport.path_requests.pop(destination_hash)
                            i += 1

                    if i > 0:
                        if i == 1: RNS.log("Removed "+str(i)+" path request entry", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Removed "+str(i)+" path request entries", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    i = 0
                    with Transport.discovery_pr_lock:
                        for destination_hash in stale_discovery_path_requests:
                            Transport.discovery_path_requests.pop(destination_hash)
                            i += 1

                    if i > 0:
                        if i == 1: RNS.log("Removed "+str(i)+" waiting path request", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Removed "+str(i)+" waiting path requests", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    i = 0
                    with Transport.tunnels_lock:
                        for tunnel_id in stale_tunnels:
                            Transport.tunnels.pop(tunnel_id)
                            i += 1

                    if i > 0:
                        if i == 1: RNS.log("Removed "+str(i)+" tunnel", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Removed "+str(i)+" tunnels", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    i = 0
                    with Transport.path_states_lock:
                        for destination_hash in stale_path_states:
                            Transport.path_states.pop(destination_hash)
                            i += 1

                    if i > 0:
                        if i == 1: RNS.log("Removed "+str(i)+" path state entry", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else: RNS.log("Removed "+str(i)+" path state entries", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                    Transport.tables_last_culled = time.time()

                # Run interface-related jobs
                if time.time() > Transport.interface_last_jobs + Transport.interface_jobs_interval:
                    Transport.prioritize_interfaces()
                    try:
                        for interface in Transport.interfaces:
                            interface.should_ingress_limit()
                            interface.should_ingress_limit_pr()
                            interface.process_held_announces()
                            if interface.phy_keepalive: interface.send_keepalive()
                        Transport.interface_last_jobs = time.time()
                    except Exception as e:
                        RNS.log(f"Error while processing held per-interface announces: {e}", RNS.LOG_WARNING)
                        RNS.log(f"Postponing until next job run", RNS.LOG_WARNING)

                # Clean packet caches
                if time.time() > Transport.cache_last_cleaned+Transport.cache_clean_interval:
                    Transport.cache_last_cleaned = time.time()
                    def job(): Transport.clean_cache()
                    threading.Thread(target=job, daemon=True).start()

                # Clean known destinations
                if time.time() > Transport.destinations_last_cleaned+Transport.known_destinations_interval:
                    Transport.destinations_last_cleaned = time.time()
                    def job(): RNS.Identity.clean_known_destinations()
                    threading.Thread(target=job, daemon=True).start()

                # Send announces for management destinations
                if time.time() > Transport.last_mgmt_announce+Transport.mgmt_announce_interval:
                    try:
                        Transport.last_mgmt_announce = time.time()
                        def job():
                            for destination in Transport.mgmt_destinations: destination.announce()
                        threading.Thread(target=job, daemon=True).start()

                    except Exception as e:
                        RNS.log(f"Error while sending management announces: {e}", RNS.LOG_ERROR)

                # Check expired blackhole entries
                if time.time() > Transport.blackhole_last_checked+Transport.blackhole_check_interval:
                    try:
                        Transport.blackhole_last_checked = time.time()
                        stale_blackhole_entries = []
                        for identity_hash in Transport.blackholed_identities.copy():
                            try:
                                until = Transport.blackholed_identities[identity_hash]["until"]
                                if until and time.time() > until: stale_blackhole_entries.append(identity_hash)

                            except Exception as e:
                                RNS.log(f"Error while checking blackhole expiry for {RNS.prettyhexrep(identity_hash)}: {e}", RNS.LOG_ERROR)

                        i = 0
                        for identity_hash in stale_blackhole_entries:
                            if identity_hash in Transport.blackholed_identities:
                                Transport.blackholed_identities.pop(identity_hash)
                                i += 1

                        if i > 0:
                            if i == 1: RNS.log("Removed "+str(i)+" blackholed identity", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None
                            else: RNS.log("Removed "+str(i)+" blackholed identities", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None

                    except Exception as e:
                        RNS.log(f"Error while checking blackholed identities: {e}", RNS.LOG_ERROR)

                if should_collect: gc.collect()

        except Exception as e:
            RNS.log("An exception occurred while running Transport jobs.", RNS.LOG_ERROR)
            RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
            RNS.trace_exception(e) # TODO: Remove

        if outgoing:
            def job(): Transport.handle_outgoing_announces(outgoing)
            threading.Thread(target=job).start()

        if path_requests:
            with Transport.discovery_pr_tx_lock:
                for destination_hash in path_requests:
                    if not destination_hash in Transport.pending_discovery_prs:
                        if not len(Transport.pending_discovery_prs) >= Transport.max_queued_discovery_prs:
                            Transport.pending_discovery_prs.append([destination_hash, path_requests[destination_hash]])

        if len(Transport.pending_discovery_prs):
            def job(): Transport.handle_disovery_path_requests()
            threading.Thread(target=job).start()


    discovery_pr_tx_throttle = 0.5
    discovery_pr_tx_lock     = Lock()
    discovery_pr_handle_lock = Lock()
    pending_discovery_prs    = deque(maxlen=max_queued_discovery_prs)
    @staticmethod
    def handle_disovery_path_requests():
        if Transport.discovery_pr_handle_lock.locked(): return
        with Transport.discovery_pr_handle_lock:
            while len(Transport.pending_discovery_prs):
                time.sleep(Transport.discovery_pr_tx_throttle)
                destination_hash = None
                blocked_if = None
                with Transport.discovery_pr_tx_lock:
                    entry = Transport.pending_discovery_prs.popleft()
                    destination_hash = entry[0]
                    blocked_if = entry[1]

                if destination_hash:
                    if blocked_if == None: Transport.request_path(destination_hash)
                    else:
                        for interface in Transport.interfaces:
                            if interface != blocked_if: Transport.request_path(destination_hash, on_interface=interface)
                            else: pass


    @staticmethod
    def handle_outgoing_announces(outgoing):
        for packet in sorted(outgoing, key=lambda p: p.hops): packet.send()

    @staticmethod
    def transmit(interface, raw):
        try:
            if hasattr(interface, "ifac_identity") and interface.ifac_identity != None:
                # Calculate packet access code
                ifac = interface.ifac_identity.sign(raw)[-interface.ifac_size:]

                # Generate mask
                mask = RNS.Cryptography.hkdf(length=len(raw)+interface.ifac_size,
                                             derive_from=ifac,
                                             salt=interface.ifac_key,
                                             context=None)
                # Set IFAC flag
                new_header = bytes([raw[0] | 0x80, raw[1]])

                # Assemble new payload with IFAC
                new_raw    = new_header+ifac+raw[2:]
                
                # Mask payload
                i = 0; masked_raw = b""
                for byte in new_raw:
                    # Mask first header byte, but make sure the
                    # IFAC flag is still set
                    if i == 0: masked_raw += bytes([byte ^ mask[i] | 0x80])
                    
                    # Mask second header byte and payload
                    elif i == 1 or i > interface.ifac_size+1: masked_raw += bytes([byte ^ mask[i]])
                    
                    # Don't mask the IFAC itself
                    else: masked_raw += bytes([byte])
                    
                    i += 1

                # Send it
                interface.process_outgoing(masked_raw)

            else: interface.process_outgoing(raw)

        except Exception as e: RNS.log("Error while transmitting on "+str(interface)+". The contained exception was: "+str(e), RNS.LOG_ERROR)

    @staticmethod
    def outbound(packet):
        sent = False
        outbound_time = time.time()

        generate_receipt = False
        if (packet.create_receipt == True and
            # Only generate receipts for DATA packets
            packet.packet_type == RNS.Packet.DATA and
            # Don't generate receipts for PLAIN destinations
            packet.destination.type != RNS.Destination.PLAIN and
            # Don't generate receipts for link-related packets
            not (packet.context >= RNS.Packet.KEEPALIVE and packet.context <= RNS.Packet.LRPROOF) and
            # Don't generate receipts for resource packets
            not (packet.context >= RNS.Packet.RESOURCE and packet.context <= RNS.Packet.RESOURCE_RCL)):

            generate_receipt = True

        def packet_sent(packet):
            packet.sent = True
            packet.sent_at = time.time()

            if generate_receipt:
                packet.receipt = RNS.PacketReceipt(packet)
                with Transport.receipts_lock: Transport.receipts.append(packet.receipt)
            
            # TODO: Enable when caching has been redesigned
            # Transport.cache(packet)

        # Check if we have a known path for the destination in the path table
        if packet.packet_type != RNS.Packet.ANNOUNCE and packet.destination.type != RNS.Destination.PLAIN and packet.destination.type != RNS.Destination.GROUP and packet.destination_hash in Transport.path_table:
            with Transport.path_table_lock:
                if not packet.destination_hash in Transport.path_table:
                    RNS.log(f"Dropped packet since path table entry disappeared during outbound processing", RNS.LOG_WARNING)
                    return False
                else: path_entry = Transport.path_table[packet.destination_hash]

            outbound_interface = path_entry[IDX_PT_RVCD_IF]

            # If there's more than one hop to the destination, and we know
            # a path, we insert the packet into transport by adding the next
            # transport nodes address to the header, and modifying the flags.
            # This rule applies both for "normal" transport, and when connected
            # to a local shared Reticulum instance.
            if path_entry[IDX_PT_HOPS] > 1:
                if packet.header_type == RNS.Packet.HEADER_1:
                    # Insert packet into transport
                    new_flags = (RNS.Packet.HEADER_2) << 6 | (Transport.TRANSPORT) << 4 | (packet.flags & 0b00001111)
                    new_raw = struct.pack("!B", new_flags)
                    new_raw += packet.raw[1:2]
                    new_raw += path_entry[IDX_PT_NEXT_HOP]
                    new_raw += packet.raw[2:]
                    packet_sent(packet)
                    Transport.transmit(outbound_interface, new_raw)
                    path_entry[IDX_PT_TIMESTAMP] = time.time()
                    sent = True

            # In the special case where we are connected to a local shared
            # Reticulum instance, and the destination is one hop away, we
            # also add transport headers to inject the packet into transport
            # via the shared instance. Normally a packet for a destination
            # one hop away would just be broadcast directly, but since we
            # are "behind" a shared instance, we need to get that instance
            # to transport it onto the network.
            elif path_entry[IDX_PT_HOPS] == 1 and Transport.owner.is_connected_to_shared_instance:
                if packet.header_type == RNS.Packet.HEADER_1:
                    # Insert packet into transport
                    new_flags = (RNS.Packet.HEADER_2) << 6 | (Transport.TRANSPORT) << 4 | (packet.flags & 0b00001111)
                    new_raw = struct.pack("!B", new_flags)
                    new_raw += packet.raw[1:2]
                    new_raw += path_entry[IDX_PT_NEXT_HOP]
                    new_raw += packet.raw[2:]
                    packet_sent(packet)
                    Transport.transmit(outbound_interface, new_raw)
                    path_entry[IDX_PT_TIMESTAMP] = time.time()
                    sent = True

            # If none of the above applies, we know the destination is
            # directly reachable, and also on which interface, so we
            # simply transmit the packet directly on that one.
            else:
                packet_sent(packet)
                Transport.transmit(outbound_interface, packet.raw)
                sent = True

        # If we don't have a known path for the destination, we'll
        # broadcast the packet on all outgoing interfaces, or the
        # just the relevant interface if the packet has an attached
        # interface, or belongs to a link.
        else:
            stored_hash = False
            for interface in Transport.interfaces:
                if interface.OUT:
                    should_transmit = True

                    if packet.destination.type == RNS.Destination.LINK:
                        if packet.destination.status == RNS.Link.CLOSED:       should_transmit = False
                        if interface != packet.destination.attached_interface: should_transmit = False
                    
                    if packet.attached_interface != None and interface != packet.attached_interface:
                        should_transmit = False

                    if packet.packet_type == RNS.Packet.ANNOUNCE:
                        if packet.attached_interface == None:
                            if interface.mode == RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT:
                                RNS.log("Blocking announce broadcast on "+str(interface)+" due to AP mode", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                should_transmit = False

                            elif interface.mode == RNS.Interfaces.Interface.Interface.MODE_ROAMING:
                                local_destination = None
                                with Transport.destinations_map_lock:
                                    if packet.destination_hash in Transport.destinations_map:
                                        local_destination = Transport.destinations_map[packet.destination_hash]

                                if local_destination != None:
                                    # RNS.log("Allowing announce broadcast on roaming-mode interface from instance-local destination", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                    pass
                                else:
                                    from_interface = Transport.next_hop_interface(packet.destination_hash)
                                    if from_interface == None or not hasattr(from_interface, "mode"):
                                        should_transmit = False
                                        if from_interface == None:
                                            RNS.log("Blocking announce broadcast on "+str(interface)+" since next hop interface doesn't exist", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                        elif not hasattr(from_interface, "mode"):
                                            RNS.log("Blocking announce broadcast on "+str(interface)+" since next hop interface has no mode configured", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                    else:
                                        if from_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ROAMING:
                                            RNS.log("Blocking announce broadcast on "+str(interface)+" due to roaming-mode next-hop interface", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                            should_transmit = False
                                        elif from_interface.mode == RNS.Interfaces.Interface.Interface.MODE_BOUNDARY:
                                            RNS.log("Blocking announce broadcast on "+str(interface)+" due to boundary-mode next-hop interface", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                            should_transmit = False

                            elif interface.mode == RNS.Interfaces.Interface.Interface.MODE_BOUNDARY:
                                local_destination = None
                                with Transport.destinations_map_lock:
                                    if packet.destination_hash in Transport.destinations_map:
                                        local_destination = Transport.destinations_map[packet.destination_hash]

                                if local_destination != None:
                                    # RNS.log("Allowing announce broadcast on boundary-mode interface from instance-local destination", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                    pass
                                else:
                                    from_interface = Transport.next_hop_interface(packet.destination_hash)
                                    if from_interface == None or not hasattr(from_interface, "mode"):
                                        should_transmit = False
                                        if from_interface == None:
                                            RNS.log("Blocking announce broadcast on "+str(interface)+" since next hop interface doesn't exist", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                        elif not hasattr(from_interface, "mode"):
                                            RNS.log("Blocking announce broadcast on "+str(interface)+" since next hop interface has no mode configured", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                    else:
                                        if from_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ROAMING:
                                            RNS.log("Blocking announce broadcast on "+str(interface)+" due to roaming-mode next-hop interface", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                            should_transmit = False

                            else:
                                # Currently, annouces originating locally are always
                                # allowed, and do not conform to bandwidth caps.
                                # TODO: Rethink whether this is actually optimal.
                                if packet.hops > 0:

                                    if not hasattr(interface, "announce_cap"):        interface.announce_cap = RNS.Reticulum.ANNOUNCE_CAP
                                    if not hasattr(interface, "announce_allowed_at"): interface.announce_allowed_at = 0
                                    if not hasattr(interface, "announce_queue"):      interface.announce_queue = []

                                    queued_announces = True if len(interface.announce_queue) > 0 else False
                                    if not queued_announces and outbound_time > interface.announce_allowed_at and interface.bitrate != None and interface.bitrate != 0:
                                        tx_time   = (len(packet.raw)*8) / interface.bitrate
                                        wait_time = (tx_time / interface.announce_cap)
                                        interface.announce_allowed_at = outbound_time + wait_time
                                    
                                    else:
                                        should_transmit = False
                                        if not len(interface.announce_queue) >= RNS.Reticulum.MAX_QUEUED_ANNOUNCES:
                                            should_queue = True

                                            already_queued = False
                                            for e in interface.announce_queue:
                                                if e["destination"] == packet.destination_hash:
                                                    already_queued = True
                                                    existing_entry = e

                                            emission_timestamp = Transport.announce_emitted(packet)
                                            if already_queued:
                                                should_queue = False

                                                if emission_timestamp > existing_entry["emitted"]:
                                                    e["time"] = outbound_time
                                                    e["hops"] = packet.hops
                                                    e["emitted"] = emission_timestamp
                                                    e["raw"] = packet.raw

                                            if should_queue:
                                                entry = { "destination": packet.destination_hash,
                                                          "time": outbound_time,
                                                          "hops": packet.hops,
                                                          "emitted": Transport.announce_emitted(packet),
                                                          "raw": packet.raw }

                                                queued_announces = True if len(interface.announce_queue) > 0 else False
                                                interface.announce_queue.append(entry)

                                                if not queued_announces:
                                                    wait_time = max(interface.announce_allowed_at - time.time(), 0)
                                                    timer = threading.Timer(wait_time, interface.process_announce_queue)
                                                    timer.start()

                                                    if wait_time < 1: wait_time_str = str(round(wait_time*1000,2))+"ms"
                                                    else:             wait_time_str = str(round(wait_time*1,2))+"s"

                                                    ql_str = str(len(interface.announce_queue))
                                                    RNS.log("Added announce to queue (height "+ql_str+") on "+str(interface)+" for processing in "+wait_time_str, RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                                                else:
                                                    wait_time = max(interface.announce_allowed_at - time.time(), 0)

                                                    if wait_time < 1: wait_time_str = str(round(wait_time*1000,2))+"ms"
                                                    else:             wait_time_str = str(round(wait_time*1,2))+"s"

                                                    ql_str = str(len(interface.announce_queue))
                                                    RNS.log("Added announce to queue (height "+ql_str+") on "+str(interface)+" for processing in "+wait_time_str, RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                                        else: pass
                                
                                else: pass

                    if should_transmit:
                        if not stored_hash:
                            Transport.add_packet_hash(packet.packet_hash)
                            stored_hash = True

                        Transport.transmit(interface, packet.raw)
                        if packet.packet_type == RNS.Packet.ANNOUNCE: interface.sent_announce()
                        if packet.destination.type == RNS.Destination.PLAIN and packet.is_outbound_pr: interface.sent_path_request()
                        packet_sent(packet)
                        sent = True

        return sent

    @staticmethod
    def add_packet_hash(packet_hash):
        if not Transport.owner.is_connected_to_shared_instance:
            Transport.packet_hashlist.add(packet_hash)

    @staticmethod
    def packet_filter(packet):
        # If connected to a shared instance, it will handle
        # packet filtering
        if Transport.owner.is_connected_to_shared_instance: return True

        # Filter packets intended for other transport instances
        if packet.transport_id != None and packet.packet_type != RNS.Packet.ANNOUNCE:
            if packet.transport_id != Transport.identity.hash:
                RNS.log("Ignored packet "+RNS.prettyhexrep(packet.packet_hash)+" in transport for other transport instance", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                return False

        if packet.context == RNS.Packet.KEEPALIVE:     return True
        if packet.context == RNS.Packet.RESOURCE_REQ:  return True
        if packet.context == RNS.Packet.RESOURCE_PRF:  return True
        if packet.context == RNS.Packet.RESOURCE:      return True
        if packet.context == RNS.Packet.CACHE_REQUEST: return True
        if packet.context == RNS.Packet.CHANNEL:       return True

        if packet.destination_type == RNS.Destination.PLAIN:
            if packet.packet_type != RNS.Packet.ANNOUNCE:
                if packet.hops > 1:
                    RNS.log("Dropped PLAIN packet "+RNS.prettyhexrep(packet.packet_hash)+" with "+str(packet.hops)+" hops", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                    return False
                else:
                    return True
            else:
                RNS.log("Dropped invalid PLAIN announce packet", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                return False

        if packet.destination_type == RNS.Destination.GROUP:
            if packet.packet_type != RNS.Packet.ANNOUNCE:
                if packet.hops > 1:
                    RNS.log("Dropped GROUP packet "+RNS.prettyhexrep(packet.packet_hash)+" with "+str(packet.hops)+" hops", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                    return False
                else:
                    return True
            else:
                RNS.log("Dropped invalid GROUP announce packet", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                return False

        if not packet.packet_hash in Transport.packet_hashlist and not packet.packet_hash in Transport.packet_hashlist_prev: return True
        else:
            if packet.packet_type == RNS.Packet.ANNOUNCE:
                if packet.destination_type == RNS.Destination.SINGLE:
                    return True
                else:
                    RNS.log("Dropped invalid announce packet", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                    return False

        RNS.log("Filtered packet with hash "+RNS.prettyhexrep(packet.packet_hash), RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
        return False

    @staticmethod
    def inbound(raw, interface=None):
        if not Transport.ready:
            wait_start = time.time()
            while not Transport.ready:
                time.sleep(0.25)
                if time.time() > wait_start + Transport.READY_WAIT:
                    RNS.log("Inbound packet timed out waiting for transport startup, dropping", RNS.LOG_WARNING)
                    return

        # If interface access codes are enabled,
        # we must authenticate each packet.
        if len(raw) > 2:
            if interface != None and hasattr(interface, "ifac_identity") and interface.ifac_identity != None:
                # Check that IFAC flag is set
                if raw[0] & 0x80 == 0x80:
                    if len(raw) > 2+interface.ifac_size:
                        # Extract IFAC
                        ifac = raw[2:2+interface.ifac_size]

                        # Generate mask
                        mask = RNS.Cryptography.hkdf(length=len(raw),
                                                     derive_from=ifac,
                                                     salt=interface.ifac_key,
                                                     context=None)
                        # Unmask payload
                        i = 0; unmasked_raw = b""
                        for byte in raw:
                            # Unmask header bytes and payload
                            if i <= 1 or i > interface.ifac_size+1: unmasked_raw += bytes([byte ^ mask[i]])
                            # Don't unmask IFAC itself
                            else: unmasked_raw += bytes([byte])
                            i += 1
                        
                        raw = unmasked_raw

                        # Unset IFAC flag
                        new_header = bytes([raw[0] & 0x7f, raw[1]])

                        # Re-assemble packet
                        new_raw = new_header+raw[2+interface.ifac_size:]

                        # Calculate expected IFAC
                        expected_ifac = interface.ifac_identity.sign(new_raw)[-interface.ifac_size:]

                        # Check it
                        if ifac == expected_ifac: raw = new_raw
                        else:                     return

                    else: return

                # If the IFAC flag is not set, but should be,
                # drop the packet.
                else: return

            else:
                # If the interface does not have IFAC enabled,
                # check the received packet IFAC flag.
                # If the flag is set, drop the packet
                if raw[0] & 0x80 == 0x80: return

        else: return

        if Transport.identity == None: return
            
        packet = RNS.Packet(None, raw)
        if not packet.unpack(): return
            
        packet.receiving_interface = interface
        packet.hops += 1

        if interface != None:
            if hasattr(interface, "r_stat_rssi"):
                if interface.r_stat_rssi != None:
                    packet.rssi = interface.r_stat_rssi
                    Transport.local_client_rssi_cache.append([packet.packet_hash, packet.rssi])
                    while len(Transport.local_client_rssi_cache) > Transport.LOCAL_CLIENT_CACHE_MAXSIZE:
                        Transport.local_client_rssi_cache.pop(0)

            if hasattr(interface, "r_stat_snr"):
                if interface.r_stat_rssi != None:
                    packet.snr = interface.r_stat_snr
                    Transport.local_client_snr_cache.append([packet.packet_hash, packet.snr])
                    while len(Transport.local_client_snr_cache) > Transport.LOCAL_CLIENT_CACHE_MAXSIZE:
                        Transport.local_client_snr_cache.pop(0)

            if hasattr(interface, "r_stat_q"):
                if interface.r_stat_q != None:
                    packet.q = interface.r_stat_q
                    Transport.local_client_q_cache.append([packet.packet_hash, packet.q])
                    while len(Transport.local_client_q_cache) > Transport.LOCAL_CLIENT_CACHE_MAXSIZE:
                        Transport.local_client_q_cache.pop(0)

        if len(Transport.local_client_interfaces) > 0:
            if Transport.is_local_client_interface(interface): packet.hops -= 1

        elif Transport.interface_to_shared_instance(interface): packet.hops -= 1

        if Transport.packet_filter(packet):
            # By default, remember packet hashes to avoid routing
            # loops in the network, using the packet filter.
            remember_packet_hash = True

            # If this packet belongs to a link in our link table,
            # we'll have to defer adding it to the filter list.
            # In some cases, we might see a packet over a shared-
            # medium interface, belonging to a link that transports
            # or terminates with this instance, but before it would
            # normally reach us. If the packet is appended to the
            # filter list at this point, link transport will break.
            if packet.destination_hash in Transport.link_table: remember_packet_hash = False

            # If this is a link request proof, don't add it until
            # we are sure it's not actually somewhere else in the
            # routing chain.
            if packet.packet_type == RNS.Packet.PROOF and packet.context == RNS.Packet.LRPROOF: remember_packet_hash = False

            if remember_packet_hash:
                Transport.add_packet_hash(packet.packet_hash)
                # TODO: Enable when caching has been redesigned
                # Transport.cache(packet)
            
            # Check special conditions for local clients connected
            # through a shared Reticulum instance
            from_local_client         = (packet.receiving_interface in Transport.local_client_interfaces)
            for_local_client          = (packet.packet_type != RNS.Packet.ANNOUNCE) and (packet.destination_hash in Transport.path_table and Transport.path_table[packet.destination_hash][IDX_PT_HOPS] == 0)
            for_local_client_link     = (packet.packet_type != RNS.Packet.ANNOUNCE) and (packet.destination_hash in Transport.link_table and Transport.link_table[packet.destination_hash][IDX_LT_RCVD_IF] in Transport.local_client_interfaces)
            for_local_client_link    |= (packet.packet_type != RNS.Packet.ANNOUNCE) and (packet.destination_hash in Transport.link_table and Transport.link_table[packet.destination_hash][IDX_LT_NH_IF] in Transport.local_client_interfaces)
            proof_for_local_client    = (packet.destination_hash in Transport.reverse_table) and (Transport.reverse_table[packet.destination_hash][IDX_RT_RCVD_IF] in Transport.local_client_interfaces)

            # Plain broadcast packets from local clients are sent
            # directly on all attached interfaces, since they are
            # never injected into transport.
            if not packet.destination_hash in Transport.control_hashes:
                if packet.destination_type == RNS.Destination.PLAIN and packet.transport_type == Transport.BROADCAST:
                    # Send to all interfaces except the originator
                    if from_local_client:
                        for interface in Transport.interfaces:
                            if interface != packet.receiving_interface:
                                Transport.transmit(interface, packet.raw)
                    # If the packet was not from a local client, send
                    # it directly to all local clients
                    else:
                        for interface in Transport.local_client_interfaces:
                            Transport.transmit(interface, packet.raw)


            # General transport handling. Takes care of directing
            # packets according to transport tables and recording
            # entries in reverse and link tables.
            if RNS.Reticulum.transport_enabled() or from_local_client or for_local_client or for_local_client_link:

                # If there is no transport id, but the packet is
                # for a local client, we generate the transport
                # id (it was stripped on the previous hop, since
                # we "spoof" the hop count for clients behind a
                # shared instance, so they look directly reach-
                # able), and reinsert, so the normal transport
                # implementation can handle the packet.
                if packet.transport_id == None and for_local_client:
                    packet.transport_id = Transport.identity.hash

                # If this is a cache request, and we can fullfill
                # it, do so and stop processing. Otherwise resume
                # normal processing.
                if packet.context == RNS.Packet.CACHE_REQUEST:
                    if Transport.cache_request_packet(packet): return

                # If the packet is in transport, check whether we
                # are the designated next hop, and process it
                # accordingly if we are.
                if packet.transport_id != None and packet.packet_type != RNS.Packet.ANNOUNCE:
                    if packet.transport_id == Transport.identity.hash:
                        if packet.destination_hash in Transport.path_table:
                            next_hop = Transport.path_table[packet.destination_hash][IDX_PT_NEXT_HOP]
                            remaining_hops = Transport.path_table[packet.destination_hash][IDX_PT_HOPS]
                            
                            if remaining_hops > 1:
                                # Just increase hop count and transmit
                                new_raw  = packet.raw[0:1]
                                new_raw += struct.pack("!B", packet.hops)
                                new_raw += next_hop
                                new_raw += packet.raw[(RNS.Identity.TRUNCATED_HASHLENGTH//8)+2:]
                            elif remaining_hops == 1:
                                # Strip transport headers and transmit
                                new_flags = (RNS.Packet.HEADER_1) << 6 | (Transport.BROADCAST) << 4 | (packet.flags & 0b00001111)
                                new_raw = struct.pack("!B", new_flags)
                                new_raw += struct.pack("!B", packet.hops)
                                new_raw += packet.raw[(RNS.Identity.TRUNCATED_HASHLENGTH//8)+2:]
                            elif remaining_hops == 0:
                                # Just increase hop count and transmit
                                new_raw  = packet.raw[0:1]
                                new_raw += struct.pack("!B", packet.hops)
                                new_raw += packet.raw[2:]

                            outbound_interface = Transport.path_table[packet.destination_hash][IDX_PT_RVCD_IF]

                            if packet.packet_type == RNS.Packet.LINKREQUEST:
                                now = time.time()
                                proof_timeout  = Transport.extra_link_proof_timeout(packet.receiving_interface)
                                proof_timeout += now + RNS.Link.ESTABLISHMENT_TIMEOUT_PER_HOP * max(1, remaining_hops)
                                
                                path_mtu       = RNS.Link.mtu_from_lr_packet(packet)
                                mode           = RNS.Link.mode_from_lr_packet(packet)
                                ph_mtu         = interface.HW_MTU if interface else None
                                nh_mtu         = outbound_interface.HW_MTU
                                if path_mtu:
                                    if outbound_interface.HW_MTU == None:
                                        RNS.log(f"No next-hop HW MTU, disabling link MTU upgrade", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        path_mtu = None
                                        new_raw  = new_raw[:-RNS.Link.LINK_MTU_SIZE]
                                    elif not outbound_interface.AUTOCONFIGURE_MTU and not outbound_interface.FIXED_MTU:
                                        RNS.log(f"Outbound interface doesn't support MTU autoconfiguration, disabling link MTU upgrade", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                        path_mtu = None
                                        new_raw  = new_raw[:-RNS.Link.LINK_MTU_SIZE]
                                    else:
                                        if nh_mtu < path_mtu or (ph_mtu and ph_mtu < path_mtu):
                                            try:
                                                path_mtu = min(nh_mtu, ph_mtu)
                                                clamped_mtu = RNS.Link.signalling_bytes(path_mtu, mode)
                                                RNS.log(f"Clamping link MTU to {RNS.prettysize(path_mtu)}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                                new_raw  = new_raw[:-RNS.Link.LINK_MTU_SIZE]+clamped_mtu
                                            except Exception as e:
                                                RNS.log(f"Dropping link request packet. The contained exception was: {e}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                                return

                                # Entry format is
                                link_entry = [  now,                            # 0: Timestamp,
                                                next_hop,                       # 1: Next-hop transport ID
                                                outbound_interface,             # 2: Next-hop interface
                                                remaining_hops,                 # 3: Remaining hops
                                                packet.receiving_interface,     # 4: Received on interface
                                                packet.hops,                    # 5: Taken hops
                                                packet.destination_hash,        # 6: Original destination hash
                                                False,                          # 7: Validated
                                                proof_timeout ]                 # 8: Proof timeout timestamp

                                with Transport.link_table_lock: Transport.link_table[RNS.Link.link_id_from_lr_packet(packet)] = link_entry

                            else:
                                # Entry format is
                                reverse_entry = [ packet.receiving_interface,   # 0: Received on interface
                                                  outbound_interface,           # 1: Outbound interface
                                                  time.time() ]                 # 2: Timestamp

                                with Transport.reverse_table_lock: Transport.reverse_table[packet.getTruncatedHash()] = reverse_entry

                            Transport.transmit(outbound_interface, new_raw)
                            with Transport.path_table_lock: Transport.path_table[packet.destination_hash][IDX_PT_TIMESTAMP] = time.time()

                        else:
                            # TODO: There should probably be some kind of REJECT
                            # mechanism here, to signal to the source that their
                            # expected path failed.
                            RNS.log("Got packet in transport, but no known path to final destination "+RNS.prettyhexrep(packet.destination_hash)+". Dropping packet.", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None

                # Link transport handling. Directs packets according
                # to entries in the link tables
                if packet.packet_type != RNS.Packet.ANNOUNCE and packet.packet_type != RNS.Packet.LINKREQUEST and packet.context != RNS.Packet.LRPROOF:
                    if packet.destination_hash in Transport.link_table:
                        link_entry = Transport.link_table[packet.destination_hash]
                        # If receiving and outbound interface is
                        # the same for this link, direction doesn't
                        # matter, and we simply repeat the packet.
                        outbound_interface = None
                        if link_entry[IDX_LT_NH_IF] == link_entry[IDX_LT_RCVD_IF]:
                            # But check that taken hops matches one
                            # of the expectede values.
                            if packet.hops == link_entry[IDX_LT_REM_HOPS] or packet.hops == link_entry[IDX_LT_HOPS]:
                                outbound_interface = link_entry[IDX_LT_NH_IF]
                        else:
                            # If interfaces differ, we transmit on
                            # the opposite interface of what the
                            # packet was received on.
                            if packet.receiving_interface == link_entry[IDX_LT_NH_IF]:
                                # Also check that expected hop count matches
                                if packet.hops == link_entry[IDX_LT_REM_HOPS]:
                                    outbound_interface = link_entry[IDX_LT_RCVD_IF]
                            elif packet.receiving_interface == link_entry[IDX_LT_RCVD_IF]:
                                # Also check that expected hop count matches
                                if packet.hops == link_entry[IDX_LT_HOPS]:
                                    outbound_interface = link_entry[IDX_LT_NH_IF]

                        if outbound_interface != None:
                            # Add this packet to the filter hashlist if we
                            # have determined that it's actually our turn
                            # to process it.
                            Transport.add_packet_hash(packet.packet_hash)

                            new_raw = packet.raw[0:1]
                            new_raw += struct.pack("!B", packet.hops)
                            new_raw += packet.raw[2:]
                            Transport.transmit(outbound_interface, new_raw)
                            Transport.link_table[packet.destination_hash][IDX_LT_TIMESTAMP] = time.time()
                        
                        # TODO: Can we return safely here? Test and possibly enable this at some point.
                        # return


            # Announce handling. Handles logic related to incoming
            # announces, queueing rebroadcasts of these, and removal
            # of queued announce rebroadcasts once handed to the next node.
            if packet.packet_type == RNS.Packet.ANNOUNCE:
                announce_signature_valid = RNS.Identity.validate_announce(packet, only_validate_signature=True)
                if not announce_signature_valid: return
                elif interface != None: interface.received_announce()
                announced_destination_known = packet.destination_hash in Transport.path_table

                if not announced_destination_known:
                    # This is an unknown destination, and we'll apply
                    # potential ingress limiting. Already known
                    # destinations will have re-announces controlled
                    # by normal announce rate limiting.
                    if packet.destination_hash in Transport.path_requests or packet.destination_hash in Transport.discovery_path_requests:
                        # RNS.log(f"Skipping ingress limit check for {RNS.prettyhexrep(packet.destination_hash)} due to waiting path requests", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                        pass
                    
                    elif interface.should_ingress_limit():
                        interface.hold_announce(packet)
                        return

                local_destination = None
                with Transport.destinations_map_lock:
                    if packet.destination_hash in Transport.destinations_map:
                        local_destination = Transport.destinations_map[packet.destination_hash]

                if local_destination == None and RNS.Identity.validate_announce(packet):
                    if packet.transport_id != None:
                        received_from = packet.transport_id
                        
                        # Check if this is a next retransmission from
                        # another node. If it is, we're removing the
                        # announce in question from our pending table
                        if RNS.Reticulum.transport_enabled() and packet.destination_hash in Transport.announce_table:
                            announce_entry = Transport.announce_table[packet.destination_hash]
                            
                            if packet.hops-1 == announce_entry[IDX_AT_HOPS]:
                                RNS.log(f"Heard a rebroadcast of announce for {RNS.prettyhexrep(packet.destination_hash)} on {packet.receiving_interface}", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                announce_entry[IDX_AT_LCL_RBRD] += 1
                                if announce_entry[IDX_AT_RETRIES] > 0:
                                    if announce_entry[IDX_AT_LCL_RBRD] >= Transport.LOCAL_REBROADCASTS_MAX:
                                        RNS.log("Completed announce processing for "+RNS.prettyhexrep(packet.destination_hash)+", local rebroadcast limit reached", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                        with Transport.announce_table_lock:
                                            if packet.destination_hash in Transport.announce_table: Transport.announce_table.pop(packet.destination_hash)

                            if packet.hops-1 == announce_entry[IDX_AT_HOPS]+1 and announce_entry[IDX_AT_RETRIES] > 0:
                                now = time.time()
                                if now < announce_entry[IDX_AT_RTRNS_TMO]:
                                    RNS.log("Rebroadcasted announce for "+RNS.prettyhexrep(packet.destination_hash)+" has been passed on to another node, no further tries needed", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                    with Transport.announce_table_lock:
                                        if packet.destination_hash in Transport.announce_table: Transport.announce_table.pop(packet.destination_hash)

                    else:
                        received_from = packet.destination_hash

                    # Check if this announce should be inserted into
                    # announce and destination tables
                    should_add = False

                    # First, check that the announce is not for a destination
                    # local to this system, and that hops are less than the max
                    with Transport.destinations_map_lock:
                        local_and_hops_condition = (packet.hops < Transport.PATHFINDER_M+1) and (not packet.destination_hash in Transport.destinations_map)

                    if local_and_hops_condition:
                        announce_emitted = Transport.announce_emitted(packet)
                        
                        random_blob = packet.data[RNS.Identity.KEYSIZE//8+RNS.Identity.NAME_HASH_LENGTH//8:RNS.Identity.KEYSIZE//8+RNS.Identity.NAME_HASH_LENGTH//8+10]
                        random_blobs = []
                        with Transport.inbound_announce_lock:
                            if announced_destination_known:
                                random_blobs = Transport.path_table[packet.destination_hash][IDX_PT_RANDBLOBS]

                                # If we already have a path to the announced
                                # destination, but the hop count is equal or
                                # less, we'll update our tables.
                                if packet.hops <= Transport.path_table[packet.destination_hash][IDX_PT_HOPS]:
                                    # Make sure we haven't heard the random
                                    # blob before, so announces can't be
                                    # replayed to forge paths.
                                    # TODO: Check whether this approach works
                                    # under all circumstances
                                    path_timebase = Transport.timebase_from_random_blobs(random_blobs)
                                    if not random_blob in random_blobs and announce_emitted > path_timebase:
                                        Transport.mark_path_unknown_state(packet.destination_hash)
                                        should_add = True
                                    else:
                                        should_add = False
                                else:
                                    # If an announce arrives with a larger hop
                                    # count than we already have in the table,
                                    # ignore it, unless the path is expired, or
                                    # the emission timestamp is more recent.
                                    now = time.time()
                                    path_expires = Transport.path_table[packet.destination_hash][IDX_PT_EXPIRES]
                                    
                                    path_announce_emitted = 0
                                    for path_random_blob in random_blobs:
                                        path_announce_emitted = max(path_announce_emitted, int.from_bytes(path_random_blob[5:10], "big"))
                                        if path_announce_emitted >= announce_emitted:
                                            break

                                    # If the path has expired, consider this
                                    # announce for adding to the path table.
                                    if (now >= path_expires):
                                        # We check that the announce is
                                        # different from ones we've already heard,
                                        # to avoid loops in the network
                                        if not random_blob in random_blobs:
                                            # TODO: Check that this ^ approach actually
                                            # works under all circumstances
                                            RNS.log("Replacing destination table entry for "+str(RNS.prettyhexrep(packet.destination_hash))+" with new announce due to expired path", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                            Transport.mark_path_unknown_state(packet.destination_hash)
                                            should_add = True
                                        else:
                                            should_add = False
                                    else:
                                        # If the path is not expired, but the emission
                                        # is more recent, and we haven't already heard
                                        # this announce before, update the path table.
                                        if (announce_emitted > path_announce_emitted):
                                            if not random_blob in random_blobs:
                                                RNS.log("Replacing destination table entry for "+str(RNS.prettyhexrep(packet.destination_hash))+" with new announce, since it was more recently emitted", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                                Transport.mark_path_unknown_state(packet.destination_hash)
                                                should_add = True
                                            else:
                                                should_add = False
                                        
                                        # If we have already heard this announce before,
                                        # but the path has been marked as unresponsive
                                        # by a failed communications attempt or similar,
                                        # allow updating the path table to this one.
                                        elif announce_emitted == path_announce_emitted:
                                            if Transport.path_is_unresponsive(packet.destination_hash):
                                                RNS.log("Replacing destination table entry for "+str(RNS.prettyhexrep(packet.destination_hash))+" with new announce, since previously tried path was unresponsive", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                                should_add = True
                                            else:
                                                should_add = False

                            else:
                                # If this destination is unknown in our table
                                # we should add it
                                Transport.mark_path_unknown_state(packet.destination_hash)
                                should_add = True

                            if should_add:
                                now = time.time()
                                is_from_local_client = Transport.from_local_client(packet)

                                rate_blocked = False
                                if packet.context != RNS.Packet.PATH_RESPONSE and packet.receiving_interface.announce_rate_target != None:
                                    with Transport.announce_rate_table_lock:
                                        if not packet.destination_hash in Transport.announce_rate_table:
                                            rate_entry = { "last": now, "rate_violations": 0, "blocked_until": 0, "timestamps": [now]}
                                            Transport.announce_rate_table[packet.destination_hash] = rate_entry

                                        else:
                                            rate_entry = Transport.announce_rate_table[packet.destination_hash]
                                            rate_entry["timestamps"].append(now)

                                            while len(rate_entry["timestamps"]) > Transport.MAX_RATE_TIMESTAMPS:
                                                rate_entry["timestamps"].pop(0)

                                            current_rate = now - rate_entry["last"]

                                            if now > rate_entry["blocked_until"]:
                                                if current_rate < packet.receiving_interface.announce_rate_target: rate_entry["rate_violations"] += 1
                                                else: rate_entry["rate_violations"] = max(0, rate_entry["rate_violations"]-1)

                                                if rate_entry["rate_violations"] > packet.receiving_interface.announce_rate_grace:
                                                    rate_target = packet.receiving_interface.announce_rate_target
                                                    rate_penalty = packet.receiving_interface.announce_rate_penalty
                                                    rate_entry["blocked_until"] = rate_entry["last"] + rate_target + rate_penalty
                                                    rate_blocked = True
                                                else:
                                                    rate_entry["last"] = now

                                            else: rate_blocked = True

                                retries            = 0
                                announce_hops      = packet.hops
                                local_rebroadcasts = 0
                                block_rebroadcasts = False
                                attached_interface = None
                                
                                retransmit_timeout = now + (RNS.rand() * Transport.PATHFINDER_RW)

                                if hasattr(packet.receiving_interface, "mode") and packet.receiving_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT:
                                    expires = now + Transport.AP_PATH_TIME
                                elif hasattr(packet.receiving_interface, "mode") and packet.receiving_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ROAMING:
                                    expires = now + Transport.ROAMING_PATH_TIME
                                else:
                                    expires = now + Transport.PATHFINDER_E
                                
                                if not random_blob in random_blobs:
                                    random_blobs.append(random_blob)
                                    random_blobs = random_blobs[-Transport.MAX_RANDOM_BLOBS:]

                                if (RNS.Reticulum.transport_enabled() or is_from_local_client) and packet.context != RNS.Packet.PATH_RESPONSE:
                                    # Insert announce into announce table for retransmission

                                    if rate_blocked: RNS.log("Blocking rebroadcast of announce from "+RNS.prettyhexrep(packet.destination_hash)+" due to excessive announce rate", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                    else:
                                        if is_from_local_client:
                                            # If the announce is from a local client,
                                            # it is announced immediately, but only one time.
                                            retransmit_timeout = now
                                            retries = Transport.PATHFINDER_R

                                        with Transport.announce_table_lock:
                                            Transport.announce_table[packet.destination_hash] = [
                                                now,                # 0: IDX_AT_TIMESTAMP
                                                retransmit_timeout, # 1: IDX_AT_RTRNS_TMO
                                                retries,            # 2: IDX_AT_RETRIES
                                                received_from,      # 3: IDX_AT_RCVD_IF
                                                announce_hops,      # 4: IDX_AT_HOPS
                                                packet,             # 5: IDX_AT_PACKET
                                                local_rebroadcasts, # 6: IDX_AT_LCL_RBRD
                                                block_rebroadcasts, # 7: IDX_AT_BLCK_RBRD
                                                attached_interface, # 8: IDX_AT_ATTCHD_IF
                                            ]

                                elif is_from_local_client and packet.context == RNS.Packet.PATH_RESPONSE:
                                    # If this is a path response from a local client,
                                    # check if any external interfaces have pending
                                    # path requests.
                                    with Transport.pending_local_prs_lock:
                                        if packet.destination_hash in Transport.pending_local_path_requests:
                                            desiring_interface = Transport.pending_local_path_requests.pop(packet.destination_hash)
                                            retransmit_timeout = now
                                            retries = Transport.PATHFINDER_R

                                            with Transport.announce_table_lock:
                                                Transport.announce_table[packet.destination_hash] = [
                                                    now,
                                                    retransmit_timeout,
                                                    retries,
                                                    received_from,
                                                    announce_hops,
                                                    packet,
                                                    local_rebroadcasts,
                                                    block_rebroadcasts,
                                                    attached_interface
                                                ]

                                # If we have any local clients connected, we re-
                                # transmit the announce to them immediately
                                if (len(Transport.local_client_interfaces)):
                                    announce_identity = RNS.Identity.recall(packet.destination_hash, _no_use=True)
                                    announce_destination = RNS.Destination(announce_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "unknown", "unknown");
                                    announce_destination.hash = packet.destination_hash
                                    announce_destination.hexhash = announce_destination.hash.hex()
                                    announce_context = RNS.Packet.NONE
                                    announce_data = packet.data

                                    # TODO: Shouldn't the context be PATH_RESPONSE in the first case here?
                                    if is_from_local_client and packet.context == RNS.Packet.PATH_RESPONSE:
                                        for local_interface in Transport.local_client_interfaces:
                                            if packet.receiving_interface != local_interface:
                                                new_announce = RNS.Packet(
                                                    announce_destination,
                                                    announce_data,
                                                    RNS.Packet.ANNOUNCE, # <-- This one?
                                                    context = announce_context,
                                                    header_type = RNS.Packet.HEADER_2,
                                                    transport_type = Transport.TRANSPORT,
                                                    transport_id = Transport.identity.hash,
                                                    attached_interface = local_interface,
                                                    context_flag = packet.context_flag,
                                                )
                                                
                                                new_announce.hops = packet.hops
                                                new_announce.send()

                                    else:
                                        for local_interface in Transport.local_client_interfaces:
                                            if packet.receiving_interface != local_interface:
                                                new_announce = RNS.Packet(
                                                    announce_destination,
                                                    announce_data,
                                                    RNS.Packet.ANNOUNCE,
                                                    context = announce_context,
                                                    header_type = RNS.Packet.HEADER_2,
                                                    transport_type = Transport.TRANSPORT,
                                                    transport_id = Transport.identity.hash,
                                                    attached_interface = local_interface,
                                                    context_flag = packet.context_flag,
                                                )

                                                new_announce.hops = packet.hops
                                                new_announce.send()

                                # If we have any waiting discovery path requests
                                # for this destination, we retransmit to that
                                # interface immediately
                                if packet.destination_hash in Transport.discovery_path_requests:
                                    pr_entry = Transport.discovery_path_requests[packet.destination_hash]
                                    attached_interface = pr_entry["requesting_interface"]

                                    interface_str = " on "+str(attached_interface)

                                    RNS.log("Got matching announce, answering waiting discovery path request for "+RNS.prettyhexrep(packet.destination_hash)+interface_str, RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                    announce_identity = RNS.Identity.recall(packet.destination_hash, _no_use=False)
                                    announce_destination = RNS.Destination(announce_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "unknown", "unknown");
                                    announce_destination.hash = packet.destination_hash
                                    announce_destination.hexhash = announce_destination.hash.hex()
                                    announce_context = RNS.Packet.NONE
                                    announce_data = packet.data

                                    new_announce = RNS.Packet(
                                        announce_destination,
                                        announce_data,
                                        RNS.Packet.ANNOUNCE,
                                        context = RNS.Packet.PATH_RESPONSE,
                                        header_type = RNS.Packet.HEADER_2,
                                        transport_type = Transport.TRANSPORT,
                                        transport_id = Transport.identity.hash,
                                        attached_interface = attached_interface,
                                        context_flag = packet.context_flag,
                                    )

                                    new_announce.hops = packet.hops
                                    new_announce.send()

                                if not Transport.owner.is_connected_to_shared_instance: Transport.cache(packet, force_cache=True, packet_type="announce")
                                path_table_entry = [now, received_from, announce_hops, expires, random_blobs, packet.receiving_interface, packet.packet_hash]
                                with Transport.path_table_lock: Transport.path_table[packet.destination_hash] = path_table_entry
                                Transport.mark_path_unknown_state(packet.destination_hash)
                                RNS.log("Destination "+RNS.prettyhexrep(packet.destination_hash)+" is now "+str(announce_hops)+" hops away via "+RNS.prettyhexrep(received_from)+" on "+str(packet.receiving_interface), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                                if packet.destination_hash in Transport.path_requests:
                                    RNS.Reticulum.get_instance()._used_destination_data(packet.destination_hash)

                                # If the receiving interface is a tunnel, we add the
                                # announce to the tunnels table
                                if hasattr(packet.receiving_interface, "tunnel_id") and packet.receiving_interface.tunnel_id != None:
                                    with Transport.tunnels_lock:
                                        if not packet.receiving_interface.tunnel_id in Transport.tunnels:
                                            RNS.log(f"Tunnel ID for {packet.receiving_interface} was not found in tunnel table", RNS.LOG_WARNING)
                                        else:
                                            tunnel_entry = Transport.tunnels[packet.receiving_interface.tunnel_id]
                                            paths = tunnel_entry[IDX_TT_PATHS]
                                            paths[packet.destination_hash] = [now, received_from, announce_hops, expires, random_blobs, None, packet.packet_hash]
                                            expires = time.time() + Transport.TUNNEL_TIMEOUT
                                            tunnel_entry[IDX_TT_EXPIRES] = expires
                                            RNS.log("Path to "+RNS.prettyhexrep(packet.destination_hash)+" associated with tunnel "+RNS.prettyhexrep(packet.receiving_interface.tunnel_id), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                                # Call externally registered callbacks from apps
                                # wanting to know when an announce arrives
                                with Transport.announce_handler_lock:
                                    for handler in Transport.announce_handlers:
                                        try:
                                            # Check that the announced destination matches
                                            # the handlers aspect filter
                                            execute_callback = False
                                            announce_identity = RNS.Identity.recall(packet.destination_hash, _no_use=True)
                                            if handler.aspect_filter == None:
                                                # If the handlers aspect filter is set to
                                                # None, we execute the callback in all cases
                                                execute_callback = True
                                            else:
                                                handler_expected_hash = RNS.Destination.hash_from_name_and_identity(handler.aspect_filter, announce_identity)
                                                if packet.destination_hash == handler_expected_hash: execute_callback = True

                                            # If this is a path response, check whether the
                                            # handler wants to receive it.
                                            if packet.context == RNS.Packet.PATH_RESPONSE:
                                                if hasattr(handler, "receive_path_responses") and handler.receive_path_responses == True: pass
                                                else: execute_callback = False

                                            if execute_callback:
                                                if len(inspect.signature(handler.received_announce).parameters) == 3:
                                                    def job(handler=handler, packet=packet, announce_identity=announce_identity):
                                                        handler.received_announce(destination_hash=packet.destination_hash,
                                                                                  announced_identity=announce_identity,
                                                                                  app_data=RNS.Identity.recall_app_data(packet.destination_hash, _no_use=True))
                                                    threading.Thread(target=job, daemon=True).start()
                                                
                                                elif len(inspect.signature(handler.received_announce).parameters) == 4:
                                                    def job(handler=handler, packet=packet, announce_identity=announce_identity):
                                                        handler.received_announce(destination_hash=packet.destination_hash,
                                                                                  announced_identity=announce_identity,
                                                                                  app_data=RNS.Identity.recall_app_data(packet.destination_hash, _no_use=True),
                                                                                  announce_packet_hash = packet.packet_hash)
                                                    threading.Thread(target=job, daemon=True).start()
                                                
                                                elif len(inspect.signature(handler.received_announce).parameters) == 5:
                                                    def job(handler=handler, packet=packet, announce_identity=announce_identity):
                                                        handler.received_announce(destination_hash=packet.destination_hash,
                                                                                  announced_identity=announce_identity,
                                                                                  app_data=RNS.Identity.recall_app_data(packet.destination_hash, _no_use=True),
                                                                                  announce_packet_hash = packet.packet_hash,
                                                                                  is_path_response = packet.context == RNS.Packet.PATH_RESPONSE)
                                                    threading.Thread(target=job, daemon=True).start()

                                                else:
                                                    raise TypeError("Invalid signature for announce handler callback")

                                        except Exception as e:
                                            RNS.log("Error while processing external announce callback.", RNS.LOG_ERROR)
                                            RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
                                            RNS.trace_exception(e)

            # Handling for link requests to local destinations
            elif packet.packet_type == RNS.Packet.LINKREQUEST:
                if packet.transport_id == None or packet.transport_id == Transport.identity.hash:
                    destination = None
                    with Transport.destinations_map_lock:
                        if packet.destination_hash in Transport.destinations_map:
                            destination = Transport.destinations_map[packet.destination_hash]

                    if destination and destination.type == packet.destination_type:
                        path_mtu       = RNS.Link.mtu_from_lr_packet(packet)
                        mode           = RNS.Link.mode_from_lr_packet(packet)
                        if packet.receiving_interface.AUTOCONFIGURE_MTU or packet.receiving_interface.FIXED_MTU:
                            nh_mtu     = packet.receiving_interface.HW_MTU
                        else:
                            nh_mtu     = RNS.Reticulum.MTU

                        if path_mtu:
                            if packet.receiving_interface.HW_MTU == None:
                                path_mtu = None
                                packet.data  = packet.data[:-RNS.Link.LINK_MTU_SIZE]
                            else:
                                if nh_mtu < path_mtu:
                                    try:
                                        path_mtu = nh_mtu
                                        clamped_mtu = RNS.Link.signalling_bytes(path_mtu, mode)
                                        packet.data  = packet.data[:-RNS.Link.LINK_MTU_SIZE]+clamped_mtu
                                    except Exception as e:
                                        RNS.log(f"Dropping link request packet to local destination. The contained exception was: {e}", RNS.LOG_WARNING)
                                        return

                        packet.destination = destination
                        destination.receive(packet)
            
            # Handling for local data packets
            elif packet.packet_type == RNS.Packet.DATA:
                if packet.destination_type == RNS.Destination.LINK:
                    with Transport.active_links_lock:
                        for link in Transport.active_links:
                            if link.link_id == packet.destination_hash:
                                if link.attached_interface == packet.receiving_interface:
                                    packet.link = link
                                    if packet.context == RNS.Packet.CACHE_REQUEST:
                                        cached_packet = Transport.get_cached_packet(packet.data)
                                        if cached_packet != None:
                                            cached_packet.unpack()
                                            RNS.Packet(destination=link, data=cached_packet.data,
                                                       packet_type=cached_packet.packet_type, context=cached_packet.context).send()
                                    
                                    else: link.receive(packet)
                                    break
                                
                                else:
                                    # In the strange and rare case that an interface
                                    # is partly malfunctioning, and a link-associated
                                    # packet is being received on an interface that
                                    # has failed sending, and transport has failed over
                                    # to another path, we remove this packet hash from
                                    # the filter hashlist so the link can receive the
                                    # packet when it finally arrives over another path.
                                    while packet.packet_hash in Transport.packet_hashlist:
                                        Transport.packet_hashlist.remove(packet.packet_hash)
                else:
                    destination = None
                    with Transport.destinations_map_lock:
                        if packet.destination_hash in Transport.destinations_map:
                            destination = Transport.destinations_map[packet.destination_hash]

                    if destination and destination.type == packet.destination_type:
                        packet.destination = destination
                        if destination.receive(packet):
                            if destination.proof_strategy == RNS.Destination.PROVE_ALL: packet.prove()

                            elif destination.proof_strategy == RNS.Destination.PROVE_APP:
                                if destination.callbacks.proof_requested:
                                    try:
                                        if destination.callbacks.proof_requested(packet): packet.prove()
                                    except Exception as e:
                                        RNS.log("Error while executing proof request callback. The contained exception was: "+str(e), RNS.LOG_ERROR)

            # Handling for proofs and link-request proofs
            elif packet.packet_type == RNS.Packet.PROOF:
                if packet.context == RNS.Packet.LRPROOF:
                    # This is a link request proof, check if it
                    # needs to be transported
                    if (RNS.Reticulum.transport_enabled() or for_local_client_link or from_local_client) and packet.destination_hash in Transport.link_table:
                        link_entry = Transport.link_table[packet.destination_hash]
                        if packet.hops == link_entry[IDX_LT_REM_HOPS]:
                            if packet.receiving_interface == link_entry[IDX_LT_NH_IF]:
                                try:
                                    if len(packet.data) == RNS.Identity.SIGLENGTH//8+RNS.Link.ECPUBSIZE//2 or len(packet.data) == RNS.Identity.SIGLENGTH//8+RNS.Link.ECPUBSIZE//2+RNS.Link.LINK_MTU_SIZE:
                                        signalling_bytes = b""
                                        if len(packet.data) == RNS.Identity.SIGLENGTH//8+RNS.Link.ECPUBSIZE//2+RNS.Link.LINK_MTU_SIZE:
                                            signalling_bytes = RNS.Link.signalling_bytes(RNS.Link.mtu_from_lp_packet(packet), RNS.Link.mode_from_lp_packet(packet))

                                        peer_pub_bytes = packet.data[RNS.Identity.SIGLENGTH//8:RNS.Identity.SIGLENGTH//8+RNS.Link.ECPUBSIZE//2]
                                        peer_identity = RNS.Identity.recall(link_entry[IDX_LT_DSTHASH], _no_use=True)
                                        peer_sig_pub_bytes = peer_identity.get_public_key()[RNS.Link.ECPUBSIZE//2:RNS.Link.ECPUBSIZE]

                                        signed_data = packet.destination_hash+peer_pub_bytes+peer_sig_pub_bytes+signalling_bytes
                                        signature = packet.data[:RNS.Identity.SIGLENGTH//8]

                                        if peer_identity.validate(signature, signed_data):
                                            RNS.log("Link request proof validated for transport via "+str(link_entry[IDX_LT_RCVD_IF]), RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                                            new_raw = packet.raw[0:1]
                                            new_raw += struct.pack("!B", packet.hops)
                                            new_raw += packet.raw[2:]
                                            Transport.link_table[packet.destination_hash][IDX_LT_VALIDATED] = True
                                            Transport.transmit(link_entry[IDX_LT_RCVD_IF], new_raw)
                                            if not Transport.owner.is_connected_to_shared_instance:
                                                RNS.Identity._used_destination_data(link_entry[IDX_LT_DSTHASH])

                                        else:
                                            RNS.log("Invalid link request proof in transport for link "+RNS.prettyhexrep(packet.destination_hash)+", dropping proof.", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                                except Exception as e: RNS.log("Could not transport link request proof. The contained exception was: "+str(e), RNS.LOG_DEBUG) if RNS.sl(LOG_DEBUG) else None
                            else: RNS.log("Link request proof received on wrong interface, not transporting it.", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                        else: RNS.log("Received link request proof with hop mismatch, not transporting it", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                    
                    else:
                        # Check if we can deliver it to a local
                        # pending link

                        pending_link = None
                        with Transport.pending_links_lock:
                            for link in Transport.pending_links:
                                if link.link_id == packet.destination_hash:
                                    # We need to also allow an expected hops value of
                                    # PATHFINDER_M, since in some cases, the number of hops
                                    # to the destination will be unknown at link creation
                                    # time. The real chance of this occuring is likely to be
                                    # extremely small, and this allowance could probably
                                    # be discarded without major issues, but it is kept
                                    # for now to ensure backwards compatibility.

                                    # TODO: Probably reset check back to
                                    # if packet.hops == link.expected_hops:
                                    # within one of the next releases

                                    if packet.hops == link.expected_hops or link.expected_hops == RNS.Transport.PATHFINDER_M:
                                        # Add this packet to the filter hashlist if we
                                        # have determined that it's actually destined
                                        # for this system, and then validate the proof
                                        Transport.add_packet_hash(packet.packet_hash)
                                        pending_link = link
                                        break

                        if pending_link: pending_link.validate_proof(packet)

                elif packet.context == RNS.Packet.RESOURCE_PRF:
                    with Transport.active_links_lock:
                        for link in Transport.active_links:
                            if link.link_id == packet.destination_hash:
                                link.receive(packet)
                                break
                else:
                    if packet.destination_type == RNS.Destination.LINK:
                        with Transport.active_links_lock:
                            for link in Transport.active_links:
                                if link.link_id == packet.destination_hash:
                                    packet.link = link
                                    break
                                
                    if len(packet.data) == RNS.PacketReceipt.EXPL_LENGTH: proof_hash = packet.data[:RNS.Identity.HASHLENGTH//8]
                    else:                                                 proof_hash = None

                    # Check if this proof needs to be transported
                    if (RNS.Reticulum.transport_enabled() or from_local_client or proof_for_local_client) and packet.destination_hash in Transport.reverse_table:
                        with Transport.reverse_table_lock: reverse_entry = Transport.reverse_table.pop(packet.destination_hash)
                        if packet.receiving_interface == reverse_entry[IDX_RT_OUTB_IF]:
                            RNS.log("Proof received on correct interface, transporting it via "+str(reverse_entry[IDX_RT_RCVD_IF]), RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                            new_raw = packet.raw[0:1]
                            new_raw += struct.pack("!B", packet.hops)
                            new_raw += packet.raw[2:]
                            Transport.transmit(reverse_entry[IDX_RT_RCVD_IF], new_raw)
                        else:
                            RNS.log("Proof received on wrong interface, not transporting it.", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                    with Transport.receipts_lock:
                        for receipt in Transport.receipts:
                            receipt_validated = False
                            if proof_hash != None:
                                # Only test validation if hash matches
                                if receipt.hash == proof_hash:
                                    receipt_validated = receipt.validate_proof_packet(packet)
                            else:
                                # In case of an implicit proof, we have
                                # to check every single outstanding receipt
                                receipt_validated = receipt.validate_proof_packet(packet)

                            if receipt_validated:
                                if receipt in Transport.receipts:
                                    Transport.receipts.remove(receipt)

    @staticmethod
    def synthesize_tunnel(interface):
        try:
            interface_hash = interface.get_hash()
            public_key     = RNS.Transport.identity.get_public_key()
            random_hash    = RNS.Identity.get_random_hash()
            
            tunnel_id_data = public_key+interface_hash
            tunnel_id      = RNS.Identity.full_hash(tunnel_id_data)

            signed_data    = tunnel_id_data+random_hash
            signature      = Transport.identity.sign(signed_data)
            
            data           = signed_data+signature

            tnl_snth_dst   = RNS.Destination(None, RNS.Destination.OUT, RNS.Destination.PLAIN, Transport.APP_NAME, "tunnel", "synthesize")

            packet = RNS.Packet(tnl_snth_dst, data, packet_type = RNS.Packet.DATA, transport_type = RNS.Transport.BROADCAST, header_type = RNS.Packet.HEADER_1, attached_interface = interface)
            packet.send()

            interface.wants_tunnel = False

        except Exception as e: RNS.log(f"Could not synthesize tunnel for {interface}: {e}", RNS.LOG_ERROR)

    @staticmethod
    def tunnel_synthesize_handler(data, packet):
        try:
            expected_length = RNS.Identity.KEYSIZE//8+RNS.Identity.HASHLENGTH//8+RNS.Reticulum.TRUNCATED_HASHLENGTH//8+RNS.Identity.SIGLENGTH//8
            if len(data) == expected_length:
                public_key     = data[:RNS.Identity.KEYSIZE//8]
                interface_hash = data[RNS.Identity.KEYSIZE//8:RNS.Identity.KEYSIZE//8+RNS.Identity.HASHLENGTH//8]
                tunnel_id_data = public_key+interface_hash
                tunnel_id      = RNS.Identity.full_hash(tunnel_id_data)
                random_hash    = data[RNS.Identity.KEYSIZE//8+RNS.Identity.HASHLENGTH//8:RNS.Identity.KEYSIZE//8+RNS.Identity.HASHLENGTH//8+RNS.Reticulum.TRUNCATED_HASHLENGTH//8]
                
                signature      = data[RNS.Identity.KEYSIZE//8+RNS.Identity.HASHLENGTH//8+RNS.Reticulum.TRUNCATED_HASHLENGTH//8:expected_length]
                signed_data    = tunnel_id_data+random_hash

                remote_transport_identity = RNS.Identity(create_keys=False)
                remote_transport_identity.load_public_key(public_key)

                if remote_transport_identity.validate(signature, signed_data):
                    Transport.handle_tunnel(tunnel_id, packet.receiving_interface)

        except Exception as e:
            RNS.log("An error occurred while validating tunnel establishment packet.", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
            RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

    @staticmethod
    def void_tunnel_interface(tunnel_id):
        with Transport.tunnels_lock:
            if tunnel_id in Transport.tunnels:
                RNS.log(f"Voiding tunnel interface {Transport.tunnels[tunnel_id][IDX_TT_IF]}", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                Transport.tunnels[tunnel_id][IDX_TT_IF] = None

    @staticmethod
    def handle_tunnel(tunnel_id, interface):
        expires = time.time() + Transport.TUNNEL_TIMEOUT
        if not tunnel_id in Transport.tunnels:
            RNS.log("Tunnel endpoint "+RNS.prettyhexrep(tunnel_id)+" established.", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
            paths = {}
            with Transport.tunnels_lock:
                tunnel_entry = [tunnel_id, interface, paths, expires]
                interface.tunnel_id = tunnel_id
                Transport.tunnels[tunnel_id] = tunnel_entry
        else:
            RNS.log("Tunnel endpoint "+RNS.prettyhexrep(tunnel_id)+" reappeared. Restoring paths...", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
            tunnel_entry = Transport.tunnels[tunnel_id]
            tunnel_entry[IDX_TT_IF] = interface
            tunnel_entry[IDX_TT_EXPIRES] = expires
            interface.tunnel_id = tunnel_id
            paths = tunnel_entry[IDX_TT_PATHS]

            deprecated_paths = []
            with Transport.tunnels_lock:
                for destination_hash, path_entry in paths.items():
                    received_from = path_entry[1]
                    announce_hops = path_entry[2]
                    expires = path_entry[3]
                    random_blobs = list(set(path_entry[4]))
                    receiving_interface = interface
                    packet_hash = path_entry[6]
                    new_entry = [time.time(), received_from, announce_hops, expires, random_blobs, receiving_interface, packet_hash]

                    should_add = False
                    with Transport.path_table_lock:
                        if destination_hash in Transport.path_table:
                            old_entry = Transport.path_table[destination_hash]
                            old_hops = old_entry[IDX_PT_HOPS]
                            old_expires = old_entry[IDX_PT_EXPIRES]
                            if announce_hops <= old_hops or time.time() > old_expires:
                                current_random_blobs     = Transport.path_table[destination_hash][IDX_PT_RANDBLOBS]
                                current_path_timebase    = Transport.timebase_from_random_blobs(current_random_blobs)
                                tunnel_announce_timebase = Transport.timebase_from_random_blobs(random_blobs)
                                if tunnel_announce_timebase >= current_path_timebase: should_add = True
                                else: RNS.log("Did not restore path to "+RNS.prettyhexrep(destination_hash)+" because existing path is more recent", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                            else: RNS.log("Did not restore path to "+RNS.prettyhexrep(destination_hash)+" because a newer path with fewer hops exist", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                        
                        else:
                            if time.time() < expires: should_add = True
                            else: RNS.log("Did not restore path to "+RNS.prettyhexrep(destination_hash)+" because it has expired", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                    if should_add:
                        with Transport.path_table_lock: Transport.path_table[destination_hash] = new_entry
                        RNS.log("Restored path to "+RNS.prettyhexrep(destination_hash)+" is now "+str(announce_hops)+" hops away via "+RNS.prettyhexrep(received_from)+" on "+str(receiving_interface), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                    
                    else: deprecated_paths.append(destination_hash)

            for deprecated_path in deprecated_paths:
                RNS.log("Removing path to "+RNS.prettyhexrep(deprecated_path)+" from tunnel "+RNS.prettyhexrep(tunnel_id), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                with Transport.tunnels_lock: paths.pop(deprecated_path)

    @staticmethod
    def clean_destinations_map():
        with Transport.destinations_lock:
            for destination in Transport.destinations:
                with Transport.destinations_map_lock:
                    if not destination.hash in Transport.destinations_map:
                        Transport.destinations_map[destination.hash] = destination

        with Transport.destinations_map_lock:
            stale_destination_hashes = []
            for destination_hash in Transport.destinations_map:
                with Transport.destinations_lock:
                    found = False
                    for destination in Transport.destinations:
                        if destination.hash == destination_hash: found = True

                    if not found: stale_destination_hashes.append(destination_hash)

            for destination_hash in stale_destination_hashes:
                Transport.destinations_map.pop(destination_hash)

    @staticmethod
    def register_destination(destination):
        destination.MTU = RNS.Reticulum.MTU
        if destination.direction == RNS.Destination.IN:
            with Transport.destinations_lock:
                for registered_destination in Transport.destinations:
                    if destination.hash == registered_destination.hash:
                        raise KeyError("Attempt to register an already registered destination.")
                
                Transport.destinations.append(destination)

            with Transport.destinations_map_lock:
                Transport.destinations_map[destination.hash] = destination

            if Transport.owner.is_connected_to_shared_instance:
                if destination.type == RNS.Destination.SINGLE:
                    def job():
                        time.sleep(0.25)
                        destination.announce(path_response=True)
                    threading.Thread(target=job, daemon=True).start()

    @staticmethod
    def deregister_destination(destination):
        with Transport.destinations_lock:
            if destination in Transport.destinations: Transport.destinations.remove(destination)

        with Transport.destinations_map_lock:
            if destination.hash in Transport.destinations_map:
                Transport.destinations_map.pop(destination.hash)

    @staticmethod
    def register_link(link):
        RNS.log("Registering link "+str(link), RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
        if link.initiator:
            with Transport.pending_links_lock: Transport.pending_links.append(link)
        else:
            with Transport.active_links_lock: Transport.active_links.append(link)

    @staticmethod
    def activate_link(link):
        RNS.log("Activating link "+str(link), RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
        with Transport.pending_links_lock:
            if link in Transport.pending_links:
                if link.status != RNS.Link.ACTIVE: raise IOError("Invalid link state for link activation: "+str(link.status))
                Transport.pending_links.remove(link)
                with Transport.active_links_lock: Transport.active_links.append(link)
                link.status = RNS.Link.ACTIVE
            else:
                RNS.log("Attempted to activate a link that was not in the pending table", RNS.LOG_ERROR)

    @staticmethod
    def register_announce_handler(handler):
        """
        Registers an announce handler.

        :param handler: Must be an object with an *aspect_filter* attribute and a *received_announce(destination_hash, announced_identity, app_data)*
                        or *received_announce(destination_hash, announced_identity, app_data, announce_packet_hash)* or
                        *received_announce(destination_hash, announced_identity, app_data, announce_packet_hash, is_path_response)* callable. Can
                        optionally have a *receive_path_responses* attribute set to ``True``, to also receive all path responses, in addition to live
                        announces. See the :ref:`Announce Example<example-announce>` for more info.
        """
        with Transport.announce_handler_lock:
            if hasattr(handler, "received_announce") and callable(handler.received_announce):
                if hasattr(handler, "aspect_filter"): Transport.announce_handlers.append(handler)

    @staticmethod
    def deregister_announce_handler(handler):
        """
        Deregisters an announce handler.

        :param handler: The announce handler to be deregistered.
        """
        with Transport.announce_handler_lock:
            while handler in Transport.announce_handlers: Transport.announce_handlers.remove(handler)

        gc.collect()

    @staticmethod
    def find_interface_from_hash(interface_hash):
        for interface in Transport.interfaces:
            if interface.get_hash() == interface_hash:
                return interface

        return None

    @staticmethod
    def should_cache(packet):
        # TODO: Rework the caching system. It's currently
        # not very useful to even cache Resource proofs,
        # disabling it for now, until redesigned.
        # if packet.context == RNS.Packet.RESOURCE_PRF:
        #     return True

        return False

    @staticmethod
    def clean_cache():
        if not Transport.owner.is_connected_to_shared_instance:
            if Transport.cache_clean_lock.locked():
                RNS.log(f"Cache clean job still running, postponing until next scheduler interval", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None
            
            else:
                try:
                    acquired_lock = Transport.cache_clean_lock.acquire(blocking=False)
                    if acquired_lock:
                        Transport.clean_announce_cache()
                        Transport.cache_last_cleaned = time.time()

                except Exception as e:
                    RNS.log(f"An error occurred while launching the cache clean job. The contained exception was: {e}", RNS.LOG_ERROR)
                    RNS.trace_exception(e)

                finally:
                    if acquired_lock: Transport.cache_clean_lock.release()

    @staticmethod
    def clean_announce_cache():
        st = time.time()
        target_path = os.path.join(RNS.Reticulum.cachepath, "announces")
        cached_announce_hashes = os.listdir(target_path)
        with Transport.path_table_lock: active_path_hashes = list(set([Transport.path_table[dst_hash][IDX_PT_PACKET] for dst_hash in Transport.path_table]))
        with Transport.tunnels_lock:    tunnel_path_hashes = list(set([path_dict[dst_hash][IDX_PT_PACKET] for path_dict in [Transport.tunnels[tunnel_id][IDX_TT_PATHS] for tunnel_id in Transport.tunnels] for dst_hash in path_dict]))
        removed = 0; total = 0
        for packet_hash in cached_announce_hashes:
            remove = False
            full_path = os.path.join(target_path, packet_hash)
            if os.path.isfile(full_path):
                try: target_hash = bytes.fromhex(packet_hash)
                except: remove = True
                if (not target_hash in active_path_hashes) and (not target_hash in tunnel_path_hashes): remove = True
                if remove: os.unlink(full_path); removed += 1
                total += 1

            # Low priority, yield thread
            time.sleep(0.001)

        if removed > 0: RNS.log(f"Removed {removed} cached announces in {RNS.prettytime(time.time()-st)}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

    # When caching packets to storage, they are written
    # exactly as they arrived over their interface. This
    # means that they have not had their hop count
    # increased yet! Take note of this when reading from
    # the packet cache.
    @staticmethod
    def cache(packet, force_cache=False, packet_type=None):
        if force_cache or RNS.Transport.should_cache(packet):
            try:
                packet_hash = RNS.hexrep(packet.get_hash(), delimit=False)
                interface_reference = None
                if packet.receiving_interface != None: interface_reference = str(packet.receiving_interface)
                if packet_type == "announce": cachepath = os.path.join(RNS.Reticulum.cachepath, "announces", packet_hash)
                else:                         cachepath = os.path.join(RNS.Reticulum.cachepath, packet_hash)

                file = open(cachepath, "wb")
                file.write(umsgpack.packb([packet.raw, interface_reference]))
                file.close()

            except Exception as e:
                RNS.log("Error writing packet to cache. The contained exception was: "+str(e), RNS.LOG_ERROR)

    @staticmethod
    def get_cached_packet(packet_hash, packet_type=None):
        try:
            packet_hash = RNS.hexrep(packet_hash, delimit=False)
            if packet_type == "announce": path = os.path.join(RNS.Reticulum.cachepath, "announces", packet_hash)
            else:                         path = os.path.join(RNS.Reticulum.cachepath, packet_hash)

            if os.path.isfile(path):
                file = open(path, "rb")
                cached_data = umsgpack.unpackb(file.read())
                file.close()

                packet = RNS.Packet(None, cached_data[0])
                interface_reference = cached_data[1]

                for interface in Transport.interfaces:
                    if str(interface) == interface_reference:
                        packet.receiving_interface = interface

                return packet

            else: return None
        
        except Exception as e:
            RNS.log("Exception occurred while getting cached packet.", RNS.LOG_ERROR)
            RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
            return None

    @staticmethod
    def cache_request_packet(packet):
        if len(packet.data) == RNS.Identity.HASHLENGTH/8:
            packet = Transport.get_cached_packet(packet.data)

            if packet != None:
                # If the packet was retrieved from the local
                # cache, replay it to the Transport instance,
                # so that it can be directed towards it original
                # destination.
                Transport.inbound(packet.raw, packet.receiving_interface)
                return True
            else:
                return False
        else:
            return False

    @staticmethod
    def cache_request(packet_hash, destination):
        cached_packet = Transport.get_cached_packet(packet_hash)
        if cached_packet:
            # The packet was found in the local cache,
            # replay it to the Transport instance.
            Transport.inbound(cached_packet.raw, cached_packet.receiving_interface)
        else:
            # The packet is not in the local cache,
            # query the network.
            RNS.Packet(destination, packet_hash, context = RNS.Packet.CACHE_REQUEST).send()

    @staticmethod
    def has_path(destination_hash):
        """
        :param destination_hash: A destination hash as *bytes*.
        :returns: *True* if a path to the destination is known, otherwise *False*.
        """
        if destination_hash in Transport.path_table: return True
        else: return False

    @staticmethod
    def hops_to(destination_hash):
        """
        :param destination_hash: A destination hash as *bytes*.
        :returns: The number of hops to the specified destination, or ``RNS.Transport.PATHFINDER_M`` if the number of hops is unknown.
        """
        with Transport.path_table_lock:
            if destination_hash in Transport.path_table: return Transport.path_table[destination_hash][IDX_PT_HOPS]
            else: return Transport.PATHFINDER_M

    @staticmethod
    def next_hop(destination_hash):
        """
        :param destination_hash: A destination hash as *bytes*.
        :returns: The destination hash as *bytes* for the next hop to the specified destination, or *None* if the next hop is unknown.
        """
        with Transport.path_table_lock:
            if destination_hash in Transport.path_table: return Transport.path_table[destination_hash][IDX_PT_NEXT_HOP]
            else: return None

    @staticmethod
    def next_hop_interface(destination_hash):
        """
        :param destination_hash: A destination hash as *bytes*.
        :returns: The interface for the next hop to the specified destination, or *None* if the interface is unknown.
        """
        with Transport.path_table_lock:
            if destination_hash in Transport.path_table: return Transport.path_table[destination_hash][IDX_PT_RVCD_IF]
            else: return None

    @staticmethod
    def next_hop_interface_bitrate(destination_hash):
        next_hop_interface = Transport.next_hop_interface(destination_hash)
        if next_hop_interface != None: return next_hop_interface.bitrate
        else: return None

    @staticmethod
    def next_hop_interface_hw_mtu(destination_hash):
        next_hop_interface = Transport.next_hop_interface(destination_hash)
        if next_hop_interface != None:
            if next_hop_interface.AUTOCONFIGURE_MTU or next_hop_interface.FIXED_MTU: return next_hop_interface.HW_MTU
            else: return None
        
        else: return None

    @staticmethod
    def next_hop_per_bit_latency(destination_hash):
        next_hop_interface_bitrate = Transport.next_hop_interface_bitrate(destination_hash)
        if next_hop_interface_bitrate != None: return (1/next_hop_interface_bitrate)
        else: return None

    @staticmethod
    def next_hop_per_byte_latency(destination_hash):
        per_bit_latency = Transport.next_hop_per_bit_latency(destination_hash)
        if per_bit_latency != None: return per_bit_latency*8
        else: return None

    @staticmethod
    def first_hop_timeout(destination_hash):
        latency = Transport.next_hop_per_byte_latency(destination_hash)
        if latency != None: return RNS.Reticulum.MTU * latency + RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT
        else: return RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT

    @staticmethod
    def extra_link_proof_timeout(interface):
        if interface != None: return ((1/interface.bitrate)*8)*RNS.Reticulum.MTU
        else: return 0

    @staticmethod
    def expire_path(destination_hash):
        with Transport.path_table_lock:
            if destination_hash in Transport.path_table:
                Transport.path_table[destination_hash][IDX_PT_TIMESTAMP] = 0
                Transport.tables_last_culled = 0
                return True
            
            else: return False

    @staticmethod
    def mark_path_unresponsive(destination_hash):
        if destination_hash in Transport.path_table:
            with Transport.path_states_lock: Transport.path_states[destination_hash] = Transport.STATE_UNRESPONSIVE
            return True
        
        else: return False

    @staticmethod
    def mark_path_responsive(destination_hash):
        if destination_hash in Transport.path_table:
            with Transport.path_states_lock: Transport.path_states[destination_hash] = Transport.STATE_RESPONSIVE
            return True
        
        else: return False

    @staticmethod
    def mark_path_unknown_state(destination_hash):
        if destination_hash in Transport.path_table:
            with Transport.path_states_lock: Transport.path_states[destination_hash] = Transport.STATE_UNKNOWN
            return True
        
        else: return False

    @staticmethod
    def path_is_unresponsive(destination_hash):
        with Transport.path_states_lock:
            if destination_hash in Transport.path_states:
                if Transport.path_states[destination_hash] == Transport.STATE_UNRESPONSIVE:
                    return True

            return False

    @staticmethod
    def await_path(destination_hash, timeout=None, on_interface=None):
        """
        Requests a path to the destination from the network and
        blocks until the path is available, or the timeout is reached.

        :param destination_hash: A destination hash as *bytes*.
        :param timeout: An optional timeout in seconds.
        :param on_interface: If specified, the path request will only be sent on this interface. In normal use, Reticulum handles this automatically, and this parameter should not be used.
        :returns: *True* if a path to the destination is found, otherwise *False*.
        """
        timeout = time.time()+(timeout if timeout else Transport.PATH_REQUEST_TIMEOUT)
        if Transport.has_path(destination_hash): return True
        else: Transport.request_path(destination_hash, on_interface=on_interface)
        while not Transport.has_path(destination_hash) and time.time() < timeout: time.sleep(0.05)
        return Transport.has_path(destination_hash)

    @staticmethod
    def request_path(destination_hash, on_interface=None, tag=None, recursive=False):
        """
        Requests a path to the destination from the network. If
        another reachable peer on the network knows a path, it
        will announce it.

        :param destination_hash: A destination hash as *bytes*.
        :param on_interface: If specified, the path request will only be sent on this interface. In normal use, Reticulum handles this automatically, and this parameter should not be used.
        """
        if tag == None: request_tag = RNS.Identity.get_random_hash()
        else:           request_tag = tag

        if RNS.Reticulum.transport_enabled(): path_request_data = destination_hash+Transport.identity.hash+request_tag
        else:                                 path_request_data = destination_hash+request_tag

        path_request_dst = RNS.Destination(None, RNS.Destination.OUT, RNS.Destination.PLAIN, Transport.APP_NAME, "path", "request")
        packet = RNS.Packet(path_request_dst, path_request_data, packet_type = RNS.Packet.DATA, transport_type = RNS.Transport.BROADCAST, header_type = RNS.Packet.HEADER_1, attached_interface = on_interface)

        if on_interface != None and recursive:
            if not hasattr(on_interface, "announce_cap"):        on_interface.announce_cap = RNS.Reticulum.ANNOUNCE_CAP
            if not hasattr(on_interface, "announce_allowed_at"): on_interface.announce_allowed_at = 0
            if not hasattr(on_interface, "announce_queue"):      on_interface.announce_queue = []

            queued_announces = True if len(on_interface.announce_queue) > 0 else False
            if queued_announces:
                RNS.log("Blocking recursive path request on "+str(on_interface)+" due to queued announces", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                return
            else:
                now = time.time()
                if now < on_interface.announce_allowed_at:
                    RNS.log("Blocking recursive path request on "+str(on_interface)+" due to active announce cap", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                    return
                else:
                    tx_time   = ((len(path_request_data)+RNS.Reticulum.HEADER_MINSIZE)*8) / on_interface.bitrate
                    wait_time = (tx_time / on_interface.announce_cap)
                    on_interface.announce_allowed_at = now + wait_time

        packet.is_outbound_pr = True
        packet.send()

        with Transport.path_requests_lock: Transport.path_requests[destination_hash] = time.time()

    @staticmethod
    def remote_status_handler(path, data, request_id, link_id, remote_identity, requested_at):
        if remote_identity != None:
            response = None
            try:
                if isinstance(data, list) and len(data) > 0:
                    response = []
                    response.append(Transport.owner.get_interface_stats())
                    if data[0] == True: response.append(Transport.owner.get_link_count())

                    return response

            except Exception as e:
                RNS.log("An error occurred while processing remote status request from "+str(remote_identity), RNS.LOG_ERROR)
                RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)

            return None

    @staticmethod
    def remote_path_handler(path, data, request_id, link_id, remote_identity, requested_at):
        if remote_identity != None:
            response = None
            try:
                if isinstance(data, list) and len(data) > 0:
                    command = data[0]
                    destination_hash = None
                    max_hops = None
                    if len(data) > 1: destination_hash = data[1]
                    if len(data) > 2: max_hops = data[2]

                    if command == "table":
                        table = Transport.owner.get_path_table(max_hops=max_hops)
                        response = []
                        for path in table:
                            if destination_hash == None or destination_hash == path["hash"]:
                                response.append(path)

                    elif command == "rates":
                        table = Transport.owner.get_rate_table()
                        response = []
                        for path in table:
                            if destination_hash == None or destination_hash == path["hash"]:
                                response.append(path)

                    return response

            except Exception as e:
                RNS.log("An error occurred while processing remote status request from "+str(remote_identity), RNS.LOG_ERROR)
                RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)

            return None

    @staticmethod
    def path_request_handler(data, packet):
        try:
            # If there is at least bytes enough for a destination
            # hash in the packet, we assume those bytes are the
            # destination being requested.
            if len(data) >= RNS.Identity.TRUNCATED_HASHLENGTH//8:
                destination_hash = data[:RNS.Identity.TRUNCATED_HASHLENGTH//8]
                # If there is also enough bytes for a transport
                # instance ID and at least one tag byte, we
                # assume the next bytes to be the trasport ID
                # of the requesting transport instance.
                if len(data) > (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2:
                    requesting_transport_instance = data[RNS.Identity.TRUNCATED_HASHLENGTH//8:(RNS.Identity.TRUNCATED_HASHLENGTH//8)*2]
                else:
                    requesting_transport_instance = None

                tag_bytes = None
                if len(data) > (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2:
                    tag_bytes = data[RNS.Identity.TRUNCATED_HASHLENGTH//8*2:]

                elif len(data) > (RNS.Identity.TRUNCATED_HASHLENGTH//8):
                    tag_bytes = data[RNS.Identity.TRUNCATED_HASHLENGTH//8:]

                if tag_bytes != None:
                    if len(tag_bytes) > RNS.Identity.TRUNCATED_HASHLENGTH//8:
                        tag_bytes = tag_bytes[:RNS.Identity.TRUNCATED_HASHLENGTH//8]

                    unique_tag = destination_hash+tag_bytes

                    if packet.receiving_interface: packet.receiving_interface.received_path_request()
                    with Transport.discovery_pr_tags_lock:
                        if not unique_tag in Transport.discovery_pr_tags:
                            Transport.discovery_pr_tags.append(unique_tag)

                            Transport.path_request(destination_hash,
                                                   Transport.from_local_client(packet),
                                                   packet.receiving_interface,
                                                   requestor_transport_id = requesting_transport_instance,
                                                   tag=tag_bytes)

                        else: RNS.log("Ignoring duplicate path request for "+RNS.prettyhexrep(destination_hash)+" with tag "+RNS.prettyhexrep(unique_tag), RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                else: RNS.log("Ignoring tagless path request for "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
        except Exception as e: RNS.log(f"Error while handling path request. The contained exception was: {e}", RNS.LOG_ERROR)

    @staticmethod
    def path_request(destination_hash, is_from_local_client, attached_interface, requestor_transport_id=None, tag=None):
        should_search_for_unknown = False
        should_ingress_limit = False

        if attached_interface != None:
            should_ingress_limit = attached_interface.should_ingress_limit_pr()
            if RNS.Reticulum.transport_enabled():
                if attached_interface.mode in RNS.Interfaces.Interface.Interface.DISCOVER_PATHS_FOR: should_search_for_unknown = True

        if RNS.sl(RNS.LOG_DEBUG):
            interface_str = f" on {attached_interface}"
            RNS.log(f"Path request for {RNS.prettyhexrep(destination_hash)}{interface_str}", RNS.LOG_DEBUG)

        destination_exists_on_local_client = False
        if len(Transport.local_client_interfaces) > 0:
            if destination_hash in Transport.path_table:
                destination_interface = Transport.path_table[destination_hash][IDX_PT_RVCD_IF]
                
                if Transport.is_local_client_interface(destination_interface):
                    destination_exists_on_local_client = True
                    with Transport.pending_local_prs_lock:
                        Transport.pending_local_path_requests[destination_hash] = attached_interface
                        RNS.Reticulum.get_instance()._used_destination_data(destination_hash)

        local_destination = None
        with Transport.destinations_map_lock:
            if destination_hash in Transport.destinations_map: local_destination = Transport.destinations_map[destination_hash]

        if local_destination != None:
            local_destination.announce(path_response=True, tag=tag, attached_interface=attached_interface)
            RNS.log("Answering path request for "+RNS.prettyhexrep(destination_hash)+interface_str+", destination is local to this system", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

        elif (RNS.Reticulum.transport_enabled() or is_from_local_client) and (destination_hash in Transport.path_table):
            packet = Transport.get_cached_packet(Transport.path_table[destination_hash][IDX_PT_PACKET], packet_type="announce")
            next_hop = Transport.path_table[destination_hash][IDX_PT_NEXT_HOP]
            received_from = Transport.path_table[destination_hash][IDX_PT_RVCD_IF]

            if packet == None:
                RNS.log(f"Could not retrieve announce packet from cache while answering path request for {RNS.prettyhexrep(destination_hash)}, ignoring path request", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

            elif attached_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ROAMING and attached_interface == received_from:
                RNS.log("Not answering path request on roaming-mode interface, since next hop is on same roaming-mode interface", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

            else:
                packet.unpack()
                packet.hops = Transport.path_table[destination_hash][IDX_PT_HOPS]

                if requestor_transport_id != None and next_hop == requestor_transport_id:
                    # TODO: Find a bandwidth efficient way to invalidate our
                    # known path on this signal. The obvious way of signing
                    # path requests with transport instance keys is quite
                    # inefficient. There is probably a better way. Doing
                    # path invalidation here would decrease the network
                    # convergence time. Maybe just drop it?
                    RNS.log("Not answering path request for "+RNS.prettyhexrep(destination_hash)+interface_str+", since next hop is the requestor", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                else:
                    RNS.log("Answering path request for "+RNS.prettyhexrep(destination_hash)+interface_str+", path is known", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                    now = time.time()
                    retries = Transport.PATHFINDER_R
                    local_rebroadcasts = 0
                    block_rebroadcasts = True
                    announce_hops      = packet.hops

                    if is_from_local_client:
                        retransmit_timeout = now
                    else:
                        if Transport.is_local_client_interface(Transport.next_hop_interface(destination_hash)):
                            RNS.log("Path request destination "+RNS.prettyhexrep(destination_hash)+" is on a local client interface, rebroadcasting immediately", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                            retransmit_timeout = now

                        else:
                            retransmit_timeout = now + Transport.PATH_REQUEST_GRACE

                            # If we are answering on a roaming-mode interface, wait a
                            # little longer, to allow potential more well-connected
                            # peers to answer first.
                            if attached_interface.mode == RNS.Interfaces.Interface.Interface.MODE_ROAMING:
                                retransmit_timeout += Transport.PATH_REQUEST_RG

                    # This handles an edge case where a peer sends a past
                    # request for a destination just after an announce for
                    # said destination has arrived, but before it has been
                    # rebroadcast locally. In such a case the actual announce
                    # is temporarily held, and then reinserted when the path
                    # request has been served to the peer.
                    if packet.destination_hash in Transport.announce_table:
                        held_entry = Transport.announce_table[packet.destination_hash]
                        Transport.held_announces[packet.destination_hash] = held_entry
                    
                    with Transport.announce_table_lock:
                        Transport.announce_table[packet.destination_hash] = [now, retransmit_timeout, retries, received_from, announce_hops, packet, local_rebroadcasts, block_rebroadcasts, attached_interface]

                    if not Transport.owner.is_connected_to_shared_instance: RNS.Identity._used_destination_data(packet.destination_hash)

        elif is_from_local_client:
            # Forward path request on all interfaces
            # except the local client
            RNS.log("Forwarding path request from local client for "+RNS.prettyhexrep(destination_hash)+interface_str+" to all other interfaces", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
            request_tag = RNS.Identity.get_random_hash()
            for interface in Transport.interfaces:
                if not interface == attached_interface:
                    Transport.request_path(destination_hash, interface, tag = request_tag)

        elif should_search_for_unknown:
            if destination_hash in Transport.discovery_path_requests:
                RNS.log("There is already a waiting path request for "+RNS.prettyhexrep(destination_hash)+" on behalf of path request"+interface_str, RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
            else:
                # Abort recursive path request if receiving
                # interface has PR burst active, or should
                # otherwise ingress limit path requests.
                if should_ingress_limit:
                    if RNS.sl(RNS.LOG_DEBUG):
                        interface_str = f" for {attached_interface}" if attached_interface else ""
                        RNS.log(f"Not sending recursive path request{interface_str} due to active ingress limiting", RNS.LOG_DEBUG)
                    return

                # Forward path request on all interfaces
                # except the requestor interface
                RNS.log("Attempting to discover unknown path to "+RNS.prettyhexrep(destination_hash)+" on behalf of path request"+interface_str, RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
                pr_entry = { "destination_hash": destination_hash, "timeout": time.time()+Transport.PATH_REQUEST_TIMEOUT, "requesting_interface": attached_interface }
                with Transport.discovery_pr_lock: Transport.discovery_path_requests[destination_hash] = pr_entry

                for interface in Transport.interfaces:
                    if not interface == attached_interface:
                        if interface.should_egress_limit_pr():
                            RNS.log(f"Not sending recursive path request on {interface} due to active egress limiting", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        else:
                            # Use the previously extracted tag from this path request
                            # on the new path requests as well, to avoid potential loops
                            Transport.request_path(destination_hash, on_interface=interface, tag=tag, recursive=True)

        elif not is_from_local_client and len(Transport.local_client_interfaces) > 0:
            # Forward the path request on all local
            # client interfaces
            RNS.log("Forwarding path request for "+RNS.prettyhexrep(destination_hash)+interface_str+" to local clients", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
            for interface in Transport.local_client_interfaces:
                Transport.request_path(destination_hash, on_interface=interface)

        else:
            RNS.log("Ignoring path request for "+RNS.prettyhexrep(destination_hash)+interface_str+", no path known", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

    @staticmethod
    def from_local_client(packet):
        if hasattr(packet.receiving_interface, "parent_interface"):
            return Transport.is_local_client_interface(packet.receiving_interface)
        else:
            return False

    @staticmethod
    def is_local_client_interface(interface):
        if hasattr(interface, "parent_interface"):
            if hasattr(interface.parent_interface, "is_local_shared_instance"):
                return True
            else:
                return False
        else:
            return False

    @staticmethod
    def interface_to_shared_instance(interface):
        if hasattr(interface, "is_connected_to_shared_instance"):
            return True
        else:
            return False

    @staticmethod
    def detach_interfaces():
        closed_links = 0
        for link in Transport.active_links.copy():
            try: link.teardown(); closed_links += 1
            except Exception as e: RNS.log(f"Could not tear down active link before interface detach: {e}", RNS.LOG_WARNING)

        for link in Transport.pending_links.copy():
            try: link.teardown(); closed_links += 1
            except Exception as e: RNS.log(f"Could not tear down pending link before interface detach: {e}", RNS.LOG_WARNING)

        # Provide a 150ms window to allow link teardown
        # packets to leave local transport
        if closed_links: time.sleep(0.15)

        detachable_interfaces = []

        for interface in Transport.interfaces:
            # Currently no rules are being applied
            # here, and all interfaces will be sent
            # the detach call on RNS teardown.
            if not interface.detached: detachable_interfaces.append(interface)
            else: pass
        
        for interface in Transport.local_client_interfaces:
            # Currently no rules are being applied
            # here, and all interfaces will be sent
            # the detach call on RNS teardown.
            if not interface.detached: detachable_interfaces.append(interface)
            else: pass

        shared_instance_master = None
        local_interfaces = []
        detach_threads = []
        RNS.log("Detaching interfaces", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
        for interface in detachable_interfaces:
            try:
                if type(interface) == RNS.Interfaces.LocalInterface.LocalServerInterface:
                    shared_instance_master = interface
                elif type(interface) == RNS.Interfaces.LocalInterface.LocalClientInterface:
                    local_interfaces.append(interface)
                else:
                    def detach_job(interface=interface):
                        RNS.log(f"Detaching {interface}", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
                        interface.detach()
                    dt = threading.Thread(target=detach_job, daemon=False)
                    dt.start()
                    detach_threads.append(dt)

            except Exception as e:
                RNS.log("An error occurred while detaching "+str(interface)+". The contained exception was: "+str(e), RNS.LOG_ERROR)

        for dt in detach_threads:
            dt.join()

        RNS.log("Detaching local clients", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
        for li in local_interfaces:
            li.detach()

        RNS.log("Detaching shared instance", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
        if shared_instance_master != None: shared_instance_master.detach()
        BackboneInterface.deregister_listeners()

        RNS.log("All interfaces detached", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

    @staticmethod
    def shared_connection_disappeared():
        with Transport.active_links_lock:
            for link in Transport.active_links: link.teardown()

        with Transport.pending_links_lock:
            for link in Transport.pending_links: link.teardown()

        Transport.announce_table    = {}
        Transport.path_table        = {}
        Transport.reverse_table     = {}
        Transport.link_table        = {}
        Transport.held_announces    = {}
        Transport.tunnels           = {}

    @staticmethod
    def shared_connection_reappeared():
        if Transport.owner.is_connected_to_shared_instance:
            for registered_destination in Transport.destinations.copy():
                if registered_destination.type == RNS.Destination.SINGLE:
                    registered_destination.announce(path_response=True)

    @staticmethod
    def drop_announce_queues():
        for interface in Transport.interfaces:
            if hasattr(interface, "announce_queue") and interface.announce_queue != None:
                na = len(interface.announce_queue)
                if na > 0:
                    if na == 1: na_str = "1 announce"
                    else: na_str = str(na)+" announces"
                    interface.announce_queue = []
                    RNS.log("Dropped "+na_str+" on "+str(interface), RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None

        gc.collect()

    @staticmethod
    def timebase_from_random_blob(random_blob):
        return int.from_bytes(random_blob[5:10], "big")

    @staticmethod
    def timebase_from_random_blobs(random_blobs):
        timebase = 0
        for random_blob in random_blobs:
            emitted = Transport.timebase_from_random_blob(random_blob)
            if emitted > timebase: timebase = emitted

        return timebase

    @staticmethod
    def announce_emitted(packet):
        random_blob = packet.data[RNS.Identity.KEYSIZE//8+RNS.Identity.NAME_HASH_LENGTH//8:RNS.Identity.KEYSIZE//8+RNS.Identity.NAME_HASH_LENGTH//8+10]
        announce_emitted = Transport.timebase_from_random_blob(random_blob)

        return announce_emitted

    @staticmethod
    def save_packet_hashlist(background=False):
        if not Transport.owner.is_connected_to_shared_instance:
            if hasattr(Transport, "saving_packet_hashlist"):
                wait_interval = 0.2
                wait_timeout = 5
                wait_start = time.time()
                while Transport.saving_packet_hashlist:
                    time.sleep(wait_interval)
                    if time.time() > wait_start+wait_timeout:
                        RNS.log("Could not save packet hashlist to storage, waiting for previous save operation timed out.", RNS.LOG_ERROR)
                        return False

            try:
                Transport.saving_packet_hashlist = True
                save_start = time.time()

                if not RNS.Reticulum.transport_enabled(): Transport.packet_hashlist = set()
                else: RNS.log("Saving packet hashlist to storage...", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                packet_hashlist_path = RNS.Reticulum.storagepath+"/packet_hashlist"
                file = open(packet_hashlist_path, "wb")
                file.write(umsgpack.packb(list(Transport.packet_hashlist.copy())))
                file.close()

                save_time = time.time() - save_start
                if save_time < 1: time_str = str(round(save_time*1000,2))+"ms"
                else: time_str = str(round(save_time,2))+"s"
                RNS.log("Saved packet hashlist in "+time_str, RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

            except Exception as e:
                RNS.log("Could not save packet hashlist to storage, the contained exception was: "+str(e), RNS.LOG_ERROR)

            Transport.saving_packet_hashlist = False
            gc.collect()


    @staticmethod
    def save_path_table(background=False):
        if not Transport.owner.is_connected_to_shared_instance:
            if hasattr(Transport, "saving_path_table"):
                wait_interval = 0.2
                wait_timeout = 5
                wait_start = time.time()
                while Transport.saving_path_table:
                    time.sleep(wait_interval)
                    if time.time() > wait_start+wait_timeout:
                        RNS.log("Could not save path table to storage, waiting for previous save operation timed out.", RNS.LOG_ERROR)
                        return False

            try:
                Transport.saving_path_table = True
                save_start = time.time()
                RNS.log("Saving path table to storage...", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                serialised_destinations = []
                path_table = Transport.path_table.copy()
                for destination_hash in path_table:
                    try:
                        # Get the destination entry from the destination table
                        de = path_table[destination_hash]
                        interface_hash = de[IDX_PT_RVCD_IF].get_hash()

                        # Only store destination table entry if the associated
                        # interface is still active
                        interface = Transport.find_interface_from_hash(interface_hash)
                        if interface != None:
                            # Get the destination entry from the destination table
                            if not destination_hash in path_table:
                                RNS.log(f"Skipping persist for path table entry {RNS.prettyhexrep(destination_hash)}, no longer in table", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                            de = path_table[destination_hash]
                            timestamp = de[IDX_PT_TIMESTAMP]
                            received_from = de[IDX_PT_NEXT_HOP]
                            hops = de[IDX_PT_HOPS]
                            expires = de[IDX_PT_EXPIRES]
                            random_blobs = de[IDX_PT_RANDBLOBS]
                            packet_hash = de[IDX_PT_PACKET]

                            serialised_entry = [
                                destination_hash,
                                timestamp,
                                received_from,
                                hops,
                                expires,
                                random_blobs,
                                interface_hash,
                                packet_hash
                            ]

                            serialised_destinations.append(serialised_entry)

                            # TODO: Reevaluate whether there is any cases where this is needed
                            # Transport.cache(de[IDX_PT_PACKET], force_cache=True)

                    except Exception as e: RNS.log(f"Skipping persist for path table entry due to error: {e}", RNS.LOG_ERROR)

                path_table_path = RNS.Reticulum.storagepath+"/destination_table"
                file = open(path_table_path, "wb")
                file.write(umsgpack.packb(serialised_destinations))
                file.close()

                save_time = time.time() - save_start
                if save_time < 1: time_str = str(round(save_time*1000,2))+"ms"
                else: time_str = str(round(save_time,2))+"s"
                RNS.log("Saved "+str(len(serialised_destinations))+" path table entries in "+time_str, RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

            except Exception as e:
                RNS.log("Could not save path table to storage, the contained exception was: "+str(e), RNS.LOG_ERROR)
                RNS.trace_exception(e)

            Transport.saving_path_table = False
            gc.collect()


    @staticmethod
    def save_tunnel_table(background=False):
        if not Transport.owner.is_connected_to_shared_instance:
            if hasattr(Transport, "saving_tunnel_table"):
                wait_interval = 0.2
                wait_timeout = 5
                wait_start = time.time()
                while Transport.saving_tunnel_table:
                    time.sleep(wait_interval)
                    if time.time() > wait_start+wait_timeout:
                        RNS.log("Could not save tunnel table to storage, waiting for previous save operation timed out.", RNS.LOG_ERROR)
                        return False

            try:
                Transport.saving_tunnel_table = True
                save_start = time.time()
                RNS.log("Saving tunnel table to storage...", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None

                serialised_tunnels = []
                for tunnel_id in Transport.tunnels.copy():
                    te = Transport.tunnels[tunnel_id]
                    interface = te[1]
                    tunnel_paths = te[2].copy()
                    expires = te[3]

                    if interface != None: interface_hash = interface.get_hash()
                    else: interface_hash = None

                    serialised_paths = []
                    for destination_hash in tunnel_paths:
                        de = tunnel_paths[destination_hash]

                        timestamp = de[0]
                        received_from = de[1]
                        hops = de[2]
                        expires = de[3]
                        random_blobs = de[4][-Transport.PERSIST_RANDOM_BLOBS:]
                        packet_hash = de[6]

                        serialised_entry = [
                            destination_hash,
                            timestamp,
                            received_from,
                            hops,
                            expires,
                            random_blobs,
                            interface_hash,
                            packet_hash
                        ]

                        serialised_paths.append(serialised_entry)

                        # TODO: Reevaluate whether there are any cases where this is needed
                        # Transport.cache(de[6], force_cache=True)

                    serialised_tunnel = [tunnel_id, interface_hash, serialised_paths, expires]
                    serialised_tunnels.append(serialised_tunnel)

                tunnels_path = RNS.Reticulum.storagepath+"/tunnels"
                file = open(tunnels_path, "wb")
                file.write(umsgpack.packb(serialised_tunnels))
                file.close()

                save_time = time.time() - save_start
                if save_time < 1: time_str = str(round(save_time*1000,2))+"ms"
                else: time_str = str(round(save_time,2))+"s"
                RNS.log("Saved "+str(len(serialised_tunnels))+" tunnel table entries in "+time_str, RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
            
            except Exception as e:
                RNS.log("Could not save tunnel table to storage, the contained exception was: "+str(e), RNS.LOG_ERROR)

            Transport.saving_tunnel_table = False
            gc.collect()

    @staticmethod
    def persist_data(background=False):
        if Transport.persist_lock.locked(): return
        with Transport.persist_lock:
            Transport.save_packet_hashlist(background=background)
            Transport.save_path_table(background=background)
            Transport.save_tunnel_table(background=background)
            gc.collect()

    @staticmethod
    def void_queues():
        Transport.held_announces = {}
        Transport.receipts = []
        Transport.reverse_table = {}

    @staticmethod
    def exit_handler():
        Transport._should_run = False
        Transport.void_queues()
        if not Transport.owner.is_connected_to_shared_instance:
            Transport.persist_data()

    @staticmethod
    def blackhole_identity(identity_hash, until=None, reason=None):
        """
        Blackholes an identity.

        :param identity_hash: The identity hash to blackhole as *bytes*.
        :param until: Optional unix timestamp of when the blackhole expires as *float* or *int*.
        :param reason: Optional reason for the blackhole as *str*.
        :returns: *True* if successful, otherwise *False*.
        """
        try:
            if not identity_hash in Transport.blackholed_identities:
                entry = {"source": Transport.identity.hash, "until": until, "reason": reason}
                Transport.blackholed_identities[identity_hash] = entry
                Transport.persist_blackhole()
                Transport.remove_blackholed_paths()
                RNS.log(f"Blackholed identity {RNS.prettyhexrep(identity_hash)}", RNS.LOG_INFO)
                return True

            else: return None
        
        except Exception as e:
            RNS.log(f"Error while blackholing identity: {e}", RNS.LOG_ERROR)
            return False

    @staticmethod
    def unblackhole_identity(identity_hash):
        """
        Lifts blackhole for an identity.

        :param identity_hash: The identity hash to blackhole as *bytes*.
        :returns: *True* if successful, otherwise *False*.
        """
        try:
            if identity_hash in Transport.blackholed_identities:
                Transport.blackholed_identities.pop(identity_hash)
                Transport.persist_blackhole()
                RNS.log(f"Lifted blackhole for identity {RNS.prettyhexrep(identity_hash)}", RNS.LOG_INFO)
                return True

            else: return None

        except Exception as e:
            RNS.log(f"Error while unblackholing identity: {e}", RNS.LOG_ERROR)
            return False

    @staticmethod
    def reload_blackhole():
        now = time.time()
        source_count = 0
        dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
        for filename in os.listdir(RNS.Reticulum.blackholepath):
            try:
                if filename == "local": source_identity_hash = Transport.identity.hash
                else:
                    if len(filename) != dest_len: raise ValueError(f"Identity hash length for blackhole source {filename} is invalid")
                    source_identity_hash = bytes.fromhex(filename)
                    if not source_identity_hash in RNS.Reticulum.blackhole_sources():
                        RNS.log(f"Skipping disabled blackhole source {RNS.prettyhexrep(source_identity_hash)}", RNS.LOG_VERBOSE) if RNS.sl(RNS.LOG_VERBOSE) else None
                        continue

                sourcepath = os.path.join(RNS.Reticulum.blackholepath, filename)
                with open(sourcepath, "rb") as f:
                    packed = f.read()
                    source_list = umsgpack.unpackb(packed)
                    for identity_hash in source_list:
                        if len(identity_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8:
                            if identity_hash in Transport.blackholed_identities:
                                if Transport.blackholed_identities[identity_hash]["source"] == Transport.identity.hash:
                                    continue
                            
                            se        = source_list[identity_hash]
                            until     = se["until"] if "until" in se else None
                            reason    = se["reason"] if "reason" in se else None
                            entry     = {"source": source_identity_hash, "until": until, "reason": reason}
                            
                            if until == None or now < until: Transport.blackholed_identities[identity_hash] = entry

                source_count += 1

            except Exception as e:
                RNS.log(f"Could not load blackholed identities from source file {filename}: {e}", RNS.LOG_ERROR)
                RNS.trace_exception(e)

        Transport.remove_blackholed_paths()

    def remove_blackholed_paths():
        drop_destinations = []
        for destination_hash in Transport.path_table.copy():
            try:
                associated_identity = RNS.Identity.recall(destination_hash, _no_use=True)
                if associated_identity and associated_identity.hash in Transport.blackholed_identities:
                    if not destination_hash in drop_destinations: drop_destinations.append(destination_hash)
            except Exception as e:
                RNS.log(f"Error while enumerating blackhole-associated destinations: {e}", RNS.LOG_ERROR)

        for destination_hash in drop_destinations:
            try:
                with Transport.path_table_lock:
                    if destination_hash in Transport.path_table: Transport.path_table.pop(destination_hash)
            except Exception as e:
                RNS.log(f"Error while dropping blackhole-associated destination from path table: {e}", RNS.LOG_ERROR)

        if len(drop_destinations) > 0:
            ms = "" if len(drop_destinations) == 1 else "s"
            RNS.log(f"Removed {len(drop_destinations)} destination{ms} associated with blackholed identities from path table", RNS.LOG_INFO)

    @staticmethod
    def blackhole_list_handler(path, data, request_id, link_id, remote_identity, requested_at):
        try: return Transport.blackholed_identities
        except Exception as e:
            RNS.log("An error occurred while processing blackhole list request from "+str(remote_identity), RNS.LOG_ERROR)
            RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)

        return None

    @staticmethod
    def persist_blackhole():
        try:
            local_blackhole = {}
            for identity_hash in Transport.blackholed_identities:
                if Transport.blackholed_identities[identity_hash]["source"] == Transport.identity.hash:
                    local_blackhole[identity_hash] = Transport.blackholed_identities[identity_hash]

            packed = umsgpack.packb(local_blackhole)
            localpath = os.path.join(RNS.Reticulum.blackholepath, "local")
            tmppath = f"{localpath}.tmp"
            with open(tmppath, "wb") as f: f.write(packed)
            if os.path.isfile(localpath): os.unlink(localpath)
            os.rename(tmppath, localpath)

        except Exception as e:
            RNS.log(f"Error while persisting blackhole list: {e}", RNS.LOG_ERROR)
            RNS.trace_exception(e)


# Table entry indices

# Transport.path_table entry indices
IDX_PT_TIMESTAMP = 0
IDX_PT_NEXT_HOP  = 1
IDX_PT_HOPS      = 2
IDX_PT_EXPIRES   = 3
IDX_PT_RANDBLOBS = 4
IDX_PT_RVCD_IF   = 5
IDX_PT_PACKET    = 6

# Transport.reverse_table entry indices
IDX_RT_RCVD_IF   = 0
IDX_RT_OUTB_IF   = 1
IDX_RT_TIMESTAMP = 2

# Transport.announce_table entry indices
IDX_AT_TIMESTAMP = 0
IDX_AT_RTRNS_TMO = 1
IDX_AT_RETRIES   = 2
IDX_AT_RCVD_IF   = 3
IDX_AT_HOPS      = 4
IDX_AT_PACKET    = 5
IDX_AT_LCL_RBRD  = 6
IDX_AT_BLCK_RBRD = 7
IDX_AT_ATTCHD_IF = 8

# Transport.link_table entry indices
IDX_LT_TIMESTAMP = 0
IDX_LT_NH_TRID   = 1
IDX_LT_NH_IF     = 2
IDX_LT_REM_HOPS  = 3
IDX_LT_RCVD_IF   = 4
IDX_LT_HOPS      = 5
IDX_LT_DSTHASH   = 6
IDX_LT_VALIDATED = 7
IDX_LT_PROOF_TMO = 8

# Transport.tunnels entry indices
IDX_TT_TUNNEL_ID = 0
IDX_TT_IF        = 1
IDX_TT_PATHS     = 2
IDX_TT_EXPIRES   = 3