Compare commits

...

71 Commits

Author SHA1 Message Date
Mark Qvist 95502e2c21 Prepare release 2026-05-14 01:56:30 +02:00
Mark Qvist 3dd4145e62 Updated changelog 2026-05-14 01:53:33 +02:00
Mark Qvist 1d7ddc3f8a Implemented rngit work document signing 2026-05-14 01:51:22 +02:00
Mark Qvist d731b4396c Repo page rendering 2026-05-14 00:32:22 +02:00
Mark Qvist c186a1f6b0 Updated version 2026-05-14 00:16:33 +02:00
Mark Qvist a049ec8b7b Updated changelog 2026-05-14 00:16:28 +02:00
Mark Qvist 4c93f6c7f4 Added local URL resolution to repo frontpage markdown readme renderer 2026-05-13 23:41:07 +02:00
Mark Qvist 35c7a89b19 Fixed typo 2026-05-13 22:58:50 +02:00
Mark Qvist c86b9c9703 Fixed missing none check in interface discovery sanitizer thanks to PAzter1101 2026-05-13 10:34:58 +02:00
Mark Qvist 64ebdd0ee3 Cleanup 2026-05-13 01:19:51 +02:00
Mark Qvist 9179b914d5 Added embedded message signing, validation and viewing to rnid 2026-05-13 01:14:41 +02:00
Mark Qvist eb5d46b20b Added file decryption for multiple file path inputs and shell expansions to rnid 2026-05-12 23:20:28 +02:00
Mark Qvist 54c36f515b Added file encryption for multiple file path inputs and shell expansions to rnid 2026-05-12 23:14:01 +02:00
Mark Qvist 5c5668a4fc Added signature creation for multiple file path inputs and shell expansions to rnid 2026-05-12 23:09:50 +02:00
Mark Qvist eeefb60c89 Added signature validation of multiple file path inputs and shell expansions to rnid 2026-05-12 23:00:06 +02:00
Mark Qvist 018df10a26 Fixed rngit remote helper hanging on startup if no client config had been created previously, and RNS loglevel was configured at debug or higher 2026-05-12 22:21:53 +02:00
Mark Qvist 93ead77435 Added workdoc downloads 2026-05-12 21:47:10 +02:00
Mark Qvist bd0e1ad0ca Better workdoc page handling 2026-05-12 21:05:15 +02:00
Mark Qvist d0ceeacb37 Allow setting title on workdoc edit 2026-05-12 15:04:02 +02:00
Mark Qvist 7d5fb6a13f Cleanup 2026-05-11 23:31:25 +02:00
Mark Qvist 855ef7bfd1 Base256 encoding 2026-05-11 23:22:13 +02:00
Mark Qvist 323890021a Better remote monitor loop 2026-05-11 00:20:02 +02:00
Mark Qvist e004e7592b Added lock to interface discovery 2026-05-10 00:29:48 +02:00
Mark Qvist 0ebec014e5 Improved release page 2026-05-10 00:26:55 +02:00
Mark Qvist 1b624cc0e2 Updated manual 2026-05-09 19:20:38 +02:00
Mark Qvist e8d161c0d5 Yes, that was indeed a bit overkill 2026-05-09 19:17:38 +02:00
Mark Qvist e5c7dd7ec7 Prepare release 2026-05-09 18:59:29 +02:00
Mark Qvist 7d6ed59e6e Added hex/b32/b64 output to rnid rsg signature generator 2026-05-09 18:34:28 +02:00
Mark Qvist 11e4e7953a Consistency 2026-05-09 15:11:33 +02:00
Mark Qvist a5b292ee81 Dreaming of a universe without escape characters 2026-05-09 14:58:43 +02:00
Mark Qvist d619bafb8d People use tabs, I guess 2026-05-09 13:51:55 +02:00
Mark Qvist 0119a589dc Improved transport jobs error handling 2026-05-09 13:32:32 +02:00
Mark Qvist b7346bed4d Fixed announce processing edge case where path was cleaned while waiting for announce rebroadcast 2026-05-09 13:29:31 +02:00
Mark Qvist fcea57cb8e Added burst filter to rnstatus 2026-05-09 13:28:49 +02:00
Mark Qvist 8d8af5e60a Improved git command timeout logging 2026-05-09 12:51:28 +02:00
Mark Qvist 1a732ac1c1 Adjusted logging 2026-05-09 12:35:39 +02:00
Mark Qvist f827d945be Implemented path request ingress burst control and egress limiting 2026-05-09 04:43:22 +02:00
Mark Qvist e03c4ee455 Added path request burst control to manual 2026-05-09 03:21:09 +02:00
Mark Qvist 35e7ccb773 Fixed invalid handling of corrupted discovery file 2026-05-09 02:52:01 +02:00
Mark Qvist a932a10492 Inherit egress and PR burst settings from parent interface 2026-05-09 02:27:31 +02:00
Mark Qvist c5108c3a19 Added path request frequency sorting to rnstatus 2026-05-09 01:45:04 +02:00
Mark Qvist 767782e425 Cleanup 2026-05-09 01:27:22 +02:00
Mark Qvist 60c440a3b6 Transport logic for path request ingress and egress control 2026-05-09 01:14:40 +02:00
Mark Qvist 6551a25877 Cleanup 2026-05-09 01:10:49 +02:00
Mark Qvist 70db2c5369 Updated log levels 2026-05-09 01:08:19 +02:00
Mark Qvist 8ed31d0dc8 Added path request frequency monitoring support to interfaces subsystem 2026-05-09 00:51:44 +02:00
Mark Qvist ef1ecb35e1 Fixed formatting 2026-05-09 00:50:19 +02:00
Mark Qvist 6768f10631 Improved discovery persist error handling 2026-05-09 00:26:42 +02:00
Mark Qvist fee6a53473 Added path request frequency display to rnstatus 2026-05-09 00:05:39 +02:00
Mark Qvist bbfa3b0aa0 Use validation functions canonically from util 2026-05-08 20:03:48 +02:00
Mark Qvist 325ae654ef Template rendering sequence 2026-05-08 18:24:30 +02:00
Mark Qvist 8655a4fb37 Cleaned up error messages 2026-05-08 18:18:28 +02:00
Mark Qvist b30d272ee6 Ensure non-corrupting stats writes 2026-05-08 17:37:32 +02:00
Mark Qvist cc90ac2853 Fixed workdoc limit 2026-05-08 17:27:56 +02:00
Mark Qvist 55473f39cb Improved rngit error logging 2026-05-08 17:25:46 +02:00
Mark Qvist 6d73881b07 Ensure error return consistency 2026-05-08 17:08:27 +02:00
Mark Qvist d107cd4b42 Cleanup 2026-05-08 17:00:49 +02:00
Mark Qvist 33247e21b2 Added AutoInterface per-peer announce rate display to rnstatus 2026-05-08 16:48:50 +02:00
Mark Qvist 6bdc769af3 Ensure SHA validation is canonical 2026-05-08 16:22:21 +02:00
Mark Qvist e923ccbf1b Improved ref name validation in rngit 2026-05-08 16:07:16 +02:00
Mark Qvist d402ee33a2 Formatting and cleanup 2026-05-08 12:00:39 +02:00
Mark Qvist d8d420745f Removed programs from docs using non-verified/LLM-generated implementations of Reticulum 2026-05-08 11:33:51 +02:00
Mark Qvist 524f2068cd Fixed regression in link close handling in rnstatus and rnpath remote management 2026-05-08 02:47:43 +02:00
Mark Qvist 5db089ff19 Updated version 2026-05-08 02:15:28 +02:00
Mark Qvist 08d6780c73 Tuned default IC params. Show burst status in rnstatus. 2026-05-08 01:13:49 +02:00
Mark Qvist ca3f0bba6d Cleanup 2026-05-08 00:28:02 +02:00
Mark Qvist 830327e4a2 IC default config 2026-05-08 00:26:01 +02:00
Mark Qvist f96409dfa9 IC config stuff 2026-05-08 00:11:18 +02:00
Mark Qvist 18e2da7d2b Updated manual 2026-05-07 21:08:24 +02:00
Mark Qvist dfd046afb6 Fixed f-string for old snakes 2026-05-07 20:59:44 +02:00
Mark Qvist 63d7f1e295 Fixed page formatting 2026-05-07 20:49:20 +02:00
59 changed files with 1861 additions and 768 deletions
+67
View File
@@ -1,3 +1,70 @@
### 2026-05-14: RNS 1.2.6
This release adds further improvements to the `rnid` and `rngit` utilities, and includes several bugfixes and other improvements.
**Changes**
- Added embedded message signing, validation and viewing to `rnid`
- Added file encryption for multiple file path inputs and shell expansions to `rnid`
- Added file decryption for multiple file path inputs and shell expansions to `rnid`
- Added signature creation for multiple file path inputs and shell expansions to `rnid`
- Added signature validation of multiple file path inputs and shell expansions to `rnid`
- Added workdoc signing and validation to `rngit`
- Added ability to edit workdoc titles to `rngit`
- Added ability to download workdocs via the `nomadnet` interface to `rngit`
- Added local URL resolution to the `rngit` repository frontpage markdown readme renderer
- Improved `rnstatus` remote monitor loop
- Improved `rngit` workdoc page handling
- Improved `rngit` release page rendering
- Fixed missing none check in interface discovery sanitizer thanks to PAzter1101
- Fixed potential race condition in interface discovery
- Fixed `rngit` remote helper hanging on startup if no client config had been created previously, and RNS loglevel was configured at debug or higher
**Release Signatures**
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`. To verify files, download the `rsg` signatures, make sure they are in the same folder as the release artifact, and run `rnid` signature verification with the release identity as the required signer:
```sh
rnid -i bc7291552be7a58f361522990465165c -V rns*.whl
```
The `rnid` utility will then verify the signatures, and display whether it is valid. If the signature cannot be verified, the file has been tampered with and should be thrown very far away in a jiffy.
### 2026-05-09: RNS 1.2.5
This release brings substantial improvements to path request handling, and should significantly reduce overall network and local transport node processing loads. Path requests are now automatically ingress and egress limited per interface and sub-interface. Although the defaults are effective and sane, and should work right out of the box bring an end to practically all the PR and announce spam going on lately, the backend is fully configurable for both defaults and per interface, if you want to fiddle with the settings.
People who have written (ahem... *prompted into existence*) strange applications, that believed sending 25 random path requests every 10 seconds to try and punch holes through announce limiting, will now most likely find any potential users of such applications complaining that they are losing the ability to resolve paths alltogether, which is (entirely) by design, of course. Seriously, don't do crap like that.
You can read more about how the new ingress and egress controls work in the updated manual sections, in the Interfaces chapter.
For all node ops out there, I'd recomment updating to this at some sort of semi-expedient, but of course not un-leisurely pace, so peace and order on the networks can be restored.
**Changes**
- Added path request ingress and egress control with sane defaults for transport nodes
- Added full configurability of ingress and egress controls per interface and for instance-wide defaults
- Significantly improved transport logic for path request and announce handling
- Added path request frequency display to `rnstatus`
- Added AutoInterface per-peer announe rate display to `rnstatus`
- Added abilit to filter interfaces by burst state to `rnstatus`
- Added hex/base32/base64 ASCII-wrapped output to `rnid` signature generator
- Tuned default ingress control parameters
- Fixed regression in link close handling in `rnstatus` and `rnpath` remote management handling
- Fixed invalid handling of corrupted interface discovery files
- Fixed announce processing edge case handling if path was cleaned while waiting for rebroadcast
- Improved `rngit` error logging
- Improved transport background jobs error handling
- Fixed various edge-cases and inconsistencies in markdown rendering in `rngit`
- Ensured canonical validation functions in `rngit`
- Lots of other small fixes and stability improvements to `rngit`
**Release Signatures**
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`. To verify files, download the `rsg` signatures, make sure they are in the same folder as the release artifact, and run `rnid` signature verification with the release identity as the required signer:
```sh
rnid -i bc7291552be7a58f361522990465165c -V rns-1.2.5-py3-none-any.whl
```
The `rnid` utility will then verify the signatures, and display whether it is valid. If the signature cannot be verified, the file has been tampered with and should be thrown very far away in a jiffy.
### 2026-05-07: RNS 1.2.4
This release brings a complete rewrite and update to the `rnid` utility, which is now a lot more useful, and better at finding and saving identities. It also includes a bunch of other improvements, such as expanded `rngit` functionality, better transport performance and a few bugfixes. Enjoy!
+47 -36
View File
@@ -6,6 +6,7 @@ import random
import threading
import ipaddress
import subprocess
from threading import Lock
from .vendor import umsgpack as msgpack
NAME = 0xFF
@@ -86,6 +87,7 @@ class InterfaceAnnouncer():
RNS.trace_exception(e)
def sanitize(self, in_str):
if in_str == None: return None
sanitized = in_str.replace("\n", "")
sanitized = sanitized.replace("\r", "")
sanitized = sanitized.strip()
@@ -374,6 +376,8 @@ class InterfaceDiscovery():
AUTOCONNECT_TYPES = ["BackboneInterface", "TCPServerInterface"]
DISCOVERABLE_TYPES = ["BackboneInterface", "TCPServerInterface", "I2PInterface", "RNodeInterface", "WeaveInterface", "KISSInterface"]
discovery_lock = Lock()
def __init__(self, required_value=InterfaceAnnouncer.DEFAULT_STAMP_VALUE, callback=None, discover_interfaces=True):
if not required_value: required_value = InterfaceAnnouncer.DEFAULT_STAMP_VALUE
@@ -401,8 +405,10 @@ class InterfaceDiscovery():
discovery_sources = RNS.Reticulum.interface_discovery_sources()
for filename in os.listdir(self.storagepath):
try:
filepath = os.path.join(self.storagepath, filename)
with open(filepath, "rb") as f: info = msgpack.unpackb(f.read())
with self.discovery_lock:
filepath = os.path.join(self.storagepath, filename)
with open(filepath, "rb") as f: info = msgpack.unpackb(f.read())
should_remove = False
heard_delta = now-info["last_heard"]
info["name"] = InterfaceAnnounceHandler.sanitize_name(info["name"])
@@ -434,8 +440,8 @@ class InterfaceDiscovery():
if should_append: discovered_interfaces.append(info)
except Exception as e:
RNS.log(f"Error while loading discovered interface data: {e}", RNS.LOG_ERROR)
RNS.log(f"The interface data file {os.path.join(self.storagepath, filename)} may be corrupt", RNS.LOG_ERROR)
RNS.log(f"Error while loading discovered interface data: {e}", RNS.LOG_WARNING)
RNS.log(f"The interface data file {os.path.join(self.storagepath, filename)} may be corrupt", RNS.LOG_WARNING)
RNS.trace_exception(e)
discovered_interfaces.sort(key=lambda info: (info["status_code"], info["value"], info["last_heard"]), reverse=True)
@@ -453,41 +459,45 @@ class InterfaceDiscovery():
filename = RNS.hexrep(discovery_hash, delimit=False)
filepath = os.path.join(self.storagepath, filename)
RNS.log(f"Discovered {interface_type} {hops} hop{ms} away with stamp value {value}: {name}", RNS.LOG_DEBUG)
if not os.path.isfile(filepath):
try:
with open(filepath, "wb") as f:
info["discovered"] = info["received"]
info["last_heard"] = info["received"]
info["heard_count"] = 0
f.write(msgpack.packb(info))
except Exception as e:
RNS.log(f"Error while persisting discovered interface data: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return
with self.discovery_lock:
if not os.path.isfile(filepath):
try:
with open(filepath, "wb") as f:
info["discovered"] = info["received"]
info["last_heard"] = info["received"]
info["heard_count"] = 0
f.write(msgpack.packb(info))
except Exception as e:
RNS.log(f"Error while persisting discovered interface data: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return
else:
discovered = None
heard_count = None
try:
with open(filepath, "rb") as f:
last_info = msgpack.unpackb(f.read())
discovered = last_info["discovered"]
heard_count = last_info["heard_count"]
else:
discovered = None
heard_count = None
try:
try:
with open(filepath, "rb") as f:
last_info = msgpack.unpackb(f.read())
discovered = last_info["discovered"]
heard_count = last_info["heard_count"]
if discovered == None: discovered = info["discovered"]
if heard_count == None: heard_count = 0
except Exception as e: RNS.log(f"Error while reading existing data for discovered interface, re-creating data", RNS.LOG_ERROR)
with open(filepath, "wb") as f:
info["discovered"] = discovered
info["last_heard"] = info["received"]
info["heard_count"] = heard_count+1
f.write(msgpack.packb(info))
if discovered == None: discovered = info["received"]
if heard_count == None: heard_count = 0
except Exception as e:
RNS.log(f"Error while persisting discovered interface data: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return
with open(filepath, "wb") as f:
info["discovered"] = discovered
info["last_heard"] = info["received"]
info["heard_count"] = heard_count+1
f.write(msgpack.packb(info))
except Exception as e:
RNS.log(f"Error while persisting discovered interface data: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return
except Exception as e:
RNS.log(f"Error processing discovered interface data: {e}", RNS.LOG_ERROR)
@@ -658,10 +668,11 @@ class InterfaceDiscovery():
RNS.log(f"Auto-connecting discovered {interface_type} {interface_name}")
interface.autoconnect_hash = endpoint_hash
interface.autoconnect_source = info["network_id"]
mode = RNS.Interfaces.Interface.Interface.MODE_GATEWAY if RNS.Reticulum.transport_enabled() else None
ar_target = RNS.Reticulum.get_instance()._default_ar_target() if RNS.Reticulum.transport_enabled() else None
ar_penalty = RNS.Reticulum.get_instance()._default_ar_penalty() if RNS.Reticulum.transport_enabled() else None
ar_grace = RNS.Reticulum.get_instance()._default_ar_grace() if RNS.Reticulum.transport_enabled() else None
RNS.Reticulum.get_instance()._add_interface(interface, ifac_netname=ifac_netname, ifac_netkey=ifac_netkey, configured_bitrate=5E6,
RNS.Reticulum.get_instance()._add_interface(interface, mode=mode, ifac_netname=ifac_netname, ifac_netkey=ifac_netkey, configured_bitrate=5E6,
announce_rate_target=ar_target, announce_rate_grace=ar_grace, announce_rate_penalty=ar_penalty)
self.monitor_interface(interface)
+5
View File
@@ -539,6 +539,11 @@ class AutoInterface(Interface):
spawned_interface.ic_new_time = self.ic_new_time
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
spawned_interface.egress_control = self.egress_control
spawned_interface.ec_pr_freq = self.ec_pr_freq
spawned_interface.ic_pr_burst_freq_new = self.ic_pr_burst_freq_new
spawned_interface.ic_pr_burst_freq = self.ic_pr_burst_freq
spawned_interface.parent_interface = self
spawned_interface.bitrate = self.bitrate
+81 -12
View File
@@ -156,6 +156,58 @@ class BackboneInterface(Interface):
else:
raise SystemError("Insufficient parameters to create listener")
__last_ic_burst_check = 0
__last_ic_burst_state = False
@property
def ic_burst_active(self):
if time.time() > self.__last_ic_burst_check + 2:
self.__last_ic_burst_state = any(i.ic_burst_active for i in self.spawned_interfaces)
return self.__last_ic_burst_state
@ic_burst_active.setter
def ic_burst_active(self, value): pass
__ic_burst_activated_check = 0
__ic_burst_activated = 0
@property
def ic_burst_activated(self):
if time.time() > self.__ic_burst_activated_check + 2:
activated = [i.ic_burst_activated for i in self.spawned_interfaces if i.ic_burst_active]
if activated: self.__ic_burst_activated = min(activated)
return self.__ic_burst_activated
@ic_burst_activated.setter
def ic_burst_activated(self, value): pass
__last_ic_pr_burst_check = 0
__last_ic_pr_burst_state = False
@property
def ic_pr_burst_active(self):
if time.time() > self.__last_ic_pr_burst_check + 2:
self.__last_ic_pr_burst_state = any(i.ic_pr_burst_active for i in self.spawned_interfaces)
return self.__last_ic_pr_burst_state
@ic_pr_burst_active.setter
def ic_pr_burst_active(self, value): pass
__ic_pr_burst_activated_check = 0
__ic_pr_burst_activated = 0
@property
def ic_pr_burst_activated(self):
if time.time() > self.__ic_pr_burst_activated_check + 2:
activated = [i.ic_pr_burst_activated for i in self.spawned_interfaces if i.ic_pr_burst_active]
if activated: self.__ic_pr_burst_activated = min(activated)
return self.__ic_pr_burst_activated
@ic_pr_burst_activated.setter
def ic_pr_burst_activated(self, value): pass
@staticmethod
def start():
if not BackboneInterface._job_active: threading.Thread(target=BackboneInterface.__job, daemon=True).start()
@@ -196,17 +248,17 @@ class BackboneInterface(Interface):
@staticmethod
def register_in(fileno):
if fileno < 0:
RNS.log(f"Attempt to register invalid file descriptor {fileno}", RNS.LOG_ERROR)
RNS.log(f"Attempt to register invalid file descriptor {fileno}", RNS.LOG_WARNING)
return
try: BackboneInterface.epoll.register(fileno, select.EPOLLIN)
except Exception as e:
RNS.log(f"An error occurred while registering EPOLL_IN for file descriptor {fileno}: {e}", RNS.LOG_ERROR)
RNS.log(f"An error occurred while registering EPOLL_IN for file descriptor {fileno}: {e}", RNS.LOG_WARNING)
@staticmethod
def deregister_fileno(fileno):
if fileno < 0:
RNS.log(f"Attempt to deregister invalid file descriptor {fileno}", RNS.LOG_ERROR)
RNS.log(f"Attempt to deregister invalid file descriptor {fileno}", RNS.LOG_WARNING)
return
try: BackboneInterface.epoll.unregister(fileno)
@@ -288,7 +340,7 @@ class BackboneInterface(Interface):
except Exception as e: RNS.log(f"Error while removing spawned interface from {pif}: {e}", RNS.LOG_ERROR)
try: client_socket.close()
except Exception as e: RNS.log(f"Error while closing socket for {spawned_interface}: {e}", RNS.LOG_ERROR)
except Exception as e: RNS.log(f"Error while closing socket for {spawned_interface}: {e}", RNS.LOG_WARNING)
spawned_interface.receive(b"")
spawned_interface.transmit_buffer = spawned_interface.transmit_buffer[written:]
@@ -320,18 +372,24 @@ class BackboneInterface(Interface):
elif fileno in BackboneInterface.listener_filenos:
owner_interface, server_socket = BackboneInterface.listener_filenos[fileno]
if fileno == server_socket.fileno() and (event & select.EPOLLIN):
client_socket, address = server_socket.accept()
client_socket.setblocking(0)
if not owner_interface.incoming_connection(client_socket):
try:
client_socket, address = server_socket.accept()
client_socket.setblocking(0)
if not owner_interface.incoming_connection(client_socket):
try: client_socket.close()
except Exception as e: RNS.log(f"Error while closing socket for failed incoming connection: {e}", RNS.LOG_WARNING)
except:
RNS.log(f"Accepting socket failed for incoming connection: {e}", RNS.LOG_WARNING)
try: client_socket.close()
except Exception as e: RNS.log(f"Error while closing socket for failed incoming connection: {e}", RNS.LOG_ERROR)
except Exception as e: RNS.log(f"Error while closing socket for failed incoming socket accept: {e}", RNS.LOG_WARNING)
elif fileno == server_socket.fileno() and (event & select.EPOLLHUP):
try: BackboneInterface.deregister_fileno(fileno)
except Exception as e: RNS.log(f"Error while deregistering listener file descriptor {fileno}: {e}", RNS.LOG_ERROR)
try: server_socket.close()
except Exception as e: RNS.log(f"Error while closing listener socket for {server_socket}: {e}", RNS.LOG_ERROR)
except Exception as e: RNS.log(f"Error while closing listener socket for {server_socket}: {e}", RNS.LOG_WARNING)
except Exception as e:
RNS.log(f"BackboneInterface error: {e}", RNS.LOG_ERROR)
@@ -356,6 +414,11 @@ class BackboneInterface(Interface):
spawned_interface.ic_new_time = self.ic_new_time
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
spawned_interface.egress_control = self.egress_control
spawned_interface.ec_pr_freq = self.ec_pr_freq
spawned_interface.ic_pr_burst_freq_new = self.ic_pr_burst_freq_new
spawned_interface.ic_pr_burst_freq = self.ic_pr_burst_freq
spawned_interface.socket = socket
spawned_interface.target_ip = socket.getpeername()[0]
@@ -408,6 +471,12 @@ class BackboneInterface(Interface):
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def process_outgoing(self, data):
pass
@@ -578,8 +647,8 @@ class BackboneClientInterface(Interface):
except Exception as e:
if initial:
RNS.log("Initial connection for "+str(self)+" could not be established: "+str(e), RNS.LOG_ERROR)
RNS.log("Leaving unconnected and retrying connection in "+str(BackboneClientInterface.RECONNECT_WAIT)+" seconds.", RNS.LOG_ERROR)
RNS.log("Initial connection for "+str(self)+" could not be established: "+str(e), RNS.LOG_WARNING)
RNS.log("Leaving unconnected and retrying connection in "+str(BackboneClientInterface.RECONNECT_WAIT)+" seconds.", RNS.LOG_WARNING)
return False
else:
@@ -602,7 +671,7 @@ class BackboneClientInterface(Interface):
attempts += 1
if self.max_reconnect_tries != None and attempts > self.max_reconnect_tries:
RNS.log("Max reconnection attempts reached for "+str(self), RNS.LOG_ERROR)
RNS.log("Max reconnection attempts reached for "+str(self), RNS.LOG_WARNING)
self.teardown()
break
+11
View File
@@ -957,6 +957,11 @@ class I2PInterface(Interface):
spawned_interface.ic_new_time = self.ic_new_time
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
spawned_interface.egress_control = self.egress_control
spawned_interface.ec_pr_freq = self.ec_pr_freq
spawned_interface.ic_pr_burst_freq_new = self.ic_pr_burst_freq_new
spawned_interface.ic_pr_burst_freq = self.ic_pr_burst_freq
spawned_interface.parent_interface = self
spawned_interface.online = True
@@ -1003,6 +1008,12 @@ class I2PInterface(Interface):
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def detach(self):
RNS.log("Detaching "+str(self), RNS.LOG_DEBUG)
self.i2p.stop()
+115 -29
View File
@@ -55,8 +55,15 @@ class Interface:
# How many samples to use for announce
# frequency calculations
IA_FREQ_SAMPLES = 128
OA_FREQ_SAMPLES = 128
IA_FREQ_SAMPLES = 48
OA_FREQ_SAMPLES = 48
IP_FREQ_SAMPLES = 48
OP_FREQ_SAMPLES = 48
AR_MINFREQ_HZ = 0.1
PR_MINFREQ_HZ = 0.1
AR_FREQ_DECAY = 1/AR_MINFREQ_HZ
PR_FREQ_DECAY = 1/PR_MINFREQ_HZ
# Maximum amount of ingress limited announces
# to hold at any given time.
@@ -66,12 +73,17 @@ class Interface:
# considered to be newly created. Two
# hours by default.
IC_NEW_TIME = 2*60*60
IC_BURST_FREQ_NEW = 6
IC_BURST_FREQ = 35
IC_BURST_HOLD = 1*60
IC_BURST_FREQ_NEW = 3
IC_BURST_FREQ = 10
IC_PR_BURST_FREQ_NEW = 3
IC_PR_BURST_FREQ = 8
IC_BURST_HOLD = 15
IC_BURST_PENALTY = 15
IC_HELD_RELEASE_INTERVAL = 2
IC_DEQUE_MIN_SAMPLE = 32
IC_HELD_RELEASE_INTERVAL = 5
IC_DEQUE_MIN_SAMPLE = 2
IC_BURST_MIN_SAMPLES = 6
EC_PR_FREQ = 5
EGRESS_CONTROL = False
# Default announce rate targets
DEFAULT_AR_TARGET = 3600
@@ -90,29 +102,38 @@ class Interface:
self.bitrate = 62500
self.HW_MTU = None
self.supports_discovery = False
self.discoverable = False
self.last_discovery_announce = 0
self.bootstrap_only = False
self.parent_interface = None
self.spawned_interfaces = None
self.tunnel_id = None
self.ingress_control = True
self.phy_keepalive = False
self.ic_max_held_announces = Interface.MAX_HELD_ANNOUNCES
self.ic_burst_hold = Interface.IC_BURST_HOLD
self.ic_burst_active = False
self.ic_burst_activated = 0
self.ic_held_release = 0
self.ic_burst_freq_new = Interface.IC_BURST_FREQ_NEW
self.ic_burst_freq = Interface.IC_BURST_FREQ
self.ic_new_time = Interface.IC_NEW_TIME
self.ic_burst_penalty = Interface.IC_BURST_PENALTY
self.ic_held_release_interval = Interface.IC_HELD_RELEASE_INTERVAL
self.held_announces = {}
self.supports_discovery = False
self.discoverable = False
self.last_discovery_announce = 0
self.bootstrap_only = False
self.parent_interface = None
self.spawned_interfaces = None
self.tunnel_id = None
self.ingress_control = True
self.phy_keepalive = False
self.ic_burst_active = False
self.ic_burst_activated = 0
self.ic_pr_burst_active = False
self.ic_pr_burst_activated = 0
self.ic_held_release = 0
self.ic_max_held_announces = RNS.Reticulum.get_instance()._default_ic_max_held_announces()
self.ic_burst_hold = RNS.Reticulum.get_instance()._default_ic_burst_hold()
self.ic_burst_freq_new = RNS.Reticulum.get_instance()._default_ic_burst_freq_new()
self.ic_burst_freq = RNS.Reticulum.get_instance()._default_ic_burst_freq()
self.ic_pr_burst_freq_new = RNS.Reticulum.get_instance()._default_ic_pr_burst_freq_new()
self.ic_pr_burst_freq = RNS.Reticulum.get_instance()._default_ic_pr_burst_freq()
self.ic_new_time = RNS.Reticulum.get_instance()._default_ic_new_time()
self.ic_burst_penalty = RNS.Reticulum.get_instance()._default_ic_burst_penalty()
self.ic_held_release_interval = RNS.Reticulum.get_instance()._default_ic_held_release_interval()
self.ec_pr_freq = RNS.Reticulum.get_instance()._default_ec_pr_freq()
self.egress_control = RNS.Reticulum.get_instance()._default_egress_control()
self.held_announces = {}
self.ia_freq_deque = deque(maxlen=Interface.IA_FREQ_SAMPLES)
self.oa_freq_deque = deque(maxlen=Interface.OA_FREQ_SAMPLES)
self.ip_freq_deque = deque(maxlen=Interface.IA_FREQ_SAMPLES)
self.op_freq_deque = deque(maxlen=Interface.OA_FREQ_SAMPLES)
def get_hash(self):
return RNS.Identity.full_hash(str(self).encode("utf-8"))
@@ -128,7 +149,7 @@ class Interface:
if self.ic_burst_active:
if ia_freq < freq_threshold and time.time() > self.ic_burst_activated+self.ic_burst_hold:
self.ic_burst_active = False
if len(self.ia_freq_deque) >= self.IC_BURST_MIN_SAMPLES: self.ic_burst_active = False
return True
@@ -143,6 +164,37 @@ class Interface:
else: return False
def should_ingress_limit_pr(self):
if self.ingress_control:
freq_threshold = self.ic_pr_burst_freq_new if self.age() < self.ic_new_time else self.ic_pr_burst_freq
ip_freq = self.incoming_pr_frequency()
if self.ic_pr_burst_active:
if ip_freq < freq_threshold and time.time() > self.ic_pr_burst_activated+self.ic_burst_hold:
self.ic_pr_burst_active = False
return True
else:
if ip_freq > freq_threshold:
self.ic_pr_burst_active = True
self.ic_pr_burst_activated = time.time()
return True
else: return False
else: return False
def should_egress_limit_pr(self):
if self.egress_control:
freq_threshold = self.ec_pr_freq
op_freq = self.outgoing_pr_frequency()
if op_freq > freq_threshold:
if len(self.op_freq_deque) >= self.IC_BURST_MIN_SAMPLES: return True
return False
def optimise_mtu(self):
if self.AUTOCONFIGURE_MTU:
if self.bitrate >= 1_000_000_000:
@@ -168,7 +220,7 @@ class Interface:
else:
self.HW_MTU = None
RNS.log(f"{self} hardware MTU set to {self.HW_MTU}", RNS.LOG_DEBUG) # TODO: Remove debug
RNS.log(f"{self} hardware MTU set to {self.HW_MTU}", RNS.LOG_DEBUG)
def age(self):
return time.time()-self.created
@@ -214,12 +266,23 @@ class Interface:
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.sent_announce(from_spawned=True)
def received_path_request(self, from_spawned=False):
self.ip_freq_deque.append(time.time())
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.received_path_request(from_spawned=True)
def sent_path_request(self, from_spawned=False):
self.op_freq_deque.append(time.time())
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.sent_path_request(from_spawned=True)
def incoming_announce_frequency(self):
n = len(self.ia_freq_deque)
if not n > self.IC_DEQUE_MIN_SAMPLE: return 0
else:
oldest = self.ia_freq_deque[0]
span = time.time() - oldest
if span > self.AR_FREQ_DECAY: self.ia_freq_deque.popleft()
if span <= 0: return 0
hz = n / span
return hz
@@ -230,6 +293,29 @@ class Interface:
else:
oldest = self.oa_freq_deque[0]
span = time.time() - oldest
if span > self.AR_FREQ_DECAY: self.oa_freq_deque.popleft()
if span <= 0: return 0
hz = n / span
return hz
def incoming_pr_frequency(self):
n = len(self.ip_freq_deque)
if not n > self.IC_DEQUE_MIN_SAMPLE: return 0
else:
oldest = self.ip_freq_deque[0]
span = time.time() - oldest
if span > self.PR_FREQ_DECAY: self.ip_freq_deque.popleft()
if span <= 0: return 0
hz = n / span
return hz
def outgoing_pr_frequency(self):
n = len(self.op_freq_deque)
if not len(self.op_freq_deque) > 1: return 0
else:
oldest = self.op_freq_deque[0]
span = time.time() - oldest
if span > self.PR_FREQ_DECAY: self.op_freq_deque.popleft()
if span <= 0: return 0
hz = n / span
return hz
+6
View File
@@ -488,6 +488,12 @@ class LocalServerInterface(Interface):
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def __str__(self):
if self.socket_path: return "Shared Instance["+str(self.socket_path.replace("\0", ""))+"]"
else: return "Shared Instance["+str(self.bind_port)+"]"
+6
View File
@@ -549,6 +549,12 @@ class RNodeMultiInterface(Interface):
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def readLoop(self):
try:
in_frame = False
+11
View File
@@ -589,6 +589,11 @@ class TCPServerInterface(Interface):
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
spawned_interface.egress_control = self.egress_control
spawned_interface.ec_pr_freq = self.ec_pr_freq
spawned_interface.ic_pr_burst_freq_new = self.ic_pr_burst_freq_new
spawned_interface.ic_pr_burst_freq = self.ic_pr_burst_freq
spawned_interface.target_ip = handler.client_address[0]
spawned_interface.target_port = str(handler.client_address[1])
spawned_interface.parent_interface = self
@@ -634,6 +639,12 @@ class TCPServerInterface(Interface):
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def process_outgoing(self, data):
pass
+2 -1
View File
@@ -117,7 +117,7 @@ class Packet:
__slots__ = "hops", "header", "header_type", "packet_type", "transport_type", "context", "context_flag", "destination"
__slots__ += "transport_id", "data", "flags", "raw", "packed", "sent", "create_receipt", "receipt", "fromPacked", "MTU"
__slots__ += "sent_at", "packet_hash", "ratchet_id", "attached_interface", "receiving_interface", "rssi", "snr", "q"
__slots__ += "ciphertext", "plaintext", "destination_hash", "destination_type", "link", "map_hash"
__slots__ += "ciphertext", "plaintext", "destination_hash", "destination_type", "link", "map_hash", "is_outbound_pr"
def __init__(self, destination, data, packet_type = DATA, context = NONE, transport_type = RNS.Transport.BROADCAST,
header_type = HEADER_1, transport_id = None, attached_interface = None, create_receipt = True, context_flag=FLAG_UNSET):
@@ -161,6 +161,7 @@ class Packet:
self.attached_interface = attached_interface
self.receiving_interface = None
self.is_outbound_pr = False
self.rssi = None
self.snr = None
self.q = None
+122 -15
View File
@@ -249,22 +249,33 @@ class Reticulum:
Reticulum.blackholepath = Reticulum.configdir+"/storage/blackhole"
Reticulum.interfacepath = Reticulum.configdir+"/interfaces"
Reticulum.__network_identity = None
Reticulum.__transport_enabled = False
Reticulum.__link_mtu_discovery = Reticulum.LINK_MTU_DISCOVERY
Reticulum.__remote_management_enabled = False
Reticulum.__use_implicit_proof = True
Reticulum.__allow_probes = False
Reticulum.__discovery_enabled = False
Reticulum.__discover_interfaces = False
Reticulum.__network_identity = None
Reticulum.__transport_enabled = False
Reticulum.__link_mtu_discovery = Reticulum.LINK_MTU_DISCOVERY
Reticulum.__remote_management_enabled = False
Reticulum.__use_implicit_proof = True
Reticulum.__allow_probes = False
Reticulum.__discovery_enabled = False
Reticulum.__discover_interfaces = False
Reticulum.__autoconnect_discovered_interfaces = False
Reticulum.__required_discovery_value = None
Reticulum.__publish_blackhole = False
Reticulum.__blackhole_sources = []
Reticulum.__interface_sources = []
Reticulum.__default_ar_target = None
Reticulum.__default_ar_penalty = None
Reticulum.__default_ar_grace = None
Reticulum.__required_discovery_value = None
Reticulum.__publish_blackhole = False
Reticulum.__blackhole_sources = []
Reticulum.__interface_sources = []
Reticulum.__default_ar_target = None
Reticulum.__default_ar_penalty = None
Reticulum.__default_ar_grace = None
Reticulum.__ic_max_held_announces = None
Reticulum.__ic_burst_hold = None
Reticulum.__ic_burst_freq_new = None
Reticulum.__ic_burst_freq = None
Reticulum.__ic_pr_burst_freq_new = None
Reticulum.__ic_pr_burst_freq = None
Reticulum.__ic_new_time = None
Reticulum.__ic_burst_penalty = None
Reticulum.__ic_held_release_interval = None
Reticulum.__ec_pr_freq = None
Reticulum.__egress_control = None
Reticulum.panic_on_interface_error = False
@@ -596,6 +607,51 @@ class Reticulum:
v = self.config["reticulum"].as_int(option)
if v >= 0: Reticulum.__default_ar_grace = v
if option == "ic_max_held_announces":
v = self.config["reticulum"].as_int(option)
if v >= 0: Reticulum.__ic_max_held_announces = v
if option == "ic_burst_hold":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_burst_hold = v
if option == "ic_burst_freq_new":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_burst_freq_new = v
if option == "ic_burst_freq":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_burst_freq = v
if option == "ic_pr_burst_freq_new":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_pr_burst_freq_new = v
if option == "ic_pr_burst_freq":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_pr_burst_freq = v
if option == "ec_pr_freq":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ec_pr_freq = v
if option == "egress_control":
v = self.config["reticulum"].as_bool(option)
if v >= 0: Reticulum.__egress_control = v
if option == "ic_new_time":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_new_time = v
if option == "ic_burst_penalty":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_burst_penalty = v
if option == "ic_held_release_interval":
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_held_release_interval = v
if RNS.compiled: RNS.log("Reticulum running in compiled mode", RNS.LOG_DEBUG)
else: RNS.log("Reticulum running in interpreted mode", RNS.LOG_DEBUG)
@@ -683,6 +739,8 @@ class Reticulum:
ingress_control = True
if "ingress_control" in c: ingress_control = c.as_bool("ingress_control")
egress_control = None
if "egress_control" in c: egress_control = c.as_bool("egress_control")
ic_max_held_announces = None
if "ic_max_held_announces" in c: ic_max_held_announces = c.as_int("ic_max_held_announces")
ic_burst_hold = None
@@ -691,6 +749,12 @@ class Reticulum:
if "ic_burst_freq_new" in c: ic_burst_freq_new = c.as_float("ic_burst_freq_new")
ic_burst_freq = None
if "ic_burst_freq" in c: ic_burst_freq = c.as_float("ic_burst_freq")
ic_pr_burst_freq_new = None
if "ic_pr_burst_freq_new" in c: ic_pr_burst_freq_new = c.as_float("ic_pr_burst_freq_new")
ic_pr_burst_freq = None
if "ic_pr_burst_freq" in c: ic_pr_burst_freq = c.as_float("ic_pr_burst_freq")
ec_pr_freq = None
if "ec_pr_freq" in c: ec_pr_freq = c.as_float("ec_pr_freq")
ic_new_time = None
if "ic_new_time" in c: ic_new_time = c.as_float("ic_new_time")
ic_burst_penalty = None
@@ -816,10 +880,14 @@ class Reticulum:
interface.announce_rate_grace = announce_rate_grace
interface.announce_rate_penalty = announce_rate_penalty
interface.ingress_control = ingress_control
if egress_control != None: interface.egress_control = egress_control
if ic_max_held_announces != None: interface.ic_max_held_announces = ic_max_held_announces
if ic_burst_hold != None: interface.ic_burst_hold = ic_burst_hold
if ic_burst_freq_new != None: interface.ic_burst_freq_new = ic_burst_freq_new
if ic_burst_freq != None: interface.ic_burst_freq = ic_burst_freq
if ic_pr_burst_freq_new != None: interface.ic_pr_burst_freq_new = ic_pr_burst_freq_new
if ic_pr_burst_freq != None: interface.ic_pr_burst_freq = ic_pr_burst_freq
if ec_pr_freq != None: interface.ec_pr_freq = ec_pr_freq
if ic_new_time != None: interface.ic_new_time = ic_new_time
if ic_burst_penalty != None: interface.ic_burst_penalty = ic_burst_penalty
if ic_held_release_interval != None: interface.ic_held_release_interval = ic_held_release_interval
@@ -1021,6 +1089,39 @@ class Reticulum:
def _default_ar_grace(self):
return self.__default_ar_grace or RNS.Interfaces.Interface.Interface.DEFAULT_AR_GRACE
def _default_ic_max_held_announces(self):
return self.__ic_max_held_announces or RNS.Interfaces.Interface.Interface.MAX_HELD_ANNOUNCES
def _default_ic_burst_hold(self):
return self.__ic_burst_hold or RNS.Interfaces.Interface.Interface.IC_BURST_HOLD
def _default_ic_burst_freq_new(self):
return self.__ic_burst_freq_new or RNS.Interfaces.Interface.Interface.IC_BURST_FREQ_NEW
def _default_ic_burst_freq(self):
return self.__ic_burst_freq or RNS.Interfaces.Interface.Interface.IC_BURST_FREQ
def _default_ic_pr_burst_freq_new(self):
return self.__ic_pr_burst_freq_new or RNS.Interfaces.Interface.Interface.IC_PR_BURST_FREQ_NEW
def _default_ic_pr_burst_freq(self):
return self.__ic_pr_burst_freq or RNS.Interfaces.Interface.Interface.IC_PR_BURST_FREQ
def _default_ec_pr_freq(self):
return self.__ec_pr_freq or RNS.Interfaces.Interface.Interface.EC_PR_FREQ
def _default_egress_control(self):
return self.__egress_control or RNS.Interfaces.Interface.Interface.EGRESS_CONTROL
def _default_ic_new_time(self):
return self.__ic_new_time or RNS.Interfaces.Interface.Interface.IC_NEW_TIME
def _default_ic_burst_penalty(self):
return self.__ic_burst_penalty or RNS.Interfaces.Interface.Interface.IC_BURST_PENALTY
def _default_ic_held_release_interval(self):
return self.__ic_held_release_interval or RNS.Interfaces.Interface.Interface.IC_HELD_RELEASE_INTERVAL
def _should_persist_data(self, background=False):
if time.time() > self.last_data_persist+Reticulum.GRACIOUS_PERSIST_INTERVAL:
def job(): self.__persist_data(background=background)
@@ -1321,10 +1422,16 @@ class Reticulum:
ifstats["txb"] = interface.txb
ifstats["incoming_announce_frequency"] = interface.incoming_announce_frequency()
ifstats["outgoing_announce_frequency"] = interface.outgoing_announce_frequency()
ifstats["incoming_pr_frequency"] = interface.incoming_pr_frequency()
ifstats["outgoing_pr_frequency"] = interface.outgoing_pr_frequency()
ifstats["announce_rate_target"] = interface.announce_rate_target
ifstats["announce_rate_penalty"] = interface.announce_rate_penalty
ifstats["announce_rate_grace"] = interface.announce_rate_grace
ifstats["held_announces"] = len(interface.held_announces)
ifstats["burst_active"] = interface.ic_burst_active
ifstats["burst_activated"] = interface.ic_burst_activated
ifstats["pr_burst_active"] = interface.ic_pr_burst_active
ifstats["pr_burst_activated"] = interface.ic_pr_burst_activated
ifstats["status"] = interface.online
ifstats["mode"] = interface.mode
+122 -60
View File
@@ -38,6 +38,7 @@ 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
@@ -124,6 +125,7 @@ class Transport:
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()
@@ -580,40 +582,41 @@ class Transport:
if block_rebroadcasts: announce_context = RNS.Packet.PATH_RESPONSE
announce_data = packet.data
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()
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,
)
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)
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)
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)
# 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
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)
@@ -772,10 +775,14 @@ class Transport:
# Cull the pending path requests table
stale_path_requests = []
with Transport.path_requests_lock:
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
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 = []
@@ -917,6 +924,8 @@ class Transport:
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()
@@ -980,15 +989,50 @@ class Transport:
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.trace_exception(e) # TODO: Remove
for packet in outgoing: packet.send()
if outgoing:
def job(): Transport.handle_outgoing_announces(outgoing)
threading.Thread(target=job).start()
for destination_hash in path_requests:
blocked_if = path_requests[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
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):
@@ -1263,6 +1307,7 @@ class Transport:
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
@@ -1629,10 +1674,12 @@ class Transport:
# announces, queueing rebroadcasts of these, and removal
# of queued announce rebroadcasts once handed to the next node.
if packet.packet_type == RNS.Packet.ANNOUNCE:
if interface != None and RNS.Identity.validate_announce(packet, only_validate_signature=True):
interface.received_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 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
@@ -1694,7 +1741,7 @@ class Transport:
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 packet.destination_hash in Transport.path_table:
if announced_destination_known:
random_blobs = Transport.path_table[packet.destination_hash][IDX_PT_RANDBLOBS]
# If we already have a path to the announced
@@ -2141,8 +2188,7 @@ class Transport:
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("Error while transporting link request proof. The contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Could not transport link request proof. The contained exception was: "+str(e), RNS.LOG_WARNING)
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:
@@ -2747,8 +2793,10 @@ class Transport:
wait_time = (tx_time / on_interface.announce_cap)
on_interface.announce_allowed_at = now + wait_time
packet.is_outbound_pr = True
packet.send()
Transport.path_requests[destination_hash] = time.time()
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):
@@ -2832,6 +2880,7 @@ class Transport:
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)
@@ -2851,15 +2900,16 @@ class Transport:
@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:
if RNS.Reticulum.transport_enabled() and attached_interface.mode in RNS.Interfaces.Interface.Interface.DISCOVER_PATHS_FOR:
should_search_for_unknown = True
interface_str = " on "+str(attached_interface)
else: interface_str = ""
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
RNS.log("Path request for "+RNS.prettyhexrep(destination_hash)+interface_str, RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
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:
@@ -2886,7 +2936,7 @@ class Transport:
received_from = Transport.path_table[destination_hash][IDX_PT_RVCD_IF]
if packet == None:
RNS.log("Could not retrieve announce packet from cache while answering path request for "+RNS.prettyhexrep(destination_hash), RNS.LOG_ERROR)
RNS.log("Could not retrieve announce packet from cache while answering path request for "+RNS.prettyhexrep(destination_hash), RNS.LOG_WARNING)
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
@@ -2956,6 +3006,15 @@ class Transport:
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
@@ -2964,9 +3023,12 @@ class Transport:
for interface in Transport.interfaces:
if not interface == attached_interface:
# 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)
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
+6 -6
View File
@@ -138,12 +138,6 @@ class ReticulumGitClient():
self.configpath = self.configdir+"/client_config"
self.identitypath = self.configdir+"/client_identity"
RNS.logfile = self.logfile
try: self.reticulum = RNS.Reticulum(configdir=rnsconfigdir, logdest=RNS.LOG_FILE)
except Exception as e:
print(f"Failed to initialize Reticulum: {e}", file=sys.stderr)
return
if os.path.isfile(self.configpath):
try: self.config = ConfigObj(self.configpath)
except Exception as e:
@@ -152,6 +146,12 @@ class ReticulumGitClient():
else: self.__create_default_config()
RNS.logfile = self.logfile
try: self.reticulum = RNS.Reticulum(configdir=rnsconfigdir, logdest=RNS.LOG_FILE)
except Exception as e:
print(f"Failed to initialize Reticulum: {e}", file=sys.stderr)
return
self.__apply_config()
self.ready = True
+5 -4
View File
@@ -165,15 +165,15 @@ class SyntaxHighlighter:
except Exception as e:
RNS.log(f"Pygments highlighting failed, falling back: {e}", RNS.LOG_WARNING)
return self._plain_text(content)
return self._plain_text(content).replace("\\", "\\\\")
# TODO: Implement Python tokenize fallback for .py files.
# For now, route to plain text
if filename and filename.endswith(".py"):
return self._plain_text(content)
return self._plain_text(content).replace("\\", "\\\\")
# Universal fallback
return self._plain_text(content)
return self._plain_text(content).replace("\\", "\\\\")
def _highlight_pygments(self, content, filename=None, language=None):
from pygments.lexers import get_lexer_for_filename, guess_lexer, get_lexer_by_name
@@ -301,7 +301,8 @@ class MicronFormatter:
return None
@staticmethod
def _escape_value(value: str) -> str: return value.replace("`", "\\`")
def _escape_value(value):
return value.replace("\\", "\\\\").replace("`", "\\`")
# Required by Pygments formatter API, returns None for Micron
def get_style_defs(self, arg=None): return None
+156 -55
View File
@@ -37,7 +37,7 @@ import RNS
from collections import deque
from datetime import datetime
from RNS.Utilities.rngit import APP_NAME
from RNS.Utilities.rngit.util import MarkdownToMicron
from RNS.Utilities.rngit.util import MarkdownToMicron, san_sha
from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from RNS.vendor.configobj import ConfigObj
from RNS.vendor import umsgpack as mp
@@ -60,14 +60,15 @@ class NomadNetworkNode():
PATH_RELEASE = "/page/release.mu"
PATH_WORK = "/page/work.mu"
PATH_WORK_DOC = "/page/work_doc.mu"
PATH_ARTIFACT = "/file/artifact"
PATH_DOWNLOAD = "/file/download"
FILE_ARTIFACT = "/file/artifact"
FILE_DOWNLOAD = "/file/download"
FILE_WORKDOC = "/file/workdoc"
BLOB_SIZE_LIMIT = 256 * 1024
TREE_ENTRIES_PER_PAGE = 1000
COMMITS_PER_PAGE = 100
SHOW_DIFF_BY_DEFAULT = True
GIT_COMMAND_TIMEOUT = 5
GIT_COMMAND_TIMEOUT = 8
MAX_RENDER_WIDTH = 100
USE_NERDFONTS = True
@@ -98,6 +99,10 @@ class NomadNetworkNode():
CLR_DIM = "`F666"
CLR_DIM_H = "`F444"
# Yes, I'm being intentionally weird here. If you
# want to use tabs, three spaces is all you get.
TAB_WIDTH = " "
RENDERABLE_EXTS = [".md", ".mu"]
RENDER_DEFAULT = [".md", ".mu"]
@@ -217,8 +222,9 @@ class NomadNetworkNode():
self.destination.register_request_handler(self.PATH_RELEASE, response_generator=self.serve_release_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_WORK, response_generator=self.serve_work_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_WORK_DOC, response_generator=self.serve_work_doc_page, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_ARTIFACT, response_generator=self.serve_artifact, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.PATH_DOWNLOAD, response_generator=self.serve_download, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.FILE_ARTIFACT, response_generator=self.serve_artifact, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.FILE_DOWNLOAD, response_generator=self.serve_download, allow=RNS.Destination.ALLOW_ALL)
self.destination.register_request_handler(self.FILE_WORKDOC, response_generator=self.serve_wd_download, allow=RNS.Destination.ALLOW_ALL)
def get_template(self, template):
filename = f"{template}.mu"
@@ -244,6 +250,7 @@ class NomadNetworkNode():
return None
def render_template(self, page_content, nav_content=None, template=None, st=None):
page_content = self.format_tabs(page_content)
custom_template = self.get_template(template) if template else None
if custom_template:
template = custom_template
@@ -254,7 +261,6 @@ class NomadNetworkNode():
page_content = template.replace("{PAGE_CONTENT}", page_content)
base_template = self.get_template("base") or self.templates["base"]
base_template = base_template.replace("{PAGE_CONTENT}", page_content)
base_template = base_template.replace("{NODE_NAME}", self.node_name)
base_template = base_template.replace("{VERSION}", __version__)
@@ -263,6 +269,7 @@ class NomadNetworkNode():
gt = f"Generated in {RNS.prettytime(time.time()-st)}" if st else "Unknown generation time"
base_template = base_template.replace("{GEN_TIME}", gt)
base_template = base_template.replace("{PAGE_CONTENT}", page_content)
return base_template.encode("utf-8")
#############################
@@ -337,7 +344,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Group page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
if not group_name:
@@ -380,7 +387,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Repository page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
ref = data.get("var_ref", "HEAD") if data else "HEAD"
@@ -449,20 +456,18 @@ class NomadNetworkNode():
content_parts.append(self.m_divider())
if readme_is_markdown:
converted = self.mdc.format_block(readme_content)
url_scope = f":/page/blob.mu`g={group_name}|r={repo_name}|ref={ref}|path="
mdc = MarkdownToMicron(max_width=self.MAX_RENDER_WIDTH, syntax_highlighter=self.highlighter, url_scope=url_scope)
converted = mdc.format_block(readme_content)
content_parts.append(converted)
else: content_parts.append(f"\n{readme_content}\n")
content_parts.append("\n")
content_parts.append(self.m_divider())
else: content_parts.append(f"\n{readme_content}")
else:
content_parts.append(self.m_divider())
content_parts.append("\n")
content_parts.append(self.m_italic("No README file found in this repository."))
content_parts.append("\n")
content_parts.append("\n")
self.owner.view_succeeded(group_name, repo_name, remote_identity)
page_content = "".join(content_parts)
@@ -473,7 +478,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Tree page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
ref = data.get("var_ref", "HEAD") if data else "HEAD"
@@ -519,7 +524,7 @@ class NomadNetworkNode():
if i == len(path_components) - 1: breadcrumb_parts.append(component) # Last component not a link
else: breadcrumb_parts.append(self.m_link(component, self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=current_path))
else: breadcrumb_parts.append("") # Could be "root" or something, but a bit confusing
else: breadcrumb_parts.append("")
breadcrumb = " / ".join(breadcrumb_parts)
nav_parts.append(">>\n" + breadcrumb + "\n")
@@ -668,11 +673,11 @@ class NomadNetworkNode():
breadcrumb = " / ".join(breadcrumb_parts)
nav_parts.append(">>\n" + breadcrumb + "\n")
sep = self.icon("sep")
dl_link = self.m_link("Download", self.PATH_DOWNLOAD, g=group_name, r=repo_name, ref=ref, path=file_path)
if not renderable: nav_parts.append(f"\n{dl_link}\n")
dl_link = self.m_link("Download", self.FILE_DOWNLOAD, g=group_name, r=repo_name, ref=ref, path=file_path)
if not renderable: nav_parts.append(f"\nDisplaying Raw {sep} {dl_link}\n")
else:
sep = self.icon("sep")
rnd_link = self.m_link("View rendered", self.PATH_BLOB, g=group_name, r=repo_name, ref=ref, path=file_path, render="y")
raw_link = self.m_link("View raw", self.PATH_BLOB, g=group_name, r=repo_name, ref=ref, path=file_path, raw="y")
if render: render_controls = f"Displaying Rendered {sep} {raw_link}"
@@ -737,7 +742,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Commits page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
ref = data.get("var_ref", "HEAD") if data else "HEAD"
@@ -820,7 +825,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Commit page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
commit_hash = data.get("var_h", "") if data else ""
@@ -863,6 +868,7 @@ class NomadNetworkNode():
content = self.m_heading("Error", 2) + f"\nThe hash {commit_hash} does not refer to a commit.\n"
return self.render_template(content, st=st)
except subprocess.TimeoutExpired: RNS.log(f"Git command execution timed out", RNS.LOG_WARNING)
except Exception:
content = self.m_heading("Error", 2) + "\nCould not verify commit object.\n"
return self.render_template(content, st=st)
@@ -954,7 +960,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Refs page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
ref_type = data.get("var_type", "") if data else "" # "heads", "tags", or empty for both
@@ -995,6 +1001,7 @@ class NomadNetworkNode():
if head_result.returncode == 0: default_branch = head_result.stdout.strip().replace("refs/heads/", "")
except subprocess.TimeoutExpired: RNS.log(f"Git command execution timed out", RNS.LOG_WARNING)
except Exception: pass
show_heads = not ref_type or ref_type == "heads"
@@ -1057,7 +1064,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Statistics page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
@@ -1131,7 +1138,7 @@ class NomadNetworkNode():
content_parts.append("\n")
content_parts.append(self.render_combined_chart(stats["views"]["daily"], stats["fetches"]["daily"], stats["pushes"]["daily"], stats["timeline_labels"]))
else: content_parts.append(self.m_italic("\nNo activity recorded for this repository in the selected time period.\n\n"))
else: content_parts.append(self.m_italic("\nNo development activity recorded for this repository in the selected time period.\n\n"))
page_content = "".join(content_parts)
nav_content = "".join(nav_parts)
@@ -1141,7 +1148,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Releases page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
@@ -1196,14 +1203,14 @@ class NomadNetworkNode():
content_parts.append("\n")
self.owner.view_succeeded(group_name, repo_name, remote_identity)
page_content = "".join(content_parts)
page_content = "".join(content_parts).rstrip()+"\n"
return self.render_template(page_content, nav_content=nav_content, template="releases", st=st)
def serve_release_page(self, path, data, request_id, link_id, remote_identity, requested_at):
st = time.time()
RNS.log(f"Release page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
tag = data.get("var_t", "") if data else ""
@@ -1253,6 +1260,11 @@ class NomadNetworkNode():
sep = self.icon("sep")
heart = self.icon("heart")
thanks = True if data.get("var_thanks", "") else False
thanks_count = self.release_thanks(release_dir, add=thanks, link_id=link_id)
content_parts.append(f"{self.m_link_r(self.icon('heart')+f' Thanks ({thanks_count})', self.PATH_RELEASE, g=group_name, r=repo_name, t=tag, thanks='y')}\n\n")
created_ts = release_info.get("created", 0)
ts_str = f" {sep} {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(created_ts))}" if created_ts else ""
content_parts.append(self.m_heading(f"Release {tag}{ts_str}", 2))
@@ -1272,7 +1284,7 @@ class NomadNetworkNode():
if artifacts:
content_parts.append(self.m_heading(f"Artifacts ({len(artifacts)})", 2))
content_parts.append("\n")
for art in artifacts:
for art in sorted(artifacts, key=lambda e: e["name"]):
name = art.get("name", "unknown")
size = art.get("size", 0)
size_str = RNS.prettysize(size) if size else "0 B"
@@ -1280,18 +1292,13 @@ class NomadNetworkNode():
lstr_1 = f"{self.icon('file')} {self.m_escape(name)}"
lstr_2 = f"({size_str})"
link_1 = self.m_link_r(lstr_1, self.PATH_ARTIFACT, g=group_name, r=repo_name, t=tag, a=name)
link_2 = self.m_link_r(lstr_2, self.PATH_ARTIFACT, g=group_name, r=repo_name, t=tag, a=name)
link_1 = self.m_link_r(lstr_1, self.FILE_ARTIFACT, g=group_name, r=repo_name, t=tag, a=name)
link_2 = self.m_link_r(lstr_2, self.FILE_ARTIFACT, g=group_name, r=repo_name, t=tag, a=name)
content_parts.append(f"{link_1} {self.CLR_DIM}{link_2}`f\n")
content_parts.append("\n")
else:
content_parts.append(self.m_heading("Artifacts", 2))
content_parts.append("\nNo artifacts for this release.\n\n")
thanks = True if data.get("var_thanks", "") else False
thanks_count = self.release_thanks(release_dir, add=thanks, link_id=link_id)
content_parts.append(f"{self.m_link_r(self.icon('heart')+f' Thanks ({thanks_count})', self.PATH_RELEASE, g=group_name, r=repo_name, t=tag, thanks='y')}\n")
content_parts.append("\n`*No artifacts for this release`*\n")
self.owner.view_succeeded(group_name, repo_name, remote_identity)
page_content = "".join(content_parts)
@@ -1301,7 +1308,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Work page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
scope = data.get("var_scope", "active") if data else "active"
@@ -1395,11 +1402,11 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Work document page request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
doc_id = data.get("var_id", "") if data else ""
scope = data.get("var_scope", "active") if data else "active"
scope = data.get("var_scope", "all") if data else "all"
if scope not in ["active", "completed", "all"]: scope = "active"
if not group_name or not repo_name or not doc_id:
@@ -1417,7 +1424,19 @@ class NomadNetworkNode():
return self.render_template(content, st=st)
work_path = f"{repo['path']}.work"
doc_dir = os.path.join(work_path, scope, str(doc_id))
active_dir = os.path.join(work_path, "active", str(doc_id))
completed_dir = os.path.join(work_path, "completed", str(doc_id))
if scope == "active": doc_dir = active_dir
elif scope == "completed": doc_dir = completed_dir
elif scope == "all":
if os.path.isdir(active_dir):
doc_dir = active_dir
scope = "active"
else:
doc_dir = completed_dir
scope = "completed"
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path):
@@ -1433,29 +1452,45 @@ class NomadNetworkNode():
nav_parts = []
# Breadcrumb navigation
dl_link = self.m_link("Download", self.FILE_WORKDOC, g=group_name, r=repo_name, id=doc_id)
breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / {self.m_link('work', self.PATH_WORK, g=group_name, r=repo_name)} / #{doc_id}"
nav_parts.append(breadcrumb + "\n")
nav_parts.append(f"\n{dl_link}\n")
nav_content = "".join(nav_parts)
doc_title = doc['meta'].get('title', 'Untitled')[:64]
doc_title = doc['meta'].get('title', 'Untitled')[:256]
if len(doc_title) < len(doc['meta'].get('title', 'Untitled')): doc_title += ""
meta = doc.get("meta", {})
author = meta.get("author", b"")
author_str = RNS.prettyhexrep(author) if author else "Unknown"
signature = meta.get("signature", None)
pubkey = meta.get("identity", None)
created = meta.get("created", 0)
edited = meta.get("edited", 0)
fmt = meta.get("format", "markdown")
content = doc.get("content", "")
signature_validated = False
signature_str = "Document not signed"
if signature and type(signature) == bytes and len(signature) == RNS.Identity.SIGLENGTH//8:
if pubkey and type(pubkey) == bytes and len(pubkey) == RNS.Identity.KEYSIZE//8:
signature_str = "Not valid"
identity = RNS.Identity(create_keys=False)
identity.load_public_key(pubkey)
signature_validated = identity.validate(signature, content.encode("utf-8"))
if signature_validated: signature_str = "Valid"
# Document header
content_parts.append(self.m_heading(f"{doc_title}", 2))
content_parts.append(f"\n{self.CLR_DIM}Author : {author_str}`f\n")
content_parts.append(f"{self.CLR_DIM}Created : {time.strftime('%Y-%m-%d %H:%M', time.localtime(created)) if created else 'unknown'}`f\n")
content_parts.append(f"\n{self.CLR_DIM}Author : {author_str}`f\n")
content_parts.append(f"{self.CLR_DIM}Signature : {signature_str}`f\n")
content_parts.append(f"{self.CLR_DIM}Created : {time.strftime('%Y-%m-%d %H:%M', time.localtime(created)) if created else 'unknown'}`f\n")
if edited and edited != created:
content_parts.append(f"{self.CLR_DIM}Edited : {time.strftime('%Y-%m-%d %H:%M', time.localtime(edited))}`f\n")
content_parts.append(f"{self.CLR_DIM}Status : {scope.capitalize()}`f\n\n")
content_parts.append(f"{self.CLR_DIM}Edited : {time.strftime('%Y-%m-%d %H:%M', time.localtime(edited))}`f\n")
content_parts.append(f"{self.CLR_DIM}Status : {scope.capitalize()}`f\n\n")
# Document content
content = doc.get("content", "").strip()
content = content.strip()
if content:
if fmt == "micron": content_parts.append(content)
else: content_parts.append(self.mdc.format_block(content))
@@ -1504,7 +1539,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"Artifact file request from {remote_identity}", RNS.LOG_DEBUG)
if not data: data = {}
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
tag = data.get("var_t", "") if data else ""
@@ -1560,6 +1595,7 @@ class NomadNetworkNode():
st = time.time()
RNS.log(f"File download request from {remote_identity}", RNS.LOG_DEBUG)
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
ref = data.get("var_ref", "HEAD") if data else "HEAD"
@@ -1597,6 +1633,68 @@ class NomadNetworkNode():
return None
def serve_wd_download(self, path, data, request_id, link_id, remote_identity, requested_at):
st = time.time()
RNS.log(f"Workdoc download request from {remote_identity}", RNS.LOG_DEBUG)
if not data or not type(data) == dict: data = {}
group_name = data.get("var_g", "") if data else ""
repo_name = data.get("var_r", "") if data else ""
doc_id = data.get("var_id", "") if data else ""
scope = data.get("var_scope", "all") if data else "all"
if scope not in ["active", "completed", "all"]: scope = "active"
if not group_name or not repo_name or not doc_id:
RNS.log(f"Invalid workdoc download request for {group_name[:128]}/{repo_name[:128]}/{doc_id[:128]}", RNS.LOG_WARNING)
return None
try: doc_id = int(doc_id)
except:
RNS.log(f"Could not parse document ID for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id[:128]}", RNS.LOG_WARNING)
return None
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
RNS.log(f"Repository not found or no access for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id[:128]}", RNS.LOG_WARNING)
return None
work_path = f"{repo['path']}.work"
active_dir = os.path.join(work_path, "active", str(doc_id))
completed_dir = os.path.join(work_path, "completed", str(doc_id))
if scope == "active": doc_dir = active_dir
elif scope == "completed": doc_dir = completed_dir
elif scope == "all":
if os.path.isdir(active_dir):
doc_dir = active_dir
scope = "active"
else:
doc_dir = completed_dir
scope = "completed"
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path):
RNS.log(f"Document not found for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id[:128]}", RNS.LOG_WARNING)
return None
doc = self.owner._work_load_document(root_path)
if not doc:
RNS.log(f"Could not load document for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id[:128]}", RNS.LOG_WARNING)
return None
meta = doc.get("meta", {})
fmt = meta.get("format", "markdown")
title = meta.get('title', 'Untitled')[:256]
content = doc.get("content", "").strip()
if content:
if fmt == "micron": file_name = f"{title}.mu"
else: file_name = f"{title}.md"
return [file_name, content.encode("utf-8")]
return None
#######################
# Git Data Extraction #
#######################
@@ -1658,8 +1756,7 @@ class NomadNetworkNode():
if result.returncode == 0:
hash_val = result.stdout.strip()
# Validate it's a 40-char hex string
if len(hash_val) == 40 and all(c in "0123456789abcdef" for c in hash_val.lower()): return hash_val.lower()
return san_sha(hash_val.lower())
except subprocess.TimeoutExpired: RNS.log(f"Timeout resolving ref '{ref}'", RNS.LOG_WARNING)
except Exception as e: RNS.log(f"Error resolving ref: {e}", RNS.LOG_WARNING)
@@ -2085,8 +2182,12 @@ class NomadNetworkNode():
years = int(diff / 31536000)
return f"{years} year{'s' if years != 1 else ''} ago"
def format_diff(self, diff_text: str) -> str:
lines = diff_text.split("\n")
def format_tabs(self, text):
if text == None: return None
else: return text.replace("\t", self.TAB_WIDTH)
def format_diff(self, diff_text):
lines = diff_text.replace("\\", "\\\\").split("\n")
formatted_lines = []
for line in lines:
@@ -2131,7 +2232,7 @@ class NomadNetworkNode():
with open(thanks_path, "wb") as fh: fh.write(mp.packb({"count": thanks_count}))
return thanks_count
except Exception as e: RNS.log(f"Error while processing repository thanks for {group_name}/{repo_name}: {e}", RNS.LOG_ERROR)
except Exception as e: RNS.log(f"Error while processing repository thanks for {repo_path}: {e}", RNS.LOG_ERROR)
return 0
def release_thanks(self, release_path, add=False, link_id=None):
@@ -2156,7 +2257,7 @@ class NomadNetworkNode():
with open(thanks_path, "wb") as fh: fh.write(mp.packb({"count": thanks_count}))
return thanks_count
except Exception as e: RNS.log(f"Error while processing release thanks for {group_name}/{repo_name}: {e}", RNS.LOG_ERROR)
except Exception as e: RNS.log(f"Error while processing release thanks for {release_path}: {e}", RNS.LOG_ERROR)
return 0
###################
+194 -96
View File
@@ -44,6 +44,7 @@ from tempfile import NamedTemporaryFile
from RNS._version import __version__
from RNS.Utilities.rngit import APP_NAME
from RNS.Utilities.rngit.pages import NomadNetworkNode
from RNS.Utilities.rngit.util import san_ref, san_refs, san_sha
from RNS.vendor.configobj import ConfigObj
from RNS.vendor import umsgpack as mp
@@ -94,7 +95,7 @@ def program_setup(configdir, rnsconfigdir=None, verbosity=0, quietness=0, servic
if operation == "list": git_client.work_list(remote=task["remote"], scope=scope)
elif operation == "view": git_client.work_view(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "create": git_client.work_create(remote=task["remote"], title=title)
elif operation == "edit": git_client.work_edit(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "edit": git_client.work_edit(remote=task["remote"], title=title, doc_id=doc_id, scope=scope)
elif operation == "delete": git_client.work_delete(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "update": git_client.work_comment(remote=task["remote"], doc_id=doc_id, scope=scope)
elif operation == "complete": git_client.work_complete(remote=task["remote"], doc_id=doc_id)
@@ -316,8 +317,7 @@ class ReticulumGitClient():
self.response_speed = (bd/td)*8 if td > 0 else 0
self.previous_progress = self.response_progress
self.progress_updated_at = now
# Report progress to git via stderr
if self.progress_enabled and self.response_size:
percent = round(self.response_progress * 100, 1)
size = self.response_size
@@ -355,8 +355,7 @@ class ReticulumGitClient():
subprocess.run(["which", fallback], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
editor = fallback
break
except subprocess.CalledProcessError:
continue
except subprocess.CalledProcessError: continue
if not editor:
print("No editor found. Please set $EDITOR environment variable.")
@@ -727,16 +726,32 @@ class ReticulumGitClient():
if len(response) <= 1: self.abort("Empty response from remote")
doc = mp.unpackb(response[1:])
author_str = f"{doc['meta']['author']} (not locally validated)"
signature_str = "Document not signed"
signature = doc["meta"].get("signature", None)
pubkey = doc["meta"].get("identity", None)
content = doc.get("content", "")
if signature and type(signature) == bytes and len(signature) == RNS.Identity.SIGLENGTH//8:
if pubkey and type(pubkey) == bytes and len(pubkey) == RNS.Identity.KEYSIZE//8:
signature_str = "Not valid"
identity = RNS.Identity(create_keys=False)
identity.load_public_key(pubkey)
signature_validated = identity.validate(signature, content.encode("utf-8"))
if signature_validated:
signature_str = "Valid"
author_str = RNS.prettyhexrep(identity.hash)
dt = f"{doc['meta']['title']} (#{doc['id']})"
print(f"{dt}")
print("="*len(dt))
print(f"Author : {doc['meta']['author']}")
print(f"Status : {scope}")
print(f"Created : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['created']))}")
print(f"Edited : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['edited']))}")
print(f"Format : {doc['meta']['format']}")
print(f"Updates : {len(doc.get('comments', []))}")
print(f"Author : {author_str}")
print(f"Signature : {signature_str}")
print(f"Status : {scope.capitalize()}")
print(f"Created : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['created']))}")
print(f"Edited : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['edited']))}")
print(f"Format : {doc['meta']['format']}")
print(f"Updates : {len(doc.get('comments', []))}")
print()
print(doc['content'])
@@ -775,9 +790,13 @@ class ReticulumGitClient():
content = self._edit_work_content(title=title)
if content is None: print("Creation cancelled"); return
signature = self.identity.sign(content.encode("utf-8"))
if not signature: self.abort("Could not sign work document")
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "create", "title": title, "content": content, "format": "markdown" }
request_data = { self.IDX_REPOSITORY: repo_path, "operation": "create",
"title": title, "content": content, "format": "markdown",
"signature": signature }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
@@ -797,7 +816,7 @@ class ReticulumGitClient():
finally:
if self.link: self.link.teardown()
def work_edit(self, remote=None, doc_id=None, scope="active"):
def work_edit(self, remote=None, doc_id=None, title=None, scope="active"):
if not remote: print(f"No remote specified"); exit(1)
if doc_id is None: print(f"No document ID specified"); exit(1)
self.connect_remote(remote)
@@ -830,9 +849,12 @@ class ReticulumGitClient():
content = self._edit_work_content(title=current_title, content=current_content)
if content is None: print("Edit cancelled"); return
title = current_title
request_data = { self.IDX_REPOSITORY: repo_path,
"operation": "edit", "doc_id": doc_id, "scope": scope, "content": content }
signature = self.identity.sign(content.encode("utf-8"))
if not signature: self.abort("Could not sign work document")
title = title or current_title
request_data = { self.IDX_REPOSITORY: repo_path, "operation": "edit", "doc_id": doc_id,
"scope": scope, "content": content, "title": title, "signature": signature }
response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30)
if not response or not isinstance(response, bytes): self.abort("No response from remote")
@@ -1186,7 +1208,7 @@ class ReticulumGitNode():
IDX_REPOSITORY = 0x00
IDX_RESULT_CODE = 0x01
WORK_DOC_LIMIT = 256*1024*1024
WORK_DOC_LIMIT = 256*1024
def __init__(self, configdir=None, verbosity=None, print_identity=False):
self.identity = None
@@ -1295,7 +1317,9 @@ class ReticulumGitNode():
def __persist_stats(self):
with self.stats_lock:
try:
with open(self.statspath, "wb") as fh: fh.write(mp.packb(self.stats))
tmp_path = self.statspath+".tmp"
with open(tmp_path, "wb") as fh: fh.write(mp.packb(self.stats))
os.rename(tmp_path, self.statspath)
except Exception as e: RNS.log(f"Could not write stats file to {self.statspath}: {e}", RNS.LOG_ERROR)
def __apply_config(self):
@@ -1717,10 +1741,12 @@ class ReticulumGitNode():
# Check for_push permission if requested
for_push = data.get("for_push", False)
if for_push: access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
else: access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ)
write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE)
if for_push: access = write_access
else: access = read_access
if not access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
if not access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not allowed" if read_access else self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
@@ -1728,7 +1754,7 @@ class ReticulumGitNode():
RNS.log(f"Listing refs for {group_name}/{repository_name}", RNS.LOG_DEBUG)
# Get HEAD symref
head_path = os.path.join(repository_path, "HEAD")
head_path = os.path.join(repository_path, "HEAD")
head_ref = "master" # Use "master" as default
if os.path.exists(head_path):
with open(head_path, "rb") as fh:
@@ -1738,7 +1764,9 @@ class ReticulumGitNode():
execv = ["git", "for-each-ref", "--format", "%(objectname) %(refname)"]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False, text=True)
if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr.encode("utf-8")
if result.returncode != 0:
RNS.log(f"Error while listing refs for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not list refs"
# Build response in format: refs + @<ref> HEAD
response_lines = result.stdout.strip()
@@ -1765,7 +1793,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error while handling list request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def handle_fetch(self, path, data, request_id, link_id, remote_identity, requested_at):
RNS.log(f"Fetch request from remote {remote_identity}", RNS.LOG_DEBUG)
@@ -1788,7 +1816,8 @@ class ReticulumGitNode():
if not refs: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No refs specified"
try:
ref_names = [r["ref"] for r in refs]
ref_names = san_refs([r["ref"] for r in refs])
if not ref_names: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
RNS.log(f"Fetching refs {ref_names} for {group_name}/{repository_name}", RNS.LOG_DEBUG)
if not hasattr(link, "temporary_directories"): link.temporary_directories = []
@@ -1801,19 +1830,23 @@ class ReticulumGitNode():
execv = ["git", "bundle", "create", "--no-progress", bundle_path]
for r in refs:
execv.append(r["ref"])
# Per-ref have: The client already has this ancestor,
# so the server can exclude objects reachable from it.
if "have" in r and r["have"]:
have_sha = r["have"]
cat_result = subprocess.run(["git", "cat-file", "-t", have_sha], cwd=repository_path, capture_output=True, check=False)
if cat_result.returncode == 0: execv.append(f"^{have_sha}")
else: RNS.log(f"Client have-sha {have_sha} not found in repository, skipping", RNS.LOG_WARNING)
if not san_ref(r["ref"]): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
else:
execv.append(r["ref"])
# Per-ref have: The client already has this ancestor,
# so the server can exclude objects reachable from it.
if "have" in r and r["have"]:
have_sha = san_sha(r["have"])
if not have_sha: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid SHA"
cat_result = subprocess.run(["git", "cat-file", "-t", have_sha], cwd=repository_path, capture_output=True, check=False)
if cat_result.returncode == 0: execv.append(f"^{have_sha}")
else: RNS.log(f"Client have-sha {have_sha} not found in repository, skipping", RNS.LOG_WARNING)
# Global have list: SHAs of objects the client already has.
# Exclude objects reachable from these to produce thin bundles.
have_shas = data.get("have", [])
for sha in have_shas:
if not san_sha(sha): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid SHA"
cat_result = subprocess.run(["git", "cat-file", "-t", sha], cwd=repository_path, capture_output=True, check=False)
if cat_result.returncode == 0: execv.append(f"^{sha}")
else: RNS.log(f"Client have-sha {sha} not found in repository, skipping", RNS.LOG_WARNING)
@@ -1826,13 +1859,15 @@ class ReticulumGitNode():
RNS.log(f"Empty bundle for {ref_names}, all objects already on client", RNS.LOG_DEBUG)
return b"\x00"
else: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr.encode("utf-8")
else:
RNS.log(f"Error while fetching refs {ref_names} for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not fetch refs"
return open(bundle_path, "rb"), {self.IDX_RESULT_CODE: self.RES_OK}
except Exception as e:
RNS.log(f"Error while handling fetch request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def handle_push(self, path, data, request_id, remote_identity, requested_at):
RNS.log(f"Push request from remote {remote_identity}", RNS.LOG_DEBUG)
@@ -1847,8 +1882,8 @@ class ReticulumGitNode():
if not write_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
local_ref = data.get("local_ref", "")
remote_ref = data.get("remote_ref", "")
local_ref = san_ref(data.get("local_ref", ""))
remote_ref = san_ref(data.get("remote_ref", ""))
force = data.get("force", False)
bundle_data = data.get("bundle", None)
operations = data.get("operations", None)
@@ -1867,20 +1902,24 @@ class ReticulumGitNode():
execv = ["git", "bundle", "verify", bundle_path]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr
if result.returncode != 0:
RNS.log(f"Bundle verification failed for push {local_ref}:{remote_ref} to {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not verify bundle"
execv = ["git", "fetch", bundle_path, f"{local_ref}:{remote_ref}"]
if force: execv.append("--force")
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr
if result.returncode != 0:
RNS.log(f"Bundle verification failed for push {local_ref}:{remote_ref} to {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not verify bundle"
return b"\x00"
except Exception as e:
RNS.log(f"Error while handling push request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
elif operations:
if not type(operations) == list: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid data for operations"
@@ -1888,13 +1927,14 @@ class ReticulumGitNode():
try:
for op in operations:
action = op.get("action", "")
ref = op.get("ref", "")
sha = op.get("sha", "")
ref = san_ref(op.get("ref", ""))
sha = san_sha(op.get("sha", ""))
op_force = op.get("force", False)
if action != "update_ref": return self.RES_INVALID_REQ.to_bytes(1, "big") + f"Unknown operation: {action}".encode("utf-8")
if not ref.startswith("refs/"): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid ref"
if not sha or len(sha) < 40: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid SHA"
if not ref: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not ref.startswith("refs/"): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not sha: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid SHA"
# Verify the target object exists in the repository
cat_result = subprocess.run(["git", "cat-file", "-t", sha], cwd=repository_path, capture_output=True, check=False)
@@ -1912,13 +1952,15 @@ class ReticulumGitNode():
execv = ["git", "update-ref", ref, sha]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr
if result.returncode != 0:
RNS.log(f"Error while updating ref {ref} to {sha} for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not update refs"
return b"\x00"
except Exception as e:
RNS.log(f"Error while handling push operations for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request data"
@@ -1935,20 +1977,24 @@ class ReticulumGitNode():
if not write_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
else:
repository_path = self.groups[group_name]["repositories"][repository_name]["path"]
ref_to_delete = data.get("ref", "")
ref_to_delete = san_ref(data.get("ref", ""))
if not ref_to_delete.startswith("refs/"): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid ref"
if not ref_to_delete: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if not ref_to_delete.startswith("refs/"): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
try:
RNS.log(f"Deleting ref {ref_to_delete} in {group_name}/{repository_name}", RNS.LOG_DEBUG)
execv = ["git", "update-ref", "-d", ref_to_delete]
result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False)
if result.returncode != 0:
RNS.log(f"Error while deleting ref {ref_to_delete} for {group_name}/{repository_name}: {result.stderr}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Could not delete ref"
if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr
else: return b"\x00"
else: return b"\x00"
except Exception as e:
RNS.log(f"Error while handling delete request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def handle_release(self, path, data, request_id, remote_identity, requested_at):
RNS.log(f"Release request from remote {remote_identity}", RNS.LOG_DEBUG)
@@ -1984,7 +2030,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error while handling release request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def releases_list_data(self, releases_path):
try:
@@ -2114,8 +2160,9 @@ class ReticulumGitNode():
return None
def _release_view(self, releases_path, data):
tag = data.get("tag")
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
tag = data.get("tag", "")
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
release_dir = os.path.join(releases_path, tag)
@@ -2137,12 +2184,13 @@ class ReticulumGitNode():
else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
def _release_create_init(self, releases_path, repository_path, data, remote_identity):
tag = data.get("tag")
tag = data.get("tag", "")
commit_hash = data.get("hash")
notes = data.get("notes", "")
notes_format = data.get("notes_format", "markdown") # "markdown" or "micron"
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
if not tag or tag in [".", ".."]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag name"
@@ -2183,14 +2231,15 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error creating release: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _release_create_artifact(self, releases_path, data):
tag = data.get("tag")
tag = data.get("tag", "")
artifact_name = data.get("artifact_name")
artifact_data = data.get("artifact_data")
if not tag or not artifact_name: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Missing tag or artifact name"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
if artifact_data is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No artifact data"
tag = os.path.basename(tag)
@@ -2219,12 +2268,12 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error adding artifact: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _release_create_finalize(self, releases_path, data):
tag = data.get("tag")
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
tag = data.get("tag", "")
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
@@ -2247,12 +2296,13 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error finalizing release: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _release_delete(self, releases_path, data):
tag = data.get("tag")
tag = data.get("tag", "")
if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified"
if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified"
tag = os.path.basename(tag)
release_dir = os.path.join(releases_path, tag)
@@ -2266,7 +2316,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error deleting release: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
#########################
# Work Document Methods #
@@ -2324,7 +2374,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error while handling work request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_get_next_id(self, base_path):
if not os.path.isdir(base_path): return 1
@@ -2387,13 +2437,22 @@ class ReticulumGitNode():
def _work_view(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
scope = data.get("scope", "all")
if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
@@ -2421,13 +2480,16 @@ class ReticulumGitNode():
comments.sort(key=lambda x: x["id"])
meta = doc.get("meta", {})
result = { "id": doc_id, "scope": scope,
"content": doc.get("content", ""), "comments": comments,
"meta": { "title": doc.get("meta", {}).get("title", "Untitled"),
"created": doc.get("meta", {}).get("created", 0),
"edited": doc.get("meta", {}).get("edited", 0),
"author": RNS.hexrep(doc.get("meta", {}).get("author", b""), delimit=False) if doc.get("meta", {}).get("author") else "",
"format": doc.get("meta", {}).get("format", "markdown") } }
"meta": { "title": meta.get("title", "Untitled"),
"created": meta.get("created", 0),
"edited": meta.get("edited", 0),
"author": RNS.hexrep(meta.get("author", b""), delimit=False) if meta.get("author") else "",
"identity": meta.get("identity", None),
"signature": meta.get("signature", None),
"format": meta.get("format", "markdown") } }
return b"\x00" + mp.packb(result)
@@ -2436,9 +2498,13 @@ class ReticulumGitNode():
content = data.get("content", "").strip()
format_type = data.get("format", "markdown")
signature = data.get("signature", None)
signed_data = content.encode("utf-8")
sig_length = RNS.Identity.SIGLENGTH//8
limit = self.WORK_DOC_LIMIT
if signature and not len(signature) == RNS.Identity.SIGLENGTH: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided"
if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length"
if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if len(title)+len(content)+len(format_type) > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Title is required"
if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required"
@@ -2452,8 +2518,8 @@ class ReticulumGitNode():
now = time.time()
document = { "content": content,
"meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown",
"title": title, "created": now, "edited": now,
"signature": signature, "author": remote_identity.hash } }
"title": title, "created": now, "edited": now, "author": remote_identity.hash,
"signature": signature, "identity": remote_identity.get_public_key() } }
root_path = os.path.join(doc_dir, "root")
if not self._work_save_document(root_path, document):
@@ -2464,31 +2530,45 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error creating work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_edit(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
content = data.get("content")
title = data.get("title")
signature = data.get("signature", None)
limit = self.WORK_DOC_LIMIT
doc_id = data.get("doc_id")
scope = data.get("scope", "active")
content = data.get("content", "")
title = data.get("title", "")
signature = data.get("signature", None)
signed_data = content.encode("utf-8")
sig_length = RNS.Identity.SIGLENGTH//8
limit = self.WORK_DOC_LIMIT
size = 0
if title: size += len(title)
if content: size += len(content)
if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request"
if signature and not len(signature) == RNS.Identity.SIGLENGTH: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided"
if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length"
if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature"
if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded"
if content is None and title is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No changes specified"
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
if not content and not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No changes specified"
if not doc_id: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
RNS.log(f"PATH: {root_path}")
if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
@@ -2498,10 +2578,11 @@ class ReticulumGitNode():
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
try:
if title is not None: doc["meta"]["title"] = title.strip()
if content is not None: doc["content"] = content.strip()
if title: doc["meta"]["title"] = title.strip()
if content: doc["content"] = content.strip()
doc["meta"]["edited"] = time.time()
doc["meta"]["signature"] = signature
doc["meta"]["identity"] = remote_identity.get_public_key()
if not self._work_save_document(root_path, doc): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document"
@@ -2510,7 +2591,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error editing work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_delete(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
@@ -2521,6 +2602,15 @@ class ReticulumGitNode():
try: doc_id = int(doc_id)
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
scope = None
doc_dir = None
for s in ["active", "completed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
@@ -2539,7 +2629,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error deleting work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_comment(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
@@ -2558,6 +2648,15 @@ class ReticulumGitNode():
except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID"
if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required"
scope = None
doc_dir = None
for s in ["active", "completed"]:
d = os.path.join(work_path, s, str(doc_id))
if os.path.isdir(d):
scope = s
doc_dir = d
break
doc_dir = os.path.join(work_path, scope, str(doc_id))
root_path = os.path.join(doc_dir, "root")
@@ -2581,7 +2680,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error adding comment: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_complete(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
@@ -2610,7 +2709,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error completing work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_activate(self, work_path, data, remote_identity):
doc_id = data.get("doc_id")
@@ -2639,7 +2738,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error activating work document: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_perms(self, work_path, data, remote_identity):
step = data.get("step")
@@ -2698,7 +2797,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error getting document permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + f"Error getting permissions: {e}".encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error getting permissions"
def _work_set_permissions(self, work_path, data, remote_identity, group_name, repository_name):
doc_id = data.get("doc_id")
@@ -2758,7 +2857,7 @@ class ReticulumGitNode():
except Exception as e:
RNS.log(f"Error setting permissions: {e}", RNS.LOG_ERROR)
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + f"Error setting permissions: {e}".encode("utf-8")
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error setting permissions"
def repository_stats(self, remote_identity, group_name, repository_name, lookback_days=14):
if not self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_STATS): return None
@@ -2859,7 +2958,6 @@ class ReticulumGitNode():
if group_name and repository_name: self.record_fetch(group_name, repository_name)
def push_succeeded(self, group_name, repository_name, remote_identity):
if remote_identity and remote_identity.hash in self.stats_ignored: return
if self.stats_enabled:
if group_name and repository_name: self.record_push(group_name, repository_name)
+51 -4
View File
@@ -31,6 +31,51 @@
import re
import RNS
# Validate ref names according to https://git-scm.com/docs/git-check-ref-format
# This may be a bit overkill, since git validates names as well, but why not.
def san_ref(ref):
if ref.startswith("-"): return None
if ref.startswith("/"): return None
if ref.endswith("/"): return None
if ref.endswith("."): return None
if " " in ref: return None
if not "/" in ref: return None
if ".." in ref: return None
if "/." in ref: return None
if "//" in ref: return None
if "\\" in ref: return None
for comp in ref.split("/"):
if comp.endswith(".lock"): return None
if not all(ord(c) >= 40 for c in ref): return None # Any control character
if "\x7f" in ref: return None # ASCII DEL (177)
if "~" in ref: return None
if "^" in ref: return None
if ":" in ref: return None
if "?" in ref: return None
if "*" in ref: return None
if "[" in ref: return None
if "@{" in ref: return None
if "@" == ref: return None
return ref
def san_refs(refs):
if not type(refs) == list: return None
for ref in refs:
if not san_ref(ref): return None
return refs
# Git SHA format validation
def san_sha(sha):
if len(sha) < 40: return None
try: bytes.fromhex(sha)
except: return None
return sha
class MarkdownToMicron:
BOLD = "`!"
BOLD_END = "`!"
@@ -107,6 +152,7 @@ class MarkdownToMicron:
return w if w is not None and w >= 0 else len(text)
def format_block(self, text, url_scope=None):
# text = text.replace("\\", "\\\\") # Now handled in format_line instead
lines = text.split('\n')
result_lines = []
in_code_block = False
@@ -183,10 +229,6 @@ class MarkdownToMicron:
for line in lines:
is_fence, lang_hint = self._detect_code_fence(line)
if line.startswith("-") and not line.startswith("---") and not line.startswith("- "): line = f"\\{line}"
if line.startswith(">"): line = f"\\{line}"
if line.startswith("<"): line = f"\\{line}"
if is_fence:
# Flush any pending structures before code fence
flush_quote_buffer()
@@ -255,6 +297,11 @@ class MarkdownToMicron:
def format_line(self, line, mode="normal"):
if mode == "codeblock": return self._escape_literals(line)
line = line.replace("\\", "\\\\")
if line.startswith("-") and not line.startswith("---") and not line.startswith("- "): line = f"\\{line}"
if line.startswith("<"): line = f"\\{line}"
# if line.startswith(">"): line = f"\\{line}" # Now handled by blockquotes
if self.HORIZONTAL_RULE_RE.match(line): return self._format_horizontal_rule()
+269 -28
View File
@@ -45,10 +45,12 @@ from RNS.Cryptography.Hashes import file_sha256
APP_NAME = "rns"
DEFAULT_ASPECTS = f"{APP_NAME}.id"
NO_MESSAGE = 0x01
PRV_EXT = "rid"
PUB_EXT = "pub"
SIG_EXT = "rsg"
MSG_EXT = "rsm"
ENCRYPT_EXT = "rfe"
CHUNK_BLOCKS = 1024*1024
ENC_CHUNK = CHUNK_BLOCKS*RNS.Identity.AES256_BLOCKSIZE
@@ -69,6 +71,8 @@ R_INVALID_ASPECTS = 9
R_INVALID_SIGNATURE = 10
R_FILE_EXISTS = 11
R_DECRYPT_FAILED = 12
R_INVALID_ARGS = 250
R_SEQUENCE_ERROR = 251
R_READ_ERROR = 252
R_WRITE_ERROR = 253
R_UNKNOWN_ERROR = 254
@@ -78,7 +82,7 @@ reticulum = None
def validate_args(args):
ops = 0;
for o in [args.encrypt, args.decrypt, args.validate, args.sign]:
for o in [args.encrypt, args.decrypt, args.validate, args.sign, args.sign_message]:
if o: ops += 1
if ops > 1: print("This utility currently only supports one of the encrypt, decrypt, sign or verify operations per invocation"); exit(1)
@@ -88,9 +92,9 @@ def validate_args(args):
if g > 1: print("The -i, -g, -m and -M args are mutually exclusive"); exit(1)
g = 0;
for a in [args.base64, args.base32]:
for a in [args.base64, args.base32, args.base256, args.hex]:
if a: g += 1
if g > 1: print("The -b and -B args are mutually exclusive"); exit(1)
if g > 1: print("The -b, -B, --hex and --base256 args are mutually exclusive"); exit(1)
return True
@@ -114,10 +118,11 @@ def main():
# Operations
parser.add_argument("-a", "--announce", metavar="aspects", action="store", nargs="?", const=DEFAULT_ASPECTS, default=None, help="announce a destination based on this Identity")
parser.add_argument("-H", "--hash", metavar="aspects", action="store", default=None, help="show destination hashes for other aspects for this Identity")
parser.add_argument("-d", "--decrypt", metavar="file", action="store", default=None, help="decrypt file")
parser.add_argument("-e", "--encrypt", metavar="file", action="store", default=None, help="encrypt file")
parser.add_argument("-V", "--validate", metavar="path", action="store", default=None, help="validate signature")
parser.add_argument("-s", "--sign", metavar="path", action="store", default=None, help="sign file")
parser.add_argument("-d", "--decrypt", metavar="file", action="store", nargs="*", default=None, help="decrypt file")
parser.add_argument("-e", "--encrypt", metavar="file", action="store", nargs="*", default=None, help="encrypt file")
parser.add_argument("-V", "--validate", metavar="path", action="store", nargs="*", default=None, help="validate signature")
parser.add_argument("-s", "--sign", metavar="path", action="store", nargs="*", default=None, help="sign file")
parser.add_argument("-S", "--sign-message", metavar="path", action="store", nargs="?", const=NO_MESSAGE, default=None, help="create embedded signed message")
parser.add_argument("--raw", action="store_true", default=False, help="sign raw input data instead of hashing first")
# I/O Control
@@ -136,6 +141,8 @@ def main():
# Formatting Control
parser.add_argument("-b", "--base64", action="store_true", default=False, help="Use base64-encoded input and output")
parser.add_argument("-B", "--base32", action="store_true", default=False, help="Use base32-encoded input and output")
parser.add_argument("--hex", action="store_true", default=False, help="Use hex-encoded input and output")
parser.add_argument("--base256", action="store_true", default=False, help="Use base256-encoded input and output")
parser.add_argument("--version", action="version", version="rnid {version}".format(version=__version__))
@@ -154,6 +161,7 @@ def main():
if args.announce: announce(args, identity); op = True
if args.validate: validate(args, identity or args.identity); op = True
if args.sign: sign(args, identity); op = True
if args.sign_message: sign_message(args, identity); op = True
if args.encrypt: encrypt(args, identity); op = True
if args.decrypt: decrypt(args, identity); op = True
if args.write: write_identity(args, identity); op = True
@@ -375,9 +383,9 @@ def announce(args, identity):
except Exception as e: print(f"An error ocurred while attempting to send the announce: {e}"); exit(R_UNKNOWN_ERROR)
###################################
# Signing & Validation Operations #
###################################
########################################
# Canonical RSG Manipulation Functions #
########################################
def get_rsg_data(rsg):
rsg_data = None
@@ -386,11 +394,23 @@ def get_rsg_data(rsg):
elif type(rsg) == str:
try: rsg_data = base64.urlsafe_b64decode(rsg)
except: pass
try: rsg_data = base64.b32decode(rsg)
try: rsg_data = base64.b32decode(rsg.strip(RSG_PADDING))
except: pass
try: rsg_data = bytes.fromhex(rsg.strip(RSG_PADDING))
except: pass
try: rsg_data = RNS.b256_to_bytes(rsg.strip(RSG_PADDING.decode("utf-8")))
except: pass
return rsg_data
def extract_signed_rsg_data(rsg):
siglen = RNS.Identity.SIGLENGTH//8
rsg_data = get_rsg_data(rsg)
envelope = rsg_data[siglen:]
try: return mp.unpackb(envelope)
except: return None
def get_rsg_hash(message):
sha = None
if type(message) == bytes: sha = sha256(message)
@@ -459,34 +479,114 @@ def validate_rsg(rsg, message=None, required_signer=None):
return False, signed_data, signing_identity
def create_rsg(signer_identity, message, note=None, meta=None, output="bin"):
if not output in ["bin", "hex", "base32", "base64"]: raise TypeError(f"Invalid output format for rsg creation")
if not type(signer_identity) == RNS.Identity: raise TypeError(f"{signer_identity} is not a Reticulum Identity")
if not signer_identity.get_private_key(): raise ValueError(f"{signer_identity} does not hold a private key")
def create_rsg(signer_identity, message, embed=False, note=None, meta=None, output="bin"):
if not output in ["bin", "hex", "base32", "base256", "base64"]: raise TypeError(f"Invalid output format for rsg creation")
if not type(signer_identity) == RNS.Identity: raise TypeError(f"{signer_identity} is not a Reticulum Identity")
if not signer_identity.get_private_key(): raise ValueError(f"{signer_identity} does not hold a private key")
signed_data = { "hashtype": "sha256", "hash": get_rsg_hash(message),
"meta": { "signer": signer_identity.hash,
"pubkey": signer_identity.get_public_key(),
"note" : note } }
if embed:
if type(message) == str: message = message.encode("utf-8")
signed_data["message"] = message
if meta and type(meta) == dict:
for key in meta:
if not key in signed_data["meta"]: signed_data["meta"]["key"] = meta["key"]
envelope = mp.packb(signed_data)
signature = signer_identity.sign(envelope)
rsg_data = signature+envelope
return signature+envelope
if output == "bin": rsg = rsg_data
elif output == "hex": rsg = RNS.hexrep(rsg_data, delimit=False).encode("ascii")
elif output == "base32": rsg = base64.b32encode(rsg_data)
elif output == "base64": rsg = base64.urlsafe_b64encode(rsg_data)
elif output == "base256": rsg = RNS.b256rep(rsg_data)
else: return None
def validate(args, identity):
return rsg
RSG_ASCII_HEADER = b"#### Start of rsg data "
RSG_ASCII_FOOTER = b" End of rsg data ####"
RSG_ASCII_ROW_WIDTH = 64
RSG_PADDING = b"="
def wrap_rsg(rsg):
if type(rsg) == str: return wrap_rsg_str(rsg)
def pad(chunk): return chunk+(RSG_ASCII_ROW_WIDTH-len(chunk))*RSG_PADDING
header = RSG_ASCII_HEADER+b"#"*(RSG_ASCII_ROW_WIDTH-len(RSG_ASCII_HEADER))
footer = b"#"*(RSG_ASCII_ROW_WIDTH-len(RSG_ASCII_FOOTER))+RSG_ASCII_FOOTER
wrapped = header+b"\n"
read = 0
while len(rsg):
chunk = rsg[:RSG_ASCII_ROW_WIDTH]
if len(chunk) < RSG_ASCII_ROW_WIDTH: chunk = pad(chunk)
wrapped += chunk+b"\n"; read += len(chunk)
rsg = rsg[len(chunk):]
wrapped += footer
return wrapped.decode("ascii")
def wrap_rsg_str(rsg):
def pad(chunk): return chunk+(RSG_ASCII_ROW_WIDTH-len(chunk))*RSG_PADDING.decode("utf-8")
header = RSG_ASCII_HEADER.decode("utf-8")+"#"*(RSG_ASCII_ROW_WIDTH-len(RSG_ASCII_HEADER.decode("utf-8")))
footer = "#"*(RSG_ASCII_ROW_WIDTH-len(RSG_ASCII_FOOTER.decode("utf-8")))+RSG_ASCII_FOOTER.decode("utf-8")
wrapped = header+"\n"
read = 0
while len(rsg):
chunk = rsg[:RSG_ASCII_ROW_WIDTH]
if len(chunk) < RSG_ASCII_ROW_WIDTH: chunk = pad(chunk)
wrapped += chunk+"\n"; read += len(chunk)
rsg = rsg[len(chunk):]
wrapped += footer
return wrapped
def unwrap_rsg(wrapped_rsg):
unwrapped = ""
if type(wrapped_rsg) == bytes: wrapped_rsg = wrapped_rsg.decode("ascii")
elif type(wrapped_rsg) == str: pass
else: return None
for line in wrapped_rsg.splitlines():
if not line.strip(): continue
if line.startswith("#"): continue
unwrapped += line
return unwrapped if unwrapped else None
###################################
# Signing & Validation Operations #
###################################
def validate(args, identity, __recursive=False):
if type(args.validate) == list:
paths = args.validate.copy()
validated = 0
for path in paths:
args.validate = path
code = validate(args, identity, __recursive=True)
if code != 0: print(f"Sequence error on recursive signature validation"); exit(R_SEQUENCE_ERROR)
else: validated += 1
if len(paths) != validated: print(f"Sequence error on recursive signature validation"); exit(R_SEQUENCE_ERROR)
else: exit(R_OK)
msg_ext = f".{MSG_EXT}"
sig_ext = f".{SIG_EXT}"
validate_path = os.path.expanduser(args.validate)
path_is_msgfile = validate_path.lower().endswith(msg_ext)
path_is_sigfile = validate_path.lower().endswith(sig_ext)
if path_is_sigfile: signature_path = validate_path; file_path = validate_path[:-len(sig_ext)]
else: signature_path = f"{validate_path}{sig_ext}"; file_path = validate_path
signature_exists = os.path.isfile(signature_path)
file_exists = os.path.isfile(file_path)
if path_is_msgfile: return validate_message(args, identity, __recursive=__recursive)
if not file_exists: print(f"The validation target \"{file_path}\" does not exist"); exit(R_NO_FILE)
if not signature_exists: print(f"No signature file exists for \"{file_path}\""); exit(R_NO_FILE)
@@ -511,7 +611,7 @@ def validate(args, identity):
identity_str = RNS.prettyhexrep(identity) if type(identity) == bytes else f"{identity}"
signer_description = f"\nThis file was NOT signed by {identity_str or signing_identity}" if identity else ""
if not valid: print(f"Invalid signature {signature_path} for file {file_path}{signer_description}"); exit(R_INVALID_SIGNATURE)
else: print(f"Signature is valid, the file {file_path} was signed by {signing_identity}"); exit(R_OK)
else: print(f"Signature is valid, the file {file_path} was signed by {signing_identity}"); return exit(R_OK) if not __recursive else R_OK
except Exception as e: print(f"Error while validating {signature_path}: {e}"); exit(R_UNKNOWN_ERROR)
@@ -530,16 +630,67 @@ def validate(args, identity):
except Exception as e: print(f"Could not validate signature: {e}"); exit(R_READ_ERROR)
def sign(args, identity):
def validate_message(args, identity, __recursive=False):
msg_ext = f".{MSG_EXT}"
validate_path = os.path.expanduser(args.validate)
path_is_msgfile = validate_path.lower().endswith(msg_ext)
if path_is_msgfile: signature_path = validate_path
signature_exists = os.path.isfile(signature_path)
if not signature_exists: print(f"The signature file \"{signature_path}\" does not exist"); exit(R_NO_FILE)
try:
with open(signature_path, "rb") as fh: rsg = fh.read()
except Exception as e: print(f"Could not read rsg: {e}"); exit(R_READ_ERROR)
if type(identity) == str:
if not len(identity) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: print("Invalid identity hash length"); exit(R_INVALID_IDENTITY)
try: identity = bytes.fromhex(identity)
except Exception as e: print(f"Invalid identity hash: {e}"); exit(R_INVALID_IDENTITY)
try:
rsm_contents = extract_signed_rsg_data(rsg)
if not "message" in rsm_contents: print(f"No embedded message in {signature_path}"); exit(R_INVALID_SIGNATURE)
valid, signed_data, signing_identity = validate_rsg(rsg, message=rsm_contents["message"], required_signer=identity)
identity_str = RNS.prettyhexrep(identity) if type(identity) == bytes else f"{identity}"
signer_description = f"\nThe message was NOT signed by {identity_str or signing_identity}" if identity else ""
if not valid: print(f"Invalid signature in {signature_path}{signer_description}"); exit(R_INVALID_SIGNATURE)
else:
print(f"\nSignature is valid, the following message was signed by {signing_identity}:\n")
print(signed_data["message"].decode("utf-8"))
return exit(R_OK) if not __recursive else R_OK
except Exception as e: print(f"Error while validating {signature_path}: {e}"); exit(R_UNKNOWN_ERROR)
def sign(args, identity, __recursive=False):
if type(args.sign) == list:
paths = args.sign.copy()
signed = 0
for path in paths:
args.sign = path
code = sign(args, identity, __recursive=True)
if code != 0: print(f"Sequence error on recursive signature creation"); exit(R_SEQUENCE_ERROR)
else: signed += 1
if len(paths) != signed: print(f"Sequence error on recursive signature creation"); exit(R_SEQUENCE_ERROR)
else: exit(R_OK)
sig_ext = f".{SIG_EXT}"
sign_path = os.path.expanduser(args.sign)
rsg_path = f"{sign_path}{sig_ext}"
file_exists = os.path.isfile(sign_path)
signature_exists = os.path.isfile(rsg_path)
if args.base32: output = "base32"
elif args.base64: output = "base64"
elif args.base256: output = "base256"
elif args.hex: output = "hex"
else: output = "bin"
if not identity.get_private_key(): print(f"Cannot sign \"{sign_path}\", the identity does not hold a private key"); exit(R_NO_PRVKEY)
if not file_exists: print(f"The file \"{sign_path}\" does not exist"); exit(R_NO_FILE)
if signature_exists and not args.force:
if output == "bin" and signature_exists and not args.force:
print(f"The signature file \"{rsg_path}\" already exists, not overwriting"); exit(R_FILE_EXISTS)
try:
@@ -548,21 +699,72 @@ def sign(args, identity):
with open(rsg_path, "wb") as fh: fh.write(identity.sign(data))
else:
with open(sign_path, "rb") as in_file:
rsg = create_rsg(identity, in_file)
if not rsg: print(f"No signature created, not writing"); exit(R_UNKNOWN_ERROR)
with open(sign_path, "rb") as in_file: rsg = create_rsg(identity, in_file, output=output)
if not rsg: print(f"No signature created, not writing"); exit(R_UNKNOWN_ERROR)
if output == "bin":
with open(rsg_path, "wb") as out_file: out_file.write(rsg)
print(f"Signed file {sign_path} with {identity}"); exit(R_OK)
elif output in ["base32", "base64", "base256", "hex"]: print(f"\n{wrap_rsg(rsg)}\n")
else: print("No valid output format specified"); exit(R_INVALID_ARGS)
print(f"Signed file {sign_path} with {identity}"); return exit(R_OK) if not __recursive else R_OK
except Exception as e: print(f"Could not sign {sign_path}: {e}"); exit(R_UNKNOWN_ERROR)
def sign_message(args, identity):
message = args.sign_message
if args.base32: output = "base32"
elif args.base64: output = "base64"
elif args.base256: output = "base256"
elif args.hex: output = "hex"
else: output = "bin"
if output == "bin" and not args.write: print("No write path specified"); exit(R_INVALID_ARGS)
if not identity.get_private_key(): print(f"Cannot sign \"{sign_path}\", the identity does not hold a private key"); exit(R_NO_PRVKEY)
if message == NO_MESSAGE: message = get_editor_content()
if not message: print("No message specified"); exit(R_INVALID_ARGS)
try:
rsg = create_rsg(identity, message, embed=True, output=output)
if not rsg: print(f"No signature created, not writing"); exit(R_UNKNOWN_ERROR)
if output == "bin":
sig_ext = f".{MSG_EXT}"
rsg_path = os.path.expanduser(args.write)
rsg_path = f"{rsg_path}{sig_ext}" if not rsg_path.endswith(sig_ext) else rsg_path
signature_exists = os.path.isfile(rsg_path)
if signature_exists and not args.force: print(f"The signature file \"{rsg_path}\" already exists, not overwriting"); exit(R_FILE_EXISTS)
with open(rsg_path, "wb") as out_file: out_file.write(rsg)
print(f"Message signed with {identity} saved to {rsg_path}"); exit(R_OK)
elif output in ["base32", "base64", "base256", "hex"]: print(f"\n{wrap_rsg(rsg)}\n")
else: print("No valid output format specified"); exit(R_INVALID_ARGS)
print(f"Message signed with {identity}"); exit(R_OK)
except Exception as e: print(f"Could not sign message: {e}"); exit(R_UNKNOWN_ERROR)
######################################
# Encryption & Decryption Operations #
######################################
def encrypt(args, identity):
def encrypt(args, identity, __recursive=False):
if type(args.encrypt) == list:
paths = args.encrypt.copy()
encrypted = 0
for path in paths:
args.encrypt = path
code = encrypt(args, identity, __recursive=True)
if code != 0: print(f"Sequence error on recursive file encryption"); exit(R_SEQUENCE_ERROR)
else: encrypted += 1
if len(paths) != encrypted: print(f"Sequence error on recursive file encryption"); exit(R_SEQUENCE_ERROR)
else: exit(R_OK)
enc_ext = f".{ENCRYPT_EXT}"
encrypt_path = os.path.expanduser(args.encrypt)
rfe_path = args.write if args.write else f"{encrypt_path}{enc_ext}"
@@ -591,9 +793,21 @@ def encrypt(args, identity):
except Exception as e: print(f"\nError writing encrypted output to {rfe_path}: {e}"); exit(R_WRITE_ERROR)
except Exception as e: print(f"\nError reading {encrypt_path} for encryption: {e}"); exit(R_WRITE_ERROR)
print(f"\nFile {encrypt_path} encrypted for {identity} to {rfe_path}"); exit(R_OK)
print(f"\nFile {encrypt_path} encrypted for {identity} to {rfe_path}"); return exit(R_OK) if not __recursive else R_OK
def decrypt(args, identity, __recursive=False):
if type(args.decrypt) == list:
paths = args.decrypt.copy()
decrypted = 0
for path in paths:
args.decrypt = path
code = decrypt(args, identity, __recursive=True)
if code != 0: print(f"Sequence error on recursive file decryption"); exit(R_SEQUENCE_ERROR)
else: decrypted += 1
if len(paths) != decrypted: print(f"Sequence error on recursive file decryption"); exit(R_SEQUENCE_ERROR)
else: exit(R_OK)
def decrypt(args, identity):
enc_ext = f".{ENCRYPT_EXT}"
rfe_path = os.path.expanduser(args.decrypt)
if not rfe_path.endswith(enc_ext): print(f"The file {rfe_path} does not appear to be a Reticulum encrypted file"); exit(R_INVALID_FILE)
@@ -630,7 +844,7 @@ def decrypt(args, identity):
except Exception as e: print(f"\nError writing decrypted output to {decrypt_path}: {e}"); exit(R_WRITE_ERROR)
except Exception as e: print(f"\nError reading {rfe_path} for decryption: {e}"); exit(R_WRITE_ERROR)
print(f"\nFile {rfe_path} decrypted to {decrypt_path}"); exit(R_OK)
print(f"\nFile {rfe_path} decrypted to {decrypt_path}"); return exit(R_OK) if not __recursive else R_OK
################
@@ -725,6 +939,33 @@ def export_prv_identity(args, identity):
# Helper & Utility Functions #
##############################
def get_editor_content():
import subprocess
from tempfile import NamedTemporaryFile
template = ""
editor = os.environ.get("EDITOR", "")
if not editor:
for fallback in ["nano", "vim", "vi"]:
try:
subprocess.run(["which", fallback], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
editor = fallback
break
except subprocess.CalledProcessError: continue
if not editor: print("Could not launch editor"); exit(R_READ_ERROR);
try:
with NamedTemporaryFile(mode="w+", suffix=".tmp", delete=False) as tmp:
tmp_path = tmp.name
tmp.write(template)
result = subprocess.run([editor, tmp_path])
if result.returncode != 0: print(f"Editor exited with error code {result.returncode}"); os.unlink(tmp_path); exit(R_READ_ERROR)
with open(tmp_path, "r") as f: content = f.read()
os.unlink(tmp_path)
return content.encode("utf-8")
except Exception as e: print(f"Could not get content from editor: {e}"); exit(R_READ_ERROR)
def spin(until=None, msg=None, timeout=None):
i = 0
syms = "⢄⢂⢁⡁⡈⡐⡠"
+2 -1
View File
@@ -59,7 +59,8 @@ def connect_remote(destination_hash, auth_identity, timeout, no_output = False,
remote_identity = RNS.Identity.recall(destination_hash)
def remote_link_closed(link):
if link.teardown_reason == RNS.Link.TIMEOUT:
if link.teardown_reason == RNS.Link.INITIATOR_CLOSED: return
elif link.teardown_reason == RNS.Link.TIMEOUT:
if not no_output:
print(output_rst_str, end="")
print("The link timed out, exiting now")
+122 -53
View File
@@ -60,8 +60,11 @@ def size_str(num, suffix='B'):
request_result = None
request_concluded = False
first_remote_req = True
remote_destination = None
remote_link = None
def get_remote_status(destination_hash, include_lstats, identity, no_output=False, timeout=RNS.Transport.PATH_REQUEST_TIMEOUT):
global request_result, request_concluded
global request_result, request_concluded, first_remote_req, remote_destination, remote_link
link_count = None
if not RNS.Transport.has_path(destination_hash):
@@ -81,7 +84,8 @@ def get_remote_status(destination_hash, include_lstats, identity, no_output=Fals
remote_identity = RNS.Identity.recall(destination_hash)
def remote_link_closed(link):
if link.teardown_reason == RNS.Link.TIMEOUT:
if link.teardown_reason == RNS.Link.INITIATOR_CLOSED: return
elif link.teardown_reason == RNS.Link.TIMEOUT:
if not no_output:
print("\r \r", end="")
print("The link timed out, exiting now")
@@ -107,44 +111,50 @@ def get_remote_status(destination_hash, include_lstats, identity, no_output=Fals
response = request_receipt.response
if isinstance(response, list):
status = response[0]
if len(response) > 1:
link_count = response[1]
else:
link_count = None
if len(response) > 1: link_count = response[1]
else: link_count = None
request_result = (status, link_count)
request_concluded = True
def remote_link_established(link):
if not no_output:
global first_remote_req
if not no_output and first_remote_req:
print("\r \r", end="")
print("Sending request...", end=" ")
sys.stdout.flush()
link.identify(identity)
link.request("/status", data = [include_lstats], response_callback = got_response, failed_callback = request_failed)
first_remote_req = False
if not no_output:
if not remote_link and not no_output:
print("\r \r", end="")
print("Establishing link with remote transport instance...", end=" ")
sys.stdout.flush()
remote_destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "remote", "management")
link = RNS.Link(remote_destination)
link.set_link_established_callback(remote_link_established)
link.set_link_closed_callback(remote_link_closed)
if not remote_destination:
remote_destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "remote", "management")
if remote_link and remote_link.status == RNS.Link.ACTIVE:
request_concluded = False
remote_link.request("/status", data = [include_lstats], response_callback = got_response, failed_callback = request_failed)
while not request_concluded:
time.sleep(0.1)
else:
remote_link = RNS.Link(remote_destination)
remote_link.set_link_established_callback(remote_link_established)
remote_link.set_link_closed_callback(remote_link_closed)
while not request_concluded: time.sleep(0.1)
if request_result != None:
print("\r \r", end="")
return request_result
def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=False, astats=False, lstats=False, sorting=None, sort_reverse=False,
remote=None, management_identity=None, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT, must_exit=True, rns_instance=None,
traffic_totals=False, discovered_interfaces=False, config_entries=False):
def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=False, astats=False, pstats=False, lstats=False, sorting=None,
sort_reverse=False, remote=None, management_identity=None, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT, must_exit=True,
rns_instance=None, traffic_totals=False, discovered_interfaces=False, config_entries=False, burst_filter=False):
if remote: require_shared = False
else: require_shared = True
@@ -300,28 +310,22 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if remote:
try:
if management_identity is None:
raise ValueError("Remote management requires an identity file. Use -i to specify the path to a management identity.")
if management_identity is None: raise ValueError("Remote management requires an identity file. Use -i to specify the path to a management identity.")
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(remote) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
if len(remote) != dest_len: raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
identity_hash = bytes.fromhex(remote)
destination_hash = RNS.Destination.hash_from_name_and_identity("rnstransport.remote.management", identity_hash)
except Exception as e:
raise ValueError("Invalid destination entered. Check your input.")
except Exception as e: raise ValueError("Invalid destination entered. Check your input.")
identity = RNS.Identity.from_file(os.path.expanduser(management_identity))
if identity == None:
raise ValueError("Could not load management identity from "+str(management_identity))
if identity == None: raise ValueError("Could not load management identity from "+str(management_identity))
try:
remote_status = get_remote_status(destination_hash, lstats, identity, no_output=json, timeout=remote_timeout)
if remote_status != None:
stats, link_count = remote_status
except Exception as e:
raise e
if remote_status != None: stats, link_count = remote_status
except Exception as e: raise e
except Exception as e:
print(str(e))
@@ -375,6 +379,10 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
interfaces.sort(key=lambda i: i["incoming_announce_frequency"], reverse=not sort_reverse)
if sorting == "atx":
interfaces.sort(key=lambda i: i["outgoing_announce_frequency"], reverse=not sort_reverse)
if sorting == "prx":
interfaces.sort(key=lambda i: i["incoming_pr_frequency"], reverse=not sort_reverse)
if sorting == "ptx":
interfaces.sort(key=lambda i: i["outgoing_pr_frequency"], reverse=not sort_reverse)
if sorting == "held":
interfaces.sort(key=lambda i: i["held_announces"], reverse=not sort_reverse)
@@ -393,7 +401,18 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
):
if not (name.startswith("I2PInterface[") and ("i2p_connectable" in ifstat and ifstat["i2p_connectable"] == False)):
if name_filter == None or name_filter.lower() in name.lower():
if name_filter == None and burst_filter == None: show_if = True
elif not burst_filter:
if not name_filter or name_filter.lower() in name.lower(): show_if = True
else: show_if = False
elif burst_filter:
burst_act = True if ("burst_active" in ifstat and "pr_burst_active" in ifstat) and (ifstat["burst_active"] or ifstat["pr_burst_active"]) else False
nfilt = name_filter.lower() in name.lower() if name_filter else False
if burst_act or nfilt: show_if = True
else: show_if = False
else: show_if = True
if show_if:
print("")
if ifstat["status"]: ss = "Up"
@@ -542,26 +561,71 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
elif art and arp != None: art_str = f"(t:{RNS.prettytime(art)}/p:{RNS.prettytime(arp)})"
elif art: art_str = f"(t:{RNS.prettytime(art)})"
else: art_str = ""
burst_str = ""
if "burst_active" in ifstat and ifstat["burst_active"]:
for_str = RNS.prettytime(time.time()-ifstat["burst_activated"])
burst_str = f" burst for {for_str}"
pburst_str = ""
if "pr_burst_active" in ifstat and ifstat["pr_burst_active"]:
for_str = RNS.prettytime(time.time()-ifstat["pr_burst_activated"])
pburst_str = f"burst for {for_str}"
rxb_str = ""+RNS.prettysize(ifstat["rxb"])
txb_str = ""+RNS.prettysize(ifstat["txb"])
strdiff = len(rxb_str)-len(txb_str)
if strdiff > 0: txb_str += " "*strdiff
elif strdiff < 0: rxb_str += " "*-strdiff
asr = False
if astats and "incoming_announce_frequency" in ifstat and ifstat["incoming_announce_frequency"] != None:
oaf = RNS.prettyfrequency(ifstat["outgoing_announce_frequency"])+""
iaf = RNS.prettyfrequency(ifstat["incoming_announce_frequency"])+""
if clients != None and clients > 0: pc_str = f"{RNS.prettyfrequency(ifstat["outgoing_announce_frequency"]/clients)}/c"
oan = ifstat["outgoing_announce_frequency"]
ian = ifstat["incoming_announce_frequency"]
if name.startswith("Shared Instance[") and clients and clients > 0: oan = oan-(oan/clients) # Sub rnstatus own part
oaf = RNS.prettyfrequency(oan, d=1, lpf=True)
iaf = RNS.prettyfrequency(ian, d=1, lpf=True)
cspec = "c"
if clients == None and "peers" in ifstat and ifstat["peers"]: clients = ifstat["peers"]; cspec = "p"
if clients != None and clients > 0: pc_str = f"{RNS.prettyfrequency(ifstat['outgoing_announce_frequency']/clients, d=1, lpf=True)}/{cspec}"
else: pc_str = ""
strdiff = len(oaf)-len(iaf)
if strdiff > 0: iaf += " "*strdiff
elif strdiff < 0: oaf += " "*-strdiff
strdiff = len(rxb_str)-len(oaf)
if strdiff > 0: oaf += " "*strdiff
elif strdiff < 0: txb_str += " "*-strdiff; rxb_str += " "*-strdiff
asr = True
psr = False
if pstats and "incoming_pr_frequency" in ifstat and ifstat["incoming_pr_frequency"] != None:
opn = ifstat["outgoing_pr_frequency"]
ipn = ifstat["incoming_pr_frequency"]
if name.startswith("Shared Instance[") and clients and clients > 0: opn = opn-(opn/clients) # Sub rnstatus own part
if astats:
opf = ""+RNS.prettyfrequency(opn, d=1, lpf=True)
ipf = ""+RNS.prettyfrequency(ipn, d=1, lpf=True)
else:
opf = RNS.prettyfrequency(opn,d=1, lpf=True)+""
ipf = RNS.prettyfrequency(ipn,d=1, lpf=True)+""
cspec = "c"
if clients == None and "peers" in ifstat and ifstat["peers"]: clients = ifstat["peers"]; cspec = "p"
if clients != None and clients > 0: rpc_str = f"{RNS.prettyfrequency(ifstat['outgoing_pr_frequency']/clients, d=1, lpf=True)}/{cspec}"
else: rpc_str = ""
psr = True
if not asr: iaf = ""; oaf = ""
if not psr: ipf = ""; opf = ""
amlen = max(len(iaf), len(oaf))
iaf += (amlen-len(iaf))*" "+""
oaf += (amlen-len(oaf))*" "+""
mlen = max(max(len(iaf), len(oaf), len(rxb_str), len(txb_str), len(ipf), len(opf)), 10)
iaf += (mlen-len(iaf))*" "
oaf += (mlen-len(oaf))*" "
ipf += (mlen-len(ipf))*" "
opf += (mlen-len(opf))*" "
rxb_str += (mlen-len(rxb_str))*" "
txb_str += (mlen-len(txb_str))*" "
if psr:
print(f" Path Rqs. : {opf} {rpc_str}")
print(f" {ipf} {pburst_str}")
if asr:
print(f" Announces : {oaf} {pc_str}")
print(f" {iaf} {art_str}")
print(f" {iaf} {art_str}{burst_str}")
rxstat = rxb_str
txstat = txb_str
@@ -624,9 +688,11 @@ def main(must_exit=True, rns_instance=None):
parser.add_argument("-a", "--all", action="store_true", help="show all interfaces", default=False)
parser.add_argument("-A", "--announce-stats", action="store_true", help="show announce stats", default=False)
parser.add_argument("-P", "--pr-stats", action="store_true", help="show path request stats", default=False)
parser.add_argument("-l", "--link-stats", action="store_true", help="show link stats", default=False)
parser.add_argument("-B", "--burst", action="store_true", help="only show interfaces with active bursts", default=False)
parser.add_argument("-t", "--totals", action="store_true", help="display traffic totals", default=False)
parser.add_argument("-s", "--sort", action="store", help="sort interfaces by [rate, traffic, rx, tx, rxs, txs, announces, arx, atx, held]", default=None, type=str)
parser.add_argument("-s", "--sort", action="store", help="sort interfaces by [rate, traffic, rx, tx, rxs, txs, announces, arx, atx, prx, ptx, held]", default=None, type=str)
parser.add_argument("-r", "--reverse", action="store_true", help="reverse sorting", default=False)
parser.add_argument("-j", "--json", action="store_true", help="output in JSON format", default=False)
parser.add_argument("-R", action="store", metavar="hash", help="transport identity hash of remote instance to get status from", default=None, type=str)
@@ -654,15 +720,16 @@ def main(must_exit=True, rns_instance=None):
exit(1)
while True:
st = time.time()
buffer = io.StringIO()
old_stdout = sys.stdout
sys.stdout = buffer
try:
program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
astats=args.announce_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse, remote=args.R,
management_identity=args.i, remote_timeout=args.w, must_exit=False, rns_instance=reticulum, traffic_totals=args.totals,
discovered_interfaces=args.discovered, config_entries=args.D)
astats=args.announce_stats, pstats=args.pr_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse,
remote=args.R, management_identity=args.i, remote_timeout=args.w, must_exit=False, rns_instance=reticulum,
traffic_totals=args.totals, discovered_interfaces=args.discovered, config_entries=args.D, burst_filter=args.burst)
finally:
sys.stdout = old_stdout
@@ -670,14 +737,16 @@ def main(must_exit=True, rns_instance=None):
output = buffer.getvalue()
print("\033[H\033[2J", end="")
print(output, end="", flush=True)
time.sleep(args.monitor_interval)
td = time.time()-st
sleeptime = max(args.monitor_interval-td, 0.2)
time.sleep(sleeptime)
else:
program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
astats=args.announce_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse, remote=args.R,
management_identity=args.i, remote_timeout=args.w, must_exit=must_exit, rns_instance=rns_instance, traffic_totals=args.totals,
discovered_interfaces=args.discovered, config_entries=args.D)
astats=args.announce_stats, pstats=args.pr_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse,
remote=args.R, management_identity=args.i, remote_timeout=args.w, must_exit=must_exit, rns_instance=rns_instance,
traffic_totals=args.totals, discovered_interfaces=args.discovered, config_entries=args.D, burst_filter=args.burst)
except KeyboardInterrupt:
print("")
+95 -114
View File
@@ -94,22 +94,14 @@ _always_override_destination = False
logging_lock = threading.Lock()
def loglevelname(level):
if (level == LOG_CRITICAL):
return "[Critical]"
if (level == LOG_ERROR):
return "[Error] "
if (level == LOG_WARNING):
return "[Warning] "
if (level == LOG_NOTICE):
return "[Notice] "
if (level == LOG_INFO):
return "[Info] "
if (level == LOG_VERBOSE):
return "[Verbose] "
if (level == LOG_DEBUG):
return "[Debug] "
if (level == LOG_EXTREME):
return "[Extra] "
if (level == LOG_CRITICAL): return "[Critical]"
if (level == LOG_ERROR): return "[Error] "
if (level == LOG_WARNING): return "[Warning] "
if (level == LOG_NOTICE): return "[Notice] "
if (level == LOG_INFO): return "[Info] "
if (level == LOG_VERBOSE): return "[Verbose] "
if (level == LOG_DEBUG): return "[Debug] "
if (level == LOG_EXTREME): return "[Extra] "
return "Unknown"
@@ -133,13 +125,10 @@ def log(msg, level=3, _override_destination = False, pt=False):
global _always_override_destination, compact_log_fmt
msg = str(msg)
if loglevel >= level:
if pt:
logstring = "["+precise_timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg
if pt: logstring = "["+precise_timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg
else:
if not compact_log_fmt:
logstring = "["+timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg
else:
logstring = "["+timestamp_str(time.time())+"] "+msg
if not compact_log_fmt: logstring = "["+timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg
else: logstring = "["+timestamp_str(time.time())+"] "+msg
with logging_lock:
if (logdest == LOG_STDOUT or _always_override_destination or _override_destination):
@@ -182,14 +171,11 @@ def trace_exception(e):
log(exception_info, LOG_ERROR)
def hexrep(data, delimit=True):
try:
iter(data)
except TypeError:
data = [data]
try: iter(data)
except TypeError: data = [data]
delimiter = ":"
if not delimit:
delimiter = ""
if not delimit: delimiter = ""
hexrep = delimiter.join("{:02x}".format(c) for c in data)
return hexrep
@@ -198,11 +184,6 @@ def prettyhexrep(data):
hexrep = "<"+delimiter.join("{:02x}".format(c) for c in data)+">"
return hexrep
def prettyb256rep(data):
delimiter = ""
b256rep = "<"+delimiter.join(b256_rep(c) for c in data)+">"
return b256rep
def prettyspeed(num, suffix="b"):
return prettysize(num/8, suffix=suffix)+"ps"
@@ -217,23 +198,24 @@ def prettysize(num, suffix='B'):
for unit in units:
if abs(num) < 1000.0:
if unit == "":
return "%.0f %s%s" % (num, unit, suffix)
else:
return "%.2f %s%s" % (num, unit, suffix)
if unit == "": return "%.0f %s%s" % (num, unit, suffix)
else: return "%.2f %s%s" % (num, unit, suffix)
num /= 1000.0
return "%.2f%s%s" % (num, last_unit, suffix)
def prettyfrequency(hz, suffix="Hz"):
def prettyfrequency(hz, suffix="Hz", d=2, lpf=False):
if hz == 0: return "0 Hz"
num = hz*1e6
units = ["µ", "m", "", "K","M","G","T","P","E","Z"]
if not lpf: num = hz*1e6
else: num = hz
if not lpf: units = ["µ", "m", "", "K","M","G","T","P","E","Z"]
else: units = ["", "K","M","G","T","P","E","Z"]
last_unit = "Y"
for unit in units:
if abs(num) < 1000.0:
return "%.2f %s%s" % (num, unit, suffix)
if d == 2: return "%.2f %s%s" % (num, unit, suffix)
else: return "%s %s%s" % (str(round(num,d)), unit, suffix)
num /= 1000.0
return "%.2f%s%s" % (num, last_unit, suffix)
@@ -248,8 +230,7 @@ def prettydistance(m, suffix="m"):
if unit == "m": divisor = 10
if unit == "c": divisor = 100
if abs(num) < divisor:
return "%.2f %s%s" % (num, unit, suffix)
if abs(num) < divisor: return "%.2f %s%s" % (num, unit, suffix)
num /= divisor
return "%.2f %s%s" % (num, last_unit, suffix)
@@ -266,10 +247,8 @@ def prettytime(time, verbose=False, compact=False):
time %= 3600
minutes = int(time // 60)
time %= 60
if compact:
seconds = int(time)
else:
seconds = round(time, 2)
if compact: seconds = int(time)
else: seconds = round(time, 2)
ss = "" if seconds == 1 else "s"
sm = "" if minutes == 1 else "s"
@@ -298,22 +277,16 @@ def prettytime(time, verbose=False, compact=False):
tstr = ""
for c in components:
i += 1
if i == 1:
pass
elif i < len(components):
tstr += ", "
elif i == len(components):
tstr += " and "
if i == 1: pass
elif i < len(components): tstr += ", "
elif i == len(components): tstr += " and "
tstr += c
if tstr == "":
return "0s"
if tstr == "": return "0s"
else:
if not neg:
return tstr
else:
return f"-{tstr}"
if not neg: return tstr
else: return f"-{tstr}"
def prettyshorttime(time, verbose=False, compact=False):
neg = False
@@ -325,10 +298,8 @@ def prettyshorttime(time, verbose=False, compact=False):
seconds = int(time // 1e6); time %= 1e6
milliseconds = int(time // 1e3); time %= 1e3
if compact:
microseconds = int(time)
else:
microseconds = round(time, 2)
if compact: microseconds = int(time)
else: microseconds = round(time, 2)
ss = "" if seconds == 1 else "s"
sms = "" if milliseconds == 1 else "s"
@@ -352,22 +323,16 @@ def prettyshorttime(time, verbose=False, compact=False):
tstr = ""
for c in components:
i += 1
if i == 1:
pass
elif i < len(components):
tstr += ", "
elif i == len(components):
tstr += " and "
if i == 1: pass
elif i < len(components): tstr += ", "
elif i == len(components): tstr += " and "
tstr += c
if tstr == "":
return "0us"
if tstr == "": return "0us"
else:
if not neg:
return tstr
else:
return f"-{tstr}"
if not neg: return tstr
else: return f"-{tstr}"
def phyparams():
print("Required Physical Layer MTU : "+str(Reticulum.MTU)+" bytes")
@@ -378,8 +343,7 @@ def phyparams():
print("Link Public Key Size : "+str(Link.ECPUBSIZE*8)+" bits")
print("Link Private Key Size : "+str(Link.KEYSIZE*8)+" bits")
def panic():
os._exit(255)
def panic(): os._exit(255)
exit_called = False
def exit(code=0):
@@ -400,8 +364,7 @@ class Profiler:
@staticmethod
def get_profiler(tag=None, super_tag=None):
if tag in Profiler.profilers:
return Profiler.profilers[tag]
if tag in Profiler.profilers: return Profiler.profilers[tag]
else:
profiler = Profiler(tag, super_tag)
Profiler.profilers[tag] = profiler
@@ -413,13 +376,14 @@ class Profiler:
self.pause_started = None
self.tag = tag
self.super_tag = super_tag
if self.super_tag in Profiler.profilers:
self.super_profiler = Profiler.profilers[self.super_tag]
self.pause_super = self.super_profiler.pause
self.resume_super = self.super_profiler.resume
else:
def noop(self=None):
pass
def noop(self=None): pass
self.super_profiler = None
self.pause_super = noop
self.resume_super = noop
@@ -429,8 +393,7 @@ class Profiler:
tag = self.tag
super_tag = self.super_tag
thread_ident = threading.get_ident()
if not tag in Profiler.tags:
Profiler.tags[tag] = {"threads": {}, "super": super_tag}
if not tag in Profiler.tags: Profiler.tags[tag] = {"threads": {}, "super": super_tag}
if not thread_ident in Profiler.tags[tag]["threads"]:
Profiler.tags[tag]["threads"][thread_ident] = {"current_start": None, "captures": []}
@@ -466,8 +429,7 @@ class Profiler:
self.resume_super()
@staticmethod
def ran():
return Profiler._ran
def ran(): return Profiler._ran
@staticmethod
def results():
@@ -484,41 +446,35 @@ class Profiler:
sample_count = len(thread_captures)
if sample_count > 1:
thread_results = {
"count": sample_count,
"mean": mean(thread_captures),
"median": median(thread_captures),
"stdev": stdev(thread_captures)
}
thread_results = { "count": sample_count,
"mean": mean(thread_captures),
"median": median(thread_captures),
"stdev": stdev(thread_captures) }
elif sample_count == 1:
thread_results = {
"count": sample_count,
"mean": mean(thread_captures),
"median": median(thread_captures),
"stdev": None
}
thread_results = { "count": sample_count,
"mean": mean(thread_captures),
"median": median(thread_captures),
"stdev": None }
tag_captures.extend(thread_captures)
sample_count = len(tag_captures)
if sample_count > 1:
tag_results = {
"name": tag,
"super": tag_entry["super"],
"count": len(tag_captures),
"mean": mean(tag_captures),
"median": median(tag_captures),
"stdev": stdev(tag_captures)
}
tag_results = { "name": tag,
"super": tag_entry["super"],
"count": len(tag_captures),
"mean": mean(tag_captures),
"median": median(tag_captures),
"stdev": stdev(tag_captures) }
elif sample_count == 1:
tag_results = {
"name": tag,
"super": tag_entry["super"],
"count": len(tag_captures),
"mean": mean(tag_captures),
"median": median(tag_captures),
"stdev": None
}
tag_results = { "name": tag,
"super": tag_entry["super"],
"count": len(tag_captures),
"mean": mean(tag_captures),
"median": median(tag_captures),
"stdev": None }
results[tag] = tag_results
@@ -552,6 +508,8 @@ class Profiler:
profile = Profiler.get_profiler
# The base-256 table is likely to change. Currently, it is just
# experimental, so don't count on it too much just yet.
b256 = [
# 0 1 2 3 4 5 6 7 8 9 A B C D F F
"a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p", # 0x0 Latin & numerals
@@ -572,4 +530,27 @@ b256 = [
"𐌳","𐌸","𐌾","𐐀","𐐁","𐐂","𐐆","𐐇","𐐈","𐐉","𐐊","𐐋","𐐌","𐐍","𐐎","𐐏", # 0xF Gothic & Deseret
]
def b256_rep(input_byte): return b256[int(input_byte)]
def b256rep(data): return "".join(bytes_to_b256(data))
def prettyb256rep(data): return f"<{b256rep(data)}>"
def b256_to_byte(point):
if not type(point) == str or not len(point) == 1: raise TypeError("Invalid input data for base256 byte decode")
try: return b256.index(point)
except Exception as e: raise ValueError(f"Could not decode base256 byte: {e}")
def b256_to_bytes(b256rep):
if not type(b256rep) == str: raise TypeError("Invalid input data for base256 decode")
try: return bytes([b256.index(c) for c in b256rep])
except Exception as e: raise ValueError(f"Could not decode base256: {e}")
def byte_to_b256(input_byte):
if type(input_byte) == bytes and not len(input_byte) == 1: TypeError("Invalid input data for base256 byte encode")
if type(input_byte) == bytes and len(input_byte) == 1: input_byte = ord(input_byte)
if not type(input_byte) == int: raise TypeError("Invalid input data for base256 byte encode")
try: return b256[int(input_byte)]
except Exception as e: raise TypeError(f"Could not encode byte to base256: {e}")
def bytes_to_b256(data):
if not type(data) == bytes: raise TypeError("Invalid input data for base256 encode")
try: return [byte_to_b256(c) for c in data]
except Exception as e: raise TypeError(f"Could not encode to base256: {e}")
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.2.4"
__version__ = "1.2.6"
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file records the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 33b573b4fa6e799ddf8fd65e522e0b14
config: 6d7f4aac8313ba495ab156ec11ab15c0
tags: 645f666f9bcd5a90fca523b33c5a78b7
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

+3
View File
@@ -402,6 +402,9 @@ Only releases with ``published`` status are visible through the Nomad Network in
-q, --quiet
--version show program's version number and exit
.. raw:: latex
\newpage
Work Documents
==============
+63 -7
View File
@@ -1372,11 +1372,12 @@ a large amount of bogus destinations, and then disconnect, these destination wil
never make it into path tables and waste network bandwidth on retransmitted
announces.
**It's important to note** that the ingress control works at the level of *individual
sub-interfaces*. As an example, this means that one client on a :ref:`TCP Server Interface<interfaces-tcps>`
cannot disrupt processing of incoming announces for other connected clients on the same
:ref:`TCP Server Interface<interfaces-tcps>`. All other clients on the same interface will still have new announces
processed without interruption.
.. note::
It's important to remember that the ingress control works at the level of *individual
sub-interfaces*. As an example, this means that one client on a :ref:`TCP Server Interface<interfaces-tcps>`
cannot disrupt processing of incoming announces for other connected clients on the same
:ref:`TCP Server Interface<interfaces-tcps>`. All other clients on the same interface
will still have new announces processed without interruption.
By default, Reticulum will handle this automatically, and ingress announce
control will be enabled on interface where it is sensible to do so. It should
@@ -1384,8 +1385,7 @@ generally not be neccessary to modify the ingress control configuration,
but all the parameters are exposed for configuration if needed.
* | The ``ingress_control`` option tells Reticulum whether or not
to enable announce ingress control on the interface. Defaults to
``True``.
to enable ingress control on the interface. Defaults to ``True``.
* | The ``ic_new_time`` option configures how long (in seconds) an
interface is considered newly spawned. Defaults to ``2*60*60`` seconds. This
@@ -1422,3 +1422,59 @@ but all the parameters are exposed for configuration if needed.
must pass between releasing each held announce from the queue. Defaults
to ``30`` seconds.
All of the above settings can be configured both as instance-wide defaults
under the ``[reticulum]`` section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.
Path Request Burst Control
==========================
In addition the announce controls for newly created destination, Reticulum will also
monitor incoming path request activity, and enforce burst controls if per-client rates
exceed configured limits. Once path request burst control is activated on an
interface, path requests will no longer be propagated further on the network.
As with announce burst control, this happens on a per sub-interface basis. One
client connecting to a public gateway will not be able to disrupt path request
processing for other clients.
.. warning::
Applications that send large amounts of unnecessary path requests will very
quickly get rate limited by transport nodes, and the entire system they are
running on will not be able to resolve any paths on the network, until the
burst subsides and hold period expires. **Do not** write applications like
this. Only request paths for destinations you need to communicate with.
By default, Reticulum will handle this automatically, and ingress path request
control will be enabled on interface where it is sensible to do so. It should
generally not be neccessary to modify the ingress control configuration,
but all the parameters are exposed for configuration if needed.
* | The ``ingress_control`` option tells Reticulum whether or not
to enable ingress control on the interface. Defaults to ``True``.
* | The ``ic_new_time`` option configures how long (in seconds) an
interface is considered newly spawned. Defaults to ``2*60*60`` seconds. This
option is useful on publicly accessible interfaces that spawn new
sub-interfaces when a new client connects.
* | The ``ic_pr_burst_freq_new`` option sets the maximum path request
ingress frequency for newly spawned interfaces. Defaults to ``3``
path requests per second.
* | The ``ic_pr_burst_freq`` option sets the maximum path request
ingress frequency for other interfaces. Defaults to ``8`` path requests
per second.
*If an interface exceeds its burst frequency, incoming path requests
from that system will not traverse the network further.*
* | The ``egress_control`` option enables hard-limiting path request egress
control per-interface. Defaults to ``False``
* | The ``ec_pr_freq`` option sets the hard limit for outbound path requests
per second on a given interface.
All of the above settings can be configured both as instance-wide defaults
under the ``[reticulum]`` section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.
+1 -51
View File
@@ -110,7 +110,7 @@ plugin system for expandability.
MeshChatX
^^^^^^^^
A `Reticulum MeshChat fork from the future <https://git.quad4.io/RNS-Things/MeshChatX>`_, with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original Reticulum MeshChat project, and is not affiliated with the original project.
A `Reticulum MeshChat fork from the future <https://git.quad4.io/RNS-Things/MeshChatX>`_, with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original `Reticulum MeshChat <https://github.com/liamcottle/reticulum-meshchat>`_ project, and is not affiliated with the original project, but is a much more up-to-date, comprehensive and well-maintained fork.
.. only:: html
@@ -127,56 +127,6 @@ A `Reticulum MeshChat fork from the future <https://git.quad4.io/RNS-Things/Mesh
Features include full LXST support, custom voicemail, phonebook, contact sharing, and ringtone support, multi-identity handling, modern UI/UX, offline documentation, expanded tools, page archiving, integrated maps, telemetry and improved application security.
.. raw:: latex
\newpage
MeshChat
^^^^^^^^
The `Reticulum MeshChat <https://github.com/liamcottle/reticulum-meshchat>`_ application
is a user-friendly LXMF client for Linux, macOS and Windows, that also includes a Nomad Network
page browser and other interesting functionality.
.. only:: html
.. image:: screenshots/meshchat_1.webp
:align: center
:target: https://github.com/liamcottle/reticulum-meshchat
.. only:: latex
.. image:: screenshots/meshchat_1.png
:align: center
:target: https://github.com/liamcottle/reticulum-meshchat
Reticulum MeshChat is of course also compatible with Sideband and Nomad Network, or
any other LXMF client.
Columba
^^^^^^^
`Columba <https://github.com/torlando-tech/columba/>`_ is a simple and familiar LXMF
messaging app Android, built with a native Android interface and Material Design 3.
.. only:: html
.. image:: screenshots/columba.webp
:align: center
:width: 25%
:target: https://github.com/torlando-tech/columba/
.. only:: latex
.. image:: screenshots/columba.png
:align: center
:width: 25%
:target: https://github.com/torlando-tech/columba/
While still in early and very active development, it is of course also compatible
with all other LXMF clients, and allows you to message seamlessly with anyone else
using LXMF.
.. raw:: latex
\newpage
+4
View File
@@ -31,6 +31,10 @@ Donations are gratefully accepted via the following channels:
Are certain features in the development roadmap are important to you or your
organisation? Make them a reality quickly by sponsoring their implementation.
.. raw:: latex
\newpage
Provide Feedback
================
Feedback on the usage, functioning and potential dysfunctioning of any and
+1 -1
View File
@@ -1,5 +1,5 @@
const DOCUMENTATION_OPTIONS = {
VERSION: '1.2.4',
VERSION: '1.2.6',
LANGUAGE: 'en',
COLLAPSE_INDEX: false,
BUILDER: 'html',
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Code Examples - Reticulum Network Stack 1.2.4 documentation</title>
<title>Code Examples - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -3664,7 +3664,7 @@ will be fully on-par with natively included interfaces, including all supported
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>An Explanation of Reticulum for Human Beings - Reticulum Network Stack 1.2.4 documentation</title>
<title>An Explanation of Reticulum for Human Beings - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -295,7 +295,7 @@
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -5,7 +5,7 @@
<meta name="color-scheme" content="light dark"><link rel="index" title="Index" href="#"><link rel="search" title="Search" href="search.html">
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 --><title>Index - Reticulum Network Stack 1.2.4 documentation</title>
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 --><title>Index - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -178,7 +178,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -202,7 +202,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -839,7 +839,7 @@
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Getting Started Fast - Reticulum Network Stack 1.2.4 documentation</title>
<title>Getting Started Fast - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -967,7 +967,7 @@ All other available modules will still be loaded when needed.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Git Over Reticulum - Reticulum Network Stack 1.2.4 documentation</title>
<title>Git Over Reticulum - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -782,7 +782,7 @@ options:
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Communications Hardware - Reticulum Network Stack 1.2.4 documentation</title>
<title>Communications Hardware - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -675,7 +675,7 @@ can be used with Reticulum. This includes virtual software modems such as
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+6 -7
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Reticulum Network Stack 1.2.4 documentation</title>
<title>Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="#"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="#"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -378,8 +378,6 @@ to participate in the development of Reticulum itself.</p>
<li class="toctree-l3"><a class="reference internal" href="software.html#retipedia">Retipedia</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#sideband">Sideband</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#meshchatx">MeshChatX</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#meshchat">MeshChat</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#columba">Columba</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#reticulum-relay-chat">Reticulum Relay Chat</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#retibbs">RetiBBS</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#rbrowser">RBrowser</a></li>
@@ -394,7 +392,7 @@ to participate in the development of Reticulum itself.</p>
</li>
<li class="toctree-l2"><a class="reference internal" href="software.html#protocols">Protocols</a><ul>
<li class="toctree-l3"><a class="reference internal" href="software.html#lxmf">LXMF</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#id17">LXST</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#id16">LXST</a></li>
<li class="toctree-l3"><a class="reference internal" href="software.html#rrc">RRC</a></li>
</ul>
</li>
@@ -511,6 +509,7 @@ to participate in the development of Reticulum itself.</p>
<li class="toctree-l2"><a class="reference internal" href="interfaces.html#interfaces-modes">Interface Modes</a></li>
<li class="toctree-l2"><a class="reference internal" href="interfaces.html#announce-rate-control">Announce Rate Control</a></li>
<li class="toctree-l2"><a class="reference internal" href="interfaces.html#new-destination-rate-limiting">New Destination Rate Limiting</a></li>
<li class="toctree-l2"><a class="reference internal" href="interfaces.html#path-request-burst-control">Path Request Burst Control</a></li>
</ul>
</li>
<li class="toctree-l1"><a class="reference internal" href="networks.html">Building Networks</a><ul>
@@ -644,7 +643,7 @@ to participate in the development of Reticulum itself.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+82 -9
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Configuring Interfaces - Reticulum Network Stack 1.2.4 documentation</title>
<title>Configuring Interfaces - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -1533,11 +1533,14 @@ also means, that should a node decide to connect to a public interface, announce
a large amount of bogus destinations, and then disconnect, these destination will
never make it into path tables and waste network bandwidth on retransmitted
announces.</p>
<p><strong>Its important to note</strong> that the ingress control works at the level of <em>individual
<div class="admonition note">
<p class="admonition-title">Note</p>
<p>Its important to remember that the ingress control works at the level of <em>individual
sub-interfaces</em>. As an example, this means that one client on a <a class="reference internal" href="#interfaces-tcps"><span class="std std-ref">TCP Server Interface</span></a>
cannot disrupt processing of incoming announces for other connected clients on the same
<a class="reference internal" href="#interfaces-tcps"><span class="std std-ref">TCP Server Interface</span></a>. All other clients on the same interface will still have new announces
processed without interruption.</p>
<a class="reference internal" href="#interfaces-tcps"><span class="std std-ref">TCP Server Interface</span></a>. All other clients on the same interface
will still have new announces processed without interruption.</p>
</div>
<p>By default, Reticulum will handle this automatically, and ingress announce
control will be enabled on interface where it is sensible to do so. It should
generally not be neccessary to modify the ingress control configuration,
@@ -1546,8 +1549,7 @@ but all the parameters are exposed for configuration if needed.</p>
<div><ul>
<li><div class="line-block">
<div class="line">The <code class="docutils literal notranslate"><span class="pre">ingress_control</span></code> option tells Reticulum whether or not
to enable announce ingress control on the interface. Defaults to
<code class="docutils literal notranslate"><span class="pre">True</span></code>.</div>
to enable ingress control on the interface. Defaults to <code class="docutils literal notranslate"><span class="pre">True</span></code>.</div>
</div>
</li>
<li><div class="line-block">
@@ -1602,6 +1604,76 @@ to <code class="docutils literal notranslate"><span class="pre">30</span></code>
</li>
</ul>
</div></blockquote>
<p>All of the above settings can be configured both as instance-wide defaults
under the <code class="docutils literal notranslate"><span class="pre">[reticulum]</span></code> section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.</p>
</section>
<section id="path-request-burst-control">
<h2>Path Request Burst Control<a class="headerlink" href="#path-request-burst-control" title="Link to this heading"></a></h2>
<p>In addition the announce controls for newly created destination, Reticulum will also
monitor incoming path request activity, and enforce burst controls if per-client rates
exceed configured limits. Once path request burst control is activated on an
interface, path requests will no longer be propagated further on the network.
As with announce burst control, this happens on a per sub-interface basis. One
client connecting to a public gateway will not be able to disrupt path request
processing for other clients.</p>
<div class="admonition warning">
<p class="admonition-title">Warning</p>
<p>Applications that send large amounts of unnecessary path requests will very
quickly get rate limited by transport nodes, and the entire system they are
running on will not be able to resolve any paths on the network, until the
burst subsides and hold period expires. <strong>Do not</strong> write applications like
this. Only request paths for destinations you need to communicate with.</p>
</div>
<p>By default, Reticulum will handle this automatically, and ingress path request
control will be enabled on interface where it is sensible to do so. It should
generally not be neccessary to modify the ingress control configuration,
but all the parameters are exposed for configuration if needed.</p>
<blockquote>
<div><ul>
<li><div class="line-block">
<div class="line">The <code class="docutils literal notranslate"><span class="pre">ingress_control</span></code> option tells Reticulum whether or not
to enable ingress control on the interface. Defaults to <code class="docutils literal notranslate"><span class="pre">True</span></code>.</div>
</div>
</li>
<li><div class="line-block">
<div class="line">The <code class="docutils literal notranslate"><span class="pre">ic_new_time</span></code> option configures how long (in seconds) an
interface is considered newly spawned. Defaults to <code class="docutils literal notranslate"><span class="pre">2*60*60</span></code> seconds. This
option is useful on publicly accessible interfaces that spawn new
sub-interfaces when a new client connects.</div>
</div>
</li>
<li><div class="line-block">
<div class="line">The <code class="docutils literal notranslate"><span class="pre">ic_pr_burst_freq_new</span></code> option sets the maximum path request
ingress frequency for newly spawned interfaces. Defaults to <code class="docutils literal notranslate"><span class="pre">3</span></code>
path requests per second.</div>
</div>
</li>
<li><div class="line-block">
<div class="line">The <code class="docutils literal notranslate"><span class="pre">ic_pr_burst_freq</span></code> option sets the maximum path request
ingress frequency for other interfaces. Defaults to <code class="docutils literal notranslate"><span class="pre">8</span></code> path requests
per second.</div>
</div>
<blockquote>
<div><p><em>If an interface exceeds its burst frequency, incoming path requests
from that system will not traverse the network further.</em></p>
</div></blockquote>
</li>
<li><div class="line-block">
<div class="line">The <code class="docutils literal notranslate"><span class="pre">egress_control</span></code> option enables hard-limiting path request egress
control per-interface. Defaults to <code class="docutils literal notranslate"><span class="pre">False</span></code></div>
</div>
</li>
<li><div class="line-block">
<div class="line">The <code class="docutils literal notranslate"><span class="pre">ec_pr_freq</span></code> option sets the hard limit for outbound path requests
per second on a given interface.</div>
</div>
</li>
</ul>
</div></blockquote>
<p>All of the above settings can be configured both as instance-wide defaults
under the <code class="docutils literal notranslate"><span class="pre">[reticulum]</span></code> section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.</p>
</section>
</section>
@@ -1689,6 +1761,7 @@ to <code class="docutils literal notranslate"><span class="pre">30</span></code>
<li><a class="reference internal" href="#interfaces-modes">Interface Modes</a></li>
<li><a class="reference internal" href="#announce-rate-control">Announce Rate Control</a></li>
<li><a class="reference internal" href="#new-destination-rate-limiting">New Destination Rate Limiting</a></li>
<li><a class="reference internal" href="#path-request-burst-control">Path Request Burst Control</a></li>
</ul>
</li>
</ul>
@@ -1700,7 +1773,7 @@ to <code class="docutils literal notranslate"><span class="pre">30</span></code>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Reticulum License - Reticulum Network Stack 1.2.4 documentation</title>
<title>Reticulum License - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -344,7 +344,7 @@ SOFTWARE.
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Building Networks - Reticulum Network Stack 1.2.4 documentation</title>
<title>Building Networks - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -663,7 +663,7 @@ differently than a mobile device roaming between radio cells.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
Binary file not shown.
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>API Reference - Reticulum Network Stack 1.2.4 documentation</title>
<title>API Reference - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -2484,7 +2484,7 @@ will announce it.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -8,7 +8,7 @@
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<meta name="robots" content="noindex" />
<title>Search - Reticulum Network Stack 1.2.4 documentation</title><link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<title>Search - Reticulum Network Stack 1.2.6 documentation</title><link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo-extensions.css?v=8dab3a3b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="#" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -303,7 +303,7 @@
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
File diff suppressed because one or more lines are too long
+8 -30
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Programs Using Reticulum - Reticulum Network Stack 1.2.4 documentation</title>
<title>Programs Using Reticulum - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -328,31 +328,11 @@ plugin system for expandability.</p>
</section>
<section id="meshchatx">
<h3>MeshChatX<a class="headerlink" href="#meshchatx" title="Link to this heading"></a></h3>
<p>A <a class="reference external" href="https://git.quad4.io/RNS-Things/MeshChatX">Reticulum MeshChat fork from the future</a>, with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original Reticulum MeshChat project, and is not affiliated with the original project.</p>
<p>A <a class="reference external" href="https://git.quad4.io/RNS-Things/MeshChatX">Reticulum MeshChat fork from the future</a>, with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original <a class="reference external" href="https://github.com/liamcottle/reticulum-meshchat">Reticulum MeshChat</a> project, and is not affiliated with the original project, but is a much more up-to-date, comprehensive and well-maintained fork.</p>
<a class="reference external image-reference" href="https://git.quad4.io/RNS-Things/MeshChatX"><img alt="_images/meshchatx.webp" class="align-center" src="_images/meshchatx.webp" />
</a>
<p>Features include full LXST support, custom voicemail, phonebook, contact sharing, and ringtone support, multi-identity handling, modern UI/UX, offline documentation, expanded tools, page archiving, integrated maps, telemetry and improved application security.</p>
</section>
<section id="meshchat">
<h3>MeshChat<a class="headerlink" href="#meshchat" title="Link to this heading"></a></h3>
<p>The <a class="reference external" href="https://github.com/liamcottle/reticulum-meshchat">Reticulum MeshChat</a> application
is a user-friendly LXMF client for Linux, macOS and Windows, that also includes a Nomad Network
page browser and other interesting functionality.</p>
<a class="reference external image-reference" href="https://github.com/liamcottle/reticulum-meshchat"><img alt="_images/meshchat_1.webp" class="align-center" src="_images/meshchat_1.webp" />
</a>
<p>Reticulum MeshChat is of course also compatible with Sideband and Nomad Network, or
any other LXMF client.</p>
</section>
<section id="columba">
<h3>Columba<a class="headerlink" href="#columba" title="Link to this heading"></a></h3>
<p><a class="reference external" href="https://github.com/torlando-tech/columba/">Columba</a> is a simple and familiar LXMF
messaging app Android, built with a native Android interface and Material Design 3.</p>
<a class="reference external image-reference" href="https://github.com/torlando-tech/columba/"><img alt="_images/columba.webp" class="align-center" src="_images/columba.webp" style="width: 25%;" />
</a>
<p>While still in early and very active development, it is of course also compatible
with all other LXMF clients, and allows you to message seamlessly with anyone else
using LXMF.</p>
</section>
<section id="reticulum-relay-chat">
<h3>Reticulum Relay Chat<a class="headerlink" href="#reticulum-relay-chat" title="Link to this heading"></a></h3>
<p><a class="reference external" href="https://rrc.kc1awv.net/">Reticulum Relay Chat</a> is a live chat system built on top of the Reticulum Network Stack. It exists to let people talk to each other in real time over Reticulum without dragging in message databases, synchronization engines, or architectural commitments they did not ask for.</p>
@@ -417,8 +397,8 @@ using LXMF.</p>
<p>LXMF is efficient enough that it can deliver messages over extremely low-bandwidth systems such as packet radio or LoRa. Encrypted LXMF messages can also be encoded as QR-codes or text-based URIs, allowing completely analog paper message transport.</p>
<p>Using Propagation Nodes, LXMF also offer a way to store and forward messages to users or endpoints that are not directly reachable at the time of message emission.</p>
</section>
<section id="id17">
<h3>LXST<a class="headerlink" href="#id17" title="Link to this heading"></a></h3>
<section id="id16">
<h3>LXST<a class="headerlink" href="#id16" title="Link to this heading"></a></h3>
<p><a class="reference external" href="https://github.com/markqvist/lxst">LXST</a> is a simple and flexible real-time streaming format and delivery protocol that allows a wide variety of applications, while using as little bandwidth as possible. It is built on top of Reticulum and offers zero-conf stream routing, end-to-end encryption and Forward Secrecy, and can be transported over any kind of medium that Reticulum supports. It currently powers real-time voice and telephony applications over Reticulum.</p>
</section>
<section id="rrc">
@@ -502,8 +482,6 @@ using LXMF.</p>
<li><a class="reference internal" href="#retipedia">Retipedia</a></li>
<li><a class="reference internal" href="#sideband">Sideband</a></li>
<li><a class="reference internal" href="#meshchatx">MeshChatX</a></li>
<li><a class="reference internal" href="#meshchat">MeshChat</a></li>
<li><a class="reference internal" href="#columba">Columba</a></li>
<li><a class="reference internal" href="#reticulum-relay-chat">Reticulum Relay Chat</a></li>
<li><a class="reference internal" href="#retibbs">RetiBBS</a></li>
<li><a class="reference internal" href="#rbrowser">RBrowser</a></li>
@@ -518,7 +496,7 @@ using LXMF.</p>
</li>
<li><a class="reference internal" href="#protocols">Protocols</a><ul>
<li><a class="reference internal" href="#lxmf">LXMF</a></li>
<li><a class="reference internal" href="#id17">LXST</a></li>
<li><a class="reference internal" href="#id16">LXST</a></li>
<li><a class="reference internal" href="#rrc">RRC</a></li>
</ul>
</li>
@@ -534,7 +512,7 @@ using LXMF.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Support Reticulum - Reticulum Network Stack 1.2.4 documentation</title>
<title>Support Reticulum - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -382,7 +382,7 @@ circumstances, so we rely on old-fashioned human feedback.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Understanding Reticulum - Reticulum Network Stack 1.2.4 documentation</title>
<title>Understanding Reticulum - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -1337,7 +1337,7 @@ those risks are acceptable to you.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Using Reticulum on Your System - Reticulum Network Stack 1.2.4 documentation</title>
<title>Using Reticulum on Your System - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -1635,7 +1635,7 @@ systemctl --user enable rnsd.service
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>What is Reticulum? - Reticulum Network Stack 1.2.4 documentation</title>
<title>What is Reticulum? - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -504,7 +504,7 @@ network, and vice versa.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
<title>Zen of Reticulum - Reticulum Network Stack 1.2.4 documentation</title>
<title>Zen of Reticulum - Reticulum Network Stack 1.2.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
@@ -180,7 +180,7 @@
</label>
</div>
<div class="header-center">
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.4 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.6 documentation</div></a>
</div>
<div class="header-right">
<div class="theme-toggle-container theme-toggle-header">
@@ -204,7 +204,7 @@
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
</div>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.4 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.6 documentation</span>
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
@@ -676,7 +676,7 @@ Imagine a messaging app. You write it once. It works on a laptop connected to fi
</aside>
</div>
</div><script src="_static/documentation_options.js?v=928db92d"></script>
</div><script src="_static/documentation_options.js?v=010db75e"></script>
<script src="_static/doctools.js?v=9bcbadda"></script>
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
+2 -3
View File
@@ -82,8 +82,6 @@ to participate in the development of Reticulum itself.
* [Retipedia](software.md#retipedia)
* [Sideband](software.md#sideband)
* [MeshChatX](software.md#meshchatx)
* [MeshChat](software.md#meshchat)
* [Columba](software.md#columba)
* [Reticulum Relay Chat](software.md#reticulum-relay-chat)
* [RetiBBS](software.md#retibbs)
* [RBrowser](software.md#rbrowser)
@@ -96,7 +94,7 @@ to participate in the development of Reticulum itself.
* [RNMon](software.md#rnmon)
* [Protocols](software.md#protocols)
* [LXMF](software.md#lxmf)
* [LXST](software.md#id17)
* [LXST](software.md#id16)
* [RRC](software.md#rrc)
* [Interface Modules & Connectivity Resources](software.md#interface-modules-connectivity-resources)
* [Using Reticulum on Your System](using.md)
@@ -183,6 +181,7 @@ to participate in the development of Reticulum itself.
* [Interface Modes](interfaces.md#interfaces-modes)
* [Announce Rate Control](interfaces.md#announce-rate-control)
* [New Destination Rate Limiting](interfaces.md#new-destination-rate-limiting)
* [Path Request Burst Control](interfaces.md#path-request-burst-control)
* [Building Networks](networks.md)
* [Concepts & Overview](networks.md#concepts-overview)
* [Introductory Considerations](networks.md#introductory-considerations)
+61 -6
View File
@@ -1295,11 +1295,12 @@ a large amount of bogus destinations, and then disconnect, these destination wil
never make it into path tables and waste network bandwidth on retransmitted
announces.
**Its important to note** that the ingress control works at the level of *individual
#### NOTE
Its important to remember that the ingress control works at the level of *individual
sub-interfaces*. As an example, this means that one client on a [TCP Server Interface](#interfaces-tcps)
cannot disrupt processing of incoming announces for other connected clients on the same
[TCP Server Interface](#interfaces-tcps). All other clients on the same interface will still have new announces
processed without interruption.
[TCP Server Interface](#interfaces-tcps). All other clients on the same interface
will still have new announces processed without interruption.
By default, Reticulum will handle this automatically, and ingress announce
control will be enabled on interface where it is sensible to do so. It should
@@ -1307,8 +1308,7 @@ generally not be neccessary to modify the ingress control configuration,
but all the parameters are exposed for configuration if needed.
> * The `ingress_control` option tells Reticulum whether or not
> to enable announce ingress control on the interface. Defaults to
> `True`.
> to enable ingress control on the interface. Defaults to `True`.
> <br/>
> * The `ic_new_time` option configures how long (in seconds) an
> interface is considered newly spawned. Defaults to `2*60*60` seconds. This
@@ -1343,4 +1343,59 @@ but all the parameters are exposed for configuration if needed.
> * The `ic_held_release_interval` option sets how much time (in seconds)
> must pass between releasing each held announce from the queue. Defaults
> to `30` seconds.
> <br/>
> <br/>
All of the above settings can be configured both as instance-wide defaults
under the `[reticulum]` section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.
## Path Request Burst Control
In addition the announce controls for newly created destination, Reticulum will also
monitor incoming path request activity, and enforce burst controls if per-client rates
exceed configured limits. Once path request burst control is activated on an
interface, path requests will no longer be propagated further on the network.
As with announce burst control, this happens on a per sub-interface basis. One
client connecting to a public gateway will not be able to disrupt path request
processing for other clients.
#### WARNING
Applications that send large amounts of unnecessary path requests will very
quickly get rate limited by transport nodes, and the entire system they are
running on will not be able to resolve any paths on the network, until the
burst subsides and hold period expires. **Do not** write applications like
this. Only request paths for destinations you need to communicate with.
By default, Reticulum will handle this automatically, and ingress path request
control will be enabled on interface where it is sensible to do so. It should
generally not be neccessary to modify the ingress control configuration,
but all the parameters are exposed for configuration if needed.
> * The `ingress_control` option tells Reticulum whether or not
> to enable ingress control on the interface. Defaults to `True`.
> <br/>
> * The `ic_new_time` option configures how long (in seconds) an
> interface is considered newly spawned. Defaults to `2*60*60` seconds. This
> option is useful on publicly accessible interfaces that spawn new
> sub-interfaces when a new client connects.
> <br/>
> * The `ic_pr_burst_freq_new` option sets the maximum path request
> ingress frequency for newly spawned interfaces. Defaults to `3`
> path requests per second.
> <br/>
> * The `ic_pr_burst_freq` option sets the maximum path request
> ingress frequency for other interfaces. Defaults to `8` path requests
> per second.
> <br/>
> > *If an interface exceeds its burst frequency, incoming path requests
> > from that system will not traverse the network further.*
> * The `egress_control` option enables hard-limiting path request egress
> control per-interface. Defaults to `False`
> <br/>
> * The `ec_pr_freq` option sets the hard limit for outbound path requests
> per second on a given interface.
> <br/>
All of the above settings can be configured both as instance-wide defaults
under the `[reticulum]` section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.
+1 -19
View File
@@ -72,28 +72,10 @@ plugin system for expandability.
### MeshChatX
A [Reticulum MeshChat fork from the future](https://git.quad4.io/RNS-Things/MeshChatX), with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original Reticulum MeshChat project, and is not affiliated with the original project.
A [Reticulum MeshChat fork from the future](https://git.quad4.io/RNS-Things/MeshChatX), with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) project, and is not affiliated with the original project, but is a much more up-to-date, comprehensive and well-maintained fork.
Features include full LXST support, custom voicemail, phonebook, contact sharing, and ringtone support, multi-identity handling, modern UI/UX, offline documentation, expanded tools, page archiving, integrated maps, telemetry and improved application security.
### MeshChat
The [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) application
is a user-friendly LXMF client for Linux, macOS and Windows, that also includes a Nomad Network
page browser and other interesting functionality.
Reticulum MeshChat is of course also compatible with Sideband and Nomad Network, or
any other LXMF client.
### Columba
[Columba](https://github.com/torlando-tech/columba/) is a simple and familiar LXMF
messaging app Android, built with a native Android interface and Material Design 3.
While still in early and very active development, it is of course also compatible
with all other LXMF clients, and allows you to message seamlessly with anyone else
using LXMF.
### Reticulum Relay Chat
[Reticulum Relay Chat](https://rrc.kc1awv.net/) is a live chat system built on top of the Reticulum Network Stack. It exists to let people talk to each other in real time over Reticulum without dragging in message databases, synchronization engines, or architectural commitments they did not ask for.
+3
View File
@@ -402,6 +402,9 @@ Only releases with ``published`` status are visible through the Nomad Network in
-q, --quiet
--version show program's version number and exit
.. raw:: latex
\newpage
Work Documents
==============
+63 -7
View File
@@ -1372,11 +1372,12 @@ a large amount of bogus destinations, and then disconnect, these destination wil
never make it into path tables and waste network bandwidth on retransmitted
announces.
**It's important to note** that the ingress control works at the level of *individual
sub-interfaces*. As an example, this means that one client on a :ref:`TCP Server Interface<interfaces-tcps>`
cannot disrupt processing of incoming announces for other connected clients on the same
:ref:`TCP Server Interface<interfaces-tcps>`. All other clients on the same interface will still have new announces
processed without interruption.
.. note::
It's important to remember that the ingress control works at the level of *individual
sub-interfaces*. As an example, this means that one client on a :ref:`TCP Server Interface<interfaces-tcps>`
cannot disrupt processing of incoming announces for other connected clients on the same
:ref:`TCP Server Interface<interfaces-tcps>`. All other clients on the same interface
will still have new announces processed without interruption.
By default, Reticulum will handle this automatically, and ingress announce
control will be enabled on interface where it is sensible to do so. It should
@@ -1384,8 +1385,7 @@ generally not be neccessary to modify the ingress control configuration,
but all the parameters are exposed for configuration if needed.
* | The ``ingress_control`` option tells Reticulum whether or not
to enable announce ingress control on the interface. Defaults to
``True``.
to enable ingress control on the interface. Defaults to ``True``.
* | The ``ic_new_time`` option configures how long (in seconds) an
interface is considered newly spawned. Defaults to ``2*60*60`` seconds. This
@@ -1422,3 +1422,59 @@ but all the parameters are exposed for configuration if needed.
must pass between releasing each held announce from the queue. Defaults
to ``30`` seconds.
All of the above settings can be configured both as instance-wide defaults
under the ``[reticulum]`` section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.
Path Request Burst Control
==========================
In addition the announce controls for newly created destination, Reticulum will also
monitor incoming path request activity, and enforce burst controls if per-client rates
exceed configured limits. Once path request burst control is activated on an
interface, path requests will no longer be propagated further on the network.
As with announce burst control, this happens on a per sub-interface basis. One
client connecting to a public gateway will not be able to disrupt path request
processing for other clients.
.. warning::
Applications that send large amounts of unnecessary path requests will very
quickly get rate limited by transport nodes, and the entire system they are
running on will not be able to resolve any paths on the network, until the
burst subsides and hold period expires. **Do not** write applications like
this. Only request paths for destinations you need to communicate with.
By default, Reticulum will handle this automatically, and ingress path request
control will be enabled on interface where it is sensible to do so. It should
generally not be neccessary to modify the ingress control configuration,
but all the parameters are exposed for configuration if needed.
* | The ``ingress_control`` option tells Reticulum whether or not
to enable ingress control on the interface. Defaults to ``True``.
* | The ``ic_new_time`` option configures how long (in seconds) an
interface is considered newly spawned. Defaults to ``2*60*60`` seconds. This
option is useful on publicly accessible interfaces that spawn new
sub-interfaces when a new client connects.
* | The ``ic_pr_burst_freq_new`` option sets the maximum path request
ingress frequency for newly spawned interfaces. Defaults to ``3``
path requests per second.
* | The ``ic_pr_burst_freq`` option sets the maximum path request
ingress frequency for other interfaces. Defaults to ``8`` path requests
per second.
*If an interface exceeds its burst frequency, incoming path requests
from that system will not traverse the network further.*
* | The ``egress_control`` option enables hard-limiting path request egress
control per-interface. Defaults to ``False``
* | The ``ec_pr_freq`` option sets the hard limit for outbound path requests
per second on a given interface.
All of the above settings can be configured both as instance-wide defaults
under the ``[reticulum]`` section of the configuration file, or on a per-
interface basis under the relevant interface configuration section.
+1 -51
View File
@@ -110,7 +110,7 @@ plugin system for expandability.
MeshChatX
^^^^^^^^
A `Reticulum MeshChat fork from the future <https://git.quad4.io/RNS-Things/MeshChatX>`_, with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original Reticulum MeshChat project, and is not affiliated with the original project.
A `Reticulum MeshChat fork from the future <https://git.quad4.io/RNS-Things/MeshChatX>`_, with the goal of providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application. This project is separate from the original `Reticulum MeshChat <https://github.com/liamcottle/reticulum-meshchat>`_ project, and is not affiliated with the original project, but is a much more up-to-date, comprehensive and well-maintained fork.
.. only:: html
@@ -127,56 +127,6 @@ A `Reticulum MeshChat fork from the future <https://git.quad4.io/RNS-Things/Mesh
Features include full LXST support, custom voicemail, phonebook, contact sharing, and ringtone support, multi-identity handling, modern UI/UX, offline documentation, expanded tools, page archiving, integrated maps, telemetry and improved application security.
.. raw:: latex
\newpage
MeshChat
^^^^^^^^
The `Reticulum MeshChat <https://github.com/liamcottle/reticulum-meshchat>`_ application
is a user-friendly LXMF client for Linux, macOS and Windows, that also includes a Nomad Network
page browser and other interesting functionality.
.. only:: html
.. image:: screenshots/meshchat_1.webp
:align: center
:target: https://github.com/liamcottle/reticulum-meshchat
.. only:: latex
.. image:: screenshots/meshchat_1.png
:align: center
:target: https://github.com/liamcottle/reticulum-meshchat
Reticulum MeshChat is of course also compatible with Sideband and Nomad Network, or
any other LXMF client.
Columba
^^^^^^^
`Columba <https://github.com/torlando-tech/columba/>`_ is a simple and familiar LXMF
messaging app Android, built with a native Android interface and Material Design 3.
.. only:: html
.. image:: screenshots/columba.webp
:align: center
:width: 25%
:target: https://github.com/torlando-tech/columba/
.. only:: latex
.. image:: screenshots/columba.png
:align: center
:width: 25%
:target: https://github.com/torlando-tech/columba/
While still in early and very active development, it is of course also compatible
with all other LXMF clients, and allows you to message seamlessly with anyone else
using LXMF.
.. raw:: latex
\newpage
+4
View File
@@ -31,6 +31,10 @@ Donations are gratefully accepted via the following channels:
Are certain features in the development roadmap are important to you or your
organisation? Make them a reality quickly by sponsoring their implementation.
.. raw:: latex
\newpage
Provide Feedback
================
Feedback on the usage, functioning and potential dysfunctioning of any and