mirror of
https://github.com/markqvist/Reticulum.git
synced 2024-11-22 13:40:19 +00:00
Implemented link peer identification
This commit is contained in:
parent
772ae44ab8
commit
384a7db974
310
Examples/Identify.py
Normal file
310
Examples/Identify.py
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
##########################################################
|
||||||
|
# This RNS example demonstrates how to set up a link to #
|
||||||
|
# a destination, and identify the initiator to it's peer #
|
||||||
|
##########################################################
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import RNS
|
||||||
|
|
||||||
|
# Let's define an app name. We'll use this for all
|
||||||
|
# destinations we create. Since this echo example
|
||||||
|
# is part of a range of example utilities, we'll put
|
||||||
|
# them all within the app namespace "example_utilities"
|
||||||
|
APP_NAME = "example_utilities"
|
||||||
|
|
||||||
|
##########################################################
|
||||||
|
#### Server Part #########################################
|
||||||
|
##########################################################
|
||||||
|
|
||||||
|
# A reference to the latest client link that connected
|
||||||
|
latest_client_link = None
|
||||||
|
|
||||||
|
# This initialisation is executed when the users chooses
|
||||||
|
# to run as a server
|
||||||
|
def server(configpath):
|
||||||
|
# We must first initialise Reticulum
|
||||||
|
reticulum = RNS.Reticulum(configpath)
|
||||||
|
|
||||||
|
# Randomly create a new identity for our link example
|
||||||
|
server_identity = RNS.Identity()
|
||||||
|
|
||||||
|
# We create a destination that clients can connect to. We
|
||||||
|
# want clients to create links to this destination, so we
|
||||||
|
# need to create a "single" destination type.
|
||||||
|
server_destination = RNS.Destination(
|
||||||
|
server_identity,
|
||||||
|
RNS.Destination.IN,
|
||||||
|
RNS.Destination.SINGLE,
|
||||||
|
APP_NAME,
|
||||||
|
"identifyexample"
|
||||||
|
)
|
||||||
|
|
||||||
|
# We configure a function that will get called every time
|
||||||
|
# a new client creates a link to this destination.
|
||||||
|
server_destination.set_link_established_callback(client_connected)
|
||||||
|
|
||||||
|
# Everything's ready!
|
||||||
|
# Let's Wait for client requests or user input
|
||||||
|
server_loop(server_destination)
|
||||||
|
|
||||||
|
def server_loop(destination):
|
||||||
|
# Let the user know that everything is ready
|
||||||
|
RNS.log(
|
||||||
|
"Link identification example "+
|
||||||
|
RNS.prettyhexrep(destination.hash)+
|
||||||
|
" running, waiting for a connection."
|
||||||
|
)
|
||||||
|
|
||||||
|
RNS.log("Hit enter to manually send an announce (Ctrl-C to quit)")
|
||||||
|
|
||||||
|
# We enter a loop that runs until the users exits.
|
||||||
|
# If the user hits enter, we will announce our server
|
||||||
|
# destination on the network, which will let clients
|
||||||
|
# know how to create messages directed towards it.
|
||||||
|
while True:
|
||||||
|
entered = input()
|
||||||
|
destination.announce()
|
||||||
|
RNS.log("Sent announce from "+RNS.prettyhexrep(destination.hash))
|
||||||
|
|
||||||
|
# When a client establishes a link to our server
|
||||||
|
# destination, this function will be called with
|
||||||
|
# a reference to the link.
|
||||||
|
def client_connected(link):
|
||||||
|
global latest_client_link
|
||||||
|
|
||||||
|
RNS.log("Client connected")
|
||||||
|
link.set_link_closed_callback(client_disconnected)
|
||||||
|
link.set_packet_callback(server_packet_received)
|
||||||
|
link.set_remote_identified_callback(remote_identified)
|
||||||
|
latest_client_link = link
|
||||||
|
|
||||||
|
def client_disconnected(link):
|
||||||
|
RNS.log("Client disconnected")
|
||||||
|
|
||||||
|
def remote_identified(identity):
|
||||||
|
RNS.log("Remote identified as: "+str(identity))
|
||||||
|
|
||||||
|
def server_packet_received(message, packet):
|
||||||
|
global latest_client_link
|
||||||
|
|
||||||
|
# Get the originating identity for display
|
||||||
|
remote_peer = "unidentified peer"
|
||||||
|
if packet.link.get_remote_identity() != None:
|
||||||
|
remote_peer = str(packet.link.get_remote_identity())
|
||||||
|
|
||||||
|
# When data is received over any active link,
|
||||||
|
# it will all be directed to the last client
|
||||||
|
# that connected.
|
||||||
|
text = message.decode("utf-8")
|
||||||
|
|
||||||
|
RNS.log("Received data from "+remote_peer+": "+text)
|
||||||
|
|
||||||
|
reply_text = "I received \""+text+"\" over the link from "+remote_peer
|
||||||
|
reply_data = reply_text.encode("utf-8")
|
||||||
|
RNS.Packet(latest_client_link, reply_data).send()
|
||||||
|
|
||||||
|
|
||||||
|
##########################################################
|
||||||
|
#### Client Part #########################################
|
||||||
|
##########################################################
|
||||||
|
|
||||||
|
# A reference to the server link
|
||||||
|
server_link = None
|
||||||
|
|
||||||
|
# A reference to the client identity
|
||||||
|
client_identity = None
|
||||||
|
|
||||||
|
# This initialisation is executed when the users chooses
|
||||||
|
# to run as a client
|
||||||
|
def client(destination_hexhash, configpath):
|
||||||
|
global client_identity
|
||||||
|
# We need a binary representation of the destination
|
||||||
|
# hash that was entered on the command line
|
||||||
|
try:
|
||||||
|
if len(destination_hexhash) != 20:
|
||||||
|
raise ValueError("Destination length is invalid, must be 20 hexadecimal characters (10 bytes)")
|
||||||
|
destination_hash = bytes.fromhex(destination_hexhash)
|
||||||
|
except:
|
||||||
|
RNS.log("Invalid destination entered. Check your input!\n")
|
||||||
|
exit()
|
||||||
|
|
||||||
|
# We must first initialise Reticulum
|
||||||
|
reticulum = RNS.Reticulum(configpath)
|
||||||
|
|
||||||
|
# Create a new client identity
|
||||||
|
client_identity = RNS.Identity()
|
||||||
|
RNS.log(
|
||||||
|
"Client created new identity "+
|
||||||
|
str(client_identity)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we know a path to the destination
|
||||||
|
if not RNS.Transport.has_path(destination_hash):
|
||||||
|
RNS.log("Destination is not yet known. Requesting path and waiting for announce to arrive...")
|
||||||
|
RNS.Transport.request_path(destination_hash)
|
||||||
|
while not RNS.Transport.has_path(destination_hash):
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Recall the server identity
|
||||||
|
server_identity = RNS.Identity.recall(destination_hash)
|
||||||
|
|
||||||
|
# Inform the user that we'll begin connecting
|
||||||
|
RNS.log("Establishing link with server...")
|
||||||
|
|
||||||
|
# When the server identity is known, we set
|
||||||
|
# up a destination
|
||||||
|
server_destination = RNS.Destination(
|
||||||
|
server_identity,
|
||||||
|
RNS.Destination.OUT,
|
||||||
|
RNS.Destination.SINGLE,
|
||||||
|
APP_NAME,
|
||||||
|
"identifyexample"
|
||||||
|
)
|
||||||
|
|
||||||
|
# And create a link
|
||||||
|
link = RNS.Link(server_destination)
|
||||||
|
|
||||||
|
# We set a callback that will get executed
|
||||||
|
# every time a packet is received over the
|
||||||
|
# link
|
||||||
|
link.set_packet_callback(client_packet_received)
|
||||||
|
|
||||||
|
# We'll also set up functions to inform the
|
||||||
|
# user when the link is established or closed
|
||||||
|
link.set_link_established_callback(link_established)
|
||||||
|
link.set_link_closed_callback(link_closed)
|
||||||
|
|
||||||
|
# Everything is set up, so let's enter a loop
|
||||||
|
# for the user to interact with the example
|
||||||
|
client_loop()
|
||||||
|
|
||||||
|
def client_loop():
|
||||||
|
global server_link
|
||||||
|
|
||||||
|
# Wait for the link to become active
|
||||||
|
while not server_link:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
should_quit = False
|
||||||
|
while not should_quit:
|
||||||
|
try:
|
||||||
|
print("> ", end=" ")
|
||||||
|
text = input()
|
||||||
|
|
||||||
|
# Check if we should quit the example
|
||||||
|
if text == "quit" or text == "q" or text == "exit":
|
||||||
|
should_quit = True
|
||||||
|
server_link.teardown()
|
||||||
|
|
||||||
|
# If not, send the entered text over the link
|
||||||
|
if text != "":
|
||||||
|
data = text.encode("utf-8")
|
||||||
|
if len(data) <= RNS.Link.MDU:
|
||||||
|
RNS.Packet(server_link, data).send()
|
||||||
|
else:
|
||||||
|
RNS.log(
|
||||||
|
"Cannot send this packet, the data size of "+
|
||||||
|
str(len(data))+" bytes exceeds the link packet MDU of "+
|
||||||
|
str(RNS.Link.MDU)+" bytes",
|
||||||
|
RNS.LOG_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log("Error while sending data over the link: "+str(e))
|
||||||
|
should_quit = True
|
||||||
|
server_link.teardown()
|
||||||
|
|
||||||
|
# This function is called when a link
|
||||||
|
# has been established with the server
|
||||||
|
def link_established(link):
|
||||||
|
# We store a reference to the link
|
||||||
|
# instance for later use
|
||||||
|
global server_link, client_identity
|
||||||
|
server_link = link
|
||||||
|
|
||||||
|
# Inform the user that the server is
|
||||||
|
# connected
|
||||||
|
RNS.log("Link established with server, identifying to remote peer...")
|
||||||
|
|
||||||
|
link.identify(client_identity)
|
||||||
|
|
||||||
|
# When a link is closed, we'll inform the
|
||||||
|
# user, and exit the program
|
||||||
|
def link_closed(link):
|
||||||
|
if link.teardown_reason == RNS.Link.TIMEOUT:
|
||||||
|
RNS.log("The link timed out, exiting now")
|
||||||
|
elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED:
|
||||||
|
RNS.log("The link was closed by the server, exiting now")
|
||||||
|
else:
|
||||||
|
RNS.log("Link closed, exiting now")
|
||||||
|
|
||||||
|
RNS.Reticulum.exit_handler()
|
||||||
|
time.sleep(1.5)
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
# When a packet is received over the link, we
|
||||||
|
# simply print out the data.
|
||||||
|
def client_packet_received(message, packet):
|
||||||
|
text = message.decode("utf-8")
|
||||||
|
RNS.log("Received data on the link: "+text)
|
||||||
|
print("> ", end=" ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
##########################################################
|
||||||
|
#### Program Startup #####################################
|
||||||
|
##########################################################
|
||||||
|
|
||||||
|
# This part of the program runs at startup,
|
||||||
|
# and parses input of from the user, and then
|
||||||
|
# starts up the desired program mode.
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
parser = argparse.ArgumentParser(description="Simple link example")
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--server",
|
||||||
|
action="store_true",
|
||||||
|
help="wait for incoming link requests from clients"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
action="store",
|
||||||
|
default=None,
|
||||||
|
help="path to alternative Reticulum config directory",
|
||||||
|
type=str
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"destination",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="hexadecimal hash of the server destination",
|
||||||
|
type=str
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.config:
|
||||||
|
configarg = args.config
|
||||||
|
else:
|
||||||
|
configarg = None
|
||||||
|
|
||||||
|
if args.server:
|
||||||
|
server(configarg)
|
||||||
|
else:
|
||||||
|
if (args.destination == None):
|
||||||
|
print("")
|
||||||
|
parser.print_help()
|
||||||
|
print("")
|
||||||
|
else:
|
||||||
|
client(args.destination, configarg)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("")
|
||||||
|
exit()
|
52
RNS/Link.py
52
RNS/Link.py
@ -23,6 +23,7 @@ class LinkCallbacks:
|
|||||||
self.resource = None
|
self.resource = None
|
||||||
self.resource_started = None
|
self.resource_started = None
|
||||||
self.resource_concluded = None
|
self.resource_concluded = None
|
||||||
|
self.remote_identified = None
|
||||||
|
|
||||||
class Link:
|
class Link:
|
||||||
"""
|
"""
|
||||||
@ -125,6 +126,7 @@ class Link:
|
|||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.destination = destination
|
self.destination = destination
|
||||||
self.attached_interface = None
|
self.attached_interface = None
|
||||||
|
self.__remote_identity = None
|
||||||
self.__encryption_disabled = False
|
self.__encryption_disabled = False
|
||||||
if self.destination == None:
|
if self.destination == None:
|
||||||
self.initiator = False
|
self.initiator = False
|
||||||
@ -226,6 +228,7 @@ class Link:
|
|||||||
if self.destination.identity.validate(signature, signed_data):
|
if self.destination.identity.validate(signature, signed_data):
|
||||||
self.rtt = time.time() - self.request_time
|
self.rtt = time.time() - self.request_time
|
||||||
self.attached_interface = packet.receiving_interface
|
self.attached_interface = packet.receiving_interface
|
||||||
|
self.__remote_identity = self.destination.identity
|
||||||
RNS.Transport.activate_link(self)
|
RNS.Transport.activate_link(self)
|
||||||
RNS.log("Link "+str(self)+" established with "+str(self.destination)+", RTT is "+str(self.rtt), RNS.LOG_VERBOSE)
|
RNS.log("Link "+str(self)+" established with "+str(self.destination)+", RTT is "+str(self.rtt), RNS.LOG_VERBOSE)
|
||||||
rtt_data = umsgpack.packb(self.rtt)
|
rtt_data = umsgpack.packb(self.rtt)
|
||||||
@ -243,6 +246,25 @@ class Link:
|
|||||||
RNS.log("Invalid link proof signature received by "+str(self)+". Ignoring.", RNS.LOG_DEBUG)
|
RNS.log("Invalid link proof signature received by "+str(self)+". Ignoring.", RNS.LOG_DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
def identify(self, identity):
|
||||||
|
"""
|
||||||
|
Identifies the initiator of the link to the remote peer. This can only happen
|
||||||
|
once the link has been established, and is carried out over the encrypted link.
|
||||||
|
The identity is only revealed to the remote peer, and initiator anonymity is
|
||||||
|
thus preserved. This method can be used for authentication.
|
||||||
|
|
||||||
|
:param identity: An RNS.Identity instance to identify as.
|
||||||
|
"""
|
||||||
|
if self.initiator:
|
||||||
|
signed_data = self.link_id + identity.get_public_key()
|
||||||
|
signature = identity.sign(signed_data)
|
||||||
|
proof_data = identity.get_public_key() + signature
|
||||||
|
|
||||||
|
proof = RNS.Packet(self, proof_data, RNS.Packet.DATA, context = RNS.Packet.LINKIDENTIFY)
|
||||||
|
proof.send()
|
||||||
|
self.had_outbound()
|
||||||
|
|
||||||
|
|
||||||
def rtt_packet(self, packet):
|
def rtt_packet(self, packet):
|
||||||
try:
|
try:
|
||||||
# TODO: This is crude, we should use the delta
|
# TODO: This is crude, we should use the delta
|
||||||
@ -286,6 +308,12 @@ class Link:
|
|||||||
"""
|
"""
|
||||||
return min(self.no_inbound_for(), self.no_outbound_for())
|
return min(self.no_inbound_for(), self.no_outbound_for())
|
||||||
|
|
||||||
|
def get_remote_identity(self):
|
||||||
|
"""
|
||||||
|
:returns: The identity of the remote peer, if it is known
|
||||||
|
"""
|
||||||
|
return self.__remote_identity
|
||||||
|
|
||||||
def had_outbound(self):
|
def had_outbound(self):
|
||||||
self.last_outbound = time.time()
|
self.last_outbound = time.time()
|
||||||
|
|
||||||
@ -424,6 +452,21 @@ class Link:
|
|||||||
if self.destination.callbacks.proof_requested:
|
if self.destination.callbacks.proof_requested:
|
||||||
self.destination.callbacks.proof_requested(packet)
|
self.destination.callbacks.proof_requested(packet)
|
||||||
|
|
||||||
|
elif packet.context == RNS.Packet.LINKIDENTIFY:
|
||||||
|
plaintext = self.decrypt(packet.data)
|
||||||
|
|
||||||
|
if not self.initiator and len(plaintext) == RNS.Identity.KEYSIZE//8 + RNS.Identity.SIGLENGTH//8:
|
||||||
|
public_key = plaintext[:RNS.Identity.KEYSIZE//8]
|
||||||
|
signed_data = self.link_id+public_key
|
||||||
|
signature = plaintext[RNS.Identity.KEYSIZE//8:RNS.Identity.KEYSIZE//8+RNS.Identity.SIGLENGTH//8]
|
||||||
|
identity = RNS.Identity(create_keys=False)
|
||||||
|
identity.load_public_key(public_key)
|
||||||
|
|
||||||
|
if identity.validate(signature, signed_data):
|
||||||
|
self.__remote_identity = identity
|
||||||
|
if self.callbacks.remote_identified != None:
|
||||||
|
self.callbacks.remote_identified(self.__remote_identity)
|
||||||
|
|
||||||
elif packet.context == RNS.Packet.LRRTT:
|
elif packet.context == RNS.Packet.LRRTT:
|
||||||
if not self.initiator:
|
if not self.initiator:
|
||||||
self.rtt_packet(packet)
|
self.rtt_packet(packet)
|
||||||
@ -574,6 +617,15 @@ class Link:
|
|||||||
"""
|
"""
|
||||||
self.callbacks.resource_concluded = callback
|
self.callbacks.resource_concluded = callback
|
||||||
|
|
||||||
|
def set_remote_identified_callback(self, callback):
|
||||||
|
"""
|
||||||
|
Registers a function to be called when an initiating peer has
|
||||||
|
identified over this link.
|
||||||
|
|
||||||
|
:param callback: A function or method with the signature *callback(identity)* to be called.
|
||||||
|
"""
|
||||||
|
self.callbacks.remote_identified = callback
|
||||||
|
|
||||||
def resource_concluded(self, resource):
|
def resource_concluded(self, resource):
|
||||||
if resource in self.incoming_resources:
|
if resource in self.incoming_resources:
|
||||||
self.incoming_resources.remove(resource)
|
self.incoming_resources.remove(resource)
|
||||||
|
@ -56,7 +56,8 @@ class Packet:
|
|||||||
PATH_RESPONSE = 0x0B # Packet is a response to a path request
|
PATH_RESPONSE = 0x0B # Packet is a response to a path request
|
||||||
COMMAND = 0x0C # Packet is a command
|
COMMAND = 0x0C # Packet is a command
|
||||||
COMMAND_STATUS = 0x0D # Packet is a status of an executed command
|
COMMAND_STATUS = 0x0D # Packet is a status of an executed command
|
||||||
KEEPALIVE = 0xFB # Packet is a keepalive packet
|
KEEPALIVE = 0xFA # Packet is a keepalive packet
|
||||||
|
LINKIDENTIFY = 0xFB # Packet is a link peer identification proof
|
||||||
LINKCLOSE = 0xFC # Packet is a link close message
|
LINKCLOSE = 0xFC # Packet is a link close message
|
||||||
LINKPROOF = 0xFD # Packet is a link packet proof
|
LINKPROOF = 0xFD # Packet is a link packet proof
|
||||||
LRRTT = 0xFE # Packet is a link request round-trip time measurement
|
LRRTT = 0xFE # Packet is a link request round-trip time measurement
|
||||||
|
@ -49,6 +49,8 @@ class Transport:
|
|||||||
receipts = [] # Receipts of all outgoing packets for proof processing
|
receipts = [] # Receipts of all outgoing packets for proof processing
|
||||||
|
|
||||||
# TODO: "destination_table" should really be renamed to "path_table"
|
# TODO: "destination_table" should really be renamed to "path_table"
|
||||||
|
# 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
|
announce_table = {} # A table for storing announces currently waiting to be retransmitted
|
||||||
destination_table = {} # A lookup table containing the next hop to a given destination
|
destination_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
|
reverse_table = {} # A lookup table for storing packet hashes used to return proofs and replies
|
||||||
|
@ -5,6 +5,8 @@ import time
|
|||||||
import random
|
import random
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
from ._version import __version__
|
||||||
|
|
||||||
from .Reticulum import Reticulum
|
from .Reticulum import Reticulum
|
||||||
from .Identity import Identity
|
from .Identity import Identity
|
||||||
from .Link import Link
|
from .Link import Link
|
||||||
@ -60,6 +62,9 @@ def loglevelname(level):
|
|||||||
|
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
|
def version():
|
||||||
|
return __version__
|
||||||
|
|
||||||
def log(msg, level=3, _override_destination = False):
|
def log(msg, level=3, _override_destination = False):
|
||||||
global _always_override_destination
|
global _always_override_destination
|
||||||
|
|
||||||
@ -68,7 +73,7 @@ def log(msg, level=3, _override_destination = False):
|
|||||||
logstring = "["+time.strftime(logtimefmt)+"] ["+loglevelname(level)+"] "+msg
|
logstring = "["+time.strftime(logtimefmt)+"] ["+loglevelname(level)+"] "+msg
|
||||||
logging_lock.acquire()
|
logging_lock.acquire()
|
||||||
|
|
||||||
if (logdest == LOG_STDOUT or _always_override_destination):
|
if (logdest == LOG_STDOUT or _always_override_destination or _override_destination):
|
||||||
print(logstring)
|
print(logstring)
|
||||||
logging_lock.release()
|
logging_lock.release()
|
||||||
|
|
||||||
|
1
RNS/_version.py
Normal file
1
RNS/_version.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.2.2"
|
4
setup.py
4
setup.py
@ -1,11 +1,13 @@
|
|||||||
import setuptools
|
import setuptools
|
||||||
|
|
||||||
|
exec(open("RNS/_version.py", "r").read())
|
||||||
|
|
||||||
with open("README.md", "r") as fh:
|
with open("README.md", "r") as fh:
|
||||||
long_description = fh.read()
|
long_description = fh.read()
|
||||||
|
|
||||||
setuptools.setup(
|
setuptools.setup(
|
||||||
name="rns",
|
name="rns",
|
||||||
version="0.2.1",
|
version=__version__,
|
||||||
author="Mark Qvist",
|
author="Mark Qvist",
|
||||||
author_email="mark@unsigned.io",
|
author_email="mark@unsigned.io",
|
||||||
description="Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between",
|
description="Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between",
|
||||||
|
Loading…
Reference in New Issue
Block a user