Compare commits

..

306 commits

Author SHA1 Message Date
Mark Qvist
e40191b3d1
Added link
Some checks failed
Build Reticulum / test (push) Has been cancelled
Build Reticulum / package (push) Has been cancelled
Build Reticulum / release (push) Has been cancelled
2026-09-30 15:41:49 +02:00
Mark Qvist
7f2b3b9b52
Prepare release
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-29 21:51:21 +02:00
Mark Qvist
0a25e1a9c8
Updated changelog 2026-09-29 21:51:00 +02:00
Mark Qvist
ddeb44b1de
Updated changelog 2026-09-29 21:27:17 +02:00
Mark Qvist
39a888c1e2
Added --show-stale and --show-unknown options to rnstatus 2026-09-29 21:05:39 +02:00
Mark Qvist
1f74990752
Added auto-connect filtering criteria 2026-09-29 20:53:11 +02:00
Mark Qvist
a714c20043
Set detached immediately 2026-09-29 20:44:42 +02:00
Mark Qvist
942434b288
Fixed missing detached property handling 2026-09-29 20:21:32 +02:00
Mark Qvist
47318034dd
Added auto-connect support on Windows and macOS 2026-09-29 18:15:45 +02:00
Mark Qvist
bb1320913a
Added discovered interface auto-connect criteria constants 2026-09-29 17:54:49 +02:00
Mark Qvist
fc95caf519
Added atomic, sequential naming of auto-connected interfaces on announced name collisions 2026-09-29 17:19:22 +02:00
Mark Qvist
6d1409be1c
Ensure idempotency in auto-connected interfaces monitoring on manual interface detach 2026-09-29 16:49:32 +02:00
Mark Qvist
0a0e661beb
Propagate internal_forced on interface reload 2026-09-29 16:48:42 +02:00
Mark Qvist
26aec0040e
Use canonical interface detach in auto-connected interface monitor job 2026-09-29 14:52:49 +02:00
Mark Qvist
135e941fa2
Improved sanitization of nonsensical interface discovery IFAC announce data 2026-09-29 14:32:49 +02:00
Mark Qvist
1d9ebe8c38
Fixed missing configurable property propagations on spawned interfaces. By JRG <fccab3ba809f89d870d5b8b2e2cdbad1> (commit fd1c463fa7e940e5ccea87bc51291fff6ff75f8d) 2026-09-29 12:39:42 +02:00
Mark Qvist
4f79110995
Ensure announce_cap property is always initialized 2026-09-29 12:37:10 +02:00
Mark Qvist
5cdff28703
Fixed timeout and response rejects not being processed for single-packet requests. By JRG <fccab3ba809f89d870d5b8b2e2cdbad1> (commit 238e108678b7b5f58b770b44a817495f74595332) 2026-09-29 12:08:11 +02:00
Mark Qvist
06dc4ad550
Fixed instances of announce_cap property ensurance logic not storing announce cap value as a fraction. By JRG <fccab3ba809f89d870d5b8b2e2cdbad1> (commit 0b88890f512410d6e76e3785c3919657a2b34aa4) 2026-09-29 11:58:50 +02:00
Mark Qvist
6c9d717cd6
Fixed ingress control producer comprehension at mid-watermark using incorrect variable name for interface type check. By JRG <fccab3ba809f89d870d5b8b2e2cdbad1> (commit a790cb02152f80d7cdb9677c90672905b8b62c44) 2026-09-29 11:48:56 +02:00
Mark Qvist
f25b31702b
Added disclosure requirements to contribution guidelines
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-29 11:33:59 +02:00
Mark Qvist
73c60e6a49
Updated contribution guidelines 2026-09-29 11:20:46 +02:00
Mark Qvist
cce96d38c6
Updated changelog 2026-09-28 19:09:31 +02:00
Mark Qvist
71583c5c2d
Set interface owner before connect 2026-09-28 19:00:18 +02:00
Mark Qvist
d5962d14eb
Updated documentation
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-28 18:49:38 +02:00
Mark Qvist
d3153bd778
Added stack info to rnstatus discovered interfaces output 2026-09-28 18:34:14 +02:00
Mark Qvist
69c425d9ae
Updated changelog 2026-09-28 18:33:18 +02:00
Mark Qvist
a93c6ba5be
Added ability to download markdown files as converted micron to rngit 2026-09-28 17:45:54 +02:00
Mark Qvist
1cf176f6e5
Added work doc counts to filter links 2026-09-28 15:58:13 +02:00
Mark Qvist
b899389956
Fixed outdated meta-docs
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-28 15:02:19 +02:00
Mark Qvist
3ad70c63ea
Fixed outdated meta-docs 2026-09-28 14:47:06 +02:00
Mark Qvist
3b7429149e
Fixed outdated meta-docs 2026-09-28 14:42:39 +02:00
Mark Qvist
e2ba876ebf
Updated version 2026-09-27 21:45:34 +02:00
Mark Qvist
6ecda49394
Updated documentation 2026-09-27 21:44:01 +02:00
Mark Qvist
d23261c8d9
Added live interface attach/detach/reload 2026-09-27 21:42:56 +02:00
Mark Qvist
84709ccf09
Prepared TCP and UDP interfaces for live detach/attach 2026-09-27 20:44:38 +02:00
Mark Qvist
7283cb417a
Prepared serial-based interfaces for live detach/attach 2026-09-27 20:30:02 +02:00
Mark Qvist
2c30a88e85
Fixed I2P interface discovery config snippet generation not including .b32.i2p 2026-09-27 19:32:57 +02:00
Mark Qvist
24b1ac521e
Prepared RNodeInterface for live detach/attach 2026-09-27 19:26:09 +02:00
Mark Qvist
e68f4ff118
Prepared AutoInterface for live detach/attach 2026-09-27 17:48:13 +02:00
Mark Qvist
c95fd8e6ca
Updated rngit documentation
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-27 14:55:21 +02:00
Mark Qvist
3f95b47282
Updated AGENTS.md
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-26 22:09:29 +02:00
Mark Qvist
aeccf69fc7
Cleanup 2026-09-26 14:34:17 +02:00
Mark Qvist
8a7ad40d64
Fixed typo
Some checks failed
Build Reticulum / test (push) Has been cancelled
Build Reticulum / package (push) Has been cancelled
Build Reticulum / release (push) Has been cancelled
2026-09-24 16:39:04 +02:00
Mark Qvist
c4be063650
Fixed typo 2026-09-24 16:37:53 +02:00
Mark Qvist
87a75c2553
Added link 2026-09-24 16:18:14 +02:00
Mark Qvist
c963581a6f
Updated readme 2026-09-24 16:01:29 +02:00
Mark Qvist
1f7dbe6dde
Updated readme 2026-09-24 15:41:04 +02:00
Mark Qvist
0af42a6885
Updated readme 2026-09-24 15:32:11 +02:00
Mark Qvist
ff4e04099c
Updated readme 2026-09-24 14:44:04 +02:00
Mark Qvist
780b7e6bfc
Updated software chapter of the manual 2026-09-24 14:38:29 +02:00
Mark Qvist
192898864c
Formatting and typos
Some checks failed
Build Reticulum / test (push) Has been cancelled
Build Reticulum / package (push) Has been cancelled
Build Reticulum / release (push) Has been cancelled
2026-09-22 11:26:56 +02:00
Mark Qvist
c7be675739
Formatting and wording
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-22 00:56:43 +02:00
Mark Qvist
21a2365045
Formatting and wording 2026-09-22 00:37:39 +02:00
Mark Qvist
a22de54b4a
Added Void Grants 2026-09-21 22:47:49 +02:00
Mark Qvist
99de23c040
Updated docs
Some checks failed
Build Reticulum / test (push) Has been cancelled
Build Reticulum / package (push) Has been cancelled
Build Reticulum / release (push) Has been cancelled
2026-09-20 01:58:49 +02:00
Mark Qvist
395241b190
Updated docs 2026-09-20 01:55:25 +02:00
Mark Qvist
15e6dd4a88
Updated docs 2026-09-19 20:25:43 +02:00
Mark Qvist
34b36362ab
Scratch that 2026-09-19 20:25:14 +02:00
Mark Qvist
ddd55d50cc
Updated readme
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-19 19:11:48 +02:00
Mark Qvist
cb66b3d10f
Updated readme 2026-09-19 19:08:25 +02:00
Mark Qvist
8743ddcbe5
Updated readme 2026-09-19 19:07:57 +02:00
Mark Qvist
23101e45f6
Updated readme 2026-09-19 19:06:53 +02:00
Mark Qvist
2289a8effa
Updated readme 2026-09-19 18:01:02 +02:00
Mark Qvist
3c479ddb65
Updated readme 2026-09-19 17:37:13 +02:00
Mark Qvist
9d85931c9d
Updates 2026-09-19 17:31:48 +02:00
Mark Qvist
58881af307
Updates 2026-09-19 17:30:19 +02:00
Mark Qvist
7373f81349
Markdown is markdown 2026-09-19 16:45:04 +02:00
Mark Qvist
29f6bc8652
Done, next chapter 2026-09-19 16:43:31 +02:00
Mark Qvist
abf355b4d7
Formatting, link, wording, typos 2026-09-19 16:42:03 +02:00
Mark Qvist
484cf2d1a5
Added history folder 2026-09-19 16:13:01 +02:00
Mark Qvist
e699bb23f1
Structural cleanup 2026-09-19 11:16:58 +02:00
Mark Qvist
badd850088
Cleanup and formatting
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-19 00:36:53 +02:00
Mark Qvist
ffad91431a
Formatting 2026-09-19 00:17:58 +02:00
Mark Qvist
d0231f6ee3
Formatting 2026-09-19 00:17:48 +02:00
Mark Qvist
b4c6deee78
Cleanup and formatting 2026-09-19 00:14:18 +02:00
Mark Qvist
900b4c607c
Cleanup 2026-09-18 23:42:46 +02:00
Mark Qvist
9397114f56
Fixed typo 2026-09-18 23:33:03 +02:00
Mark Qvist
16ba292bd0
Fixed typo 2026-09-18 23:32:41 +02:00
Mark Qvist
032e14384f
Formatting 2026-09-18 22:31:06 +02:00
Mark Qvist
b79f6e0b1c
Updated readme
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-09-18 18:05:12 +02:00
Mark Qvist
3b0425c8a8
Updated readme 2026-09-18 18:02:53 +02:00
Mark Qvist
b043414add
Updated readme 2026-09-18 18:00:57 +02:00
Mark Qvist
1e7432af39
Added AGENTS.md 2026-09-18 17:52:40 +02:00
Mark Qvist
b67872d946
Added Brandolini's Reference chapter to the manual 2026-09-18 17:51:29 +02:00
Mark Qvist
99bfde84bf
Added Brandolini's Reference chapter to the manual 2026-09-18 17:50:58 +02:00
Mark Qvist
0bc132d7df
Updated readme 2026-09-18 01:51:55 +02:00
Mark Qvist
28eba69900
Added generation notices 2026-09-18 01:49:52 +02:00
Mark Qvist
1565126ffd
Updated readme
Some checks failed
Build Reticulum / test (push) Has been cancelled
Build Reticulum / package (push) Has been cancelled
Build Reticulum / release (push) Has been cancelled
2026-09-12 10:54:51 +02:00
Mark Qvist
0dbc9e90a3
Prepare release 2026-09-11 16:59:21 +02:00
Mark Qvist
9199cc9e4b
Updated changelog 2026-09-11 16:57:52 +02:00
Mark Qvist
7785fd277a
Updated version 2026-09-11 16:22:24 +02:00
Mark Qvist
851c18bc27
Improved RNode BLE reconnect reliability 2026-09-11 16:18:24 +02:00
Mark Qvist
996d822393
Fixed RNode BLE re-connection deadlock on desktop. Cleanup. 2026-09-11 12:49:05 +02:00
Mark Qvist
0c97853554
Fix RNode BLE device address acquisition on windows, by Nickie Deuxyeux 2026-09-11 11:49:24 +02:00
Mark Qvist
fae64abf05
Prepare release 2026-09-10 22:00:14 +02:00
Mark Qvist
7330c20c6d
Updated changelog 2026-09-10 21:58:03 +02:00
Mark Qvist
e7a7c48c0a
Added importable webp media file conversion helper 2026-09-10 19:37:24 +02:00
Mark Qvist
f231fdcb4d
Added immediate permissions change activation for rngit perms 2026-09-10 15:35:48 +02:00
Mark Qvist
87823c091f
Cleanup 2026-09-10 13:48:59 +02:00
Mark Qvist
36a62e7b1a
Cleanup, align with existing pattern of minimal information disclosure on insufficient permissions 2026-09-10 11:31:18 +02:00
Mark Qvist
859dc81805
Cleanup, in-method atomicity of group and repository name derivation to ensure no discrepancies between request data and method invocation parameters 2026-09-10 11:28:24 +02:00
Henri Bergius
409380a458
Allow admins to activate and complete work docs 2026-09-06 20:57:36 -11:00
Henri Bergius
b0933d5c6f
activate should work also with proposed work documents 2026-09-06 20:50:19 -11:00
Mark Qvist
0bb41bf948
Updated version 2026-09-06 17:39:53 +02:00
Mark Qvist
27910f25a1
Added rngit media conversion 2026-09-06 17:39:07 +02:00
Mark Qvist
602d52f178
Don't auto-compress media responses 2026-09-06 16:27:21 +02:00
Mark Qvist
7396e3994e
Basic rngit media handling 2026-09-06 15:55:34 +02:00
Mark Qvist
8a82a50fc9
Updated readme 2026-09-06 15:37:36 +02:00
Mark Qvist
5c1141d1ce
Updated readme 2026-09-06 15:34:34 +02:00
Mark Qvist
6b6dd73bc8
Updated readme 2026-09-06 15:20:45 +02:00
Mark Qvist
1a7e9e31a1
Fixed typo 2026-09-06 14:55:50 +02:00
Mark Qvist
2f29b56e96
Adjusted logging 2026-09-04 11:41:27 +02:00
Mark Qvist
3bc149e3d5
Added no_ident template support to rngit 2026-09-01 17:05:28 +02:00
Mark Qvist
eb8414bee9
Killed a couple of allocs, canonical HDLC frame method 2026-09-01 14:45:54 +02:00
Mark Qvist
ea98db4f53
Prepare release
Some checks failed
Build Reticulum / test (push) Has been cancelled
Build Reticulum / package (push) Has been cancelled
Build Reticulum / release (push) Has been cancelled
2026-08-29 04:16:13 +02:00
Mark Qvist
ad3c06636b
Adjusted logging 2026-08-29 04:10:50 +02:00
Mark Qvist
9ce4502961
Updated changelog 2026-08-29 01:36:31 +02:00
Mark Qvist
a3cd84111f
Updated default config with null_ident blocking example 2026-08-29 01:31:05 +02:00
Mark Qvist
d5b2fc5609
Updated changelog 2026-08-29 00:16:42 +02:00
Mark Qvist
83a30b187a
Tuned dataplane control defaults 2026-08-28 23:57:31 +02:00
Mark Qvist
943771a3f9
Updated version 2026-08-28 23:41:20 +02:00
Mark Qvist
5b4117dadc
Skip local shared instance interfaces in ingress/egress control 2026-08-28 23:41:00 +02:00
Mark Qvist
6bc0481cdf
Always remember to flush 2026-08-28 23:33:32 +02:00
Mark Qvist
4eebf0b685
Guard empty keepalive frames 2026-08-28 21:47:24 +02:00
Mark Qvist
149e415109
Prepare release
Some checks are pending
Build Reticulum / test (push) Waiting to run
Build Reticulum / package (push) Blocked by required conditions
Build Reticulum / release (push) Blocked by required conditions
2026-08-28 16:03:16 +02:00
Mark Qvist
53ed9c1ed3
Updated version 2026-08-28 15:28:48 +02:00
Mark Qvist
ccc5468bcc
Updated changelog 2026-08-28 15:21:02 +02:00
Mark Qvist
c1d7c12b52
Fixed stream-based resource initialized transfer failing when total sized happened to not fall withing MAX_EFFICIENT_SIZE 2026-08-28 14:43:39 +02:00
Mark Qvist
9878b18377
Added transmit buffer, TX drops and stalled status output to rnstatus 2026-08-28 13:44:14 +02:00
Mark Qvist
6e8c976421
Worker spawn prep 2026-08-28 12:44:59 +02:00
Mark Qvist
b28f5ebf7a
Allow inbound queue utilization in drainer benchmarks 2026-08-28 11:11:42 +02:00
Mark Qvist
77c8256a1a
Fixed invalid prefix stripping in rngit page server 2026-08-28 01:50:46 +02:00
Mark Qvist
dc9cf0c2f4
Updated setup.py 2026-08-28 00:09:16 +02:00
Mark Qvist
fa07c8c87c
Updated setup.py 2026-08-27 23:56:19 +02:00
Mark Qvist
b7fe01237d
Updated setup.py 2026-08-27 23:38:29 +02:00
Mark Qvist
3543dc9802
Load module compilation status from buildinfo if available 2026-08-27 23:37:43 +02:00
Mark Qvist
5b04a88026
Updated makefile 2026-08-27 23:36:34 +02:00
Mark Qvist
5fdd661081
Updated makefile 2026-08-27 22:53:15 +02:00
Mark Qvist
ae0191578f
Updated Cython dev shim 2026-08-27 22:37:51 +02:00
Mark Qvist
e714c559cc
Updated tests init 2026-08-27 22:17:38 +02:00
Mark Qvist
75cbc73f64
Removed dead Python 2 code from umsgpack 2026-08-27 21:05:27 +02:00
Mark Qvist
d32ba8c1a1
Fixed inconsistency against advertised return type 2026-08-27 21:04:40 +02:00
Mark Qvist
ff3a72209e
Avoid rebind of exception reference 2026-08-27 18:32:41 +02:00
Mark Qvist
803b548951
Allow loading compiled modules 2026-08-27 14:56:47 +02:00
Mark Qvist
67a45ac5e9
Fixed nav init order 2026-08-27 14:55:20 +02:00
Mark Qvist
e71c01959f
Fixed stray non-imported variable 2026-08-27 14:49:32 +02:00
Mark Qvist
0a1b9453b2
Fixed non-epoll backend keepalive 2026-08-27 14:47:05 +02:00
Mark Qvist
79cad39e78
Fixed missing import 2026-08-27 14:43:21 +02:00
Mark Qvist
de0dac695b
Fixed missing exception reference 2026-08-27 13:12:53 +02:00
Mark Qvist
281c47f3e0
Fixed invalid reference in exception description 2026-08-27 13:12:04 +02:00
Mark Qvist
99c428a9f5
Added dataplane egress control 2026-08-27 02:22:55 +02:00
Mark Qvist
cb9071b478
Added HWM limit option to TransmitBuffer 2026-08-27 01:56:43 +02:00
Mark Qvist
10848b7cff
Added egress control HWM limiter test 2026-08-27 01:55:28 +02:00
Mark Qvist
89e73db94a
Wired coalescing transmit buffer and optimized HDLC deframer into Backbone and Local interfaces 2026-08-26 22:18:37 +02:00
Mark Qvist
0ec84c7491
Added coalescing transmit buffer 2026-08-26 21:58:51 +02:00
Mark Qvist
ac9130f01f
Added coalesced transmit test 2026-08-26 21:38:15 +02:00
Mark Qvist
54a919f045
Added optimized HDLC implementation 2026-08-26 20:59:38 +02:00
Mark Qvist
bafc09d3a6
Cleanup 2026-08-26 14:26:34 +02:00
Mark Qvist
419956bada
Over-optimization is the root of all evil 2026-08-26 13:25:14 +02:00
Mark Qvist
1a16fdb727
Fixed inbound queue high-water mark initialization order 2026-08-26 01:05:01 +02:00
Mark Qvist
3b7cfc0666
Cleanup 2026-08-26 00:15:44 +02:00
Mark Qvist
de76b8939f
Added dataplane ingress control 2026-08-25 23:30:59 +02:00
Mark Qvist
d80245b62c
Traffic class and violation handling 2026-08-25 14:02:08 +02:00
Mark Qvist
dcbc7638d0
Fixed RSSI/SNR reporting regression 2026-08-25 13:22:46 +02:00
Mark Qvist
0536b972d7
Tuned QUEUED_ANNOUNCES. Logging. 2026-08-25 12:08:43 +02:00
Mark Qvist
47add6381d
Increase queues for throughput tests 2026-08-25 11:14:38 +02:00
Mark Qvist
be2ba7c2f3
Tuned auto MTU configurration 2026-08-25 11:08:01 +02:00
Mark Qvist
dd204e11ce
Added 32k throughput run 2026-08-25 11:07:38 +02:00
Mark Qvist
be4ee32908
Tuned queuelen defaults 2026-08-25 10:58:08 +02:00
Mark Qvist
9f66b5a6a3
Merge branch 'optimize' 2026-08-25 02:51:54 +02:00
Mark Qvist
d38a8de571
Fixed f-strings for old snakes 2026-08-25 02:18:23 +02:00
Mark Qvist
dea0124c57
Reduced lock acquisition 2026-08-25 02:06:34 +02:00
Mark Qvist
aba8d606dd
Cleanup 2026-08-25 01:52:53 +02:00
Mark Qvist
8221f82dc0
Cleanup 2026-08-25 01:52:05 +02:00
Mark Qvist
38e9d1cdd4
Cleanup 2026-08-25 01:45:34 +02:00
Mark Qvist
17e980ff79
Cleanup 2026-08-25 01:44:50 +02:00
Mark Qvist
f111709902
Updated througput benchmarker 2026-08-25 01:41:31 +02:00
Mark Qvist
2d2167140d
FP cache experiment 2026-08-25 01:41:02 +02:00
Mark Qvist
d044db2931
Cache announce signature validation 2026-08-24 22:12:04 +02:00
Mark Qvist
516cb106c1 Updated througput benchmarker 2026-08-24 21:54:11 +02:00
Mark Qvist
7e197542e5 Updated througput bench 2026-08-24 21:54:11 +02:00
Mark Qvist
77f7632584 Avoid extra epoll modifies when EPOLLOUT already set 2026-08-24 21:54:11 +02:00
Mark Qvist
5e013464da Added throughput benchmarker 2026-08-24 21:54:11 +02:00
Mark Qvist
b9278ce352
Added throughput benchmarker 2026-08-24 20:42:57 +02:00
Mark Qvist
d81421dad3
Avoid additional packet hashing under lock in inbound 2026-08-24 17:58:29 +02:00
Mark Qvist
629e4fde2d
Added hash map lookups for pending and active links 2026-08-24 17:45:29 +02:00
Mark Qvist
7311dc8544
Reduced path table lock acquisitions in inbound processing 2026-08-24 17:03:48 +02:00
Mark Qvist
0e070aacf6
Ah well, of course. 2026-08-24 11:29:01 +02:00
Mark Qvist
39899651ee
Merge adjustments 2026-08-24 10:56:26 +02:00
Mark Qvist
1034d78828
Merge adjustments 2026-08-24 10:52:47 +02:00
Mark Qvist
9c2f424aaa Merge branch 'live_profiling' into live_profiler_merge 2026-08-24 10:31:13 +02:00
K8
dca5b9639e
Limit total profiler captures per tag, not per thread; handle reentrant profilers; make stats time windows be non-overlapping. 2026-08-23 22:39:32 -06:00
K8
40281f91da
Decorator for profiling functions. 2026-08-23 21:53:33 -06:00
K8
cf5d6a796e
Rework profilers for running indefinitely. 2026-08-23 21:53:33 -06:00
K8
9302415f9e
Add live profiling results output to rnstatus. 2026-08-23 21:53:33 -06:00
K8
9da6664976
Move log writing to a dedicated thread. 2026-08-23 21:53:33 -06:00
K8
1694a17a75
Full and configurable logfile rotation. 2026-08-23 21:53:33 -06:00
Mark Qvist
3fdfe93ec7
Don't loop on attached interface packets 2026-08-24 01:26:24 +02:00
Mark Qvist
a9538e9fd1
Loglevel 2026-08-24 00:53:35 +02:00
Mark Qvist
956d688e10
Check before cancel incoming 2026-08-24 00:14:54 +02:00
Mark Qvist
1c83e732ad
Use optimized IFAC handlers 2026-08-23 23:54:45 +02:00
Mark Qvist
5da0870e2a
Optimized HKDF 2026-08-23 23:54:17 +02:00
Mark Qvist
171868c6cb
Added IFAC and HKDF tests to test runner 2026-08-23 23:49:59 +02:00
Mark Qvist
cfddb9abe6
Added HKDF tests 2026-08-23 23:48:48 +02:00
Mark Qvist
929aba0282
Added IFAC tests 2026-08-23 23:47:34 +02:00
Mark Qvist
7347034f9a
Optimized outbound IFAC handling 2026-08-23 22:27:53 +02:00
Mark Qvist
aef9e5b416
Extract outbound IFAC handling 2026-08-23 22:05:36 +02:00
Mark Qvist
e806ae5837
Optimized inbound IFAC handling 2026-08-23 21:57:22 +02:00
Mark Qvist
602085a1cd
Extract inbound IFAC handler 2026-08-23 20:11:29 +02:00
Mark Qvist
84597f3186
Added MTU output to rnstatus 2026-08-23 18:01:24 +02:00
Mark Qvist
88c629e3ad
Logging and MTU adjustments 2026-08-23 18:01:13 +02:00
Mark Qvist
2058596dd1
Check before cancel outgoing 2026-08-23 15:32:04 +02:00
Mark Qvist
b123a756b0
Added transport implementation name and version to discovery information requirements
Some checks failed
Build Reticulum / test (push) Has been cancelled
Build Reticulum / package (push) Has been cancelled
Build Reticulum / release (push) Has been cancelled
2026-08-23 11:56:27 +02:00
Mark Qvist
bfab2964b6
Fixed rnstatus including not-yet-blocked IPs in blocked IP list output 2026-08-23 11:30:43 +02:00
Mark Qvist
1bad7f5807
Remove from previous hashlist under transport edge case handling 2026-08-23 11:14:47 +02:00
Mark Qvist
26e3ca4f2b
Added shared medium hints to interfaces 2026-08-23 10:46:21 +02:00
Mark Qvist
d25ea38c84
Added PPS stats to rnstatus. Don't count local shared instance in traffic totals. 2026-08-22 22:41:03 +02:00
Mark Qvist
e32d4df754
Updated docs 2026-08-22 14:54:40 +02:00
Mark Qvist
561e2f23e1
Updated changelog 2026-08-22 14:51:49 +02:00
Mark Qvist
b397870c97
Log levels 2026-08-22 13:18:01 +02:00
Mark Qvist
6f6751d6b6
Fixed PR egress limiter not preemptively considering potential outbound, and added late egress check to avoid state race under high incoming PR load 2026-08-22 12:23:37 +02:00
Mark Qvist
2aed542e61
Get path_entry directly in _outbound 2026-08-22 11:36:41 +02:00
Mark Qvist
091e021d0e
Early return on excessive hop count packets 2026-08-22 11:09:39 +02:00
Mark Qvist
fc69f323a8
Cleanup 2026-08-22 01:18:01 +02:00
Mark Qvist
6b2c5bcf0b
Don't reprocess IFAC for held announces 2026-08-22 00:18:48 +02:00
Mark Qvist
3885d23293
Added protocol violations 2026-08-21 23:51:52 +02:00
Mark Qvist
b1aa527861
Track in-flight path requests separately 2026-08-21 23:16:20 +02:00
Mark Qvist
4c69b376b2
Added adaptive timeouts to utilities, by Zenith 2026-08-21 22:20:00 +02:00
Mark Qvist
2b1b9589f1
Added medium bitrate based timeout calculation helpers and RPC functions and added dynamic timeout calculation to rngit, by Zenith 2026-08-21 22:16:10 +02:00
Mark Qvist
378840a6bf
Added total announce/pr count stats per interface 2026-08-21 20:59:32 +02:00
Mark Qvist
7b8923b61f
Added protocol violations 2026-08-21 20:35:52 +02:00
Mark Qvist
b3ef214e72
Added total announce/pr frequency stats 2026-08-21 18:25:59 +02:00
Mark Qvist
cab513fa31
Logging 2026-08-21 18:08:35 +02:00
Mark Qvist
60eb0509f2
Signal blackholed status in validate_announce 2026-08-21 17:40:12 +02:00
Mark Qvist
42f6d64b9c
Implemented per-interface protocol violation tracking 2026-08-21 16:48:05 +02:00
Mark Qvist
6ba7cc8cb4
Added data flow speeds to rnstatus 2026-08-21 13:37:09 +02:00
Mark Qvist
880db0a7b7
Added active links stat to rnstatus 2026-08-21 13:23:30 +02:00
Mark Qvist
5f2f4438d0
Added detailed announce and path request traffic stats 2026-08-21 13:06:48 +02:00
Mark Qvist
7488336985
Use test runner config loglevel setting 2026-08-21 11:32:53 +02:00
Mark Qvist
614e7bd834
Improved path request handling, batch same-destination PRs when existing in-flight path request exists 2026-08-21 00:54:08 +02:00
Mark Qvist
386ef1f370
Transport jobs optimizations 2026-08-20 22:08:06 +02:00
Mark Qvist
4ab0755d0a
Changed PR ingress accounting point 2026-08-20 17:16:01 +02:00
Mark Qvist
d478e380c9
Use sets for discovery pr tags 2026-08-20 17:08:22 +02:00
Mark Qvist
05e6717d21
Fixed rngit file resource operations failing on Windows 2026-08-20 10:14:49 +02:00
Mark Qvist
9ae3db169e
Fixed medium_timeout init 2026-08-20 08:38:09 +02:00
Mark Qvist
68cda4a855
Added discovery_lxmf_address to documentation 2026-08-19 21:15:30 +02:00
Mark Qvist
4b914fb9a4
Include extra timeout for discovery PRs when slow interfaces are online, thanks to Zenith 2026-08-19 21:07:50 +02:00
Mark Qvist
bde5611a0d
Fixed missing interface.bitrate validation in extra link proof timeout calculation, thanks to Zenith 2026-08-19 20:15:17 +02:00
Mark Qvist
49073fcca5
Fixed invalid interface basis for extra link proof timeout calculation, thanks to Zenith 2026-08-19 20:13:32 +02:00
Mark Qvist
6738db5437
Cleaned up deprecated logic block indent in relation to inbound processing refactor 2026-08-19 20:08:34 +02:00
Mark Qvist
49918c7e1d
Updated docs 2026-08-19 19:57:17 +02:00
Mark Qvist
954567c581
Allow disabling link MTU discovery 2026-08-19 18:06:17 +02:00
Mark Qvist
77a1bb9b19
Consistency 2026-08-19 18:03:23 +02:00
Mark Qvist
df80181006
Utilize full link MDU in RawChannelWriter 2026-08-19 17:57:14 +02:00
Mark Qvist
0c0034f1ae
Tuned burst stats throttle 2026-08-19 16:44:29 +02:00
Mark Qvist
2e9443ffe2
Fixed typo 2026-08-19 15:49:09 +02:00
JRG
65222e0de8 Resource: align indexes in receive_part with request_next 2026-08-19 15:44:53 +02:00
Mark Qvist
db7daa4d94
Method naming 2026-08-19 14:22:36 +02:00
JRG
c95bb551ab Transport: handle exceptions in inbound() 2026-08-19 14:19:49 +02:00
Mark Qvist
acecc1f490
Error logging 2026-08-19 13:39:53 +02:00
JRG
39e3854dac Link: reset watchdog if exception happens in receive 2026-08-19 13:38:18 +02:00
JRG
6cd5be18ec Transport: slice discovery_pr_tags to the end
Keep the last element.
2026-08-19 13:33:40 +02:00
JRG
0e2041c837 BackboneInterface: update timestamps in ic checks 2026-08-19 13:23:16 +02:00
JRG
338173802d Transport: fix destination_hash check in pending_discovery_prs 2026-08-19 13:07:55 +02:00
Mark Qvist
40a862acbd
Break loop on existing entry in announce queue 2026-08-19 13:02:28 +02:00
JRG
d825e39379 Transport: fix updating announce_queue 2026-08-19 12:59:41 +02:00
JRG
731a63b01b Transport: update interface_hashes_updated_at timestamp 2026-08-19 12:54:49 +02:00
JRG
7c912d0936 rnstatus: use proper stats 2026-08-19 12:53:15 +02:00
Mark Qvist
503bd6c87b
Improved PR ingress limiter 2026-08-18 13:17:34 +02:00
Mark Qvist
1a70bd3cd1
Added queue drop stats 2026-08-18 12:34:18 +02:00
Mark Qvist
636012bbe5
Cleanup 2026-08-18 12:17:14 +02:00
Mark Qvist
e1b7bc316c
Fixed typo 2026-08-18 12:02:44 +02:00
Mark Qvist
1c488947bd
Fixed typo 2026-08-18 12:00:42 +02:00
Mark Qvist
1809461a07
Early filtering note 2026-08-18 11:59:51 +02:00
Mark Qvist
01a78bee2a
Fixed typo 2026-08-18 11:55:58 +02:00
Mark Qvist
8c08b9ce09
Added queue config to documentation 2026-08-18 11:45:34 +02:00
Mark Qvist
efb8c8500d
Made queue lengths configurable 2026-08-18 11:45:20 +02:00
Mark Qvist
f366dd9cf2
Cleanup 2026-08-18 10:34:41 +02:00
Mark Qvist
0d15cfc134
Fixed f-string for old snakes 2026-08-17 21:52:53 +02:00
Mark Qvist
e81532f541
Updated version 2026-08-17 21:40:57 +02:00
Mark Qvist
d41afd2d81
Updated rnstatus docs 2026-08-17 21:40:14 +02:00
Mark Qvist
7a1291d530
Added inbound queue pressure statistics to rnstatus 2026-08-17 20:52:02 +02:00
Mark Qvist
fc0f84f23e
Added prioritized inbound traffic processing 2026-08-17 20:51:46 +02:00
Mark Qvist
c2eac12ff5
Added burst count stat to interfaces 2026-08-17 20:47:30 +02:00
Mark Qvist
e46496032c
Contention comments 2026-08-17 01:37:22 +02:00
JRG
2d7f858a54 Transport: fix deadlock on receipts_lock when callback sends message
Deadlock on receipts_lock:

    inbound (Transport.py) <- holds receipts_lock
    validate_proof_packet (Packet.py)
    validate_link_proof (Packet.py)
    delivery_callback <- application callback, sends packet
    send (Packet.py)
    outbound (Transport.py)
    packet_sent (Transport.py) <- blocks on receipts_lock
2026-08-17 01:13:26 +02:00
Mark Qvist
9ebcb55cb5
Cleanup 2026-08-17 01:07:59 +02:00
JRG
bbc1a0d06b BackboneInterface: fix epoll RX starvation
Speedtest.py fails when running on a shared instance with data_cap = 2*1024*1024*1024

Request both EPOLLIN | EPOLLOUT in tx_ready() and process both events in __job()

Not included:
Speedtest.py: change link.status == RNS.Link.ACTIVE to link.status != RNS.Link.CLOSED
2026-08-17 01:06:13 +02:00
Mark Qvist
31298e5eda
Fixed speedtest TX abort on link stale status 2026-08-17 01:00:29 +02:00
JRG
e31c570d2b Transport: fix rebind of expires variable 2026-08-17 00:51:33 +02:00
JRG
9b4947ef46 Destination: clean ratchets to retained_ratchets 2026-08-17 00:43:40 +02:00
JRG
0d9bf5b862 rncp: fix variable name 2026-08-17 00:41:14 +02:00
JRG
c6f9ef1047 RNodeInterface: fix stale ble_device 2026-08-17 00:39:25 +02:00
JRG
90ac62620d Transport: fix pending_links items removal while being iterated 2026-08-17 00:38:28 +02:00
JRG
0c41027765 Resource: avoid rebind of data variable 2026-08-17 00:36:43 +02:00
JRG
691211bf0d BackboneInterface: remove unused poll 2026-08-17 00:34:16 +02:00
JRG
0ec51cce67 Link: fix constant name 2026-08-17 00:31:59 +02:00
JRG
55755c6b3c Transport: fix check for r_stat_snr 2026-08-17 00:30:38 +02:00
JRG
72ba27d63c Link: fix resource cancellation
Resource.cancel() -> Link.cancel_{incoming/outgoing}_resource()
changes the iterated list and every second resource is skipped.

Fix: iterate over copies of the two lists
2026-08-17 00:26:19 +02:00
JRG
6c1182c2c2 Packet: reticulum variable is not defined 2026-08-17 00:24:16 +02:00
Mark Qvist
ce81b339dc
This will have *zero* effect on any actual, real-world or otherwise just tangentially relevant physically viable situation, but at this point I am fed up with receiving a daily dose of overly verbose machine soup from yet another random LLM-bro who's playing security researcher, so there you have it, now ffs leave me in peace to do some *actual* work, and keep your tech-fantasy wanking to yourself, thank you very much. 2026-08-16 21:34:34 +02:00
Mark Qvist
3db15dae44
Updated identity test 2026-08-16 21:28:03 +02:00
Mark Qvist
38a73f9508
Added ability to include operator LXMF address in interface discovery info 2026-08-16 00:29:14 +02:00
Mark Qvist
ebd9b862e9
Fixed rnodeconf config summary print 2026-08-14 11:21:04 +02:00
Mark Qvist
4fc8e03d65
Added blocked IP listing to rnstatus 2026-07-28 15:36:12 +02:00
166 changed files with 17218 additions and 2834 deletions

1386
AGENTS.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,278 @@
compiling = False
noticed = False
notice_delay = 0.3
import time
import os
import sys
import time
import threading
from importlib.util import find_spec
if find_spec("pyximport") and find_spec("cython"):
import pyximport; pyxloader = pyximport.install(pyimport=True, language_level=3)[1]
import json
import shutil
import subprocess
from importlib.abc import MetaPathFinder
from importlib.machinery import ExtensionFileLoader, SourceFileLoader
from importlib.machinery import EXTENSION_SUFFIXES
from importlib.util import spec_from_file_location
from distutils.extension import Extension
from pyximport import pyxbuild
COMPILE_DIRECTIVES = { "annotation_typing": False }
_WORKER_SCRIPT = r"""
import sys, os, json
from distutils.extension import Extension
from pyximport import pyxbuild
def main():
tasks_path = sys.argv[1]
with open(tasks_path, "r") as fh: tasks = json.load(fh)
results = []
for task in tasks:
try:
os.makedirs(task["scratch"], exist_ok=True)
extension = Extension(name=task["basename"], sources=[task["source"]])
extension.cython_directives = task["directives"]
so_path = pyxbuild.pyx_to_dll(task["source"], ext=extension, build_in_temp=True, pyxbuild_dir=task["scratch"])
os.makedirs(os.path.dirname(task["target"]), exist_ok=True)
os.replace(so_path, task["target"])
results.append({"fullname": task["fullname"], "ok": True, "error": None})
except Exception as e:
results.append({"fullname": task["fullname"], "ok": False,
"error": "{}: {}".format(type(e).__name__, e)})
with open(tasks_path + ".results", "w") as fh: json.dump(results, fh)
if __name__ == "__main__": main()
"""
def _compile_jobs():
jobs = os.environ.get("CRNS_JOBS")
if jobs: return max(1, int(jobs))
return max(1, min(os.cpu_count() or 1, 16))
class _ImportFinder(MetaPathFinder):
def __init__(self):
self.build_dir = os.path.join(os.path.expanduser("~"), ".pyxbld")
self.mirror_root = os.path.join(self.build_dir, "rnslib")
self.source_root = self._find_source_root()
self.abi_suffix = EXTENSION_SUFFIXES[0]
self.unbuildable = {}
self.unbuildable_path = os.path.join(self.build_dir, "unbuildable.json")
self._verify_mirror_directives()
try:
with open(self.unbuildable_path, "r") as fh: self.unbuildable = json.load(fh)
except Exception: self.unbuildable = {}
def _verify_mirror_directives(self):
# If the compiler directives have changed since the mirror was
# built, the entire mirror is invalidated and rebuilt.
stamp_path = os.path.join(self.build_dir, "rnslib.stamp")
directives = json.dumps(COMPILE_DIRECTIVES, sort_keys=True)
stamp = directives + "\n" + self.abi_suffix
previous = None
try:
with open(stamp_path, "r") as fh: previous = fh.read()
except Exception: pass
if previous != stamp:
shutil.rmtree(self.mirror_root, ignore_errors=True)
os.makedirs(self.mirror_root, exist_ok=True)
try:
os.makedirs(self.build_dir, exist_ok=True)
with open(stamp_path, "w") as fh: fh.write(stamp)
except Exception: pass
def _save_unbuildable(self):
try:
os.makedirs(os.path.dirname(self.unbuildable_path), exist_ok=True)
with open(self.unbuildable_path, "w") as fh: json.dump(self.unbuildable, fh, indent=1)
except Exception: pass
def _mark_unbuildable(self, fullname, source):
try:
self.unbuildable[fullname] = int(os.path.getmtime(source))
self._save_unbuildable()
except Exception: pass
def _unmark_buildable(self, fullname):
if fullname in self.unbuildable:
del self.unbuildable[fullname]
self._save_unbuildable()
def _is_unbuildable(self, fullname, source):
if fullname not in self.unbuildable: return False
try:
if int(os.path.getmtime(source)) == self.unbuildable[fullname]: return True
except Exception: pass
self.unbuildable.pop(fullname, None)
return False
def _find_source_root(self):
here = os.path.dirname(os.path.abspath(__file__))
candidate = os.path.abspath(os.path.join(here, "..", "RNS", "__init__.py"))
if os.path.exists(candidate): return os.path.dirname(os.path.dirname(candidate))
for entry in [os.getcwd()] + list(sys.path):
if not entry: continue
candidate = os.path.join(entry, "RNS", "__init__.py")
if os.path.exists(candidate): return os.path.dirname(candidate)
raise RuntimeError("CRNS could not locate the RNS source tree")
def _all_source_modules(self):
rns_root = os.path.join(self.source_root, "RNS")
for root, dirs, files in os.walk(rns_root):
dirs[:] = [d for d in dirs if d not in ("__pycache__", "i2plib")]
rel = os.path.relpath(root, self.source_root).replace(os.sep, ".")
for fn in sorted(files):
if not fn.endswith(".py"): continue
if fn == "__init__.py": fullname = rel
else: fullname = rel + "." + fn[:-3]
yield fullname
def _mirror_target(self, fullname, is_package):
rel = fullname.split(".")
basename = rel[-1]
if is_package: return os.path.join(self.mirror_root, *rel, "__init__" + self.abi_suffix)
return os.path.join(self.mirror_root, *rel[:-1], basename + self.abi_suffix)
def _source(self, fullname):
rel = fullname.split(".")
package_source = os.path.join(self.source_root, *rel, "__init__.py")
if os.path.exists(package_source): return package_source, True
module_source = os.path.join(self.source_root, *rel) + ".py"
if os.path.exists(module_source): return module_source, False
return None, None
def _compile(self, fullname, source):
basename = fullname.rsplit(".", 1)[-1]
extension = Extension(name=basename, sources=[source])
extension.cython_directives = COMPILE_DIRECTIVES
so_path = pyxbuild.pyx_to_dll(source, ext=extension, build_in_temp=True, pyxbuild_dir=self.build_dir)
abi_suffix = os.path.basename(so_path)[len(basename):]
return so_path, abi_suffix
def _is_current(self, fullname, source, is_package):
target = self._mirror_target(fullname, is_package)
if not os.path.exists(target): return False
try: return os.path.getmtime(target) >= os.path.getmtime(source)
except Exception: return False
def _ensure_compiled(self, fullname, source, is_package):
target = self._mirror_target(fullname, is_package)
if os.path.exists(target) and os.path.getmtime(target) >= os.path.getmtime(source): return target
so_path, abi_suffix = self._compile(fullname, source)
os.makedirs(os.path.dirname(target), exist_ok=True)
os.replace(so_path, target)
return target
def precompile(self):
if not os.path.isdir(self.mirror_root):
os.makedirs(self.mirror_root, exist_ok=True)
pending = []
for fullname in self._all_source_modules():
source, is_package = self._source(fullname)
if source is None:
continue
if self._is_current(fullname, source, is_package):
self._unmark_buildable(fullname)
continue
if self._is_unbuildable(fullname, source):
continue
pending.append((fullname, source, is_package))
if not pending: return
jobs = min(_compile_jobs(), len(pending))
if jobs > 1:
# Round-robin assignment spreads the large modules evenly
# across workers.
chunks = [[] for _ in range(jobs)]
for i, item in enumerate(pending): chunks[i % jobs].append(item)
workers_dir = os.path.join(self.build_dir, "workers")
os.makedirs(workers_dir, exist_ok=True)
procs = []
for w in range(jobs):
tasks = []
for fullname, source, is_package in chunks[w]:
tasks.append({ "fullname": fullname,
"basename": fullname.rsplit(".", 1)[-1],
"source": source,
"target": self._mirror_target(fullname, is_package),
"scratch": os.path.join(workers_dir, "w{}".format(w)),
"directives": COMPILE_DIRECTIVES })
task_path = os.path.join(workers_dir, "tasks{}.json".format(w))
with open(task_path, "w") as fh: json.dump(tasks, fh)
procs.append((w, task_path, subprocess.Popen([sys.executable, "-c", _WORKER_SCRIPT, task_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)))
for w, task_path, proc in procs:
stdout, stderr = proc.communicate()
if proc.returncode != 0:
for fullname, _, _ in chunks[w]:
source, _ = self._source(fullname)
self._mark_unbuildable(fullname, source)
print(f"CRNS: compile worker failed for {fullname}, using interpreted module", file=sys.stderr)
if stderr:
tail = stderr.decode(errors="replace").strip().splitlines()[-5:]
print("CRNS: worker output:\n" + "\n".join(tail), file=sys.stderr)
continue
results = {}
try:
with open(task_path + ".results", "r") as fh:
for entry in json.load(fh): results[entry["fullname"]] = entry
except Exception: results = {}
for fullname, _, _ in chunks[w]:
entry = results.get(fullname)
if entry is not None and entry.get("ok"): self._unmark_buildable(fullname)
else:
error = "" if entry is None else entry.get("error", "")
source, _ = self._source(fullname)
self._mark_unbuildable(fullname, source)
print(f"Could not compile {fullname}, using interpreted module: {error}", file=sys.stderr)
else:
for fullname, source, is_package in pending:
try:
self._ensure_compiled(fullname, source, is_package)
self._unmark_buildable(fullname)
except Exception as e:
self._mark_unbuildable(fullname, source)
print(f"Could not compile {fullname}, using interpreted module: {e}", file=sys.stderr)
def _spec(self, fullname, source, is_package, compile_attempted):
if fullname == "RNS.vendor.i2plib" or fullname.startswith("RNS.vendor.i2plib."):
rel = fullname.split(".")
locations = [os.path.join(self.source_root, *rel)] if is_package else None
return spec_from_file_location(fullname, source, loader=SourceFileLoader(fullname, source), submodule_search_locations=locations)
if compile_attempted:
try:
target = self._ensure_compiled(fullname, source, is_package)
self._unmark_buildable(fullname)
if is_package: locations = [os.path.dirname(target)]
else: locations = None
return spec_from_file_location(fullname, target, loader=ExtensionFileLoader(fullname, target),
submodule_search_locations=locations)
except Exception as e:
self._mark_unbuildable(fullname, source)
print(f"Could not compile {fullname}, using interpreted module: {e}", file=sys.stderr)
rel = fullname.split(".")
locations = [os.path.join(self.source_root, *rel)] if is_package else None
return spec_from_file_location(fullname, source,loader=SourceFileLoader(fullname, source),submodule_search_locations=locations)
def find_spec(self, fullname, path, target=None):
if not isinstance(fullname, str): return None
if not (fullname == "RNS" or fullname.startswith("RNS.")): return None
source, is_package = self._source(fullname)
if source is None: return None
return self._spec(fullname, source, is_package, compile_attempted=not self._is_unbuildable(fullname, source))
_importfinder = _ImportFinder()
sys.meta_path.insert(0, _importfinder)
def notice_job():
global noticed
@ -14,13 +280,13 @@ def notice_job():
while compiling:
if time.time() > started+notice_delay and compiling:
noticed = True
print("Compiling RNS object code... ", end="")
print("Compiling RNS object code...")
sys.stdout.flush()
break
time.sleep(0.1)
compiling = True
threading.Thread(target=notice_job, daemon=True).start()
_importfinder.precompile()
import RNS; compiling = False
if noticed: print("Done."); sys.stdout.flush()
if noticed: print("Compilation done"); sys.stdout.flush()

View file

@ -1,10 +1,27 @@
### 2026-07-26: RNS 1.4.2
### 2026-09-29: RNS 1.5.5
This maintenance release fixes bugs in blackholed identity filtering and recursive path request handling for RNode interfaces.
This release adds live interface attach, detach and reload, as well as significant improvements to interface discovery. Auto-connecting interfaces is now also supported on Windows and macOS.
It also brings improvements to `rngit` and fixes discovered `I2PInterface` configuration snippets, along with a number of other bugs.
**Changes**
- Fixed a bug where recursive path requests would cause a division by zero error on uninitialized RNode interfaces.
- Fixed slow blackholed filtering for discovered interfaces on Android in some cases.
- Added ability to `--attach`, `--detach` and `--reload` interfaces to the `rnstatus` utility
- Added interface discovery auto-connect support on Windows and macOS
- Added ability to download micron-converted versions of markdown files to the `rngit` page node
- Added workdoc counts to filter scope links to the `rngit` page node
- Added version info to `rnstatus` discovered interfaces output
- Added `--show-stale` and `--show-unknown` options to `rnstatus`
- Added sequential naming of auto-connected interfaces on announced name collisions
- Added auto-connect implementation and version criteria filtering
- Added configuration option for allowing auto-connects to discovered interfaces with missing version info
- Improved sanitization of nonsensical interface discovery IFAC announce data
- Fixed `I2PInterface` discovery config snippets not including `.b32.i2p`
- Fixed timeout and response rejects not being processed for single-packet requests, by **JRG**
- Fixed missing configurable property propagations on spawned interfaces, by **JRG**
- Fixed ingress control producer comprehension at mid-watermark using incorrect variable name for interface type check, by **JRG**
- Fixed instances of `announce_cap` property ensurance logic not storing announce cap value as a fraction, by **JRG**
- Fixed a potential race condition in `LocalInterface` initialization.
- Updated documentation and manual
**Verified Retrieval**
You can retrieve and verify this release over Reticulum using the built-in `rngit release` utility. To retrieve only the installation `.whl` package, and the release manifest for future updates, you can use:
@ -34,10 +51,145 @@ rnid -i bc7291552be7a58f361522990465165c -V rns_*.rsm *.rsg
The `rnid` utility will then verify the signatures, and display whether they are valid. If the signature cannot be verified, the release has been tampered with and should be discarded.
### 2026-09-11: RNS 1.5.4
This release improves RNode BLE connectivity reliability on desktop operating systems.
**Changes**
- Fixed RNode BLE device address acquisition on windows, by **Nickie Deuxyeux**
- Fixed RNode BLE re-connection deadlock on desktop
- Improved RNode BLE reconnect reliability
### 2026-09-10: RNS 1.5.3
This maintenance release adds functionality to, and fixes bugs in `rngit`, and includes a few other optimizations and improvements.
**Changes**
- Added media request handler to `rngit`, for serving images to `nomadnet` clients
- Added media conversion handler to `rngit`, auto-converting images to webp
- Added importable helper method for using the `rngit` media converter in other programs
- Added a `no_ident` template to the `rngit` page node
- Added immediate activation of changed permissions with `rngit perms`
- Added optimized HDLC framer
- Fixed `rngit` repo admins not being able activate/complete workdocs from other users, by **Bergie**
- Fixed a typo, I'm positively certain this is the last one
### 2026-08-28: RNS 1.5.2
This maintenance release fixes a regression in resource transfers that was introduced in 1.5.1, an `I2PInterface` bug, and sets dataplane control parameters to default values that are actually sensible. If you installed `1.5.1`, it's update time again, baby.
**Changes**
- Tuned dataplane control parameters
- Added example for blocking unidentified page node peers to `rngit` default config
- Fixed regression in resource transfers causing some `rngit` file downloads to fail
- Fixed keepalive frames being passed to transport core on `I2PInterface`
### 2026-08-28: RNS 1.5.1
This release focuses on dataplane control, memory bounding and transport throughput, with adaptive ingress and egress control for interfaces, a new (and very efficient) zero-copy coalescing transmit buffer, an optimized HDLC deframer, and a range of other improvements to the inbound packet processing paths. It also introduces support for live profiling, and various new diagnostics output in `rnstatus`, along with a number of bug fixes and general improvements.
**Changes**
- Added adaptive dataplane egress control
- Added adaptive dataplane ingress control
- Added zero-copy coalescing transmit buffers to interfaces using the `BackboneInterface` backend
- Added early protocol violation checks for invalid frames
- Added transport implementation name and version to discovery information requirements
- Added full live profiling to the built-in `Profiler` primitive, by **K8**
- Added the `@RNS.Profiler.profile` decorator, by **K8**
- Added support for indefinitely running, reentrant profilers with bounded capture, by **K8**
- Added live profiling results output to `rnstatus`, by **K8**
- Added PPS statistics to `rnstatus`
- Added interface MTU display to `rnstatus`
- Added interface TX drop statistics to `rnstatus`
- Added interface TX buffer size output to `rnstatus`
- Added throughput benchmarker to the test suite
- Added support for natively compiled module builds, and the ability to load compiled modules when available
- Added module compilation status reporting from in-wheel build information
- Added parity tests for HDLC, IFAC and HKDF against the legacy implementations
- Added shared medium hints to interfaces
- Improved memory and CPU consumption under high traffic loads
- Improved traffic class handling
- Optimized HKDF to ~5.7x performance, if this worries you, **good**; then see the full parity and standard vector test suite and read the code
- Optimized inbound and outbound IFAC handling to ~90x performance on large frames
- Optimized inbound packet processing by reducing lock acquisitions and path table contention
- Optimized lookups for pending and active links using hash maps for lookup operations
- Optimized announce validation by caching signature validation results, significantly lowering announce storm CPU load
- Optimized the HDLC deframer
- Reduced redundant packet hashing in inbound processing
- Tuned default inbound queue lengths and announce queuing
- Tuned automatic interface MTU configuration
- Fixed `Resource` transfers failing when initialized from stream-based data sources that fell outside `MAX_EFFICIENT_SIZE`
- Fixed an invalid prefix stripping bug in the `rngit` page server (`.something` files not viewable)
- Fixed RSSI/SNR reporting regression
- Fixed keepalive handling on non-epoll backends
- Fixed a bug in `rngit` page navigation content initialization order
- Fixed the `rngit` page node not being able to serve in-tree downloads for large files
- Fixed `rnstatus` blocked IP listing including IPs that were actually not yet blocked
- Fixed `rnstatus` traffic totals counting the local shared instance inter-app transit in totals
- Fixed various minor bugs in `rnsh`, `rnir`, identity handling
- Fixed a latent bug in the AES module, where an exception would not resolve it's exception description correctly, and instead raise another exception.
- Removed dead Python 2 code from `umsgpack`
### 2026-08-22: RNS 1.5.0
This release significantly improves the core Transport handling of RNS, with a priority-based ingress queue backend, substantially improved efficiency of both data and management traffic handling, more efficient ingress/egress limiting, and a long list of other improvements and bugfixes.
**Changes**
- Added ability to include operator LXMF address in interface discovery information
- Added prioritized inbound traffic processing to the transport core
- Added configurable inbound queue lengths for data, announce, path request and ingress limited traffic
- Added early filtering for inbound traffic and improved filtering performance
- Added per-interface protocol violation tracking
- Added in-flight path requests tracking
- Added request and response batching for in-flight path requests
- Added ability to signal blackholed status in announce validation return to the API
- Added full link MDU utilization for `Channel` and `Buffer`
- Added inbound queue pressure and queue drop statistics to `rnstatus`
- Added detailed announce and path request traffic flow statistics per interface to `rnstatus`
- Added total announce and PR count/frequency stats per interface to `rnstatus`
- Added data flow speed and composition stats to `rnstatus`
- Added protocol violation stats to `rnstatus`
- Added active link statistics to `rnstatus`
- Added blocked IP listings to `rnstatus`
- Added medium bitrate based timeout calculation helpers and RPC functions, by **Zenith**
- Added extra timeout for discovery path requests when slow interfaces are online, by **Zenith**
- Added adaptive timeouts to `rncp`, `rnpath`, `rnprobe` and `rnx`, by **Zenith**
- Added adaptive timeout calculation to `rngit`, by **Zenith**
- Improved overall Transport inbound processing speed
- Improved path request handling significantly
- Improved path request ingress limiting and accounting
- Improved egress limiting responsiveness under high incoming path request load
- Improved transport background job processing
- Improved early rejection of packets with excessive hop counts
- Fixed `BackboneInterface` EPOLL receive starvation and ingress control timestamp handling, by **JRG**
- Fixed potential transport deadlock on receipts lock when callbacks sends packets, by **JRG**
- Fixed transport state handling edge cases in path request, announce queue and pending link processing, by **JRG**
- Fixed link watchdog not resetting on receive exceptions, by **JRG**
- Fixed `Resource` cancellation on multi-segment resources, by **JRG**
- Fixed `Resource` transfer part index alignment and rebinding, by **JRG**
- Fixed stale BLE device reference in `RNodeInterface`, by **JRG**
- Fixed ratchet cleaning retained preservation, by **JRG**
- Fixed invalid `rnstatus` statistics handling, by **JRG**
- Fixed various bugs in packet, link and interface handling, by **JRG**
- Fixed per-interface burst count inconsistencies in `rnstatus` output
- Fixed `rngit` file resource operations failing on Windows
- Fixed `rnodeconf` config summary incorrectly displaying WiFi mode
- Fixed speedtest example aborting transfers on stale link status
- Updated documentation, including manual sections on queue tuning and interface discovery options
### 2026-07-26: RNS 1.4.2
This maintenance release fixes bugs in blackholed identity filtering and recursive path request handling for RNode interfaces.
**Changes**
- Fixed a bug where recursive path requests would cause a division by zero error on uninitialized RNode interfaces.
- Fixed slow blackholed filtering for discovered interfaces on Android in some cases.
### 2026-07-24: RNS 1.4.1
This release significantly improves path convergence and stability with the new path re-balancing functionality. It also introduces interface gravity configuration, new API functionality and fixes a range of bugs and inefficiencies.
**Changes**
- Added dynamic path re-balancing
- Added `set_max_request_size` to `Destination` API
- Added `max_response_size` to request API
@ -56,6 +208,7 @@ This release significantly improves path convergence and stability with the new
- Fixed various memory inefficiencies
- Fixed loglevel `LOG_EXTREME` not being usable in some cases
- Fixed historical interface discoveries not being cleaned according to blackholed identities
### 2026-07-20: RNS 1.4.0
This release focuses on performance, stability and reliability improvements, particularly with resource-constrained systems in mind. It also fixes several bugs, reduces logging noise, and makes information about blocked `BackboneInterface` clients available in `ifstats`.

View file

@ -2,57 +2,66 @@
Welcome, and thank you for your interest in contributing to Reticulum!
Apart from writing code, there are many ways in which you can contribute. Before interacting with this community, read these short and simple guidelines.
Before interacting with this community, read these short and simple guidelines.
If you didn't shit your pants after reading this document, you may well find that we are all really quite cuddly and nice to be around. Mostly.
## Expected Conduct
First and foremost, there is one simple requirement for taking part in this community: While we primarily interact virtually, your actions matter and have real consequences. Therefore: **Act like a responsible, civilized person** - especially in the face of disputes and heated disagreements. Speak your mind here; discussions are welcome. Just do so in the spirit of being face-to-face with everyone else. Thank you.
First and foremost, there is one simple requirement for taking part in the development of this technology: While we primarily interact virtually, your actions matter and have real consequences. Therefore: **Act like a responsible, civilized person** - especially in the face of disputes and heated disagreements. Speak your mind here; discussions are welcome. Just do so in the spirit of being face-to-face with everyone else. Thank you.
In order to keep the discussion forums and issue trackers navigable and useful, the following types of posts will be deleted without notice:
In order to keep the discourse navigable and useful, the following types of posts and inquiries will be deleted and ignored:
- Spam.
- Questions that have already been adequately answered elsewhere. Use the search function.
- Low-effort posts or comments that contain no actual information or useful content. This is not a tea-house.
- Questions that have already been adequately answered elsewhere. Utilize search functions.
- Low-effort posts or comments that contain no actual information or useful substance. [This is not a tea-house](./This%20Is%20Not%20a%20Teahouse.md).
- Post or comments solely containing personal opinions or beliefs without adding anything to the discussion. Facebook and X exists.
- Content that simply waste the developer's / maintainer's time with completely obvious "ideas", "insights" or "recommendations". Yes, we have *at least* 8 neurons ourselves.
- Posts that fail to understand that developing a highly complex software project with a very small amount of resources and people takes time. Imagining perfection on our behalf is useless.
If you're new to the community and start out your engagement with any of the above transgressions, you will simply be banned without notice or explanation, and your post will be deleted.
If you're new to the community and start out your engagement with any of the above transgressions, you will simply be banned permanently without notice or explanation, and any further communication from you will be ignored.
Except for the case of real, well-grounded, clearly documented, human-written security advisories, technical investigations or bug reports, if your inquiry or communication is not attached to a Reticulum Identity, expect it to be ignored.
If you find this "harsh", "unfair" or "unwelcoming", go somewhere else. This is not social club, but a work environment for the people contributing to the project.
## Asking Questions
## Reporting Bugs
If you want to ask a question, **do not open an issue**. The issue tracker is used by people *working on Reticulum* to track bugs, issues and improvements. Instead, ask away on the [discussions](https://github.com/markqvist/Reticulum/discussions).
Do not post feature requests or general ideas on the issue tracker, or in direct messages to the primary developers. You are much more likely to get a response and start a constructive discussion by posting your ideas in the public channels created for these purposes.
## Reporting Issues
If you have found a bug or issue in this project, please report it using the [issue tracker](https://github.com/markqvist/Reticulum/issues). Be sure to include details on how to reproduce the bug.
Anything submitted to the issue tracker that does not follow these guidelines will be closed and removed without comments or explanation.
If you have found a bug or issue in this project, please report it to the maintainer over LXMF, or if strictly not possible, over email. Detailed and serious bug reports can also be posted in the various on-network forums and RRC channels that the developers frequent. Due to the realities described in [This Is Not a Teahouse](./This%20Is%20Not%20a%20Teahouse.md), there is no way to report issues or request features over the internet.
## Writing Code
If you are interested in contributing code, fixing open issues or adding features, please coordinate the effort with the maintainer or one of the main developers **before** submitting a pull request. Before deciding to contribute, it is also a good idea to ensure your efforts are in alignment with the [Roadmap](./Roadmap.md) and current development focus.
Patches and code contributions are *generally* only welcomed from developers already known to the community, with a proven track-record of competence, skill, careful and considerate design and responsible conduct. If we do not know you yet, but you have created something that you believe lives up to such standards, we are eager to hear from you.
Pull requests have a high chance of being accepted if they are:
Current development priorities and work documents are not publicly available on the internet, but shared and coordinated directly between the developers over Reticulum itself, following the principles outlined in [Distributed Development](https://reticulum.network/manual/distributed.html).
- In alignment with the [Roadmap](./Roadmap.md) or solve an open issue or feature request
- Sufficiently tested to work with all API functions, and pass the standard test suite
- Functionally and conceptually complete and well-designed
- Not simply formatting or code style changes
- Well-documented
## Commit Signing
Even new ideas and proposals that have not been approved by a maintainer, or fall outside the established roadmap, are *occasionally* accepted - if they possess the remaining of the above qualities. If not, they will be closed and removed without comments or explanation.
All commits in Reticulum and related projects **must** be signed with a Reticulum identity. If you want your author credentials to be included directly in the commit log, ensure that you have [configured commit signing](https://reticulum.network/manual/git.html#commit-signing). Bare patches submitted manually or fetched remote commits without signatures are still very welcome, but we will review, squash merge and sign with the key of the maintainer that approved the changes. Attribution will be provided in the commit message instead (unless you wish to remain anonymous).
## Generative AI Policy
## Machine Assistance & LLMs
Contributions written using large language models (LLMs) or other generative 'AI' programs are prohibited. LLMs produce errors so frequently and in a way that is so unlike human error that such issues are incredibly time-consuming to spot and fix. This is not a worthwhile tradeoff for Reticulum.
For a highly complex, critical piece of cryptographically based networking infrastructure, we should not need to state the following, but alas, experience has proven otherwise. Therefore:
This applies to all Reticulum-related projects and documentation, as well as all submitted issues and discussion in official channels, except in cases where language translation and/or speech recogntion technologies are required for communication.
**Including material generated using LLMs or other "AI" programs in Reticulum is, almost without exception, prohibited.**
Before even *thinking* about using a generative software system to "create" something for Reticulum, read [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html), in full. If you do not have the capacity for that, you have failed the first requirement for contributing: Basic literacy.
*If* you are not already a known contributor with a proven record of being able to responsibly handle these tools, a **full** audit trail of all ML-utilizing workflows, including prompts, LLM reasoning traces, tool calls and outputs (that is, the *entire*, unaltered session log) will need to be provided with any proposed contribution. Without that, we will not even *consider* looking at it.
This applies to all Reticulum-related projects and documentation, as well as all submitted issues, reports and discussion, **except** of course in cases where language translation, speech recogntion, or other assistive technologies are required.
In **all** circumstances, clear disclosure is **mandatory**. Any piece of material, file, code snippet, method, function or similar that was generated with machine assistance must be prepended with a disclosure header or comment that states the framework, exact model used and reviewer, such as:
```python
# Generated with lc/deepseek_v4_flash_q8_k_xl
# Reviewed and corrected by Mark Qvist
```
Additionally, any explanatory comments or doc-strings generated by software must be manually re-written in clear, concise language by the reviewer stated in the disclosure header.
## Contributor License Agreement
By contributing code to this project, you agree that copyright for the code is transferred to the Reticulum maintainers and that the code is irrevocably placed under the [Reticulum License](./LICENSE).
For a more in-depth elaboration on the current CLA position, see [this post](https://rns.recipes/forum/general/brandolinis-reference-or-alternatively-agentsmd?page=1#post-3002).

View file

@ -239,7 +239,7 @@ def link_established(link):
RNS.log("Link established with server, sending...")
rd = os.urandom(link.mdu)
started = time.time()
while link.status == RNS.Link.ACTIVE and data_sent < data_cap*1.25:
while link.status != RNS.Link.CLOSED and data_sent < data_cap*1.25:
RNS.Packet(server_link, rd, create_receipt=False).send()
data_sent += len(rd)

View file

@ -21,6 +21,19 @@ clean:
@-rm -rf ./tests/__pycache__
@-rm -rf ./tests/rnsconfig/storage
@-rm -rf ./*.egg-info
@-rm -rf ./RNS/*.c
@-rm -rf ./RNS/Cryptography/*.c
@-rm -rf ./RNS/Cryptography/aes/*.c
@-rm -rf ./RNS/Cryptography/pure25519/*.c
@-rm -rf ./RNS/Interfaces/*.c
@-rm -rf ./RNS/Interfaces/util/*.c
@-rm -rf ./RNS/Interfaces/Android/*.c
@-rm -rf ./RNS/Utilities/*.c
@-rm -rf ./RNS/Utilities/rngit/*.c
@-rm -rf ./RNS/Utilities/rnsh/*.c
@-rm -rf ./RNS/vendor/*.c
@-rm -rf ./RNS/vendor/i2plib/*.c
@-rm -rf
@make -C docs clean
@echo Done
@ -50,6 +63,9 @@ build_wheel:
build_pure_wheel:
python3 setup.py bdist_wheel --pure
build_native:
python3 setup.py bdist_wheel --native
documentation:
make -C docs html markdown
@ -65,6 +81,8 @@ release: test remove_symlinks build_sdist build_wheel build_pure_wheel documenta
debug: remove_symlinks build_wheel build_pure_wheel create_symlinks
native: remove_symlinks build_native create_symlinks
local: release sign
sign:

266
README.md
View file

@ -1,40 +1,32 @@
Reticulum Network Stack <img align="right" src="https://static.pepy.tech/personalized-badge/rns?period=month&units=international_system&left_color=grey&right_color=blue&left_text=Installs/month" style="padding-left:10px"/><a href="https://github.com/markqvist/Reticulum/actions/workflows/build.yml"><img align="right" src="https://github.com/markqvist/Reticulum/actions/workflows/build.yml/badge.svg"/></a>
==========
> [!WARNING]
> A significant number of hastily launched, incorrect, dubious, directly harmful and, in most cases, entirely LLM-generated fakes of Reticulum implementations, "documentation", "guides" and user-facing applications are currently being marketed under polished, but ultimately misleading or outright deceptive claims.
>
> For more detailed information, see the relevant sections of the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the manual. Known good community implementations are listed in the [Community Implementations](#community-implementations) section of this readme. For software and projects recognized by the Reticulum community, see the [Programs Using Reticulum](https://reticulum.network/manual/software.html) chapter of the manual.
>
> Machine-generated content derived from the RNS source code or documentation (such as "implementations", "guides" for sale, et cetera), are in several, well-documented cases violating the open source licenses that granted rights of copying and distribution.
>
> Any claimed assertion of copyright or grant of license (Apache, GPL, et cetera) that such projects are making are [void grants](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code). Both the violators themselves, and all downstream projects using the infringing derivatives, are **directly** and personally liable for the legal consequences.
>
> Do **not** use these "projects", they are a hazard to the entire ecosystem we have been carefully building over the last ten years.
<p align="center"><img width="200" src="https://raw.githubusercontent.com/markqvist/Reticulum/master/docs/source/graphics/rns_logo_512.png"></p>
*This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.*
To understand the foundational philosophy and goals of this system, read the [Zen of Reticulum](Zen%20of%20Reticulum.md).
Reticulum is the cryptography-based networking stack for building local and wide-area
networks with readily available hardware. It can operate even with very high latency
and extremely low bandwidth. Reticulum allows you to build wide-area networks
with off-the-shelf tools, and offers end-to-end encryption and connectivity,
initiator anonymity, autoconfiguring cryptographically backed multi-hop
transport, efficient addressing, unforgeable delivery acknowledgements and
more.
Reticulum is the cryptography-based networking stack for building local and wide-area networks with readily available hardware. It can operate even with very high latency and extremely low bandwidth. Reticulum allows you to build wide-area networks with off-the-shelf tools, and offers end-to-end encryption and connectivity, initiator anonymity, autoconfiguring cryptographically backed multi-hop transport, efficient addressing, unforgeable delivery acknowledgements and more.
The vision of Reticulum is to allow anyone to be their own network operator,
and to make it cheap and easy to cover vast areas with a myriad of independent,
inter-connectable and autonomous networks. Reticulum **is not** *one* network.
It is **a tool** for building *thousands of networks*. Networks without
kill-switches, surveillance, censorship and control. Networks that can freely
interoperate, associate and disassociate with each other, and require no
central oversight. Networks for human beings. *Networks for the people*.
The vision of Reticulum is to allow anyone to be their own network operator, and to make it cheap and easy to cover vast areas with a myriad of independent, inter-connectable and autonomous networks. Reticulum **is not** *one* network. It is **a tool** for building *thousands of networks*. Networks without kill-switches, surveillance, censorship and control. Networks that can freely interoperate, associate and disassociate with each other, and require no central oversight. Networks for human beings. *Networks for the people*.
Reticulum is a complete networking stack, and does not rely on IP or higher
layers, but it is possible to use IP as the underlying carrier for Reticulum.
It is therefore trivial to tunnel Reticulum over the Internet or private IP
networks.
Reticulum is a complete networking stack, and does not rely on IP or higher layers, but it is possible to use IP as the underlying carrier for Reticulum. It is therefore trivial to tunnel Reticulum over the Internet or private IP networks.
Having no dependencies on traditional networking stacks frees up overhead that
has been used to implement a networking stack built directly on cryptographic
principles, allowing resilience and stable functionality, even in open and
trustless networks.
Having no dependencies on traditional networking stacks frees up overhead that has been used to implement a networking stack built directly on cryptographic principles, allowing resilience and stable functionality, even in open and trustless networks.
No kernel modules or drivers are required. Reticulum runs completely in
userland, and can run on practically any system that runs Python 3.
No kernel modules or drivers are required. Reticulum runs completely in userland, and can run on practically any system that runs Python 3.
## Read The Manual
The full documentation for Reticulum is available at [markqvist.github.io/Reticulum/manual/](https://markqvist.github.io/Reticulum/manual/).
@ -78,12 +70,20 @@ For more info, see [reticulum.network](https://reticulum.network/) and [the FAQ
- Low cost of keeping links open at only 0.44 bits per second
- Reliable sequential delivery with Channel and Buffer mechanisms
## Community Resources
The references listed here are not run, managed or moderated by the Reticulum project or its maintainer. They are provided by dedicated, independent volunteers in the community, for the benefit of anyone interested in learning more about Reticulum.
- [Reticulum Community Wiki](https://reticulum.miraheze.org/wiki)
- [RNS Recipes](https://rns.recipes/)
- [RNS Moscow](https://rns.moscow/)
- [RMAP.world](https://rmap.world/)
If you find anything that is no longer accurate, incorrect or simply not quite up to date, do not demand or complain towards the maintainers of these resources. Instead, take the task upon yourself to help out and improve things by working on, and offering the contributions you would like to see, like the creators of the above did themselves.
## Reference Implementation
The Python code in this repository is the Reference Implementation of Reticulum.
The Reticulum Protocol is defined entirely and authoritatively by this reference
implementation, and its associated manual. It is maintained by Mark Qvist,
identified by the Reticulum Identity `<bc7291552be7a58f361522990465165c>`.
The Python code in this repository is the Reference Implementation of Reticulum. The Reticulum Protocol is defined entirely and authoritatively by this reference implementation, and its associated manual. It is maintained by Mark Qvist, identified by the Reticulum Identity `<bc7291552be7a58f361522990465165c>`.
No second-order protocol specification derivatives currently do, or will ever exist. You can't get a better specification than one that is executable, functional, testable, deployed and operating 24/7 across the planet.
@ -91,18 +91,39 @@ There is not, and never will be a separate, derived "formal spec", "RFC" or anyt
**The reference implementation *is* the final, authoritative specification.**
Compatibility with the Reticulum Protocol is defined as having full interoperability,
and sufficient functional parity with this reference implementation. Any specific protocol
implementation that achieves this is Reticulum. Any that does not is not Reticulum.
Compatibility with the Reticulum Protocol is defined as having full interoperability, and sufficient functional parity with this reference implementation. Any specific protocol implementation that achieves this is Reticulum. Any that does not is not Reticulum.
The reference implementation is licensed under the Reticulum License.
The Reticulum Protocol was dedicated to the Public Domain in 2016.
## Community Implementations
The implementations listed here have demonstrated wire-compatibility, sufficient feature parity, sensible security and development practices, long-term commitment and care for implementation quality. Additionally, they have a track record of at least 18 months of active development.
Most importantly, their developers and maintainers are recognized by the community to actually know what they are doing, and as having a commitment to improving the overall Reticulum ecosystem.
- [Chad Atterman's](https://github.com/attermann) [microReticulum](https://github.com/attermann/microReticulum/) is a C++ implementation targeting 32-bit micro-controllers.
- [Quad4's](https://quad4.io/) [Reticulum-Go](https://reticulum-go.quad4.io/) is a Go implementation targeting both desktop, mobile and embedded systems.
*If you are the maintainer of an implementation not listed above, and believe that it deserves inclusion by following the same standards of rigor and commitment, your proposal will be welcomed.*
---
**Warning!** A significant number of hastily launched, incorrect, dubious, directly harmful and, in most cases, entirely LLM-generated fakes of Reticulum implementations (and user facing applications) are currently being marketed and pushed under polished, but ultimately misleading or outright deceptive claims. Most of these are either quick attention or money grabs, completely unreviewed, incompatible, without maintenance or human oversight, actively trying to fragment the community, or a combination thereof.
---
For more extensive information on this topic, read the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the manual (for the legal foundations, see [Void Grants](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code) specifically). The chapter is very long, very detailed, and is the polite, measured analysis, intended as an evolving, sectioned reference on these issues.
If you would rather just have the short, unfiltered and quite angry version, which consists of an enacted response to one particular manifestation of the phenomenon as a whole, used as an example, you can read [Yes, I am Angry](docs/history/2026_09_18_Yes_I_am_Angry.md). It gets the same point across, in a very different language, if you can handle that sort of communication.
The core Reticulum maintainers and developers **strongly advise against using any such "implementation"**. The [deceitful and self-serving behavior](https://reticulum.network/manual/brandolinis.html#a-persistent-but-fragile-triad) of a small number of either malintented, attention-seeking or incompetent individuals has caused significant [damage and waste of time and resources](https://reticulum.network/manual/brandolinis.html#intent-consequence) for both developers, node operators, users and the community and Reticulum project at large.
We kindly, but urgently ask all users to excercise caution, common sense and healthy skepticism when [evaluating](https://reticulum.network/manual/brandolinis.html#evaluating-a-reticulum-implementation) the potential use of any implementation not listed above; and to accept that **choosing to do so regardless translates the responsibility for potential damage from an irresponsible or malicious individual onto yourself**.
## Examples of Reticulum Applications
If you want to quickly get an idea of what Reticulum can do, take a look at the
[Programs Using Reticulum](https://reticulum.network/manual/software.html)
section of the manual, or the following resources:
If you want to quickly get an idea of what Reticulum can do, take a look at the [Programs Using Reticulum](https://reticulum.network/manual/software.html) section of the manual, or the following resources:
- [LXMF](https://github.com/markqvist/lxmf) is a distributed, delay and disruption tolerant message transfer protocol built on Reticulum
- The [LXST](https://github.com/markqvist/lxst) protocol and framework provides real-time audio and signals transport over Reticulum. It includes primitives and utilities for building voice-based applications and hardware devices, such as the `rnphone` program, that can be used to build hardware telephones.
@ -110,50 +131,27 @@ section of the manual, or the following resources:
- The Android, Linux, macOS and Windows app [Sideband](https://github.com/markqvist/Sideband) has a graphical interface and many advanced features, such as file transfers, image and voice messages, real-time voice calls, a distributed telemetry system, mapping capabilities and full plugin extensibility.
- [MeshChatX](https://git.quad4.io/RNS-Things/MeshChatX) is a full-featured LXMF client with many built-in tools and functionalities, that also supports image and voice messages, file transfers and voice calls. It also includes a built-in page browser for browsing Nomad Network nodes.
- You can use the included [rnsh](https://reticulum.network/manual/using.html#the-rnsh-utility) program to establish remote shell sessions over Reticulum.
- The included [rngit](https://reticulum.network/manual/git.html) system provides a full solution for managing, hosting, collaborating on and interacting with Git repositories over Reticulum.
## Where can Reticulum be used?
Over practically any medium that can support at least a half-duplex channel
with greater throughput than 5 bits per second, and an MTU of 500 bytes. Data radios,
modems, LoRa radios, serial lines, AX.25 TNCs, amateur radio digital modes,
WiFi and Ethernet devices, free-space optical links, and similar systems are
all examples of the types of physical devices Reticulum can use.
Over practically any medium that can support at least a half-duplex channel with greater throughput than 5 bits per second, and an MTU of 500 bytes. Data radios, modems, LoRa radios, serial lines, AX.25 TNCs, amateur radio digital modes, WiFi and Ethernet devices, free-space optical links, and similar systems are all examples of the types of physical devices Reticulum can use.
An open-source LoRa-based interface called
[RNode](https://markqvist.github.io/Reticulum/manual/hardware.html#rnode) has
been designed specifically for use with Reticulum. It is possible to build
yourself, or it can be purchased as a complete transceiver that just needs a
USB connection to the host.
An open-source LoRa-based interface called [RNode](https://markqvist.github.io/Reticulum/manual/hardware.html#rnode) has been designed specifically for use with Reticulum. It is possible to build yourself, or it can be purchased as a complete transceiver that just needs a USB, WiFi or Bluetooth connection to the host.
Reticulum can also be encapsulated over existing IP networks, so there's
nothing stopping you from using it over wired Ethernet, your local WiFi network
or the Internet, where it'll work just as well. In fact, one of the strengths
of Reticulum is how easily it allows you to connect different mediums into a
self-configuring, resilient and encrypted mesh, using any available mixture of
available infrastructure.
Reticulum can also be encapsulated over existing IP networks, so there's nothing stopping you from using it over wired Ethernet, your local WiFi network or the Internet, where it'll work just as well. In fact, one of the strengths of Reticulum is how easily it allows you to connect different mediums into a self-configuring, resilient and encrypted mesh, using any available mixture of available infrastructure.
As an example, it's possible to set up a Raspberry Pi connected to both a LoRa
radio, a packet radio TNC and a WiFi network. Once the interfaces are
configured, Reticulum will take care of the rest, and any device on the WiFi
network can communicate with nodes on the LoRa and packet radio sides of the
network, and vice versa.
As an example, it's possible to set up a Raspberry Pi connected to both a LoRa radio, a packet radio TNC and a WiFi network. Once the interfaces are configured, Reticulum will take care of the rest, and any device on the WiFi network can communicate with nodes on the LoRa and packet radio sides of the network, and vice versa.
## How do I get started?
The best way to get started with the Reticulum Network Stack depends on what
you want to do. For full details and examples, have a look at the
[Getting Started Fast](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html)
section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
The best way to get started with the Reticulum Network Stack depends on what you want to do. For full details and examples, have a look at the [Getting Started Fast](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html) section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
To simply install Reticulum and related utilities on your system, the easiest way is via `pip`.
You can then start any program that uses Reticulum, or start Reticulum as a system service with
[the rnsd utility](https://markqvist.github.io/Reticulum/manual/using.html#the-rnsd-utility).
To simply install Reticulum and related utilities on your system, the easiest way is via `pip`. You can then start any program that uses Reticulum, or start Reticulum as a system service with [the rnsd utility](https://markqvist.github.io/Reticulum/manual/using.html#the-rnsd-utility).
```bash
pip install rns
```
If you are using an operating system that blocks normal user package installation via `pip`,
you can return `pip` to normal behaviour by editing the `~/.config/pip/pip.conf` file,
and adding the following directive in the `[global]` section:
If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section:
```text
[global]
@ -166,23 +164,14 @@ Alternatively, you can use the `pipx` tool to install Reticulum in an isolated e
pipx install rns
```
When first started, Reticulum will create a default configuration file,
providing basic connectivity to other Reticulum peers that might be locally
reachable. The default config file contains a few examples, and references for
creating a more complex configuration.
When first started, Reticulum will create a default configuration file, providing basic connectivity to other Reticulum peers that might be locally reachable. The default config file contains a few examples, and references for creating a more complex configuration.
If you have an old version of `pip` on your system, you may need to upgrade it first with `pip install pip --upgrade`. If you no not already have `pip` installed, you can install it using the package manager of your system with `sudo apt install python3-pip` or similar.
For more detailed examples on how to expand communication over many mediums such
as packet radio or LoRa, serial ports, or over fast IP links and the Internet using
the UDP and TCP interfaces, take a look at the [Supported Interfaces](https://markqvist.github.io/Reticulum/manual/interfaces.html)
section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
For more detailed examples on how to expand communication over many mediums such as packet radio or LoRa, serial ports, or over fast IP links and the Internet using the UDP and TCP interfaces, take a look at the [Supported Interfaces](https://markqvist.github.io/Reticulum/manual/interfaces.html) section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
## Included Utilities
Reticulum includes a range of useful utilities for managing your networks,
viewing status and information, and other tasks. You can read more about these
programs in the [Included Utility Programs](https://markqvist.github.io/Reticulum/manual/using.html#included-utility-programs)
section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
Reticulum includes a range of useful utilities for managing your networks, viewing status and information, and other tasks. You can read more about these programs in the [Included Utility Programs](https://markqvist.github.io/Reticulum/manual/using.html#included-utility-programs) section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
- The system daemon `rnsd` for running Reticulum as an always-available service
- An interface status utility called `rnstatus`, that displays information about interfaces
@ -195,19 +184,13 @@ section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/)
- The `rngit` program provides a full multi-repository Git node for serving repositories over Reticulum
- The included `git-remote-rns` helper allows you to interact with Git repositories over Reticulum
All tools, including `rnx` and `rncp`, work reliably and well even over very
low-bandwidth links like LoRa or Packet Radio. For full-featured remote shells
over Reticulum, also have a look at the [rnsh](https://github.com/acehoss/rnsh)
program.
All tools, including `rnx` and `rncp`, work reliably and well even over very low-bandwidth links like LoRa or Packet Radio. For full-featured remote shells over Reticulum, also have a look at the [rnsh](https://github.com/acehoss/rnsh) program.
## Supported interface types and devices
Reticulum implements a range of generalised interface types that covers most of
the communications hardware that Reticulum can run over. If your hardware is
not supported, it's [simple to implement a custom interface module](https://markqvist.github.io/Reticulum/manual/interfaces.html#custom-interfaces).
Reticulum implements a range of generalised interface types that covers most of the communications hardware that Reticulum can run over. If your hardware is not supported, it's [simple to implement a custom interface module](https://markqvist.github.io/Reticulum/manual/interfaces.html#custom-interfaces).
Pull requests for custom interfaces are gratefully accepted, provided they are
generally useful and well-tested in real-world usage.
Contributions of custom interfaces are gratefully accepted, provided they are generally useful and well-tested in real-world usage.
Currently, the following built-in interfaces are supported:
@ -222,62 +205,42 @@ Currently, the following built-in interfaces are supported:
- Custom hardware via stdio or pipes
## Performance
Reticulum targets a *very* wide usable performance envelope, but prioritises
functionality and performance on low-bandwidth mediums. The goal is to
provide a dynamic performance envelope from 250 bits per second, to 1 gigabit
per second on normal hardware.
*All performance numbers here assume `rnsd` running in fully interpreted Python mode, limited to a single CPU core.*
Currently, the usable performance envelope is approximately 150 bits per second
to 500 megabits per second, with physical mediums faster than that not being
saturated. Performance beyond the current level is intended for future
upgrades, but not highly prioritised at this point in time.
Reticulum targets a *very* wide usable performance envelope, but prioritises functionality and performance on low-bandwidth mediums. The goal is to provide a dynamic performance envelope from 100 bits per second, to 10 gigabits per second on normal hardware.
Currently, the on-network, usable performance envelope is approximately 150 bits per second to 1 gigabit per second on modest hardware. Real-world performance will vary widely with the speed of your system, availability of cryptographic hardware acceleration, and other factors.
Raw routing throughput for a transport node hosted on a Raspberry Pi 5 is approximately 5 gigabits per second. Systems with modern crypto hardware acceleration can achieve transport throughput of 15 gigabits per second and higher.
While the reference implementation is perfectly capable of reaching 100+ Gbps, by utilizing multiple CPU cores and vectorized transport, performance beyond the current level is not higly prioritised, since RNS is already able to saturate available physical medium bandwidth in almost all real-world situations.
The primary objectives for RNS are *reliability*, *correctness*, *security*, *proper traffic management* and *actually practical performance*, not hollow on-paper "benchmarks" with little to no bearing on real-world usability.
All alternative-implementation maintainers are *highly* encouraged to adopt a similar approach, and especially to focus on getting their implementations working properly and reliably before publishing empirically dubious or factually wrong performance claims.
## Current Status
All core protocol features are implemented and functioning, but additions will
probably occur as real-world use is explored and understood. The API and wire-format
can be considered stable.
All core protocol features are implemented and functioning, but additions will probably occur as real-world use is explored and understood. The API and wire-format can be considered stable.
## Dependencies
The installation of the default `rns` package requires only two external dependencies, listed
below. Almost all systems and distributions have readily available packages for
these dependencies, and when the `rns` package is installed with `pip`, they
will be downloaded and installed as well.
The installation of the default `rns` package requires only two external dependencies, listed below. Almost all systems and distributions have readily available packages for these dependencies, and when the `rns` package is installed with `pip`, they will be downloaded and installed as well.
- [PyCA/cryptography](https://github.com/pyca/cryptography)
- [pyserial](https://github.com/pyserial/pyserial)
On more unusual systems, and in some rare cases, it might not be possible to
install or even compile one or more of the above modules. In such situations,
you can use the `rnspure` package instead, which require no external
dependencies for installation. Please note that the contents of the `rns` and
`rnspure` packages are *identical*. The only difference is that the `rnspure`
package lists no dependencies required for installation.
On more unusual systems, and in some rare cases, it might not be possible to install or even compile one or more of the above modules. In such situations, you can use the `rnspure` package instead, which require no external dependencies for installation. Please note that the contents of the `rns` and `rnspure` packages are *identical*. The only difference is that the `rnspure` package lists no dependencies required for installation.
No matter how Reticulum is installed and started, it will load external
dependencies only if they are *needed* and *available*. If for example you want
to use Reticulum on a system that cannot support
[pyserial](https://github.com/pyserial/pyserial), it is perfectly possible to
do so using the `rnspure` package, but Reticulum will not be able to use
serial-based interfaces. All other available modules will still be loaded when
needed.
No matter how Reticulum is installed and started, it will load external dependencies only if they are *needed* and *available*. If for example you want to use Reticulum on a system that cannot support [pyserial](https://github.com/pyserial/pyserial), it is perfectly possible to do so using the `rnspure` package, but Reticulum will not be able to use serial-based interfaces. All other available modules will still be loaded when needed.
**Please Note!** If you use the `rnspure` package to run Reticulum on systems
that do not support [PyCA/cryptography](https://github.com/pyca/cryptography),
it is important that you read and understand the [Cryptographic
Primitives](#cryptographic-primitives) section of this document.
**Please Note!** If you use the `rnspure` package to run Reticulum on systems that do not support [PyCA/cryptography](https://github.com/pyca/cryptography), it is important that you read and understand the [Cryptographic Primitives](#cryptographic-primitives) section of this document.
## Bootstrapping Connectivity
Reticulum is not a service you subscribe to, nor is it a single global network you "join".
Reticulum provides functionality for discovering available public interfaces
over the network itself, and the broader community has provided various directories
of publicly available entrypoints to bootstrap connectivity.
Reticulum is not a service you subscribe to, nor is it a single global network you "join". Reticulum provides functionality for discovering available public interfaces over the network itself, and the broader community has provided various directories of publicly available entrypoints to bootstrap connectivity.
To learn how to establish initial connectivity over Reticulum, read the [Bootstrapping Connectivity](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) section of the manual.
If you already have a general idea of how this works, you can use community-run
sites such as [directory.rns.recipes](https://directory.rns.recipes/) and [rmap.world](https://rmap.world)
to find interface definitions for initial connectivity to the global distributed Reticulum backbone.
If you already have a general idea of how this works, you can use community-run sites such as [directory.rns.recipes](https://directory.rns.recipes/) and [rmap.world](https://rmap.world) to find interface definitions for initial connectivity to the global distributed Reticulum backbone.
## Public Testnet
***Important!** Historically, a developer-targeted testnet was made available by the Reticulum project itself. As the amount of global Reticulum nodes and entrypoints have grown to a substantial quantity, this public testnet, including the Amsterdam Testnet entrypoint, has now been decommissioned. If your still have instances that relied on this entrypoint for connectivity, transition to using the distributed backbone instead. Reticulum now includes a full on-network interface discovery and connectivity bootstrapping system. Read the [Bootstrapping Connectivity](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) section of the manual for pointers.*
@ -302,17 +265,11 @@ For this to be possible, I need your help. Please support the continued developm
- Ko-Fi: https://ko-fi.com/markqvist
## Cryptographic Primitives
Reticulum uses a simple suite of efficient, strong and well-tested cryptographic
primitives, with widely available implementations that can be used both on
general-purpose CPUs and on microcontrollers.
Reticulum uses a simple suite of efficient, strong and well-tested cryptographic primitives, with widely available implementations that can be used both on general-purpose CPUs and on microcontrollers.
One of the primary considerations for choosing this particular set of primitives is
that they can be implemented *safely* with relatively few pitfalls, on practically
all current computing platforms.
One of the primary considerations for choosing this particular set of primitives is that they can be implemented *safely* with relatively few pitfalls, on practically all current computing platforms.
The primitives listed here **are authoritative**. Anything claiming to be Reticulum,
but not using these exact primitives **is not** Reticulum, and possibly an
intentionally compromised or weakened clone. The utilised primitives are:
The primitives listed here **are authoritative**. Anything claiming to be Reticulum, but not using these exact primitives **is not** Reticulum, and possibly an intentionally compromised or weakened clone. The utilised primitives are:
- Reticulum Identity Keys are 512-bit Curve25519 keysets
- A 256-bit Ed25519 key for signatures
@ -327,13 +284,7 @@ intentionally compromised or weakened clone. The utilised primitives are:
- SHA-256
- SHA-512
In the default installation configuration, the `X25519`, `Ed25519`,
and `AES-256-CBC` primitives are provided by [OpenSSL](https://www.openssl.org/)
(via the [PyCA/cryptography](https://github.com/pyca/cryptography) package).
The hashing functions `SHA-256` and `SHA-512` are provided by the standard
Python [hashlib](https://docs.python.org/3/library/hashlib.html). The `HKDF`,
`HMAC`, `Token` primitives, and the `PKCS7` padding function are always
provided by the following internal implementations:
In the default installation configuration, the `X25519`, `Ed25519`, and `AES-256-CBC` primitives are provided by [OpenSSL](https://www.openssl.org/) (via the [PyCA/cryptography](https://github.com/pyca/cryptography) package). The hashing functions `SHA-256` and `SHA-512` are provided by the standard Python [hashlib](https://docs.python.org/3/library/hashlib.html). The `HKDF`, `HMAC`, `Token` primitives, and the `PKCS7` padding function are always provided by the following internal implementations:
- [HKDF.py](RNS/Cryptography/HKDF.py)
- [HMAC.py](RNS/Cryptography/HMAC.py)
@ -341,39 +292,18 @@ provided by the following internal implementations:
- [PKCS7.py](RNS/Cryptography/PKCS7.py)
Reticulum also includes a complete implementation of all necessary primitives
in pure Python. If OpenSSL and PyCA are not available on the system when
Reticulum is started, Reticulum will instead use the internal pure-python
primitives. A trivial consequence of this is performance, with the OpenSSL
backend being *much* faster. The most important consequence however, is the
potential loss of security by using primitives that has not seen the same
amount of scrutiny, testing and review as those from OpenSSL.
Reticulum also includes a complete implementation of all necessary primitives in pure Python. If OpenSSL and PyCA are not available on the system when Reticulum is started, Reticulum will instead use the internal pure-python primitives. A trivial consequence of this is performance, with the OpenSSL backend being *much* faster. The most important consequence however, is the potential loss of security by using primitives that has not seen the same amount of scrutiny, testing and review as those from OpenSSL.
Please note that by default, installing Reticulum will **require** OpenSSL and
PyCA to also be automatically installed if not already available. It is only
possible to use the pure-python primitives if this requirement is specifically
overridden by the user, for example by installing the `rnspure` package instead
of the normal `rns` package, or by running directly from local source-code.
Please note that by default, installing Reticulum will **require** OpenSSL and PyCA to also be automatically installed if not already available. It is only possible to use the pure-python primitives if this requirement is specifically overridden by the user, for example by installing the `rnspure` package instead of the normal `rns` package, or by running directly from local source-code.
If you want to use the internal pure-python primitives, it is **highly
advisable** that you have a good understanding of the risks that this pose, and
make an informed decision on whether those risks are acceptable to you.
If you want to use the internal pure-python primitives, it is **highly advisable** that you have a good understanding of the risks that this pose, and make an informed decision on whether those risks are acceptable to you.
Reticulum is relatively young software, and should be considered as such. While
it has been built with cryptography best-practices very foremost in mind, it
_has not_ been externally security audited, and there could very well be
privacy or security breaking bugs. If you want to help out, or help sponsor an
audit, please do get in touch.
Having been in development for ten, and wider deployment for around five years, Reticulum is relatively young software, and should be considered as such. While it has been carefully built with cryptography best-practices very foremost in mind, there could very well still be privacy or security breaking bugs.
## Acknowledgements & Credits
Reticulum can only exist because of the mountain of Open Source work it was
built on top of, the contributions of everyone involved, and everyone that has
supported the project through the years. To everyone who has helped, thank you
so much.
Reticulum can only exist because of the mountain of Open Source work it was built on top of, the contributions of everyone involved, and everyone that has supported the project through the years. To everyone who has helped, thank you so much.
A number of other modules and projects are either part of, or used by
Reticulum. Sincere thanks to the authors and contributors of the following
projects:
A number of other modules and projects are either part of, or used by Reticulum. Sincere thanks to the authors and contributors of the following projects:
- [PyCA/cryptography](https://github.com/pyca/cryptography), *BSD License*
- [Pure-25519](https://github.com/warner/python-pure25519) by [Brian Warner](https://github.com/warner), *MIT License*

View file

@ -4,6 +4,8 @@ To understand the foundational philosophy and goals of this system, read the `_`
Reticulum is the cryptography-based networking stack for building local and wide-area networks with readily available hardware. It can operate even with very high latency and extremely low bandwidth. Reticulum allows you to build wide-area networks with off-the-shelf tools, and offers end-to-end encryption and connectivity, initiator anonymity, autoconfiguring cryptographically backed multi-hop transport, efficient addressing, unforgeable delivery acknowledgements and more.
`(RNS Logo`w=18`a=c`:/media/reticulum/reticulum/HEAD/docs/source/graphics/rns_logo_256.webp)
The vision of Reticulum is to allow anyone to be their own network operator, and to make it cheap and easy to cover vast areas with a myriad of independent, inter-connectable and autonomous networks. Reticulum `!is not`! `*one`* network. It is `!a tool`! for building `*thousands of networks`*. Networks without kill-switches, surveillance, censorship and control. Networks that can freely interoperate, associate and disassociate with each other, and require no central oversight. Networks for human beings. `*Networks for the people`*.
Reticulum is a complete networking stack, and does not rely on IP or higher layers, but it is possible to use IP as the underlying carrier for Reticulum. It is therefore trivial to tunnel Reticulum over the Internet or private IP networks.
@ -170,9 +172,19 @@ Currently, the following built-in interfaces are supported:
>> Performance
Reticulum targets a `*very`* wide usable performance envelope, but prioritises functionality and performance on low-bandwidth mediums. The goal is to provide a dynamic performance envelope from 250 bits per second, to 1 gigabit per second on normal hardware.
`*All performance numbers here assume `B333rnsd`b running in fully interpreted Python mode, limited to a single CPU core.`*
Currently, the usable performance envelope is approximately 150 bits per second to 500 megabits per second, with physical mediums faster than that not being saturated. Performance beyond the current level is intended for future upgrades, but not highly prioritised at this point in time.
Reticulum targets a `*very`* wide usable performance envelope, but prioritises functionality and performance on low-bandwidth mediums. The goal is to provide a dynamic performance envelope from 100 bits per second, to 10 gigabits per second on normal hardware.
Currently, the on-network, usable performance envelope is approximately 150 bits per second to 1 gigabit per second on modest hardware. Real-world performance will vary widely with the speed of your system, availability of cryptographic hardware acceleration, and other factors.
Raw routing throughput for a transport node hosted on a Raspberry Pi 5 is approximately 4 gigabits per second. Systems with modern crypto hardware acceleration can achieve transport throughput of 15 gigabits per second and higher.
While the reference implementation is perfectly capable of reaching 100+ Gbps, by utilizing multiple CPU cores and vectorized transport, performance beyond the current level is not higly prioritised, since RNS is already able to saturate available physical medium bandwidth in almost all real-world situations.
The primary objectives for RNS are `*reliability`*, `*correctness`*, `*security`*, `*proper traffic management`* and `*actually practical performance`*, not hollow on-paper "benchmarks" with little to no bearing on real-world usability.
All alternative-implementation maintainers are `*highly`* encouraged to adopt a similar approach, and especially to focus on getting their implementations working properly before publishing empirically dubious or factually wrong performance claims.
>> Current Status
@ -256,7 +268,7 @@ Please note that by default, installing Reticulum will `!require`! OpenSSL and P
If you want to use the internal pure-python primitives, it is `!highly advisable`! that you have a good understanding of the risks that this pose, and make an informed decision on whether those risks are acceptable to you.
Reticulum is relatively young software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy or security breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch.
Having been in development for ten, and wider deployment for around five years, Reticulum is relatively young software, and should be considered as such. While it has been carefully built with cryptography best-practices very foremost in mind, there could very well still be privacy or security breaking bugs.
>> Acknowledgements & Credits

View file

@ -53,7 +53,8 @@ class StreamDataMessage(MessageBase):
The stream id is limited to 2 bytes - 2 bit
"""
OVERHEAD = 2 + 6 # 2 for stream data message header, 6 for channel envelope
HEADER_LEN = 2 # 2 for stream data message header
OVERHEAD = HEADER_LEN + 6 # 6 for channel envelope
MAX_DATA_LEN = RNS.Link.MDU - OVERHEAD
"""
When the Buffer package is imported, this value is
@ -226,7 +227,7 @@ class RawChannelWriter(RawIOBase, AbstractContextManager):
self._stream_id = stream_id
self._channel = channel
self._eof = False
self._mdu = channel.mdu - StreamDataMessage.OVERHEAD
self._mdu = channel.mdu - StreamDataMessage.HEADER_LEN
def write(self, __b: bytes) -> int | None:
try:
@ -242,7 +243,7 @@ class RawChannelWriter(RawIOBase, AbstractContextManager):
chunk_segment_length = int(chunk_len/comp_try)
compressed_chunk = bz2.compress(__b[:chunk_segment_length])
compressed_length = len(compressed_chunk)
if compressed_length < StreamDataMessage.MAX_DATA_LEN and compressed_length < chunk_segment_length:
if compressed_length < self._mdu and compressed_length < chunk_segment_length:
comp_success = True
break
else:
@ -252,7 +253,7 @@ class RawChannelWriter(RawIOBase, AbstractContextManager):
chunk = compressed_chunk
processed_length = chunk_segment_length
else:
chunk = bytes(__b[:StreamDataMessage.MAX_DATA_LEN])
chunk = bytes(__b[:self._mdu])
processed_length = len(chunk)
message = StreamDataMessage(self._stream_id, chunk, self._eof, comp_success)

View file

@ -43,7 +43,7 @@ elif cp.PROVIDER == cp.PROVIDER_PYCA:
class AES_128_CBC:
@staticmethod
def encrypt(plaintext, key, iv):
if len(key) != 16: raise ValueError(f"Invalid key length {len(key)*8} for {self}")
if len(key) != 16: raise ValueError(f"Invalid key length {len(key)*8} for AES_128_CBC")
if cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES128(key)
return cipher.encrypt(plaintext, iv)
@ -60,7 +60,7 @@ class AES_128_CBC:
@staticmethod
def decrypt(ciphertext, key, iv):
if len(key) != 16: raise ValueError(f"Invalid key length {len(key)*8} for {self}")
if len(key) != 16: raise ValueError(f"Invalid key length {len(key)*8} for AES_128_CBC")
if cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES128(key)
return cipher.decrypt(ciphertext, iv)
@ -78,7 +78,7 @@ class AES_128_CBC:
class AES_256_CBC:
@staticmethod
def encrypt(plaintext, key, iv):
if len(key) != 32: raise ValueError(f"Invalid key length {len(key)*8} for {self}")
if len(key) != 32: raise ValueError(f"Invalid key length {len(key)*8} for AES_256_CBC")
if cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES256(key)
return cipher.encrypt_cbc(plaintext, iv)
@ -95,7 +95,7 @@ class AES_256_CBC:
@staticmethod
def decrypt(ciphertext, key, iv):
if len(key) != 32: raise ValueError(f"Invalid key length {len(key)*8} for {self}")
if len(key) != 32: raise ValueError(f"Invalid key length {len(key)*8} for AES_256_CBC")
if cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES256(key)
return cipher.decrypt_cbc(ciphertext, iv)

View file

@ -32,8 +32,59 @@ import hashlib
from math import ceil
from RNS.Cryptography import HMAC
# Precomputed byte-permutation tables used to key the inner
# and outer hashing states of the HMAC construction
trans_5C = bytes((x ^ 0x5C) for x in range(256))
trans_36 = bytes((x ^ 0x36) for x in range(256))
# Precomputed HKDF counter bytes
_counters = [bytes([(i + 1) % 0x100]) for i in range(0x100)]
def hkdf(length=None, derive_from=None, salt=None, context=None):
hash_len = 32
hash_block_size = 64
if length == None or length < 1: raise ValueError("Invalid output key length")
if derive_from == None or derive_from == "": raise ValueError("Cannot derive key from empty input material")
if salt == None or len(salt) == 0: salt = bytes([0] * hash_len)
if context == None: context = b""
pseudorandom_key = HMAC.new(salt, derive_from).digest()
# The pseudorandom key is always a 32-byte SHA-256 digest, so the
# HMAC key schedule below never needs the long-key hashing branch.
key = pseudorandom_key.ljust(hash_block_size, b"\x00")
# Precompute the keyed inner and outer hashing states of the
# HMAC construction once. Each expansion block then only requires
# two cheap hash-object copies instead of constructing a complete
# HMAC object per 32 bytes of output. This is possible since the
# PRK is **always** 32-byte SHA-256 HMAC output.
inner_state = hashlib.sha256(key.translate(trans_36))
outer_state = hashlib.sha256(key.translate(trans_5C))
# Expand
has_context = len(context) > 0
block = b""
derived = bytearray()
for i in range(ceil(length / hash_len)):
inner = inner_state.copy()
inner.update(block)
if has_context: inner.update(context)
inner.update(_counters[i & 0xFF])
outer = outer_state.copy()
outer.update(inner.digest())
block = outer.digest()
derived += block
return bytes(derived[:length])
def hkdf_legacy(length=None, derive_from=None, salt=None, context=None):
hash_len = 32
def hmac_sha256(key, data):
return HMAC.new(key, data).digest()

View file

@ -180,4 +180,9 @@ def digest(key, msg, digest):
outer.update(key.translate(trans_5C))
inner.update(msg)
outer.update(inner.digest())
return outer.digest()
return outer.digest()
def _cd_internal(a, b): return a == b
try: from hmac import compare_digest as _cd
except ImportError: _cd = _cd_internal
compare_digest = _cd

View file

@ -80,7 +80,7 @@ class Token():
received_hmac = token[-32:]
expected_hmac = HMAC.new(self._signing_key, token[:-32]).digest()
if received_hmac == expected_hmac: return True
if HMAC.compare_digest(received_hmac, expected_hmac): return True
else: return False

View file

@ -52,5 +52,7 @@ elif cp.PROVIDER == cp.PROVIDER_PYCA:
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))

View file

@ -206,7 +206,7 @@ class Destination:
def _clean_ratchets(self):
if self.ratchets != None:
if len (self.ratchets) > self.retained_ratchets:
self.ratchets = self.ratchets[:Destination.RATCHET_COUNT]
self.ratchets = self.ratchets[:self.retained_ratchets]
def _persist_ratchets(self):
try:

View file

@ -1,3 +1,33 @@
# Reticulum License
#
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import re
import RNS
@ -10,8 +40,16 @@ from threading import Lock
from .vendor import umsgpack as msgpack
from collections import deque
# Discoverable interfaces must include a short,
# unique implementation-specific identifier and
# version tag in announced discovery information.
IMPLEMENTATION_NAME = "RNS"
from RNS._version import __version__ as RNS_VERSION
NAME = 0xFF
TRANSPORT_ID = 0xFE
TRANSPORT_IMPL = 0xFD
TRANSPORT_VERS = 0xFC
INTERFACE_TYPE = 0x00
TRANSPORT = 0x01
REACHABLE_ON = 0x02
@ -27,6 +65,7 @@ SPREADINGFACTOR = 0x0B
CODINGRATE = 0x0C
MODULATION = 0x0D
CHANNEL = 0x0E
OP_ADDR = 0xF0
APP_NAME = "rnstransport"
@ -123,7 +162,7 @@ class InterfaceAnnouncer():
interface.discovery_height = dhgt
except Exception as e:
RNS.log(f"Error while getting reachable_on from executable at {interface.reachable_on}: {e}", RNS.LOG_ERROR)
RNS.log(f"Error while getting discovery location from executable at {interface.reachable_on}: {e}", RNS.LOG_ERROR)
RNS.log(f"Aborting discovery announce", RNS.LOG_ERROR)
return None
@ -131,16 +170,21 @@ class InterfaceAnnouncer():
info = {INTERFACE_TYPE: interface_type,
TRANSPORT: RNS.Reticulum.transport_enabled(),
TRANSPORT_ID: RNS.Transport.identity.hash,
TRANSPORT_IMPL: IMPLEMENTATION_NAME,
TRANSPORT_VERS: RNS_VERSION,
NAME: self.sanitize(interface.discovery_name),
LATITUDE: interface.discovery_latitude,
LONGITUDE: interface.discovery_longitude,
HEIGHT: interface.discovery_height}
if interface.discovery_lxmf_address: info[OP_ADDR] = interface.discovery_lxmf_address
if interface_type == "TCPClientInterface" and not interface.kiss_framing:
RNS.log(f"Invalid interface discovery configuration for {interface}, aborting discovery announce", RNS.LOG_ERROR)
return None
if interface_type in ["BackboneInterface", "TCPServerInterface"]:
if not interface.reachable_on: return None
reachable_on = self.sanitize(interface.reachable_on)
if not RNS.vendor.platformutils.is_windows():
@ -190,8 +234,8 @@ class InterfaceAnnouncer():
info[MODULATION] = self.sanitize(interface.discovery_modulation)
if interface.discovery_publish_ifac == True:
info[IFAC_NETNAME] = self.sanitize(interface.ifac_netname)
info[IFAC_NETKEY] = self.sanitize(interface.ifac_netkey)
if interface.ifac_netname: info[IFAC_NETNAME] = self.sanitize(interface.ifac_netname)
if interface.ifac_netkey: info[IFAC_NETKEY] = self.sanitize(interface.ifac_netkey)
packed = msgpack.packb(info)
infohash = RNS.Identity.full_hash(packed)
@ -301,6 +345,8 @@ class InterfaceAnnounceHandler:
if INTERFACE_TYPE in unpacked:
interface_type = unpacked[INTERFACE_TYPE]
name = self.sanitize_name(unpacked[NAME])
impl_name = unpacked[TRANSPORT_IMPL] if TRANSPORT_IMPL in unpacked else None
impl_version = unpacked[TRANSPORT_VERS] if TRANSPORT_VERS in unpacked else None
if type(unpacked[TRANSPORT]) != bool: raise ValueError("Invalid data in transport field of announce")
if type(unpacked[LATITUDE]) not in [type(None), float]: raise ValueError("Invalid data in latitude field of announce")
@ -315,6 +361,8 @@ class InterfaceAnnounceHandler:
raise ValueError("Invalid data in reachable_on field of announce")
info = {"type": interface_type,
"impl_name": impl_name,
"version": impl_version,
"transport": unpacked[TRANSPORT],
"name": name or f"Discovered {interface_type}",
"received": time.time(),
@ -327,8 +375,8 @@ class InterfaceAnnounceHandler:
"longitude": unpacked[LONGITUDE],
"height": unpacked[HEIGHT]}
if IFAC_NETNAME in unpacked: info["ifac_netname"] = str(unpacked[IFAC_NETNAME])
if IFAC_NETKEY in unpacked: info["ifac_netkey"] = str(unpacked[IFAC_NETKEY])
if IFAC_NETNAME in unpacked and unpacked[IFAC_NETNAME] and type(unpacked[IFAC_NETNAME]) == str: info["ifac_netname"] = str(unpacked[IFAC_NETNAME])
if IFAC_NETKEY in unpacked and unpacked[IFAC_NETKEY] and type(unpacked[IFAC_NETKEY]) == str: info["ifac_netkey"] = str(unpacked[IFAC_NETKEY])
if interface_type in ["BackboneInterface", "TCPServerInterface"]:
backbone_support = not RNS.vendor.platformutils.is_windows() and not RNS.vendor.platformutils.is_darwin()
@ -357,7 +405,7 @@ class InterfaceAnnounceHandler:
cfg_netname_str = f"\n network_name = {cfg_netname}" if cfg_netname else ""
cfg_netkey_str = f"\n passphrase = {cfg_netkey}" if cfg_netkey else ""
cfg_identity_str = f"\n transport_identity = {cfg_identity}"
info["config_entry"] = f"[[{cfg_name}]]\n type = I2PInterface\n enabled = yes\n peers = {cfg_remote}{cfg_identity_str}{cfg_netname_str}{cfg_netkey_str}"
info["config_entry"] = f"[[{cfg_name}]]\n type = I2PInterface\n enabled = yes\n peers = {cfg_remote}.b32.i2p{cfg_identity_str}{cfg_netname_str}{cfg_netkey_str}"
if interface_type == "RNodeInterface":
info["frequency"] = unpacked[FREQUENCY]
@ -410,6 +458,11 @@ class InterfaceAnnounceHandler:
discovery_hash_material = info["transport_id"]+info["name"]
info["discovery_hash"] = RNS.Identity.full_hash(discovery_hash_material.encode("utf-8"))
if info and OP_ADDR in unpacked:
if type(unpacked[OP_ADDR]) not in [type(None), bytes]: raise ValueError("Invalid data in operator LXMF address field of announce")
if unpacked[OP_ADDR] and len(unpacked[OP_ADDR]) == RNS.Identity.TRUNCATED_HASHLENGTH//8:
info["operator_lxmf_address"] = RNS.hexrep(unpacked[OP_ADDR], delimit=False)
if self.callback and callable(self.callback): self.callback(info)
except Exception as e:
@ -427,13 +480,17 @@ class InterfaceDiscovery():
STATUS_UNKNOWN = 100
STATUS_AVAILABLE = 1000
STATUS_CODE_MAP = {"available": STATUS_AVAILABLE, "unknown": STATUS_UNKNOWN, "stale": STATUS_STALE}
AUTOCONNECT_TYPES = ["BackboneInterface", "TCPServerInterface"]
AUTOCONNECT_TYPES = ["BackboneInterface"]
DISCOVERABLE_TYPES = ["BackboneInterface", "TCPServerInterface", "I2PInterface", "RNodeInterface", "WeaveInterface", "KISSInterface"]
AUTOCONNECT_IMPLS = ["RNS"]
AUTOCONNECT_MIN_V = "1.5.2"
AC_TRANSPORT_MODE = RNS.Interfaces.Interface.Interface.MODE_GATEWAY
AC_GRAVITY = 0
discovery_lock = Lock()
autoconnect_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
@ -479,7 +536,16 @@ class InterfaceDiscovery():
should_remove = False
heard_delta = now-info["last_heard"]
info["name"] = InterfaceAnnounceHandler.sanitize_name(info["name"])
# TODO: Temporary measure to sanitize invalidly persisted "None" values
# for incorrect IFAC parameters published due to unguarded nonsensical
# node-side configuration. Potentially remove once nodes upgrade to RNS
# versions that guard against the configuration mismatch.
try:
if "ifac_netname" in info and info["ifac_netname"] == "None": info.pop("ifac_netname")
if "ifac_netkey" in info and info["ifac_netkey"] == "None": info.pop("ifac_netkey")
except Exception as e: RNS.log(f"Error while sanitizing discovery info: {e}", RNS.LOG_ERROR)
if heard_delta > self.THRESHOLD_REMOVE: should_remove = True
elif not "transport_id" in info or not info["transport_id"]: should_remove = True
elif not "network_id" in info or not info["network_id"]: should_remove = True
@ -525,11 +591,13 @@ class InterfaceDiscovery():
interface_type = info["type"]
discovery_hash = info["discovery_hash"]
discovered_type = info["type"]
has_impl_info = "impl_name" in info and "version" in info and info["impl_name"] and info["version"]
version_str = f" ({info['impl_name']} {info['version']})" if has_impl_info else " (unknown implementation)"
if not discovered_type in self.DISCOVERABLE_TYPES: return
hops = info["hops"]; ms = "" if hops == 1 else "s"
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)
RNS.log(f"Discovered {interface_type}{version_str} {hops} hop{ms} away with stamp value {value}: {name}", RNS.LOG_DEBUG)
with self.discovery_lock:
if not os.path.isfile(filepath):
try:
@ -597,22 +665,27 @@ class InterfaceDiscovery():
autoconnected_interfaces = self.autoconnect_count()
for interface in self.monitored_interfaces:
try:
if interface.online:
online_interfaces += 1
if hasattr(interface, "autoconnect_down") and interface.autoconnect_down != None:
RNS.log(f"Auto-discovered interface {interface} reconnected")
interface.autoconnect_down = None
if not interface in RNS.Transport.interfaces:
RNS.log(f"A monitored auto-connected interface was manually detached, removing from monitoring", RNS.LOG_DEBUG)
detached_interfaces.append(interface)
else:
if not hasattr(interface, "autoconnect_down") or interface.autoconnect_down == None:
RNS.log(f"Auto-discovered interface {interface} disconnected", RNS.LOG_DEBUG)
interface.autoconnect_down = time.time()
if interface.online:
online_interfaces += 1
if hasattr(interface, "autoconnect_down") and interface.autoconnect_down != None:
RNS.log(f"Auto-discovered interface {interface} reconnected")
interface.autoconnect_down = None
else:
down_for = time.time()-interface.autoconnect_down
if down_for >= self.detach_threshold:
RNS.log(f"Auto-discovered interface {interface} has been down for {RNS.prettytime(down_for)}, detaching", RNS.LOG_DEBUG)
detached_interfaces.append(interface)
if not hasattr(interface, "autoconnect_down") or interface.autoconnect_down == None:
RNS.log(f"Auto-discovered interface {interface} disconnected", RNS.LOG_DEBUG)
interface.autoconnect_down = time.time()
else:
down_for = time.time()-interface.autoconnect_down
if down_for >= self.detach_threshold:
RNS.log(f"Auto-discovered interface {interface} has been down for {RNS.prettytime(down_for)}, detaching", RNS.LOG_DEBUG)
detached_interfaces.append(interface)
except Exception as e:
RNS.log(f"Error while checking auto-connected interface state for {interface}: {e}", RNS.LOG_ERROR)
@ -646,8 +719,10 @@ class InterfaceDiscovery():
RNS.log(f"Error while de-registering auto-connected interface from transport: {e}", RNS.LOG_ERROR)
def teardown_interface(self, interface):
interface.detach()
RNS.Transport.remove_interface(interface)
if interface in RNS.Transport.interfaces:
was_detached = RNS.Reticulum.get_instance()._detach_interface(interface.name, internal_forced=True)
if not was_detached: RNS.log(f"Detach of auto-connected interface {interface} did not succeed", RNS.LOG_ERROR)
if interface in self.monitored_interfaces: self.monitored_interfaces.remove(interface)
def autoconnect_count(self):
@ -694,6 +769,26 @@ class InterfaceDiscovery():
return exists
def autoconnect_interface_name(self, name):
names = {interface.name for interface in RNS.Transport.interfaces}
if not name in names: return name
else:
n = 2; seq_name = f"{name} ({n})"
while seq_name in names:
n += 1; seq_name = f"{name} ({n})"
return seq_name
def autoconnect_qualified(self, info):
if RNS.Reticulum.should_autoconnect_unverified_implementations(): return True
if not "impl_name" in info: return False
if not info["impl_name"] in self.AUTOCONNECT_IMPLS: return False
if not "version" in info or not info["version"]: return False
version = version_tuple(info["version"])
if version == None: return False
return version >= version_tuple(self.AUTOCONNECT_MIN_V)
def autoconnect(self, info):
try:
if RNS.Reticulum.should_autoconnect_discovered_interfaces():
@ -701,55 +796,84 @@ class InterfaceDiscovery():
if autoconnected_count < RNS.Reticulum.max_autoconnected_interfaces():
interface_type = info["type"]
if interface_type in self.AUTOCONNECT_TYPES:
if not self.autoconnect_qualified(info):
impl = f"{info['impl_name']} {info['version']}" if ("impl_name" in info and "version" in info and info["impl_name"] and info["version"]) else "unknown implementation"
RNS.log(f"Not auto-connecting discovered {interface_type} {info['name']} ({impl}), auto-connect criteria not satisfied", RNS.LOG_DEBUG)
return
endpoint_hash = self.endpoint_hash(info)
exists = self.interface_exists(info)
if exists: RNS.log(f"Discovered {interface_type} already exists, not auto-connecting", RNS.LOG_DEBUG)
else:
if interface_type == "TCPClientInterface":
RNS.log(f"Your operating system does not support the Backbone interface type, and must degrade to using TCPClientInterface instead", RNS.LOG_WARNING)
RNS.log(f"Auto-connecting discovered TCPClient interfaces is not yet implemented, aborting auto-connect", RNS.LOG_WARNING)
RNS.log(f"You can obtain the configuration entry and add this interface manually instead using rnstatus -D", RNS.LOG_WARNING)
return
if interface_type == "I2PInterface":
RNS.log(f"Auto-connecting discovered I2P interfaces is not yet implemented, aborting auto-connect", RNS.LOG_WARNING)
RNS.log(f"You can obtain the configuration entry and add this interface manually instead using rnstatus -D", RNS.LOG_WARNING)
RNS.log(f"Auto-connecting discovered I2P interfaces is not yet implemented, aborting auto-connect", RNS.LOG_DEBUG)
RNS.log(f"You can obtain the configuration entry and add this interface manually instead using rnstatus -D", RNS.LOG_DEBUG)
return
if is_ygg_ipv6(info["reachable_on"]):
# TODO: Somehow detect if yggdrasil is enabled on the system
return
interface_name = info["name"]
config_entry = info["config_entry"]
interface_config = {}
interface_config["name"] = f"{interface_name}"
ifac_netname = info["ifac_netname"] if "ifac_netname" in info else None
ifac_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
interface = None
if is_onion_address(info["reachable_on"]):
# TODO: Somehow detect if TOR is enabled on the system
return
if interface_type == "BackboneInterface":
from RNS.Interfaces import BackboneInterface
interface_config["target_host"] = info["reachable_on"]
interface_config["target_port"] = info["port"]
interface = BackboneInterface.BackboneClientInterface(RNS.Transport, interface_config)
if is_ip_address(info["reachable_on"]) and is_invalid_ip_address(info["reachable_on"]):
RNS.log(f"Not auto-connecting discovered interface with invalid IP address: {info['reachable_on']}", RNS.LOG_DEBUG)
return
if interface:
RNS.log(f"Auto-connecting discovered {interface_type} {interface_name}")
interface.autoconnect_hash = endpoint_hash
interface.autoconnect_source = info["network_id"]
if RNS.Reticulum.autoconnect_interface_mode(): mode = RNS.Reticulum.autoconnect_interface_mode()
else: mode = self.AC_TRANSPORT_MODE if RNS.Reticulum.transport_enabled() else None
internal_a = True if RNS.Reticulum.autoconnect_announces_to_internal() else None
gravity = RNS.Reticulum.autoconnect_interface_gravity() or self.AC_GRAVITY
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, 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,
announces_to_internal=internal_a, gravity=gravity)
self.monitor_interface(interface)
with self.autoconnect_lock:
if self.interface_exists(info): return
backbone_support = (not RNS.vendor.platformutils.is_windows() and not RNS.vendor.platformutils.is_darwin())
interface_name = self.autoconnect_interface_name(info["name"])
if interface_name != info["name"]:
RNS.log(f"Auto-connect name collision for \"{info['name']}\", connecting as \"{interface_name}\"", RNS.LOG_NOTICE)
config_entry = info["config_entry"]
interface_config = {}
interface_config["name"] = f"{interface_name}"
# TODO: Temporary measure to sanitize invalidly persisted "None" values
# for incorrect IFAC parameters published due to unguarded nonsensical
# node-side configuration. Potentially remove once nodes upgrade to RNS
# versions that guard against the configuration mismatch.
if "ifac_netname" in info and info["ifac_netname"] == "None": info["ifac_netname"] = None
if "ifac_netkey" in info and info["ifac_netkey"] == "None": info["ifac_netkey"] = None
ifac_netname = info["ifac_netname"] if "ifac_netname" in info and info["ifac_netname"] else None
ifac_netkey = info["ifac_netkey"] if "ifac_netkey" in info and info["ifac_netkey"] else None
interface = None
if interface_type == "BackboneInterface":
if backbone_support:
from RNS.Interfaces import BackboneInterface
interface_class = BackboneInterface.BackboneClientInterface
else:
from RNS.Interfaces import TCPInterface
interface_class = TCPInterface.TCPClientInterface
RNS.log(f"BackboneInterface is not yet supported on this operating system, auto-connecting discovered {interface_type} {interface_name} using TCPClientInterface", RNS.LOG_NOTICE)
interface_config["target_host"] = info["reachable_on"]
interface_config["target_port"] = info["port"]
interface = interface_class(RNS.Transport, interface_config)
if interface:
RNS.log(f"Auto-connecting discovered {interface_type} {interface_name}")
interface.autoconnect_hash = endpoint_hash
interface.autoconnect_source = info["network_id"]
if RNS.Reticulum.autoconnect_interface_mode(): mode = RNS.Reticulum.autoconnect_interface_mode()
else: mode = self.AC_TRANSPORT_MODE if RNS.Reticulum.transport_enabled() else None
internal_a = True if RNS.Reticulum.autoconnect_announces_to_internal() else None
gravity = RNS.Reticulum.autoconnect_interface_gravity() or self.AC_GRAVITY
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, 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,
announces_to_internal=internal_a, gravity=gravity)
self.monitor_interface(interface)
except Exception as e:
RNS.log(f"Error while auto-connecting discovered interface: {e}", RNS.LOG_ERROR)
@ -851,6 +975,15 @@ def is_ygg_ipv6(address_string):
try: return ipaddress.ip_address(address_string) in ipaddress.IPv6Network("200::/7")
except: return False
def is_onion_address(address_string):
try: return address_string.lower().endswith(".onion")
except: return False
INVALID_IP_ADDRESSES = ["127.0.0.1", "0.0.0.0"]
def is_invalid_ip_address(address_string):
if not address_string in INVALID_IP_ADDRESSES: return False
else: return True
def is_hostname(hostname):
if hostname[-1] == ".": hostname = hostname[:-1]
if len(hostname) > 253: return False
@ -859,6 +992,18 @@ def is_hostname(hostname):
allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(?<!-)$", re.IGNORECASE)
return all(allowed.match(label) for label in components)
def version_tuple(version_string):
if type(version_string) != str: return None
try:
components = []
for component in version_string.strip().split("."):
match = re.match(r"[0-9]+", component)
if match == None: break
components.append(int(match.group(0)))
if len(components) == 0: return None
return tuple(components)
except Exception as e: return None
san_map = ""
for i in range(48, 58): san_map += bytes([i]).decode("ascii")
for i in range(65, 91): san_map += bytes([i]).decode("ascii")

View file

@ -201,7 +201,7 @@ class Identity:
except Exception as e:
RNS.log(f"Error while serializing and writing known destinations: {e}", RNS.LOG_ERROR)
try: os.unlink(temp_file)
except Exception as e: RNS.log(f"Could not clean up temporary file {temp_file}: {e}", RNS.LOG_WARNING)
except Exception as exc: RNS.log(f"Could not clean up temporary file {temp_file}: {exc}", RNS.LOG_WARNING)
raise e
RNS.log(f"Saved known destinations to storage in {RNS.prettyshorttime(time.time()-save_start)}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
@ -508,7 +508,7 @@ class Identity:
return None
@staticmethod
def validate_announce(packet, only_validate_signature=False):
def validate_announce(packet, only_validate_signature=False, signal_blackholed=False):
try:
if packet.packet_type == RNS.Packet.ANNOUNCE:
keysize = Identity.KEYSIZE//8
@ -553,9 +553,11 @@ class Identity:
if len(RNS.Transport.blackholed_identities) > 0:
if announced_identity.hash in RNS.Transport.blackholed_identities:
RNS.log(f"Invalidated and dropped announce from blackholed identity {RNS.prettyhexrep(announced_identity.hash)}", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
return False
if signal_blackholed: return "blackholed"
else: return False
if announced_identity.pub != None and announced_identity.validate(signature, signed_data):
if announced_identity.pub != None and (packet.announce_signature_validated or announced_identity.validate(signature, signed_data)):
packet.announce_signature_validated = True
if only_validate_signature:
del announced_identity
return True
@ -757,9 +759,7 @@ class Identity:
return True
except Exception as e:
raise e
RNS.log("Failed to load identity key", RNS.LOG_ERROR)
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log(f"Failed to load identity key, the contained exception was: {e}", RNS.LOG_ERROR)
return False
def load_public_key(self, pub_bytes):
@ -777,8 +777,12 @@ class Identity:
self.sig_pub = Ed25519PublicKey.from_public_bytes(self.sig_pub_bytes)
self.update_hashes()
return True
except Exception as e:
RNS.log("Error while loading public key, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log(f"Error while loading public key, the contained exception was: {e}", RNS.LOG_ERROR)
return False
def update_hashes(self):
self.hash = Identity.truncated_hash(self.get_public_key())

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -128,7 +128,9 @@ class AX25KISSInterface(Interface):
self.packet_queue = []
self.flow_control = flow_control
self.shared_medium = True
self.interface_ready = False
self.reconnecting = False
self.flow_control_timeout = 5
self.flow_control_locked = time.time()
@ -151,14 +153,18 @@ class AX25KISSInterface(Interface):
try:
self.open_port()
if self.serial.is_open: self.configure_device()
else: raise IOError("Could not open serial port")
except Exception as e:
RNS.log("Could not open serial port for interface "+str(self), RNS.LOG_ERROR)
raise e
RNS.log(f"Could not open serial port for {self}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
RNS.log(f"Reticulum will attempt to bring up this interface periodically", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting:
thread = threading.Thread(target=self.reconnect_port)
thread.daemon = True
thread.start()
if self.serial.is_open:
self.configure_device()
else:
raise IOError("Could not open serial port")
def open_port(self):
RNS.log("Opening serial port "+self.port+"...", RNS.LOG_VERBOSE)
@ -176,6 +182,12 @@ class AX25KISSInterface(Interface):
dsrdtr = False,
)
def detach(self):
self.detached = True
self.online = False
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self} at detach: {e}", RNS.LOG_ERROR)
def configure_device(self):
# Allow time for interface to initialise before config
sleep(2.0)
@ -372,30 +384,28 @@ class AX25KISSInterface(Interface):
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
if RNS.Reticulum.panic_on_interface_error: RNS.panic()
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
self.online = False
self.serial.close()
self.reconnect_port()
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self}", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting: self.reconnect_port()
def reconnect_port(self):
while not self.online:
self.reconnecting = True
while not self.online and not self.detached:
try:
time.sleep(5)
RNS.log("Attempting to reconnect serial port "+str(self.port)+" for "+str(self)+"...", RNS.LOG_VERBOSE)
self.open_port()
if self.serial.is_open:
self.configure_device()
except Exception as e:
RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
if self.serial.is_open: self.configure_device()
except Exception as e: RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Reconnected serial port for "+str(self))
self.reconnecting = False
if self.online: RNS.log("Reconnected serial port for "+str(self))
def should_ingress_limit(self):
return False
def should_ingress_limit(self): return False
def __str__(self):
return "AX25KISSInterface["+self.name+"]"
def __str__(self): return "AX25KISSInterface["+self.name+"]"

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -128,9 +128,11 @@ class KISSInterface(Interface):
self.beacon_d = beacon_data.encode("utf-8")
self.first_tx = None
self.bitrate = KISSInterface.BITRATE_GUESS
self.reconnecting = False
self.packet_queue = []
self.flow_control = flow_control
self.shared_medium = True
self.interface_ready = False
self.flow_control_timeout = 5
self.flow_control_locked = time.time()
@ -148,15 +150,23 @@ class KISSInterface(Interface):
try:
self.open_port()
if self.serial.is_open: self.configure_device()
else: raise IOError("Could not open serial port")
except Exception as e:
RNS.log("Could not open serial port "+self.port, RNS.LOG_ERROR)
raise e
if self.serial.is_open:
self.configure_device()
else:
raise IOError("Could not open serial port")
RNS.log(f"Could not open serial port for {self}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
RNS.log(f"Reticulum will attempt to bring up this interface periodically", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting:
thread = threading.Thread(target=self.reconnect_port)
thread.daemon = True
thread.start()
def detach(self):
self.detached = True
self.online = False
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self} at detach: {e}", RNS.LOG_ERROR)
def open_port(self):
RNS.log("Opening serial port "+self.port+"...")
@ -416,24 +426,23 @@ class KISSInterface(Interface):
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
self.online = False
self.serial.close()
self.reconnect_port()
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self}", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting: self.reconnect_port()
def reconnect_port(self):
self.reconnecting = True
while not self.online:
try:
time.sleep(5)
RNS.log("Attempting to reconnect serial port "+str(self.port)+" for "+str(self)+"...", RNS.LOG_VERBOSE)
self.open_port()
if self.serial.is_open:
self.configure_device()
except Exception as e:
RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
if self.serial.is_open: self.configure_device()
except Exception as e: RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Reconnected serial port for "+str(self))
self.reconnecting = False
if self.online: RNS.log("Reconnected serial port for "+str(self))
def should_ingress_limit(self):
return False
def should_ingress_limit(self): return False
def __str__(self):
return "KISSInterface["+self.name+"]"
def __str__(self): return "KISSInterface["+self.name+"]"

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -282,12 +282,9 @@ class RNodeInterface(Interface):
if (port != None or device_serial != None) and (enable_bluetooth or disable_bluetooth or pairing_mode):
serial = None
bluetooth_state = None
if pairing_mode:
bluetooth_state = 0x01
elif enable_bluetooth:
bluetooth_state = 0x01
elif disable_bluetooth:
bluetooth_state = 0x00
if pairing_mode: bluetooth_state = 0x01
elif enable_bluetooth: bluetooth_state = 0x01
elif disable_bluetooth: bluetooth_state = 0x00
if port != None:
RNS.log("Opening serial port "+port+"...")
@ -518,6 +515,7 @@ class RNodeInterface(Interface):
self.packet_queue = []
self.flow_control = flow_control
self.shared_medium = True
self.interface_ready = False
self.announce_rate_target = None
self.last_port_io = 0
@ -1506,13 +1504,13 @@ class RNodeInterface(Interface):
except Exception as e:
self.online = False
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
if not self.detached:
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
if RNS.Reticulum.panic_on_interface_error: RNS.panic()
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
self.online = False
@ -1572,10 +1570,14 @@ class RNodeInterface(Interface):
except Exception as e:
RNS.log(f"An error occurred while detaching {self}: {e}", RNS.LOG_ERROR)
if self.use_ble: self.ble.close()
if self.use_tcp:
if self.use_ble:
self.ble.close()
self.ble.cleanup()
elif self.use_tcp:
time.sleep(0.5)
self.tcp.close()
else:
self.serial.close()
def should_ingress_limit(self):
return False
@ -1654,6 +1656,9 @@ class BLEConnection(BluetoothDispatcher):
self.owner.ble_rx_queue = b""
return data
def cleanup(self):
self.should_run = False
def close(self):
try:
if self.connected:
@ -1742,7 +1747,7 @@ class BLEConnection(BluetoothDispatcher):
ble_devices = self.find_target_devices()
if len(ble_devices) > 0: self.ble_device = ble_devices.pop()
else: self.ble_device == None
else: self.ble_device = None
if self.ble_device != None:
if self.was_connected:

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -95,6 +95,7 @@ class SerialInterface(Interface):
raise ValueError("No port specified for serial interface")
self.HW_MTU = 564
self.shared_medium = True
self.pyserial = serial
self.serial = None
@ -108,6 +109,7 @@ class SerialInterface(Interface):
self.timeout = 100
self.online = False
self.bitrate = self.speed
self.reconnecting = False
if parity.lower() == "e" or parity.lower() == "even":
self.parity = "E"
@ -117,15 +119,23 @@ class SerialInterface(Interface):
try:
self.open_port()
if self.serial.is_open: self.configure_device()
else: raise IOError("Could not open serial port")
except Exception as e:
RNS.log("Could not open serial port for interface "+str(self), RNS.LOG_ERROR)
raise e
if self.serial.is_open:
self.configure_device()
else:
raise IOError("Could not open serial port")
RNS.log(f"Could not open serial port for {self}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
RNS.log(f"Reticulum will attempt to bring up this interface periodically", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting:
thread = threading.Thread(target=self.reconnect_port)
thread.daemon = True
thread.start()
def detach(self):
self.detached = True
self.online = False
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self} at detach: {e}", RNS.LOG_ERROR)
def open_port(self):
RNS.log("Opening serial port "+self.port+"...")
@ -257,24 +267,23 @@ class SerialInterface(Interface):
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
self.online = False
self.serial.close()
self.reconnect_port()
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self}", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting: self.reconnect_port()
def reconnect_port(self):
while not self.online:
self.reconnecting = True
while not self.online and not self.detached:
try:
time.sleep(5)
RNS.log("Attempting to reconnect serial port "+str(self.port)+" for "+str(self)+"...", RNS.LOG_VERBOSE)
self.open_port()
if self.serial.is_open:
self.configure_device()
except Exception as e:
RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
if self.serial.is_open: self.configure_device()
except Exception as e: RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Reconnected serial port for "+str(self))
self.reconnecting = False
if self.online: RNS.log("Reconnected serial port for "+str(self))
def should_ingress_limit(self):
return False
def should_ingress_limit(self): return False
def __str__(self):
return "SerialInterface["+self.name+"]"
def __str__(self): return "SerialInterface["+self.name+"]"

View file

@ -33,5 +33,7 @@ import glob
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -127,6 +127,7 @@ class AutoInterface(Interface):
self.link_local_addresses = []
self.adopted_interfaces = {}
self.interface_servers = {}
self.discovery_sockets = []
self.multicast_echoes = {}
self.initial_echoes = {}
self.timed_out_interfaces = {}
@ -268,6 +269,8 @@ class AutoInterface(Interface):
addr_info = socket.getaddrinfo(link_local_addr+"%"+ifname, self.unicast_discovery_port, socket.AF_INET6, socket.SOCK_DGRAM)
unicast_discovery_socket.bind(addr_info[0][4])
self.discovery_sockets.append(unicast_discovery_socket)
mcast_addr = self.mcast_discovery_address
RNS.log(str(self)+" Creating multicast discovery listener on "+str(ifname)+" with address "+str(mcast_addr), RNS.LOG_EXTREME)
@ -296,6 +299,8 @@ class AutoInterface(Interface):
discovery_socket.bind(addr_info[0][4])
self.discovery_sockets.append(discovery_socket)
# Set up thread for multicast discovery packets
def discovery_loop(): self.discovery_handler(discovery_socket, ifname)
thread = threading.Thread(target=discovery_loop, daemon=True).start()
@ -358,9 +363,9 @@ class AutoInterface(Interface):
thread.daemon = True
thread.start()
while True:
while not self.detached:
data, ipv6_src = socket.recvfrom(1024)
if self.final_init_done:
if self.final_init_done and data and ipv6_src:
peering_hash = data[:RNS.Identity.HASHLENGTH//8]
expected_hash = RNS.Identity.full_hash(self.group_id+ipv6_src[0].encode("utf-8"))
if peering_hash == expected_hash:
@ -369,7 +374,7 @@ class AutoInterface(Interface):
RNS.log(str(self)+" received peering packet on "+str(ifname)+" from "+str(ipv6_src[0])+", but authentication hash was incorrect.", RNS.LOG_DEBUG)
def peer_jobs(self):
while True:
while not self.detached:
time.sleep(self.peer_job_interval)
now = time.time()
timed_out_peers = []
@ -478,7 +483,7 @@ class AutoInterface(Interface):
def announce_handler(self, ifname):
while True:
while not self.detached:
self.peer_announce(ifname)
time.sleep(self.announce_interval)
@ -581,6 +586,10 @@ class AutoInterface(Interface):
spawned_interface.announce_rate_penalty = self.announce_rate_penalty
spawned_interface.mode = self.mode
spawned_interface.gravity = self.gravity
spawned_interface.recursive_prs = self.recursive_prs
spawned_interface.announces_from_internal = self.announces_from_internal
spawned_interface.announces_to_internal = self.announces_to_internal
spawned_interface.announce_cap = self.announce_cap
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.online = True
RNS.Transport.add_interface(spawned_interface)
@ -604,7 +613,20 @@ class AutoInterface(Interface):
def process_outgoing(self, data): pass
def detach(self): self.online = False
def detach(self):
self.online = False
self.detached = True
RNS.log(f"Shutting down all UDP listeners for {self}", RNS.LOG_NOTICE)
for ifname in self.interface_servers:
try:
self.interface_servers[ifname].server_close()
self.interface_servers[ifname].shutdown()
except Exception as e: RNS.log(f"Could not shut down UDP listener {ifname} for {self}: {e}", RNS.LOG_ERROR)
for s in self.discovery_sockets:
try: s.close()
except Exception as e: RNS.log(f"Error while shutting down discovery socket {s} for {self}: {e}", RNS.LOG_ERROR)
def __str__(self): return f"AutoInterface[{self.name}]"

View file

@ -29,6 +29,8 @@
# SOFTWARE.
from RNS.Interfaces.Interface import Interface
from RNS.Interfaces.util.TransmitBuffer import TransmitBuffer
from RNS.Interfaces.util.HDLC import HDLC, ReceiveBuffer
import threading
import socket
import select
@ -37,22 +39,11 @@ import sys
import os
import RNS
class HDLC():
FLAG = 0x7E
ESC = 0x7D
ESC_MASK = 0x20
@staticmethod
def escape(data):
data = data.replace(bytes([HDLC.ESC]), bytes([HDLC.ESC, HDLC.ESC^HDLC.ESC_MASK]))
data = data.replace(bytes([HDLC.FLAG]), bytes([HDLC.ESC, HDLC.FLAG^HDLC.ESC_MASK]))
return data
class BackboneInterface(Interface):
HW_MTU = 1048576
BITRATE_GUESS = 1_000_000_000
DEFAULT_IFAC_SIZE = 16
AUTOCONFIGURE_MTU = True
HW_MTU = 1048576
BITRATE_GUESS = 100_000_000
DEFAULT_IFAC_SIZE = 16
AUTOCONFIGURE_MTU = True
BLOCK_FAST_FLAPPING = True
FAST_FLAP_THRESHOLD = 20
@ -61,12 +52,34 @@ class BackboneInterface(Interface):
fast_flapping_lock = threading.Lock()
fast_flapping = {}
DP_IC_HIGH_WM_PCT = 90
DP_IC_MID_WM_PCT = 68
DP_IC_LOW_WM_PCT = 10
DP_IC_INTERVAL = 0.250
DP_IC_TRIGGER = 1.5
DP_IC_RCVBUF = 32768
DP_IC_IF_HEADROOM = 32
DP_IC_PENALTY = 1.5
DP_EC_INTERVAL = 1.0
DP_EC_MID_WM = 128*1024
DP_EC_HIGH_WM = 4*1024*1024
DP_EC_STALL_TICKS = 3
DP_EC_MAX_ETA = 10.0
DP_EC_RELEASE_ETA = 5.0 # Gate release hysterisis
DP_EC_DEAD_TIME = 12.0
epoll = None
listener_filenos = {}
spawned_interface_filenos = {}
epoll = None
_job_active = False
_job_lock = threading.Lock()
_job_active = False
_ic_job_active = False
_ec_job_active = False
_job_lock = threading.Lock()
_ec_job_lock = threading.Lock()
_ic_job_lock = threading.Lock()
_dp_ic_lock = threading.Lock()
_dp_ic_snapshot = 0
@staticmethod
def get_address_for_if(name, bind_port, prefer_ipv6=False):
@ -169,13 +182,34 @@ class BackboneInterface(Interface):
else:
raise SystemError("Insufficient parameters to create listener")
__ic_burst_stats_throttle = 0.95
__last_ic_burst_count_check = 0
__last_ic_burst_count_state = 0
@property
def ic_burst_count(self):
if time.time() > self.__last_ic_burst_count_check + self.__ic_burst_stats_throttle:
self.__last_ic_burst_count_state = len([i.ic_burst_active for i in self.spawned_interfaces if i.ic_burst_active])
self.__last_ic_burst_count_check = time.time()
return self.__last_ic_burst_count_state
__last_ic_pr_burst_count_check = 0
__last_ic_pr_burst_count_state = 0
@property
def ic_pr_burst_count(self):
if time.time() > self.__last_ic_pr_burst_count_check + self.__ic_burst_stats_throttle:
self.__last_ic_pr_burst_count_state = len([i.ic_pr_burst_active for i in self.spawned_interfaces if i.ic_pr_burst_active])
self.__last_ic_pr_burst_count_check = time.time()
return self.__last_ic_pr_burst_count_state
__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:
if time.time() > self.__last_ic_burst_check + self.__ic_burst_stats_throttle:
self.__last_ic_burst_state = any(i.ic_burst_active for i in self.spawned_interfaces)
self.__last_ic_burst_check = time.time()
return self.__last_ic_burst_state
@ -186,9 +220,10 @@ class BackboneInterface(Interface):
__ic_burst_activated = 0
@property
def ic_burst_activated(self):
if time.time() > self.__ic_burst_activated_check + 2:
if time.time() > self.__ic_burst_activated_check + self.__ic_burst_stats_throttle:
activated = [i.ic_burst_activated for i in self.spawned_interfaces if i.ic_burst_active]
if activated: self.__ic_burst_activated = min(activated)
self.__ic_burst_activated_check = time.time()
return self.__ic_burst_activated
@ -200,8 +235,9 @@ class BackboneInterface(Interface):
__last_ic_pr_burst_state = False
@property
def ic_pr_burst_active(self):
if time.time() > self.__last_ic_pr_burst_check + 2:
if time.time() > self.__last_ic_pr_burst_check + self.__ic_burst_stats_throttle:
self.__last_ic_pr_burst_state = any(i.ic_pr_burst_active for i in self.spawned_interfaces)
self.__last_ic_pr_burst_check = time.time()
return self.__last_ic_pr_burst_state
@ -212,9 +248,10 @@ class BackboneInterface(Interface):
__ic_pr_burst_activated = 0
@property
def ic_pr_burst_activated(self):
if time.time() > self.__ic_pr_burst_activated_check + 2:
if time.time() > self.__ic_pr_burst_activated_check + self.__ic_burst_stats_throttle:
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)
self.__ic_pr_burst_activated_check = time.time()
return self.__ic_pr_burst_activated
@ -223,7 +260,9 @@ class BackboneInterface(Interface):
@staticmethod
def start():
if not BackboneInterface._job_active: threading.Thread(target=BackboneInterface.__job, daemon=True).start()
if not BackboneInterface._job_active: threading.Thread(target=BackboneInterface.__job, daemon=True).start()
if not BackboneInterface._ic_job_active: threading.Thread(target=BackboneInterface.__dp_ic_job, daemon=True).start()
if not BackboneInterface._ec_job_active: threading.Thread(target=BackboneInterface.__dp_ec_job, daemon=True).start()
@staticmethod
def ensure_epoll():
@ -264,9 +303,9 @@ class BackboneInterface(Interface):
RNS.log(f"Attempt to register invalid file descriptor {fileno}", RNS.LOG_WARNING)
return
try: BackboneInterface.epoll.register(fileno, select.EPOLLIN)
try: BackboneInterface.epoll.register(fileno, select.EPOLLIN | select.EPOLLHUP)
except Exception as e:
RNS.log(f"An error occurred while registering EPOLL_IN for file descriptor {fileno}: {e}", RNS.LOG_WARNING)
RNS.log(f"An error occurred while registering EPOLLIN for file descriptor {fileno}: {e}", RNS.LOG_WARNING)
@staticmethod
def deregister_fileno(fileno):
@ -295,13 +334,223 @@ class BackboneInterface(Interface):
if interface.socket:
fileno = interface.socket.fileno()
if fileno in BackboneInterface.spawned_interface_filenos:
try: BackboneInterface.epoll.modify(fileno, select.EPOLLOUT)
try:
events = select.EPOLLOUT | select.EPOLLHUP
if not interface.dp_ingress_gated: events |= select.EPOLLIN
BackboneInterface.epoll.modify(fileno, events)
except Exception as e:
if str(e).endswith("No such file or directory"): pass
elif str(e).endswith("Bad file descriptor"): pass
else: RNS.log(f"Error occurred on {interface} while modifying socket EPOLL state: {e}", RNS.LOG_WARNING)
raise e
@staticmethod
def __throttle_ingress(interface, hold=None):
if interface.dp_ingress_gated: return False
try:
fileno = interface.socket.fileno()
if fileno in BackboneInterface.spawned_interface_filenos:
interface.dp_ingress_gated = True
interface.dp_ingress_tcount += 1
events = select.EPOLLHUP
if interface.transmit_buffer.sendable > 0: events |= select.EPOLLOUT
BackboneInterface.epoll.modify(fileno, events)
if hold: interface.dp_ingress_hold = time.time() + hold
RNS.log(f"Ingress throttled on {interface}", RNS.LOG_NOTICE) if RNS.sl(RNS.LOG_NOTICE) else None
return True
except Exception as e: RNS.log(f"Error throttling {interface}: {e}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
return False
@staticmethod
def __release_ingress(interface):
if not interface.dp_ingress_gated: return False
try:
fileno = interface.socket.fileno()
if fileno in BackboneInterface.spawned_interface_filenos:
events = select.EPOLLIN | select.EPOLLHUP
if interface.transmit_buffer.sendable > 0: events |= select.EPOLLOUT
BackboneInterface.epoll.modify(fileno, events)
interface.dp_ingress_gated = False
RNS.log(f"Released ingress throttle on {interface}", RNS.LOG_NOTICE) if RNS.sl(RNS.LOG_NOTICE) else None
return True
except Exception as e: RNS.log(f"Error releasing throttle on {interface}: {e}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
return False
@staticmethod
def _throttle_immediate(q_depth):
with BackboneInterface._dp_ic_lock:
st = time.time()
interfaces = list(BackboneInterface.spawned_interface_filenos.values())
producers = [iface for iface in interfaces if iface.dp_ingress_packets > 0]
if producers:
producers.sort(key=lambda p: p.dp_ingress_packets, reverse=True)
selected = producers[0]
now = time.time()
span = now - BackboneInterface._dp_ic_snapshot
total = sum(iface.dp_ingress_packets for iface in producers)
avail = sum(iface.dp_ingress_bytes for iface in interfaces) / span
share = selected.dp_ingress_packets / total
rate = selected.dp_ingress_packets / span
speed = selected.dp_ingress_bytes / span
data = selected.dp_ingress_bytes
pkts = selected.dp_ingress_packets
alloc = 1/((max(len(interfaces), BackboneInterface.DP_IC_IF_HEADROOM))*BackboneInterface.DP_IC_PENALTY)
hold = (avail/(avail*alloc))*span
if BackboneInterface.__throttle_ingress(selected, hold=hold):
taken = time.time()-st
if RNS.sl(RNS.LOG_DEBUG):
RNS.log(f"Throttled producer with {pkts} packets at {RNS.prettysize(rate, suffix='pps')} / {RNS.prettyspeed(speed*8)}, {round(share*100.0, 2)}% ingress share", RNS.LOG_DEBUG)
RNS.log(f"Available ingress budget {RNS.prettyspeed(avail*8)}, hard-allocated {round(alloc*100.0, 2)}% ({RNS.prettyspeed(avail*alloc*8)}), handled in {RNS.prettyshorttime(taken, compact=True, tight=True)}", RNS.LOG_DEBUG)
RNS.log(f"Holding for {RNS.prettyshorttime(hold, compact=True, tight=True)}, throttling handled in {RNS.prettyshorttime(taken, compact=True, tight=True)} at depth {q_depth}", RNS.LOG_DEBUG)
@staticmethod
def _dp_ec_evaluate(interface, now):
tb = interface.transmit_buffer
drained = tb._tx_sent - interface._dp_ec_prev_sent
interface._dp_ec_prev_sent = tb._tx_sent
sendable = tb.sendable
buffered = len(tb)
if buffered == 0 or sendable == 0:
interface._dp_ec_zero_ticks = 0
interface._dp_ec_last_drain = now
interface.tx_stalled = False
return False
if now - interface._dp_ec_last_drain >= BackboneInterface.DP_EC_DEAD_TIME:
RNS.log(f"No egress control drain progress for {RNS.prettyshorttime(BackboneInterface.DP_EC_DEAD_TIME, compact=True)} on {interface}, tearing down", RNS.LOG_NOTICE)
try:
if hasattr(interface, "socket") and interface.socket:
fileno = interface.socket.fileno()
BackboneInterface.deregister_fileno(fileno)
if fileno in BackboneInterface.spawned_interface_filenos: BackboneInterface.spawned_interface_filenos.pop(fileno)
try: interface.socket.close()
except Exception as e: RNS.log(f"Egress control could not close socket for {interface}: {e}", RNS.LOG_ERROR)
except Exception as e: RNS.log(f"Egress control cleanup error for {interface}: {e}", RNS.LOG_ERROR)
interface.receive(b"")
return True
if drained > 0:
interface._dp_ec_last_drain = now
interface._dp_ec_zero_ticks = 0
drain_rate = drained / BackboneInterface.DP_EC_INTERVAL
clear_eta = buffered / drain_rate if drain_rate > 0 else float("inf")
if buffered > BackboneInterface.DP_EC_MID_WM and clear_eta > BackboneInterface.DP_EC_MAX_ETA:
if not interface.tx_stalled: RNS.log(f"Egress control drain ETA of {RNS.prettyshorttime(clear_eta, compact=True)} exceeds maximum on {interface}, gating outbound", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
interface.tx_stalled = True
elif clear_eta < BackboneInterface.DP_EC_RELEASE_ETA or buffered <= BackboneInterface.DP_EC_MID_WM:
if interface.tx_stalled: RNS.log(f"Egress control drain recovered on {interface}, resuming outbound", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
interface.tx_stalled = False
else:
# No drain progress on this tick
if buffered > BackboneInterface.DP_EC_MID_WM:
interface._dp_ec_zero_ticks += 1
if interface._dp_ec_zero_ticks >= BackboneInterface.DP_EC_STALL_TICKS:
if not interface.tx_stalled: RNS.log(f"No egress control drain progress on {interface}, gating outbound", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
interface.tx_stalled = True
else:
interface._dp_ec_zero_ticks = 0
interface.tx_stalled = False
return False
@staticmethod
def __dp_ec_job():
with BackboneInterface._ec_job_lock:
if BackboneInterface._ec_job_active: return
else:
BackboneInterface._ec_job_active = True
RNS.log(f"Started dataplane egress control", RNS.LOG_DEBUG)
try:
while True:
time.sleep(BackboneInterface.DP_EC_INTERVAL)
now = time.time()
try: interfaces = list(BackboneInterface.spawned_interface_filenos.values())
except RuntimeError as e:
RNS.log(f"Deferring egress control evaluation due to error: {e}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
continue
for interface in interfaces:
if interface.detached: continue
if isinstance(interface, RNS.Interfaces.LocalInterface.LocalClientInterface): continue
BackboneInterface._dp_ec_evaluate(interface, now)
except Exception as e:
RNS.log(f"BackboneInterface egress control error: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
@staticmethod
def __dp_ic_job():
with BackboneInterface._ic_job_lock:
if BackboneInterface._ic_job_active: return
else:
BackboneInterface._ic_job_active = True
RNS.log(f"Started dataplane ingress control: High/mid/low water marks are {BackboneInterface.DP_IC_HIGH_WM}/{BackboneInterface.DP_IC_MID_WM}/{BackboneInterface.DP_IC_LOW_WM} packets", RNS.LOG_DEBUG)
try:
while True:
time.sleep(BackboneInterface.DP_IC_INTERVAL)
try:
if RNS.Transport.inbound_queues != None: q_depth = RNS.Transport.inbound_queues.qsize(RNS.Transport.TC_DATA)
else: continue
except Exception: continue
with BackboneInterface._dp_ic_lock:
st = time.time()
interfaces = list(BackboneInterface.spawned_interface_filenos.values())
if q_depth > BackboneInterface.DP_IC_MID_WM:
producers = [iface for iface in interfaces if iface.dp_ingress_packets > 0 and not isinstance(iface, RNS.Interfaces.LocalInterface.LocalClientInterface)]
if producers:
producers.sort(key=lambda p: p.dp_ingress_packets, reverse=True)
selected = producers[0]
now = time.time()
span = max(now - BackboneInterface._dp_ic_snapshot, 0.001)
total = sum(iface.dp_ingress_packets for iface in producers)
avail = sum(iface.dp_ingress_bytes for iface in interfaces) / span
mean = total / max(len(producers), 2)
share = selected.dp_ingress_packets / total
rate = selected.dp_ingress_packets / span
speed = selected.dp_ingress_bytes / span
data = selected.dp_ingress_bytes
pkts = selected.dp_ingress_packets
alloc = 1/((max(len(interfaces), BackboneInterface.DP_IC_IF_HEADROOM))*BackboneInterface.DP_IC_PENALTY)
hold = (avail/max(avail*alloc, 1))*span
if BackboneInterface.__throttle_ingress(selected, hold=hold):
taken = time.time()-st
if RNS.sl(RNS.LOG_DEBUG):
RNS.log(f"Throttled producer with {pkts} packets at {RNS.prettysize(rate, suffix='pps')} / {RNS.prettyspeed(speed*8)}, {round(share*100.0, 2)}% ingress share", RNS.LOG_DEBUG)
RNS.log(f"Available ingress budget {RNS.prettyspeed(avail*8)}, hard-allocated {round(alloc*100.0, 2)}% ({RNS.prettyspeed(avail*alloc*8)}), handled in {RNS.prettyshorttime(taken, compact=True, tight=True)}", RNS.LOG_DEBUG)
RNS.log(f"Holding for {RNS.prettyshorttime(hold, compact=True, tight=True)}, throttling handled in {RNS.prettyshorttime(taken, compact=True, tight=True)}", RNS.LOG_DEBUG)
elif q_depth < BackboneInterface.DP_IC_LOW_WM:
now = time.time()
for iface in interfaces:
if iface.dp_ingress_gated:
if iface.dp_ingress_hold and now < iface.dp_ingress_hold: continue
BackboneInterface.__release_ingress(iface)
break
BackboneInterface._dp_ic_snapshot = time.time()
for iface in interfaces:
iface.dp_ingress_bytes = 0
iface.dp_ingress_packets = 0
except Exception as e:
RNS.log(f"BackboneInterface ingress control error: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
@staticmethod
def __job():
with BackboneInterface._job_lock:
@ -311,18 +560,21 @@ class BackboneInterface(Interface):
BackboneInterface.ensure_epoll()
try:
while True:
events = BackboneInterface.epoll.poll(1)
for fileno, event in BackboneInterface.epoll.poll(1):
if fileno in BackboneInterface.spawned_interface_filenos:
spawned_interface = BackboneInterface.spawned_interface_filenos[fileno]
client_socket = spawned_interface.socket
if client_socket and fileno == client_socket.fileno() and (event & select.EPOLLIN):
socket_valid = client_socket and fileno == client_socket.fileno()
if socket_valid and (event & select.EPOLLIN) and not spawned_interface.dp_ingress_gated:
if spawned_interface.dp_ingress_gated: continue
try: received_bytes = client_socket.recv(spawned_interface.HW_MTU)
except Exception as e:
RNS.log(f"Error while reading from {spawned_interface}: {e}", RNS.LOG_PATHING) if RNS.sl(RNS.LOG_PATHING) else None
received_bytes = b""
if len(received_bytes): spawned_interface.receive(received_bytes)
if len(received_bytes):
spawned_interface.dp_ingress_bytes += len(received_bytes)
spawned_interface.receive(received_bytes)
else:
BackboneInterface.deregister_fileno(fileno); client_socket.close()
try:
@ -337,9 +589,10 @@ class BackboneInterface(Interface):
except Exception as e: RNS.log(f"Error while removing spawned interface from {pif}: {e}", RNS.LOG_ERROR)
spawned_interface.receive(received_bytes)
elif client_socket and fileno == client_socket.fileno() and (event & select.EPOLLOUT):
try: written = client_socket.send(spawned_interface.transmit_buffer)
socket_valid_after_read = socket_valid and fileno in BackboneInterface.spawned_interface_filenos
if socket_valid_after_read and (event & select.EPOLLOUT):
try: written = spawned_interface.transmit_buffer.drain_to(client_socket)
except Exception as e:
written = 0
if not spawned_interface.detached:
@ -366,16 +619,17 @@ class BackboneInterface(Interface):
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:]
try:
if len(spawned_interface.transmit_buffer) == 0: BackboneInterface.epoll.modify(fileno, select.EPOLLIN)
except Exception as e:
RNS.log(f"Error while setting EPOLLIN on {spawned_interface}: {e}", RNS.LOG_ERROR)
if spawned_interface.transmit_buffer.sendable == 0:
events = select.EPOLLHUP
if not spawned_interface.dp_ingress_gated: events |= select.EPOLLIN
BackboneInterface.epoll.modify(fileno, events)
except Exception as e: RNS.log(f"Error while setting EPOLLIN on {spawned_interface}: {e}", RNS.LOG_ERROR)
spawned_interface.txb += written
if spawned_interface.parent_interface: spawned_interface.parent_interface.txb += written
elif client_socket and fileno == client_socket.fileno() and event & (select.EPOLLHUP):
elif socket_valid_after_read and (event & select.EPOLLHUP):
BackboneInterface.deregister_fileno(fileno)
try:
if fileno in BackboneInterface.spawned_interface_filenos: BackboneInterface.spawned_interface_filenos.pop(fileno)
@ -398,6 +652,7 @@ class BackboneInterface(Interface):
try:
client_socket, address = server_socket.accept()
client_socket.setblocking(0)
client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BackboneInterface.DP_IC_RCVBUF)
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)
@ -483,6 +738,10 @@ class BackboneInterface(Interface):
spawned_interface.announce_rate_penalty = self.announce_rate_penalty
spawned_interface.mode = self.mode
spawned_interface.gravity = self.gravity
spawned_interface.recursive_prs = self.recursive_prs
spawned_interface.announces_from_internal = self.announces_from_internal
spawned_interface.announces_to_internal = self.announces_to_internal
spawned_interface.announce_cap = self.announce_cap
spawned_interface.HW_MTU = self.HW_MTU
RNS.log("Spawned new BackboneClient Interface: "+str(spawned_interface), RNS.LOG_PATHING) if RNS.sl(RNS.LOG_PATHING) else None
RNS.Transport.add_interface(spawned_interface)
@ -498,17 +757,25 @@ class BackboneInterface(Interface):
return True
def received_announce(self, from_spawned=False):
if from_spawned: self.ia_freq_deque.append(time.time())
def received_announce(self, size=0, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
self.arxb += size
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def sent_announce(self, size=0, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
self.atxb += size
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def received_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.ip_freq_deque.append(time.time())
self.prxb += size
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def sent_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.op_freq_deque.append(time.time())
self.ptxb += size
def process_outgoing(self, data):
pass
@ -531,7 +798,7 @@ class BackboneInterface(Interface):
@property
def blocked_ip_list(self):
if not self.block_fast_flapping: return []
else: return list(self.fast_flapping.keys())
else: return [ip for ip in self.fast_flapping if self.fast_flapping[ip][2] > self.fast_flap_grace]
@property
def blocked_ip_count(self):
@ -609,8 +876,10 @@ class BackboneClientInterface(Interface):
self.i2p_tunneled = i2p_tunneled
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.bitrate = BackboneClientInterface.BITRATE_GUESS
self.frame_buffer = b""
self.transmit_buffer = b""
self.transmit_buffer = TransmitBuffer()
self.receive_buffer = ReceiveBuffer(mtu=lambda: self.HW_MTU, min_frame_len=RNS.Reticulum.HEADER_MINSIZE,
max_frame_len=lambda: self.HW_MTU+(getattr(self, "ifac_size", None) or 0),
on_frame=self.process_incoming, on_invalid=self.invalid_frame)
if max_reconnect_tries == None:
self.max_reconnect_tries = BackboneClientInterface.RECONNECT_MAX_TRIES
@ -661,11 +930,10 @@ class BackboneClientInterface(Interface):
def detach(self):
self.online = False
self.detached = True
if self.socket != None:
if hasattr(self.socket, "close"):
if callable(self.socket.close):
self.detached = True
try:
if self.socket != None: self.socket.shutdown(socket.SHUT_RDWR)
except Exception as e:
@ -699,6 +967,7 @@ class BackboneClientInterface(Interface):
self.socket.settimeout(BackboneClientInterface.INITIAL_CONNECT_TIMEOUT)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect(target_address)
# TODO: Check missing setblocking(0)
self.socket.settimeout(None)
BackboneInterface.add_client_socket(self.socket, self)
@ -758,55 +1027,31 @@ class BackboneClientInterface(Interface):
self.rxb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.rxb += len(data)
self.dp_ingress_packets += 1
self.owner.inbound(data, self)
def process_outgoing(self, data):
if self.online and not self.detached:
try:
self.transmit_buffer += bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
BackboneInterface.tx_ready(self)
frame = HDLC.frame(data)
if self.tx_stalled or not self.transmit_buffer.append(frame, self.tx_hwm):
self.tx_drops += 1
self.tx_dropped_bytes += len(frame)
RNS.log(f"Egress control dropping outbound frame of {RNS.prettysize(len(frame))} on {self}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
else: BackboneInterface.tx_ready(self)
except Exception as e:
RNS.log("Exception occurred while transmitting via "+str(self)+", tearing down interface", RNS.LOG_ERROR)
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
self.teardown()
def check_frame_len(self, frame_len):
if frame_len <= RNS.Reticulum.HEADER_MINSIZE: return False
elif frame_len > self.HW_MTU + (self.ifac_size or 0): return False
else: return True
def invalid_frame(self, frame_len):
RNS.log(f"Invalid HDLC frame of {RNS.prettysize(frame_len)} received on {self}, dropping frame", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
def receive(self, data_in):
try:
if len(data_in) > 0:
self.frame_buffer += data_in
flags_remaining = True
while flags_remaining:
frame_start = self.frame_buffer.find(HDLC.FLAG)
if frame_start != -1:
frame_end = self.frame_buffer.find(HDLC.FLAG, frame_start+1)
if frame_end != -1:
frame = self.frame_buffer[frame_start+1:frame_end]
frame = frame.replace(bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]), bytes([HDLC.FLAG]))
frame = frame.replace(bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]), bytes([HDLC.ESC]))
frame_len = len(frame)
if frame_len != 0:
if self.check_frame_len(frame_len): self.process_incoming(frame)
else: self.invalid_frame(len(frame))
self.frame_buffer = self.frame_buffer[frame_end:]
else:
if len(self.frame_buffer) > self.HW_MTU*2: self.frame_buffer = b""
flags_remaining = False
else:
self.frame_buffer = b""
flags_remaining = False
if len(data_in) > 0: self.receive_buffer.feed(data_in)
else:
self.online = False
if self.initiator and not self.detached:
@ -825,8 +1070,7 @@ class BackboneClientInterface(Interface):
RNS.log("Attempting to reconnect...", RNS.LOG_WARNING)
def job(): self.reconnect()
threading.Thread(target=job, daemon=True).start()
else:
self.teardown()
else: self.teardown()
def teardown(self):
if self.initiator and not self.detached:

View file

@ -537,6 +537,7 @@ class I2PInterfacePeer(Interface):
raise IOError("Attempt to reconnect on a non-initiator I2P interface")
def process_incoming(self, data):
if not data: return
self.rxb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None and self.parent_count:
self.parent_interface.rxb += len(data)
@ -871,17 +872,25 @@ class I2PInterface(Interface):
def process_outgoing(self, data):
pass
def received_announce(self, from_spawned=False):
if from_spawned: self.ia_freq_deque.append(time.time())
def received_announce(self, size=0, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
self.arxb += size
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def sent_announce(self, size=0, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
self.atxb += size
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def received_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.ip_freq_deque.append(time.time())
self.prxb += size
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def sent_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.op_freq_deque.append(time.time())
self.ptxb += size
def detach(self):
RNS.log("Detaching "+str(self), RNS.LOG_DEBUG)

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -82,7 +82,7 @@ class Interface:
IC_BURST_PENALTY = 15
IC_HELD_RELEASE_INTERVAL = 5
IC_DEQUE_MIN_SAMPLE = 2
IC_BURST_MIN_SAMPLES = 6
EC_BURST_MIN_SAMPLES = 2
EC_PR_FREQ = 5
EGRESS_CONTROL = False
@ -93,10 +93,19 @@ class Interface:
AUTOCONFIGURE_MTU = False
FIXED_MTU = False
DEFAULT_IFAC_SIZE = 16
def __init__(self):
self.rxb = 0
self.txb = 0
self.arxb = 0
self.atxb = 0
self.arxc = 0
self.atxc = 0
self.prxb = 0
self.ptxb = 0
self.prxc = 0
self.ptxc = 0
self.gravity = 0
self.created = time.time()
self.detached = False
@ -105,6 +114,7 @@ class Interface:
self.HW_MTU = None
self.__hash = None
self.announce_cap = RNS.Reticulum.ANNOUNCE_CAP/100.0
self.supports_discovery = False
self.discoverable = False
self.last_discovery_announce = 0
@ -117,11 +127,15 @@ class Interface:
self.tunnel_id = None
self.ingress_control = True
self.phy_keepalive = False
self.shared_medium = False
self.ic_burst_active = False
self.ic_burst_activated = 0
self.ic_burst_sustained = 0
self.ic_pr_burst_active = False
self.ic_pr_burst_activated = 0
self.ic_pr_burst_sustained = 0
self.ic_pr_burst_cooldown = 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()
@ -135,12 +149,35 @@ class Interface:
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.transmit_buffer = None
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)
self.protocol_violations = 0
self.ifac_violations = 0
self.packet_filter_hits = 0
self.dp_ingress_tcount = 0
self.dp_ingress_bytes = 0
self.dp_ingress_packets = 0
self.dp_ingress_hold = None
self.dp_ingress_gated = False
self.tx_hwm = 4*1024*1024
self.tx_stalled = False
self.tx_drops = 0
self.tx_dropped_bytes = 0
self._dp_ec_prev_sent = 0
self._dp_ec_zero_ticks = 0
self._dp_ec_last_drain = time.time()
self.reports_phy_stats = False
self.r_stat_rssi = None
self.r_stat_snr = None
self.r_stat_q = None
def get_hash(self):
if not self.__hash: self.__hash = RNS.Identity.full_hash(str(self).encode("utf-8"))
return self.__hash
@ -155,8 +192,10 @@ class Interface:
ia_freq = self.incoming_announce_frequency()
if self.ic_burst_active:
if ia_freq < freq_threshold and time.time() > self.ic_burst_activated+self.ic_burst_hold:
if ia_freq < freq_threshold and time.time() > self.ic_burst_activated+self.ic_burst_hold and time.time() > self.ic_burst_sustained+self.ic_burst_hold:
if len(self.ia_freq_deque) >= self.IC_DEQUE_MIN_SAMPLE: self.ic_burst_active = False
else:
if ia_freq >= freq_threshold: self.ic_burst_sustained = time.time()
return True
@ -164,6 +203,7 @@ class Interface:
if ia_freq > freq_threshold:
self.ic_burst_active = True
self.ic_burst_activated = time.time()
self.ic_burst_sustained = time.time()
self.ic_held_release = time.time() + self.ic_burst_penalty
return True
@ -177,8 +217,12 @@ class Interface:
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
if ip_freq < freq_threshold and time.time() > self.ic_pr_burst_activated+self.ic_burst_hold and time.time() > self.ic_pr_burst_sustained+self.ic_burst_hold:
if self.ic_pr_burst_cooldown <= 0: self.ic_pr_burst_active = False
else: self.ic_pr_burst_cooldown -= 1
else:
self.ic_pr_burst_cooldown = 3
if ip_freq >= freq_threshold: self.ic_pr_burst_sustained = time.time()
return True
@ -186,6 +230,8 @@ class Interface:
if ip_freq > freq_threshold:
self.ic_pr_burst_active = True
self.ic_pr_burst_activated = time.time()
self.ic_pr_burst_sustained = time.time()
self.ic_pr_burst_cooldown = 3
return True
else: return False
@ -195,28 +241,28 @@ class Interface:
def should_egress_limit_pr(self):
if self.egress_control:
freq_threshold = self.ec_pr_freq
op_freq = self.outgoing_pr_frequency()
op_freq = self.outgoing_pr_frequency(preemptive=True)
if op_freq > freq_threshold:
if len(self.op_freq_deque) >= self.IC_BURST_MIN_SAMPLES: return True
if len(self.op_freq_deque) >= self.EC_BURST_MIN_SAMPLES: return True
return False
def optimise_mtu(self):
if self.AUTOCONFIGURE_MTU:
if self.bitrate >= 1_000_000_000: self.HW_MTU = 524288
elif self.bitrate > 750_000_000: self.HW_MTU = 262144
elif self.bitrate > 400_000_000: self.HW_MTU = 131072
elif self.bitrate > 200_000_000: self.HW_MTU = 65536
elif self.bitrate > 100_000_000: self.HW_MTU = 32768
elif self.bitrate > 10_000_000: self.HW_MTU = 16384
elif self.bitrate > 5_000_000: self.HW_MTU = 8192
elif self.bitrate > 2_000_000: self.HW_MTU = 4096
elif self.bitrate > 1_000_000: self.HW_MTU = 2048
elif self.bitrate > 62_500: self.HW_MTU = 1024
elif self.bitrate >= 750_000_000: self.HW_MTU = 262144
elif self.bitrate >= 400_000_000: self.HW_MTU = 131072
elif self.bitrate >= 200_000_000: self.HW_MTU = 65536
elif self.bitrate >= 100_000_000: self.HW_MTU = 32768
elif self.bitrate >= 10_000_000: self.HW_MTU = 16384
elif self.bitrate >= 5_000_000: self.HW_MTU = 8192
elif self.bitrate >= 2_000_000: self.HW_MTU = 4096
elif self.bitrate >= 1_000_000: self.HW_MTU = 2048
elif self.bitrate >= 62_500: self.HW_MTU = 1024
else: self.HW_MTU = None
RNS.log(f"{self} hardware MTU set to {self.HW_MTU}", RNS.LOG_PATHING)
RNS.log(f"{self} hardware MTU set to {self.HW_MTU}", RNS.LOG_EXTREME)
def age(self):
return time.time()-self.created
@ -247,32 +293,56 @@ class Interface:
RNS.log("Releasing held announce packet "+str(selected_announce_packet)+" from "+str(self), RNS.LOG_EXTREME)
self.ic_held_release = time.time() + self.ic_held_release_interval
self.held_announces.pop(selected_announce_packet.destination_hash)
def release(): RNS.Transport.inbound(selected_announce_packet.raw, selected_announce_packet.receiving_interface)
def release(): RNS.Transport.inbound(selected_announce_packet.raw, selected_announce_packet.receiving_interface, tc=RNS.Transport.TC_INGRESS_LIMITED, ifac_handled=True)
threading.Thread(target=release, daemon=True).start()
except Exception as e:
RNS.log("An error occurred while processing held announces for "+str(self), RNS.LOG_ERROR)
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
def received_announce(self, from_spawned=False):
def received_announce(self, size=0, from_spawned=False):
self.arxc += 1; self.arxb += size
self.ia_freq_deque.append(time.time())
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.received_announce(from_spawned=True)
self.parent_interface.received_announce(size=size, from_spawned=True)
def sent_announce(self, from_spawned=False):
def sent_announce(self, size=0, from_spawned=False):
self.atxc += 1; self.atxb += size
self.oa_freq_deque.append(time.time())
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.sent_announce(from_spawned=True)
self.parent_interface.sent_announce(size=size, from_spawned=True)
def received_path_request(self, from_spawned=False):
def received_path_request(self, size=0, from_spawned=False):
self.prxc += 1; self.prxb += size
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)
self.parent_interface.received_path_request(size=size, from_spawned=True)
def sent_path_request(self, from_spawned=False):
def sent_path_request(self, size=0, from_spawned=False):
self.ptxc += 1; self.ptxb += size
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)
self.parent_interface.sent_path_request(size=size, from_spawned=True)
def protocol_violation(self, description=None):
self.protocol_violations += 1
RNS.log(f"Protocol violation on {self}: {description}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
return None
def ifac_violation(self, description=None):
self.ifac_violations += 1
RNS.log(f"IFAC violation on {self}: {description}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
return None
def packet_filter_hit(self):
self.packet_filter_hits += 1
return None
@property
def ic_burst_count(self): return None
@property
def ic_pr_burst_count(self): return None
def incoming_announce_frequency(self):
n = len(self.ia_freq_deque)
@ -307,8 +377,8 @@ class Interface:
hz = n / span
return hz
def outgoing_pr_frequency(self):
n = len(self.op_freq_deque)
def outgoing_pr_frequency(self, preemptive=False):
n = len(self.op_freq_deque)+(1 if preemptive else 0)
if not len(self.op_freq_deque) > 1: return 0
else:
oldest = self.op_freq_deque[0]
@ -320,7 +390,7 @@ class Interface:
def process_announce_queue(self):
if not hasattr(self, "announce_cap"):
self.announce_cap = RNS.Reticulum.ANNOUNCE_CAP
self.announce_cap = RNS.Reticulum.ANNOUNCE_CAP/100.0
if hasattr(self, "announce_queue"):
try:
@ -341,12 +411,13 @@ class Interface:
selected = entries[0]
now = time.time()
tx_time = (len(selected["raw"])*8) / self.bitrate
tx_size = len(selected["raw"])
tx_time = (tx_size*8) / self.bitrate
wait_time = (tx_time / self.announce_cap)
self.announce_allowed_at = now + wait_time
self.process_outgoing(selected["raw"])
self.sent_announce()
RNS.Transport.transmit(self, selected["raw"])
self.sent_announce(tx_size)
if selected in self.announce_queue:
self.announce_queue.remove(selected)
@ -366,6 +437,9 @@ class Interface:
def detach(self):
pass
def teardown(self):
pass
@staticmethod
def get_config_obj(config_in):
if type(config_in) == ConfigObj: return config_in

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -122,7 +122,9 @@ class KISSInterface(Interface):
self.packet_queue = []
self.flow_control = flow_control
self.shared_medium = True
self.interface_ready = False
self.reconnecting = False
self.flow_control_timeout = 5
self.flow_control_locked = time.time()
@ -139,15 +141,17 @@ class KISSInterface(Interface):
try:
self.open_port()
if self.serial.is_open: self.configure_device()
else: raise IOError("Could not open serial port")
except Exception as e:
RNS.log("Could not open serial port "+self.port, RNS.LOG_ERROR)
raise e
if self.serial.is_open:
self.configure_device()
else:
raise IOError("Could not open serial port")
RNS.log(f"Could not open serial port for {self}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
RNS.log(f"Reticulum will attempt to bring up this interface periodically", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting:
thread = threading.Thread(target=self.reconnect_port)
thread.daemon = True
thread.start()
def open_port(self):
RNS.log("Opening serial port "+self.port+"...", RNS.LOG_VERBOSE)
@ -165,6 +169,11 @@ class KISSInterface(Interface):
dsrdtr = False,
)
def detach(self):
self.detached = True
self.online = False
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self} at detach: {e}", RNS.LOG_ERROR)
def configure_device(self):
# Allow time for interface to initialise before config
@ -245,6 +254,7 @@ class KISSInterface(Interface):
def process_incoming(self, data):
if not data: return
self.rxb += len(data)
self.owner.inbound(data, self)
@ -359,30 +369,28 @@ class KISSInterface(Interface):
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
if RNS.Reticulum.panic_on_interface_error: RNS.panic()
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
self.online = False
self.serial.close()
self.reconnect_port()
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self}", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting: self.reconnect_port()
def reconnect_port(self):
while not self.online:
self.reconnecting = True
while not self.online and not self.detached:
try:
time.sleep(5)
RNS.log("Attempting to reconnect serial port "+str(self.port)+" for "+str(self)+"...", RNS.LOG_VERBOSE)
self.open_port()
if self.serial.is_open:
self.configure_device()
except Exception as e:
RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
if self.serial.is_open: self.configure_device()
except Exception as e: RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Reconnected serial port for "+str(self))
self.reconnecting = False
if self.online: RNS.log("Reconnected serial port for "+str(self))
def should_ingress_limit(self):
return False
def should_ingress_limit(self): return False
def __str__(self):
return "KISSInterface["+self.name+"]"
def __str__(self): return "KISSInterface["+self.name+"]"

View file

@ -30,6 +30,8 @@
from RNS.Interfaces.Interface import Interface
from RNS.Interfaces.BackboneInterface import BackboneInterface
from RNS.Interfaces.util.TransmitBuffer import TransmitBuffer
from RNS.Interfaces.util.HDLC import HDLC, ReceiveBuffer
import socketserver
import threading
import socket
@ -39,17 +41,6 @@ import os
import RNS
from threading import Lock
class HDLC():
FLAG = 0x7E
ESC = 0x7D
ESC_MASK = 0x20
@staticmethod
def escape(data):
data = data.replace(bytes([HDLC.ESC]), bytes([HDLC.ESC, HDLC.ESC^HDLC.ESC_MASK]))
data = data.replace(bytes([HDLC.FLAG]), bytes([HDLC.ESC, HDLC.FLAG^HDLC.ESC_MASK]))
return data
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
def server_bind(self):
if RNS.vendor.platformutils.is_windows():
@ -67,9 +58,12 @@ class LocalClientInterface(Interface):
def __init__(self, owner, name, target_port = None, connected_socket=None, socket_path=None):
super().__init__()
self.epoll_backend = False
self.HW_MTU = 262144
self.online = False
self.owner = owner
self.bitrate = 1_000_000_000
self.epoll_backend = False
self.HW_MTU = 262144
self.online = False
self.ifac_size = self.DEFAULT_IFAC_SIZE
if socket_path != None and RNS.Reticulum.get_instance().use_af_unix: self.socket_path = f"\0rns/{socket_path}"
else: self.socket_path = None
@ -83,8 +77,9 @@ class LocalClientInterface(Interface):
self.detached = False
self.name = name
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.frame_buffer = b""
self.transmit_buffer = b""
self.transmit_buffer = TransmitBuffer()
self.receive_buffer = ReceiveBuffer(mtu=lambda: self.HW_MTU, min_frame_len=RNS.Reticulum.HEADER_MINSIZE,
max_frame_len=lambda: self.HW_MTU, on_frame=self.process_incoming)
if RNS.vendor.platformutils.use_epoll(): self.epoll_backend = True
@ -117,8 +112,6 @@ class LocalClientInterface(Interface):
self.target_port = target_port
self.connect()
self.owner = owner
self.bitrate = 1_000_000_000
self.online = True
self.writing = False
@ -194,15 +187,15 @@ class LocalClientInterface(Interface):
def send_keepalive(self):
if self.online:
RNS.log(f"Sending keepalive on {self}", RNS.LOG_DEBUG) # TODO: Remove
RNS.log(f"Sending keepalive on {self}", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
try:
if self.epoll_backend:
self.transmit_buffer += bytes([HDLC.FLAG])+bytes([HDLC.FLAG])
self.transmit_buffer.append(bytes([HDLC.FLAG])+bytes([HDLC.FLAG]))
BackboneInterface.tx_ready(self)
else:
self.writing = True
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
data = bytes([HDLC.FLAG])+bytes([HDLC.FLAG])
self.socket.sendall(data)
self.writing = False
@ -219,28 +212,30 @@ class LocalClientInterface(Interface):
def process_outgoing(self, data):
if self.pause_on_client_sleep and time.time() > self.pause_timeout:
RNS.log(f"TX paused for LocalInterface client, dropping outbound packet", RNS.LOG_DEBUG) # TODO: Remove
RNS.log(f"TX paused for LocalInterface client, dropping outbound packet", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None # TODO: Remove
return
if self.online:
try:
if self.epoll_backend:
self.transmit_buffer += bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
BackboneInterface.tx_ready(self)
frame = HDLC.frame(data)
if self.tx_stalled or not self.transmit_buffer.append(frame, self.tx_hwm):
self.tx_drops += 1
self.tx_dropped_bytes += len(frame)
RNS.log(f"Egress control dropping outbound frame of {RNS.prettysize(len(frame))} on {self}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
else: BackboneInterface.tx_ready(self)
else:
self.writing = True
if self._force_bitrate:
if not hasattr(self, "send_lock"):
self.send_lock = Lock()
if not hasattr(self, "send_lock"): self.send_lock = Lock()
with self.send_lock:
# RNS.log(f"Simulating latency of {RNS.prettytime(s)} for {len(data)} bytes", RNS.LOG_EXTREME)
s = len(data) / self.bitrate * 8
time.sleep(s)
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
data = HDLC.frame(data)
self.socket.sendall(data)
self.writing = False
self.txb += len(data)
@ -254,22 +249,7 @@ class LocalClientInterface(Interface):
self.teardown()
def handle_hdlc(self, data_in):
self.frame_buffer += data_in
flags_remaining = True
while flags_remaining:
frame_start = self.frame_buffer.find(HDLC.FLAG)
if frame_start != -1:
frame_end = self.frame_buffer.find(HDLC.FLAG, frame_start+1)
if frame_end != -1:
frame = self.frame_buffer[frame_start+1:frame_end]
frame = frame.replace(bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]), bytes([HDLC.FLAG]))
frame = frame.replace(bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]), bytes([HDLC.ESC]))
if len(frame) > RNS.Reticulum.HEADER_MINSIZE: self.process_incoming(frame)
self.frame_buffer = self.frame_buffer[frame_end:]
else: flags_remaining = False
else: flags_remaining = False
self.receive_buffer.feed(data_in)
def receive(self, data_in):
try:
@ -283,8 +263,7 @@ class LocalClientInterface(Interface):
# there's no other connectivity left to block anyway, it might be
# unnecessary.
self.reconnect()
else:
self.teardown(nowarning=True)
else: self.teardown(nowarning=True)
except Exception as e:
self.online = False
@ -296,7 +275,7 @@ class LocalClientInterface(Interface):
def read_loop(self):
try:
self.frame_buffer = b""
self.receive_buffer.reset()
data_in = b""
while True:
data_in = self.socket.recv(4096)
@ -382,6 +361,7 @@ class LocalServerInterface(Interface):
self.epoll_backend = False
self.online = False
self.clients = 0
self.ifac_size = self.DEFAULT_IFAC_SIZE
if socket_path != None and RNS.Reticulum.get_instance().use_af_unix: self.socket_path = f"\0rns/{socket_path}"
else: self.socket_path = None
@ -429,6 +409,7 @@ class LocalServerInterface(Interface):
self.announce_rate_grace = None
self.announce_rate_penalty = None
self.HW_MTU = 262144
self.bitrate = 1000*1000*1000
self.online = True
@ -481,17 +462,25 @@ class LocalServerInterface(Interface):
def process_outgoing(self, data):
pass
def received_announce(self, from_spawned=False):
if from_spawned: self.ia_freq_deque.append(time.time())
def received_announce(self, size=0, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
self.arxb += size
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def sent_announce(self, size=0, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
self.atxb += size
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def received_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.ip_freq_deque.append(time.time())
self.prxb += size
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def sent_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.op_freq_deque.append(time.time())
self.ptxb += size
def __str__(self):
if self.socket_path: return "Shared Instance["+str(self.socket_path.replace("\0", ""))+"]"
@ -502,5 +491,4 @@ class LocalInterfaceHandler(socketserver.BaseRequestHandler):
self.callback = callback
socketserver.BaseRequestHandler.__init__(self, *args, **keys)
def handle(self):
self.callback(handler=self)
def handle(self): self.callback(handler=self)

View file

@ -84,6 +84,7 @@ class PipeInterface(Interface):
self.pipe_is_open = False
self.bitrate = PipeInterface.BITRATE_GUESS
self.respawn_delay = respawn_delay
self.shared_medium = True
try:
self.open_pipe()
@ -119,6 +120,7 @@ class PipeInterface(Interface):
def process_incoming(self, data):
if not data: return
self.rxb += len(data)
self.owner.inbound(data, self)

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -258,6 +258,7 @@ class RNodeInterface(Interface):
self.r_stat_tx = None
self.r_stat_rssi = None
self.r_stat_snr = None
self.r_stat_q = None
self.r_st_alock = None
self.r_lt_alock = None
self.r_random = None
@ -278,6 +279,7 @@ class RNodeInterface(Interface):
self.r_noise_floor = None
self.r_interference = None
self.r_interference_l = None
self.reports_phy_stats = True
self.r_battery_state = RNodeInterface.BATTERY_STATE_UNKNOWN
self.r_battery_percent = 0
@ -294,6 +296,7 @@ class RNodeInterface(Interface):
self.packet_queue = []
self.flow_control = flow_control
self.shared_medium = True
self.interface_ready = False
self.announce_rate_target = None
self.supports_discovery = True
@ -383,6 +386,7 @@ class RNodeInterface(Interface):
RNS.log(f"Opening BLE connection for {self}...")
self.timeout = 1250
if self.ble != None and self.ble.running == False:
RNS.log(f"Cleaning up previous BLE connection for {self}...")
self.ble.close()
self.ble.cleanup()
self.ble = None
@ -439,7 +443,11 @@ class RNodeInterface(Interface):
ble_detect_timeout = 5.0
detect_time = time.time()
while not self.detected and time.time() < detect_time + ble_detect_timeout: time.sleep(0.1)
if not self.detected: RNS.log(f"RNode detect timed out over BLE", RNS.LOG_ERROR)
if not self.detected:
RNS.log(f"RNode detect timed out over BLE", RNS.LOG_ERROR)
if self.ble:
self.ble.should_run = False
self.ble.must_disconnect = True
else:
sleep(0.2)
@ -477,8 +485,7 @@ class RNodeInterface(Interface):
self.setLTALock()
self.setRadioState(KISS.RADIO_STATE_ON)
if self.use_ble:
time.sleep(2)
if self.use_ble: time.sleep(2)
def detect(self):
kiss_command = bytes([KISS.FEND, KISS.CMD_DETECT, KISS.DETECT_REQ, KISS.FEND, KISS.CMD_FW_VERSION, 0x00, KISS.FEND, KISS.CMD_PLATFORM, 0x00, KISS.FEND, KISS.CMD_MCU, 0x00, KISS.FEND])
@ -684,10 +691,8 @@ class RNodeInterface(Interface):
RNS.log("Radio state mismatch", RNS.LOG_ERROR)
self.validcfg = False
if (self.validcfg):
return True
else:
return False
if (self.validcfg): return True
else: return False
def updateBitrate(self):
@ -699,11 +704,9 @@ class RNodeInterface(Interface):
self.bitrate = 0
def process_incoming(self, data):
if not data: return
self.rxb += len(data)
self.owner.inbound(data, self)
self.r_stat_rssi = None
self.r_stat_snr = None
def process_outgoing(self,data):
datalen = len(data)
@ -1154,19 +1157,17 @@ class RNodeInterface(Interface):
except Exception as e:
self.online = False
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
if not self.detached:
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
if RNS.Reticulum.panic_on_interface_error: RNS.panic()
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
self.online = False
try:
self.serial.close()
except Exception as e:
pass
try: self.serial.close()
except Exception as e: pass
if not self.detached and not self.reconnecting:
self.reconnect_port()
@ -1192,14 +1193,20 @@ class RNodeInterface(Interface):
self.disable_external_framebuffer()
self.setRadioState(KISS.RADIO_STATE_OFF)
self.leave()
self.serial.close()
except Exception as e:
RNS.log(f"An error occurred while detaching {self}: {e}", RNS.LOG_ERROR)
if self.use_ble: self.ble.close()
if self.use_tcp:
if self.use_ble:
self.ble.close()
self.ble.cleanup()
self.ble = None
elif self.use_tcp:
time.sleep(0.5)
self.tcp.close()
else:
self.serial.close()
def should_ingress_limit(self):
return False
@ -1297,6 +1304,7 @@ class BLEConnection():
self.must_disconnect = False
self.connect_job_running = False
self.device_disappeared = False
self._windows_paired_addrs = None
import importlib.util
if BLEConnection.bleak == None:
@ -1307,8 +1315,8 @@ class BLEConnection():
import asyncio
BLEConnection.asyncio = asyncio
else:
RNS.log("Using the RNode interface over BLE requires a the \"bleak\" module to be installed.", RNS.LOG_CRITICAL)
RNS.log("You can install one with the command: python3 -m pip install bleak", RNS.LOG_CRITICAL)
RNS.log("Using the RNode interface over BLE requires the \"bleak\" module to be installed.", RNS.LOG_CRITICAL)
RNS.log("You can install it with the command: python3 -m pip install bleak", RNS.LOG_CRITICAL)
RNS.panic()
self.should_run = True
@ -1324,6 +1332,7 @@ class BLEConnection():
self.should_run = False
def connection_job(self):
self.running = True
while self.should_run:
if self.ble_device == None:
self.ble_device = self.find_target_device()
@ -1350,6 +1359,7 @@ class BLEConnection():
self.owner.ble_receive(data)
self.connected = True
self.device_disappeared = False
self.ble_device = ble_client
self.last_client = ble_client
self.owner.port = str(f"ble://{ble_client.address}")
@ -1384,6 +1394,8 @@ class BLEConnection():
def find_target_device(self):
RNS.log(f"Searching for attachable BLE device for {self.owner}...", RNS.LOG_EXTREME)
if RNS.vendor.platformutils.is_windows():
self._windows_paired_addrs = self._get_windows_paired_ble_addresses()
def device_filter(device: self.bleak.backends.device.BLEDevice, adv: self.bleak.backends.scanner.AdvertisementData):
if BLEConnection.UART_SERVICE_UUID.lower() in adv.service_uuids:
if self.device_bonded(device):
@ -1415,6 +1427,9 @@ class BLEConnection():
def device_bonded(self, device):
try:
if self._windows_paired_addrs is not None:
return device.address is not None and device.address.lower() in self._windows_paired_addrs
if hasattr(device, "details"):
if "props" in device.details and "Bonded" in device.details["props"]:
if device.details["props"]["Bonded"] == True:
@ -1425,6 +1440,15 @@ class BLEConnection():
return False
def _get_windows_paired_ble_addresses(self):
from winrt.windows.devices.bluetooth import BluetoothLEDevice
from winrt.windows.devices.enumeration import DeviceInformation
async def _query():
selector = BluetoothLEDevice.get_device_selector_from_pairing_state(True)
infos = await DeviceInformation.find_all_async_aqs_filter(selector)
return set(info.id.split("-")[-1].lower() for info in infos)
return self.asyncio.run(_query())
class TCPConnection():
TARGET_PORT = 7633
CONNECT_TIMEOUT = 5.0

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -269,6 +269,7 @@ class RNodeMultiInterface(Interface):
self.r_stat_tx = None
self.r_stat_rssi = None
self.r_stat_snr = None
self.r_stat_q = None
self.r_st_alock = None
self.r_lt_alock = None
self.r_random = None
@ -276,6 +277,8 @@ class RNodeMultiInterface(Interface):
self.packet_queue = []
self.interface_ready = False
self.announce_rate_target = None
self.reports_phy_stats = True
self.validcfg = True
if id_interval != None and id_callsign != None:
@ -543,17 +546,25 @@ class RNodeMultiInterface(Interface):
if written != len(frame):
raise IOError("Serial interface only wrote "+str(written)+" bytes of "+str(len(data)))
def received_announce(self, from_spawned=False):
if from_spawned: self.ia_freq_deque.append(time.time())
def received_announce(self, size=0, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
self.arxb += size
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def sent_announce(self, size=0, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
self.atxb += size
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def received_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.ip_freq_deque.append(time.time())
self.prxb += size
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def sent_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.op_freq_deque.append(time.time())
self.ptxb += size
def readLoop(self):
try:
@ -905,6 +916,7 @@ class RNodeMultiInterface(Interface):
if interface != 0:
self.setRadioState(KISS.RADIO_STATE_OFF, interface)
self.leave()
self.serial.close()
def teardown_subinterfaces(self):
for interface in self.subinterfaces:
@ -993,6 +1005,7 @@ class RNodeSubInterface(Interface):
self.r_premable_time_ms = None
self.packet_queue = []
self.shared_medium = True
self.interface_ready = False
self.parent_interface = parent_interface
self.announce_rate_target = None
@ -1116,10 +1129,9 @@ class RNodeSubInterface(Interface):
self.bitrate = 0
def process_incoming(self, data):
if not data: return
self.rxb += len(data)
self.owner.inbound(data, self)
self.r_stat_rssi = None
self.r_stat_snr = None
def process_outgoing(self,data):
if self.online:

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -83,6 +83,7 @@ class SerialInterface(Interface):
raise ValueError("No port specified for serial interface")
self.HW_MTU = 564
self.shared_medium = True
self.pyserial = serial
self.serial = None
@ -96,6 +97,7 @@ class SerialInterface(Interface):
self.timeout = 100
self.online = False
self.bitrate = self.speed
self.reconnecting = False
if parity.lower() == "e" or parity.lower() == "even":
self.parity = serial.PARITY_EVEN
@ -105,15 +107,17 @@ class SerialInterface(Interface):
try:
self.open_port()
if self.serial.is_open: self.configure_device()
else: raise IOError("Could not open serial port")
except Exception as e:
RNS.log("Could not open serial port for interface "+str(self), RNS.LOG_ERROR)
raise e
if self.serial.is_open:
self.configure_device()
else:
raise IOError("Could not open serial port")
RNS.log(f"Could not open serial port for {self}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
RNS.log(f"Reticulum will attempt to bring up this interface periodically", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting:
thread = threading.Thread(target=self.reconnect_port)
thread.daemon = True
thread.start()
def open_port(self):
RNS.log("Opening serial port "+self.port+"...", RNS.LOG_VERBOSE)
@ -131,6 +135,11 @@ class SerialInterface(Interface):
dsrdtr = False,
)
def detach(self):
self.detached = True
self.online = False
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self} at detach: {e}", RNS.LOG_ERROR)
def configure_device(self):
sleep(0.5)
@ -140,12 +149,11 @@ class SerialInterface(Interface):
self.online = True
RNS.log("Serial port "+self.port+" is now open", RNS.LOG_VERBOSE)
def process_incoming(self, data):
if not data: return
self.rxb += len(data)
self.owner.inbound(data, self)
def process_outgoing(self,data):
if self.online:
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
@ -154,7 +162,6 @@ class SerialInterface(Interface):
if written != len(data):
raise IOError("Serial interface only wrote "+str(written)+" bytes of "+str(len(data)))
def readLoop(self):
try:
in_frame = False
@ -198,30 +205,28 @@ class SerialInterface(Interface):
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
if RNS.Reticulum.panic_on_interface_error: RNS.panic()
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
self.online = False
self.serial.close()
self.reconnect_port()
try: self.serial.close()
except Exception as e: RNS.log(f"Could not close port for {self}", RNS.LOG_ERROR)
if not self.detached and not self.reconnecting: self.reconnect_port()
def reconnect_port(self):
while not self.online:
self.reconnecting = True
while not self.online and not self.detached:
try:
time.sleep(5)
RNS.log("Attempting to reconnect serial port "+str(self.port)+" for "+str(self)+"...", RNS.LOG_VERBOSE)
self.open_port()
if self.serial.is_open:
self.configure_device()
except Exception as e:
RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
if self.serial.is_open: self.configure_device()
except Exception as e: RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Reconnected serial port for "+str(self))
self.reconnecting = False
if self.online: RNS.log("Reconnected serial port for "+str(self))
def should_ingress_limit(self):
return False
def should_ingress_limit(self): return False
def __str__(self):
return "SerialInterface["+self.name+"]"
def __str__(self): return "SerialInterface["+self.name+"]"

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -208,6 +208,7 @@ class TCPClientInterface(Interface):
def detach(self):
self.online = False
self.detached = True
if self.socket != None:
if hasattr(self.socket, "close"):
if callable(self.socket.close):
@ -272,7 +273,7 @@ class TCPClientInterface(Interface):
if not self.reconnecting:
self.reconnecting = True
attempts = 0
while not self.online:
while not self.online and not self.detached:
time.sleep(TCPClientInterface.RECONNECT_WAIT)
attempts += 1
@ -302,6 +303,7 @@ class TCPClientInterface(Interface):
raise IOError("Attempt to reconnect on a non-initiator TCP interface")
def process_incoming(self, data):
if not data: return
if self.online and not self.detached:
self.rxb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
@ -426,7 +428,7 @@ class TCPClientInterface(Interface):
self.online = False
RNS.log("An interface error occurred for "+str(self)+", the contained exception was: "+str(e), RNS.LOG_WARNING)
if self.initiator:
if self.initiator and not self.detached:
RNS.log("Attempting to reconnect...", RNS.LOG_WARNING)
self.reconnect()
else:
@ -452,12 +454,9 @@ class TCPClientInterface(Interface):
if not self.initiator:
RNS.Transport.remove_interface(self)
def __str__(self):
if ":" in self.target_ip:
ip_str = f"[{self.target_ip}]"
else:
ip_str = f"{self.target_ip}"
if ":" in self.target_ip: ip_str = f"[{self.target_ip}]"
else: ip_str = f"{self.target_ip}"
return "TCPInterface["+str(self.name)+"/"+ip_str+":"+str(self.target_port)+"]"
@ -637,6 +636,10 @@ class TCPServerInterface(Interface):
spawned_interface.announce_rate_penalty = self.announce_rate_penalty
spawned_interface.mode = self.mode
spawned_interface.gravity = self.gravity
spawned_interface.recursive_prs = self.recursive_prs
spawned_interface.announces_from_internal = self.announces_from_internal
spawned_interface.announces_to_internal = self.announces_to_internal
spawned_interface.announce_cap = self.announce_cap
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.online = True
RNS.log("Spawned new TCPClient Interface: "+str(spawned_interface), RNS.LOG_VERBOSE)
@ -646,17 +649,25 @@ class TCPServerInterface(Interface):
self.spawned_interfaces.append(spawned_interface)
spawned_interface.read_loop()
def received_announce(self, from_spawned=False):
if from_spawned: self.ia_freq_deque.append(time.time())
def received_announce(self, size=0, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
self.arxb += size
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def sent_announce(self, size=0, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
self.atxb += size
def received_path_request(self, from_spawned=False):
if from_spawned: self.ip_freq_deque.append(time.time())
def received_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.ip_freq_deque.append(time.time())
self.prxb += size
def sent_path_request(self, from_spawned=False):
if from_spawned: self.op_freq_deque.append(time.time())
def sent_path_request(self, size=0, from_spawned=False):
if from_spawned:
self.op_freq_deque.append(time.time())
self.ptxb += size
def process_outgoing(self, data):
pass
@ -678,10 +689,8 @@ class TCPServerInterface(Interface):
def __str__(self):
if ":" in self.bind_ip:
ip_str = f"[{self.bind_ip}]"
else:
ip_str = f"{self.bind_ip}"
if ":" in self.bind_ip: ip_str = f"[{self.bind_ip}]"
else: ip_str = f"{self.bind_ip}"
return "TCPServerInterface["+self.name+"/"+ip_str+":"+str(self.bind_port)+"]"

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -78,6 +78,7 @@ class UDPInterface(Interface):
self.name = name
self.online = False
self.bitrate = UDPInterface.BITRATE_GUESS
self.shared_medium = True
if device != None:
if bindip == None:
@ -112,8 +113,17 @@ class UDPInterface(Interface):
self.forward_ip = forwardip
self.forward_port = forwardport
def detach(self):
self.detached = True
self.online = False
if self.server:
try:
self.server.server_close()
self.server.shutdown()
except Exception as e: RNS.log(f"Could not shut down UDP listener for {self}: {e}", RNS.LOG_ERROR)
def process_incoming(self, data):
if not data: return
self.rxb += len(data)
self.owner.inbound(data, self)

View file

@ -36,5 +36,7 @@ import RNS.Interfaces.util.netinfo as netinfo
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))

115
RNS/Interfaces/util/HDLC.py Normal file
View file

@ -0,0 +1,115 @@
# Reticulum License
#
# Copyright (c) 2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
class HDLC():
FLAG = 0x7E
ESC = 0x7D
ESC_MASK = 0x20
FLAG_B = bytes([FLAG])
ESC_B = bytes([ESC])
ESCPD_FLAG = bytes([ESC, FLAG ^ ESC_MASK])
ESCPD_ESC = bytes([ESC, ESC ^ ESC_MASK])
@staticmethod
def escape(data):
data = data.replace(HDLC.ESC_B, HDLC.ESCPD_ESC)
data = data.replace(HDLC.FLAG_B, HDLC.ESCPD_FLAG)
return data
@staticmethod
def unescape(frame):
frame = frame.replace(HDLC.ESCPD_FLAG, HDLC.FLAG_B)
frame = frame.replace(HDLC.ESCPD_ESC, HDLC.ESC_B)
return frame
@staticmethod
def frame(data):
esc = HDLC.escape(data)
frame = bytearray(len(esc)+2)
frame[0] = HDLC.FLAG
frame[1:-1] = esc
frame[-1] = HDLC.FLAG
return frame
class ReceiveBuffer():
def __init__(self, mtu, min_frame_len, max_frame_len=None, on_frame=None, on_invalid=None):
self._mtu = mtu
self._min_frame_len = min_frame_len
self._max_frame_len = max_frame_len
self._on_frame = on_frame
self._on_invalid = on_invalid
self._buf = bytearray()
self._off = 0
def mtu(self):
return self._mtu() if callable(self._mtu) else self._mtu
def max(self):
m = self._max_frame_len
return m() if callable(m) else m
def feed(self, data):
if not data: return
self._buf.extend(data)
buf = self._buf
while True:
frame_start = buf.find(HDLC.FLAG, self._off)
if frame_start == -1:
self.reset()
return
frame_end = buf.find(HDLC.FLAG, frame_start+1)
if frame_end == -1:
if len(buf)-self._off > self.mtu()*2: self.reset()
return
frame = HDLC.unescape(bytes(buf[frame_start+1:frame_end]))
frame_len = len(frame)
if frame_len > 0:
max_len = self.max()
if frame_len > self._min_frame_len and (max_len is None or frame_len <= max_len):
if self._on_frame is not None: self._on_frame(frame)
elif self._on_invalid is not None: self._on_invalid(frame_len)
self._off = frame_end
if self._off > 0 and self._off >= len(buf) // 2:
del buf[:self._off]
self._off = 0
# Drops any partial frames from the buffer and
# resets assembly offset
def reset(self):
self._buf.clear()
self._off = 0
# Buffered bytes currently unconsumed
def __len__(self): return len(self._buf)-self._off

View file

@ -0,0 +1,171 @@
# Reticulum License
#
# Copyright (c) 2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from collections import deque
import threading
# Zero-copy coalesced transmit buffer for arbitrary
# frame sizes. It's even almost 100% lock-free.
class TransmitBuffer:
COALESCE_TARGET = 65536
def __init__(self):
self._tail_lock = threading.Lock()
# Producer-owned state
self._cur = None
self._cur_frames = 0
# Consumer-owned state
self._head_offset = 0
# Shared chunk queue. Producer appends right, consumer
# pops from left. Appended chunks are immutable.
self._chunks = deque()
# Accounting counters
# Producer-owned
self._tx_total = 0 # Total bytes appended
self._tx_visible = 0 # Total bytes pushed to the chunk queue
self._tx_frames = 0 # Total frames appended
# Consumer-owned
self._tx_sent = 0 # Total bytes sent to the kernel
self._tx_frames_sent = 0 # Total frames fully sent
# Queue a finalized and complete on-wire frame.
# Executed by producer side only.
def append(self, frame, limit=None):
with self._tail_lock:
if limit is not None and (self._tx_total - self._tx_sent) + len(frame) > limit: return False
# Allocate separate chunks to frames
# larger than the coalescing target.
if len(frame) >= self.COALESCE_TARGET:
self._flush()
self._chunks.append((frame, 1))
self._tx_visible += len(frame)
self._tx_total += len(frame)
self._tx_frames += 1
else:
if self._cur is not None and len(self._cur) + len(frame) > self.COALESCE_TARGET: self._flush()
if self._cur is None:
self._cur = bytearray()
self._cur_frames = 0
self._cur.extend(frame)
self._cur_frames += 1
self._tx_total += len(frame)
self._tx_frames += 1
if not self._chunks:
# Queue is idle. Make the frame visible immediately so
# sparse traffic does not incur coalescing latency.
self._flush()
return True
def _flush(self):
if self._cur is not None and len(self._cur) > 0:
self._chunks.append((self._cur, self._cur_frames))
self._tx_visible += len(self._cur)
self._cur = None
self._cur_frames = 0
# Make producer-side coalescing tail visible to the consumer.
# Called by producer when it has finished producing a burst,
# and wants to make the remaining bytes transmittable. Safe to
# call after every append.
def flush(self):
with self._tail_lock: self._flush()
# Drain buffered frames to a socket. Called by consumer. If the
# chunk queue is drained completely, any potential producer-side
# coalescing tail is released under the tail lock, and drained in
# the same cycle. If you possess wizardry for doing this without
# the lock, I am all ears.
def drain_to(self, socket):
# TODO: At *some* point (no, not now), replace with a
# mechanism for send_msg to immediately drain all
# chunks directly to the kernel.
total = 0
whole = True
while whole:
if not self._chunks:
with self._tail_lock: self._flush()
if not self._chunks: break
chunk, _ = self._chunks[0]
remaining = len(chunk) - self._head_offset
if remaining <= 0:
self._pop_head()
continue
try: written = socket.send(memoryview(chunk)[self._head_offset:])
except BlockingIOError: break
except InterruptedError: continue
if written <= 0: break
total += written
self._head_offset += written
self._tx_sent += written
if self._head_offset >= len(chunk): self._pop_head()
else: whole = False
return total
def _pop_head(self):
chunk, frames = self._chunks.popleft()
self._tx_frames_sent += frames
self._head_offset = 0
# Total number of bytes currently buffered, including the
# producer's in-progress coalescing chunk.
def __len__(self): return self._tx_total-self._tx_sent
# The number of bytes actually ready to send, and will be
# visible to the consumer, that is, what can be drained.
@property
def sendable(self): return self._tx_visible-self._tx_sent
# The number of complete frames currently buffere, including
# ones contained in a potential in-progress coalescing chunk.
@property
def frames_buffered(self): return self._tx_frames-self._tx_frames_sent
# Number of chunks currently queued and visible to the consumer
@property
def chunks_buffered(self): return len(self._chunks)
def __repr__(self): return f"<TransmitBuffer buffered={len(self)} sendable={self.sendable} frames={self.frames_buffered} chunks={self.chunks_buffered}>"

View file

@ -3,5 +3,7 @@ import glob
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))

View file

@ -191,7 +191,7 @@ class Link:
if len(data) == Link.ECPUBSIZE+Link.LINK_MTU_SIZE:
RNS.log("Link request includes MTU signalling", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
try: link.mtu = Link.mtu_from_lr_packet(packet) or Reticulum.MTU
try: link.mtu = Link.mtu_from_lr_packet(packet) or RNS.Reticulum.MTU
except Exception as e:
RNS.trace_exception(e)
link.mtu = RNS.Reticulum.MTU
@ -593,7 +593,7 @@ class Link:
def get_expected_rate(self):
"""
:returns: The packet expected in-flight data rate of an established link.
:returns: The expected in-flight data rate of an established link.
"""
if self.status == Link.ACTIVE: return self.expected_rate
else: return None
@ -683,8 +683,8 @@ class Link:
except Exception as e: pass
def link_closed(self):
for resource in self.incoming_resources: resource.cancel()
for resource in self.outgoing_resources: resource.cancel()
for resource in self.incoming_resources.copy(): resource.cancel()
for resource in self.outgoing_resources.copy(): resource.cancel()
if self._channel: self._channel._shutdown()
self.prv = None
@ -928,6 +928,13 @@ class Link:
def receive(self, packet):
self.watchdog_lock = True
try: self.__receive(packet)
except Exception as e:
RNS.log(f"Error on {self} while receiving packet: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
finally: self.watchdog_lock = False
def __receive(self, packet):
if not self.status == Link.CLOSED and not (self.initiator and packet.context == RNS.Packet.KEEPALIVE and packet.data == bytes([0xFF])):
if packet.receiving_interface != self.attached_interface:
RNS.log(f"Link-associated packet received on unexpected interface {packet.receiving_interface} instead of {self.attached_interface}! Someone might be trying to manipulate your communication!", RNS.LOG_ERROR)
@ -1156,8 +1163,6 @@ class Link:
threading.Thread(target=job, daemon=True).start()
self.__update_phy_stats(packet, query_shared=True)
self.watchdog_lock = False
def encrypt(self, plaintext):
try:
if not self.token:
@ -1390,7 +1395,7 @@ class RequestReceipt():
time.sleep(0.1)
def request_timed_out(self, packet_receipt):
if self in self.link.pending_requests and self.status == RequestReceipt.DELIVERED:
if self in self.link.pending_requests and self.status in [RequestReceipt.DELIVERED, RequestReceipt.SENT]:
self.status = RequestReceipt.FAILED
self.concluded_at = time.time()
self.link.pending_requests.remove(self)
@ -1400,7 +1405,7 @@ class RequestReceipt():
except Exception as e: RNS.log("Error while executing request timed out callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
def response_rejected(self):
if self in self.link.pending_requests and self.status == RequestReceipt.DELIVERED:
if self in self.link.pending_requests and self.status in [RequestReceipt.DELIVERED, RequestReceipt.SENT]:
self.status = RequestReceipt.FAILED
self.concluded_at = time.time()
self.link.pending_requests.remove(self)

View file

@ -118,6 +118,7 @@ class Packet:
__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", "is_outbound_pr"
__slots__ += "traffic_class", "announce_signature_validated"
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):
@ -159,6 +160,8 @@ class Packet:
self.packet_hash = None
self.ratchet_id = None
self.traffic_class = None
self.announce_signature_validated = None
self.attached_interface = attached_interface
self.receiving_interface = None
self.is_outbound_pr = False
@ -260,18 +263,23 @@ class Packet:
self.destination_hash = self.raw[DST_LEN+2:2*DST_LEN+2]
self.context = ord(self.raw[2*DST_LEN+2:2*DST_LEN+3])
self.data = self.raw[2*DST_LEN+3:]
if len(self.transport_id) != DST_LEN: raise ValueError("Malformed Transport ID field")
if len(self.destination_hash) != DST_LEN: raise ValueError("Malformed destination hash field")
else:
self.transport_id = None
self.destination_hash = self.raw[2:DST_LEN+2]
self.context = ord(self.raw[DST_LEN+2:DST_LEN+3])
self.data = self.raw[DST_LEN+3:]
if len(self.destination_hash) != DST_LEN: raise ValueError("Malformed destination hash field")
if len(self.data) == 0: raise ValueError("Zero-length data field")
self.packed = False
self.update_hash()
return True
except Exception as e:
RNS.log(f"Received malformed packet, dropping it. The contained exception was: {e}", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
RNS.log(f"Received malformed packet, dropping it. The contained exception was: {e}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
return False
def send(self):
@ -281,6 +289,7 @@ class Packet:
:returns: A :ref:`RNS.PacketReceipt<api-packetreceipt>` instance if *create_receipt* was set to *True* when the packet was instantiated, if not returns *None*. If the packet could not be sent *False* is returned.
"""
if not self.sent:
if self.hops >= RNS.Transport.PATHFINDER_M: return False
if not self.packed: self.pack()
if self.destination.type == RNS.Destination.LINK:
if self.destination.status == RNS.Link.CLOSED:
@ -296,7 +305,9 @@ class Packet:
if RNS.Transport.outbound(self): return self.receipt
else:
RNS.log("No interfaces could process the outbound packet", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
if not self.attached_interface:
RNS.log(f"No interfaces could process the outbound {self.hops} hop, type {self.packet_type} packet for {self.destination}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
self.sent = False
self.receipt = None
return False
@ -345,6 +356,10 @@ class Packet:
def getTruncatedHash(self): return RNS.Identity.truncated_hash(self.get_hashable_part())
@property
def truncated_packet_hash(self):
return self.packet_hash[:RNS.Reticulum.TRUNCATED_HASHLENGTH//8]
def get_hashable_part(self):
hashable_part = bytes([self.raw[0] & 0b00001111])
if self.header_type == Packet.HEADER_2: hashable_part += self.raw[(RNS.Identity.TRUNCATED_HASHLENGTH//8)+2:]
@ -357,25 +372,25 @@ class Packet:
:returns: The physical layer *Received Signal Strength Indication* if available, otherwise ``None``.
"""
if self.rssi != None: return self.rssi
else: return reticulum.get_packet_rssi(self.packet_hash)
else: return RNS.Reticulum.get_instance().get_packet_rssi(self.packet_hash)
def get_snr(self):
"""
:returns: The physical layer *Signal-to-Noise Ratio* if available, otherwise ``None``.
"""
if self.snr != None: return self.snr
else: return reticulum.get_packet_snr(self.packet_hash)
else: return RNS.Reticulum.get_instance().get_packet_snr(self.packet_hash)
def get_q(self):
"""
:returns: The physical layer *Link Quality* if available, otherwise ``None``.
"""
if self.q != None: return self.q
else: return reticulum.get_packet_q(self.packet_hash)
else: return RNS.Reticulum.get_instance().get_packet_q(self.packet_hash)
class ProofDestination:
def __init__(self, packet):
self.hash = packet.get_hash()[:RNS.Reticulum.TRUNCATED_HASHLENGTH//8];
self.hash = packet.truncated_packet_hash;
self.type = RNS.Destination.SINGLE
def encrypt(self, plaintext): return plaintext
@ -400,8 +415,8 @@ class PacketReceipt:
# Creates a new packet receipt from a sent packet
def __init__(self, packet):
self.hash = packet.get_hash()
self.truncated_hash = packet.getTruncatedHash()
self.hash = packet.packet_hash
self.truncated_hash = packet.truncated_packet_hash
self.sent = True
self.sent_at = time.time()
self.proved = False

View file

@ -30,6 +30,7 @@
import RNS
import os
import io
import bz2
import math
import time
@ -280,6 +281,19 @@ class Resource:
if hasattr(data, "read"):
if data_size == None: data_size = os.stat(data.name).st_size
if data_size == 0:
if isinstance(data, io.BufferedReader):
RNS.log(f"Attempting to proxy stream data...", RNS.LOG_DEBUG)
stream_proxy = tempfile.TemporaryFile()
stream_proxy.write(data.read())
stream_proxy.flush(); stream_proxy.seek(0)
data_size = os.stat(stream_proxy.name).st_size
if data_size: RNS.log(f"Stream proxy succeeded, final data size is {RNS.prettysize(data_size)}", RNS.LOG_DEBUG)
else: RNS.log(f"Resource initialisation received an invalid readable input, or stream data that could not be proxied, the transfer will likely fail.", RNS.LOG_WARNING)
data = stream_proxy
else: RNS.log(f"Got zero-sized readable at resource initialisation, the transfer will likely.", RNS.LOG_WARNING)
self.total_size = data_size + self.metadata_size
if self.total_size <= Resource.MAX_EFFICIENT_SIZE:
@ -290,12 +304,6 @@ class Resource:
data.close()
else:
# self.total_segments = ((data_size-1)//Resource.MAX_EFFICIENT_SIZE)+1
# self.segment_index = segment_index
# self.split = True
# seek_index = segment_index-1
# seek_position = seek_index*Resource.MAX_EFFICIENT_SIZE
self.total_segments = ((self.total_size-1)//Resource.MAX_EFFICIENT_SIZE)+1
self.segment_index = segment_index
self.split = True
@ -372,10 +380,8 @@ class Resource:
self.req_hashlist = []
self.receiver_min_consecutive_height = 0
if timeout != None:
self.timeout = timeout
else:
self.timeout = self.link.rtt * self.link.traffic_timeout_factor
if timeout != None: self.timeout = timeout
else: self.timeout = self.link.rtt * self.link.traffic_timeout_factor
if data != None:
self.initiator = True
@ -384,18 +390,21 @@ class Resource:
compression_began = time.time()
if self.auto_compress and data_size <= self.auto_compress_limit:
RNS.log("Compressing resource data...", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
self.compressed_data = bz2.compress(self.uncompressed_data)
RNS.log("Compression completed in "+str(round(time.time()-compression_began, 3))+" seconds", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
else:
self.compressed_data = self.uncompressed_data
RNS.log("Compressing resource data...", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
try:
self.compressed_data = bz2.compress(self.uncompressed_data)
RNS.log("Compression completed in "+str(round(time.time()-compression_began, 3))+" seconds", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
except Exception as e:
RNS.log(f"Could not auto-compress resource data, falling back to uncompressed transfer: {e}", RNS.LOG_DEBUG)
self.compressed_data = self.uncompressed_data
else: self.compressed_data = self.uncompressed_data
self.uncompressed_size = len(self.uncompressed_data)
self.compressed_size = len(self.compressed_data)
if (self.compressed_size < self.uncompressed_size and auto_compress):
saved_bytes = len(self.uncompressed_data) - len(self.compressed_data)
RNS.log("Compression saved "+str(saved_bytes)+" bytes, sending compressed", RNS.LOG_EXTREME) if RNS.sl(RNS.LOG_EXTREME) else None
RNS.log("Compression saved "+str(saved_bytes)+" bytes, sending compressed", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
self.data = b""
self.data += RNS.Identity.get_random_hash()[:Resource.RANDOM_HASH_SIZE]
@ -447,8 +456,8 @@ class Resource:
self.hashmap = b""
collision_guard_list = []
for i in range(0,hashmap_entries):
data = self.data[i*self.sdu:(i+1)*self.sdu]
map_hash = self.get_map_hash(data)
part_data = self.data[i*self.sdu:(i+1)*self.sdu]
map_hash = self.get_map_hash(part_data)
if map_hash in collision_guard_list:
RNS.log("Found hash collision in resource map, remapping...", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
@ -460,7 +469,7 @@ class Resource:
if len(collision_guard_list) > ResourceAdvertisement.COLLISION_GUARD_SIZE:
collision_guard_list.pop(0)
part = RNS.Packet(link, data, context=RNS.Packet.RESOURCE)
part = RNS.Packet(link, part_data, context=RNS.Packet.RESOURCE)
part.pack()
part.map_hash = map_hash
@ -865,9 +874,9 @@ class Resource:
part_data = packet.data
part_hash = self.get_map_hash(part_data)
consecutive_index = self.consecutive_completed_height if self.consecutive_completed_height >= 0 else 0
i = consecutive_index
for map_hash in self.hashmap[consecutive_index:consecutive_index+self.window]:
search_start = self.consecutive_completed_height+1
i = search_start
for map_hash in self.hashmap[search_start:search_start+self.window]:
if map_hash == part_hash:
if self.parts[i] == None:
@ -878,9 +887,6 @@ class Resource:
self.outstanding_parts -= 1
# Update consecutive completed pointer
if i == self.consecutive_completed_height + 1:
self.consecutive_completed_height = i
cp = self.consecutive_completed_height + 1
while cp < len(self.parts) and self.parts[cp] != None:
self.consecutive_completed_height = cp
@ -1088,7 +1094,7 @@ class Resource:
if self.next_segment: self.next_segment.cancel()
if self.status == Resource.CORRUPT:
self.link.cancel_incoming_resource(self)
if self in self.link.incoming_resources: self.link.cancel_incoming_resource(self)
self.reject(self.advertisement_packet)
self.link.teardown()
@ -1100,14 +1106,14 @@ class Resource:
cancel_packet = RNS.Packet(self.link, self.hash, context=RNS.Packet.RESOURCE_ICL)
cancel_packet.send()
except Exception as e: RNS.log("Could not send resource cancel packet, the contained exception was: "+str(e), RNS.LOG_ERROR)
self.link.cancel_outgoing_resource(self)
if self in self.link.outgoing_resources: self.link.cancel_outgoing_resource(self)
else:
if self.link.status == RNS.Link.ACTIVE:
try:
cancel_packet = RNS.Packet(self.link, self.hash, context=RNS.Packet.RESOURCE_RCL)
cancel_packet.send()
except Exception as e: RNS.log("Could not send resource cancel packet, the contained exception was: "+str(e), RNS.LOG_ERROR)
self.link.cancel_incoming_resource(self)
if self in self.link.incoming_resources: self.link.cancel_incoming_resource(self)
if self.callback != None:
try:
@ -1120,7 +1126,7 @@ class Resource:
if self.status < Resource.COMPLETE:
if self.initiator:
self.status = Resource.REJECTED
self.link.cancel_outgoing_resource(self)
if self in self.link.outgoing_resources: self.link.cancel_outgoing_resource(self)
if self.callback != None:
try:
self.link.resource_concluded(self)
@ -1241,7 +1247,9 @@ class Resource:
return self.compressed
def __str__(self):
return "<"+RNS.hexrep(self.hash,delimit=False)+"/"+RNS.hexrep(self.link.link_id,delimit=False)+">"
if hasattr(self, "hash") and self.hash and hasattr(self, "link") and self.link:
return "<"+RNS.hexrep(self.hash,delimit=False)+"/"+RNS.hexrep(self.link.link_id,delimit=False)+">"
else: return "<initializing_resource/unknown>"
class ResourceAdvertisement:
@ -1365,4 +1373,4 @@ class ResourceAdvertisement:
if adv.t > Resource.MAX_EFFICIENT_SIZE*3: raise ValueError("Invalid transfer size")
return adv
return adv

View file

@ -1,6 +1,6 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -108,8 +108,8 @@ class Reticulum:
discovery significantly increases throughput over fast links.
"""
MAX_QUEUED_ANNOUNCES = 16384
QUEUED_ANNOUNCE_LIFE = 60*60*24
MAX_QUEUED_ANNOUNCES = 4096
QUEUED_ANNOUNCE_LIFE = 60*60*3
ANNOUNCE_CAP = 2
"""
@ -140,6 +140,9 @@ class Reticulum:
# TODO: Let Reticulum somehow continously build a map of per-hop
# latencies and use this map for global timeout calculation.
DEFAULT_PER_HOP_TIMEOUT = 6
"""
The default per-hop timeout value used in various timeout calculations.
"""
# Length of truncated hashes in bits.
TRUNCATED_HASHLENGTH = 128
@ -187,8 +190,6 @@ class Reticulum:
RNS.Transport.exit_handler()
RNS.Identity.exit_handler()
if RNS.Profiler.ran(): RNS.Profiler.results()
RNS.loglevel = RNS.LOG_NONE
RNS._detach_stdout()
@ -207,7 +208,7 @@ class Reticulum:
@staticmethod
def get_instance():
"""
Return the currently running Reticulum instance
Returns the currently running Reticulum instance.
"""
return Reticulum.__instance
@ -256,11 +257,13 @@ class Reticulum:
Reticulum.__local_hops_delta = False
Reticulum.__link_mtu_discovery = Reticulum.LINK_MTU_DISCOVERY
Reticulum.__remote_management_enabled = False
Reticulum.__interface_management_enabled = True
Reticulum.__use_implicit_proof = True
Reticulum.__allow_probes = False
Reticulum.__discovery_enabled = False
Reticulum.__discover_interfaces = False
Reticulum.__autoconnect_discovered_interfaces = False
Reticulum.__autoconnect_unverified = False
Reticulum.__autoconnect_interface_mode = None
Reticulum.__autoconnect_interface_gravity = None
Reticulum.__autoconnect_announces_to_internal = None
@ -284,6 +287,10 @@ class Reticulum:
Reticulum.__ic_held_release_interval = None
Reticulum.__ec_pr_freq = None
Reticulum.__egress_control = None
Reticulum.__inbound_data_queue_length = None
Reticulum.__inbound_announce_queue_length = None
Reticulum.__inbound_pr_queue_length = None
Reticulum.__inbound_il_queue_length = None
Reticulum.panic_on_interface_error = False
@ -536,12 +543,18 @@ class Reticulum:
if option == "link_mtu_discovery":
v = self.config["reticulum"].as_bool(option)
if v == True: Reticulum.__link_mtu_discovery = True
if v == True: Reticulum.__link_mtu_discovery = True
elif v == False: Reticulum.__link_mtu_discovery = False
if option == "enable_remote_management":
v = self.config["reticulum"].as_bool(option)
if v == True: Reticulum.__remote_management_enabled = True
if option == "enable_interface_management":
v = self.config["reticulum"].as_bool(option)
if v == False: Reticulum.__interface_management_enabled = False
else: Reticulum.__interface_management_enabled = True
if option == "remote_management_allowed":
v = self.config["reticulum"].as_list(option)
for hexhash in v:
@ -616,6 +629,10 @@ class Reticulum:
v = self.config["reticulum"].as_int(option)
if v > 0: Reticulum.__autoconnect_discovered_interfaces = v
if option == "autoconnect_unverified_implementations":
v = self.config["reticulum"].as_bool(option)
Reticulum.__autoconnect_unverified = v
if option == "autoconnect_interface_mode":
v = None; dmode = str(self.config["reticulum"]["autoconnect_interface_mode"]).lower()
if dmode == "full": v = Interface.Interface.MODE_FULL
@ -696,6 +713,22 @@ class Reticulum:
v = self.config["reticulum"].as_float(option)
if v >= 0: Reticulum.__ic_held_release_interval = v
if option == "qlen_in_data":
v = self.config["reticulum"].as_int(option)
if v > 0: Reticulum.__inbound_data_queue_length = v
if option == "qlen_in_announce":
v = self.config["reticulum"].as_int(option)
if v > 0: Reticulum.__inbound_announce_queue_length = v
if option == "qlen_in_pr":
v = self.config["reticulum"].as_int(option)
if v > 0: Reticulum.__inbound_pr_queue_length = v
if option == "qlen_in_il":
v = self.config["reticulum"].as_int(option)
if v > 0: Reticulum.__inbound_il_queue_length = 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)
@ -713,6 +746,11 @@ class Reticulum:
if self.local_socket_path == None and self.use_af_unix:
self.local_socket_path = "default"
dql = RNS.Reticulum.default_data_queue_length() or RNS.Transport.INBOUND_DA_QUEUE_LENGTH
BackboneInterface.BackboneInterface.DP_IC_HIGH_WM = max(4, int((BackboneInterface.BackboneInterface.DP_IC_HIGH_WM_PCT/100.0) * dql))
BackboneInterface.BackboneInterface.DP_IC_MID_WM = max(2, int((BackboneInterface.BackboneInterface.DP_IC_MID_WM_PCT/100.0) * dql))
BackboneInterface.BackboneInterface.DP_IC_LOW_WM = max(0, int((BackboneInterface.BackboneInterface.DP_IC_LOW_WM_PCT/100.0) * dql))
self.__start_local_interface()
if self.is_shared_instance or self.is_standalone_instance:
@ -730,7 +768,79 @@ class Reticulum:
RNS.log("System interfaces are ready", RNS.LOG_VERBOSE)
def _synthesize_interface(self, config, name, instance_init=False):
def _attach_interface(self, name, internal_forced=False):
if not internal_forced and not Reticulum.__interface_management_enabled: return False
try:
interfaces = { iface.name: iface for iface in RNS.Transport.interfaces }
if name in interfaces:
RNS.log(f"Attempt to attach existing interface \"{name}\"", RNS.LOG_WARNING)
return False
else:
try: config = ConfigObj(self.configpath)
except Exception as e:
RNS.log(f"Could not parse the configuration at {self.configpath} during interface attach", RNS.LOG_ERROR)
return None
if "interfaces" in config:
if not name in config["interfaces"]:
RNS.log(f"Cannot attach interface \"{name}\", no configuration entry exists", RNS.LOG_ERROR)
return None
else:
interface_config = config["interfaces"][name]
self._synthesize_interface(interface_config, name, force_attach=True)
RNS.log(f"Interface \"{name}\" was attached", RNS.LOG_NOTICE)
return True
except Exception as e: RNS.log(f"Error while detaching interface \"{name}\": {e}", RNS.LOG_ERROR)
return False
def _detach_interface(self, name, internal_forced=False):
if not internal_forced and not Reticulum.__interface_management_enabled: return False
try:
interfaces = { iface.name: iface for iface in RNS.Transport.interfaces }
if not name in interfaces:
RNS.log(f"Attempt to detach non-existing interface \"{name}\"", RNS.LOG_WARNING)
return None
else:
interface = interfaces[name]
if isinstance(interface, I2PInterface.I2PInterface): return False
if isinstance(interface, LocalInterface.LocalClientInterface): return False
if isinstance(interface, LocalInterface.LocalServerInterface): return False
if interface.spawned_interfaces:
if type(interface.spawned_interfaces) == dict: spawned = list(interface.spawned_interfaces.values())
elif type(interface.spawned_interfaces) == list: spawned = interface.spawned_interfaces.copy()
for s in spawned:
s.detach()
s.teardown()
RNS.Transport.remove_interface(s)
interface.detach()
RNS.Transport.remove_interface(interface)
RNS.log(f"Interface \"{name}\" was detached", RNS.LOG_NOTICE)
return True
except Exception as e: RNS.log(f"Error while detaching interface \"{name}\": {e}", RNS.LOG_ERROR); RNS.trace_exception(e)
return False
def _reload_interface(self, name, internal_forced=False):
if not internal_forced and not Reticulum.__interface_management_enabled: return False
interfaces = { iface.name: iface for iface in RNS.Transport.interfaces }
if not name in interfaces:
RNS.log(f"Attempt to reload non-existing interface \"{name}\"", RNS.LOG_WARNING)
return None
if self._detach_interface(name, internal_forced=internal_forced) and self._attach_interface(name, internal_forced=internal_forced):
RNS.log(f"Interface \"{name}\" was reloaded", RNS.LOG_NOTICE)
return True
else:
RNS.log(f"Could not reload interface \"{name}\"", RNS.LOG_NOTICE)
return False
def _synthesize_interface(self, config, name, instance_init=False, force_attach=False):
c = config
interface_mode = Interface.Interface.MODE_FULL
@ -777,15 +887,19 @@ class Reticulum:
ifac_netname = None
if "networkname" in c:
if c["networkname"] != "": ifac_netname = c["networkname"]
if c["networkname"] == "None": RNS.log("Ambiguous IFAC network name \"None\", this value is ignored and an IFAC network name has NOT been set", RNS.LOG_WARNING)
if c["networkname"] != "" and c["networkname"] != "None": ifac_netname = c["networkname"]
if "network_name" in c:
if c["network_name"] != "": ifac_netname = c["network_name"]
if c["network_name"] == "None": RNS.log("Ambiguous IFAC network name \"None\", this value is ignored and an IFAC network name has NOT been set", RNS.LOG_WARNING)
if c["network_name"] != "" and c["network_name"] != "None": ifac_netname = c["network_name"]
ifac_netkey = None
if "passphrase" in c:
if c["passphrase"] != "": ifac_netkey = c["passphrase"]
if c["passphrase"] == "None": RNS.log("Ambiguous IFAC passphrase \"None\", this value is ignored and an IFAC passphrase has NOT been set", RNS.LOG_WARNING)
if c["passphrase"] != "" and c["passphrase"] != "None": ifac_netkey = c["passphrase"]
if "pass_phrase" in c:
if c["pass_phrase"] != "": ifac_netkey = c["pass_phrase"]
if c["pass_phrase"] == "None": RNS.log("Ambiguous IFAC passphrase \"None\", this value is ignored and an IFAC passphrase has NOT been set", RNS.LOG_WARNING)
if c["pass_phrase"] != "" and c["pass_phrase"] != "None": ifac_netkey = c["pass_phrase"]
ingress_control = True
if "ingress_control" in c: ingress_control = c.as_bool("ingress_control")
@ -860,6 +974,7 @@ class Reticulum:
discovery_announce_interval = None
discovery_stamp_value = None
discovery_name = None
discovery_lxmf_address = None
discovery_encrypt = False
reachable_on = None
publish_ifac = False
@ -891,6 +1006,11 @@ class Reticulum:
if "discovery_frequency" in c: discovery_frequency = c.as_int("discovery_frequency")
if "discovery_bandwidth" in c: discovery_bandwidth = c.as_int("discovery_bandwidth")
if "discovery_modulation" in c: discovery_modulation = c.as_int("discovery_modulation")
if "discovery_lxmf_address" in c:
if len(c["discovery_lxmf_address"]) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2:
try: discovery_lxmf_address = bytes.fromhex(c["discovery_lxmf_address"])
except: RNS.log(f"Invalid interface discovery LXMF address: {c['discovery_lxmf_address']}", RNS.LOG_ERROR)
else: RNS.log(f"Invalid length for interface discovery LXMF address: {c['discovery_lxmf_address']}", RNS.LOG_ERROR)
if not interface_mode in [Interface.Interface.MODE_GATEWAY, Interface.Interface.MODE_ACCESS_POINT, Interface.Interface.MODE_INTERNAL]:
if not ignore_config_warnings:
@ -922,6 +1042,7 @@ class Reticulum:
interface.discovery_publish_ifac = publish_ifac
interface.reachable_on = reachable_on
interface.discovery_name = discovery_name
interface.discovery_lxmf_address = discovery_lxmf_address
interface.discovery_encrypt = discovery_encrypt
interface.discovery_stamp_value = discovery_stamp_value
interface.discovery_location = discovery_location
@ -971,11 +1092,16 @@ class Reticulum:
interface.ifac_identity = RNS.Identity.from_bytes(interface.ifac_key)
interface.ifac_signature = interface.ifac_identity.sign(RNS.Identity.full_hash(interface.ifac_key))
elif interface.discoverable and interface.discovery_publish_ifac:
RNS.log(f"IFAC publishing was enabled for discoverable interface {interface}, but neither IFAC netname nor passphrase is configured", RNS.LOG_WARNING)
RNS.log(f"Disabling IFAC publishing for {interface}", RNS.LOG_WARNING)
interface.discovery_publish_ifac = False
RNS.Transport.add_interface(interface)
interface.final_init()
interface = None
if (("interface_enabled" in c) and c.as_bool("interface_enabled") == True) or (("enabled" in c) and c.as_bool("enabled") == True):
if (("interface_enabled" in c) and c.as_bool("interface_enabled") == True) or (("enabled" in c) and c.as_bool("enabled") == True) or force_attach:
interface_config = c
interface_config["name"] = name
interface_config["selected_interface_mode"] = interface_mode
@ -1079,7 +1205,6 @@ class Reticulum:
except Exception as e:
RNS.log(f"External interface initialisation failed for {interface_type} / {name}", RNS.LOG_ERROR)
RNS.trace_exception(e)
else:
RNS.log("Skipping disabled interface \""+name+"\"", RNS.LOG_DEBUG)
@ -1250,17 +1375,27 @@ class Reticulum:
mh = call["max_hops"]
self.rpc_return(conn, self.get_path_table(max_hops=mh))
if path == "interface_stats": self.rpc_return(conn, self.get_interface_stats())
if path == "rate_table": self.rpc_return(conn, self.get_rate_table())
if path == "next_hop_if_name": self.rpc_return(conn, self.get_next_hop_if_name(call["destination_hash"]))
if path == "next_hop": self.rpc_return(conn, self.get_next_hop(call["destination_hash"]))
if path == "first_hop_timeout": self.rpc_return(conn, self.get_first_hop_timeout(call["destination_hash"]))
if path == "link_count": self.rpc_return(conn, self.get_link_count())
if path == "packet_rssi": self.rpc_return(conn, self.get_packet_rssi(call["packet_hash"]))
if path == "packet_snr": self.rpc_return(conn, self.get_packet_snr(call["packet_hash"]))
if path == "packet_q": self.rpc_return(conn, self.get_packet_q(call["packet_hash"]))
if path == "blackholed_identities": self.rpc_return(conn, self.get_blackholed_identities())
if path == "is_blackholed": self.rpc_return(conn, self.is_blackholed(call["identity_hash"]))
if path == "interface_stats": self.rpc_return(conn, self.get_interface_stats())
if path == "rate_table": self.rpc_return(conn, self.get_rate_table())
if path == "next_hop_if_name": self.rpc_return(conn, self.get_next_hop_if_name(call["destination_hash"]))
if path == "next_hop": self.rpc_return(conn, self.get_next_hop(call["destination_hash"]))
if path == "first_hop_timeout": self.rpc_return(conn, self.get_first_hop_timeout(call["destination_hash"]))
if path == "lowest_interface_bitrate": self.rpc_return(conn, self.get_lowest_interface_bitrate())
if path == "medium_path_timeout": self.rpc_return(conn, self.get_medium_path_timeout())
if path == "link_count": self.rpc_return(conn, self.get_link_count())
if path == "active_link_count": self.rpc_return(conn, self.get_active_link_count())
if path == "packet_rssi": self.rpc_return(conn, self.get_packet_rssi(call["packet_hash"]))
if path == "packet_snr": self.rpc_return(conn, self.get_packet_snr(call["packet_hash"]))
if path == "packet_q": self.rpc_return(conn, self.get_packet_q(call["packet_hash"]))
if path == "profiling_results": self.rpc_return(conn, self.get_profiling_results())
if path == "blackholed_identities": self.rpc_return(conn, self.get_blackholed_identities())
if path == "is_blackholed": self.rpc_return(conn, self.is_blackholed(call["identity_hash"]))
if "manage" in call:
path = call["manage"]
if path == "attach_interface": self.rpc_return(conn, self.attach_interface(call["name"]))
if path == "detach_interface": self.rpc_return(conn, self.detach_interface(call["name"]))
if path == "reload_interface": self.rpc_return(conn, self.reload_interface(call["name"]))
if "drop" in call:
path = call["drop"]
@ -1440,6 +1575,26 @@ class Reticulum:
else: ifstats["txs"] = 0
else: ifstats["txs"] = 0
if hasattr(interface, "current_arx_speed"):
if interface.current_arx_speed != None: ifstats["arxs"] = interface.current_arx_speed
else: ifstats["arxs"] = 0
else: ifstats["arxs"] = 0
if hasattr(interface, "current_atx_speed"):
if interface.current_atx_speed != None: ifstats["atxs"] = interface.current_atx_speed
else: ifstats["atxs"] = 0
else: ifstats["atxs"] = 0
if hasattr(interface, "current_prx_speed"):
if interface.current_prx_speed != None: ifstats["prxs"] = interface.current_prx_speed
else: ifstats["prxs"] = 0
else: ifstats["prxs"] = 0
if hasattr(interface, "current_ptx_speed"):
if interface.current_ptx_speed != None: ifstats["ptxs"] = interface.current_ptx_speed
else: ifstats["ptxs"] = 0
else: ifstats["ptxs"] = 0
if hasattr(interface, "peers"):
if interface.peers != None: ifstats["peers"] = len(interface.peers)
else: ifstats["peers"] = None
@ -1470,8 +1625,21 @@ class Reticulum:
ifstats["short_name"] = str(interface.name)
ifstats["hash"] = interface.get_hash()
ifstats["type"] = str(type(interface).__name__)
ifstats["mtu"] = interface.HW_MTU
ifstats["rxb"] = interface.rxb
ifstats["txb"] = interface.txb
ifstats["arxb"] = interface.arxb
ifstats["atxb"] = interface.atxb
ifstats["arxc"] = interface.arxc
ifstats["atxc"] = interface.atxc
ifstats["prxb"] = interface.prxb
ifstats["ptxb"] = interface.ptxb
ifstats["prxc"] = interface.prxc
ifstats["ptxc"] = interface.ptxc
ifstats["txdrp"] = interface.tx_drops
ifstats["txdrb"] = interface.tx_dropped_bytes
ifstats["txstalled"] = interface.tx_stalled
ifstats["txbuffered"] = len(interface.transmit_buffer) if interface.transmit_buffer else 0
ifstats["incoming_announce_frequency"] = interface.incoming_announce_frequency()
ifstats["outgoing_announce_frequency"] = interface.outgoing_announce_frequency()
ifstats["incoming_pr_frequency"] = interface.incoming_pr_frequency()
@ -1482,21 +1650,63 @@ class Reticulum:
ifstats["held_announces"] = len(interface.held_announces)
ifstats["burst_active"] = interface.ic_burst_active
ifstats["burst_activated"] = interface.ic_burst_activated
ifstats["burst_count"] = interface.ic_burst_count
ifstats["pr_burst_active"] = interface.ic_pr_burst_active
ifstats["pr_burst_activated"] = interface.ic_pr_burst_activated
ifstats["pr_burst_count"] = interface.ic_pr_burst_count
ifstats["status"] = interface.online
ifstats["mode"] = interface.mode
ifstats["gravity"] = interface.gravity
ifstats["announces_to_internal"] = interface.announces_to_internal
ifstats["protocol_violations"] = interface.protocol_violations
ifstats["ifac_violations"] = interface.ifac_violations
ifstats["packet_filter_hits"] = interface.packet_filter_hits
interfaces.append(ifstats)
stats = {}
stats["interfaces"] = interfaces
stats["rxb"] = RNS.Transport.traffic_rxb
stats["txb"] = RNS.Transport.traffic_txb
stats["rxs"] = RNS.Transport.speed_rx
stats["txs"] = RNS.Transport.speed_tx
qsnapshot = RNS.Transport.inbound_queues.snapshot()
dql = RNS.Transport.INBOUND_DA_QUEUE_LENGTH
aql = RNS.Transport.INBOUND_AN_QUEUE_LENGTH
pql = RNS.Transport.INBOUND_PR_QUEUE_LENGTH
ilql = RNS.Transport.INBOUND_IL_QUEUE_LENGTH
tql = dql+aql+pql+ilql
stats = {}
stats["interfaces"] = interfaces
stats["rxb"] = RNS.Transport.traffic_rxb
stats["txb"] = RNS.Transport.traffic_txb
stats["rxs"] = RNS.Transport.speed_rx
stats["txs"] = RNS.Transport.speed_tx
stats["arxb"] = RNS.Transport.announce_rxb
stats["atxb"] = RNS.Transport.announce_txb
stats["arxs"] = RNS.Transport.announce_speed_rx
stats["atxs"] = RNS.Transport.announce_speed_tx
stats["arxf"] = RNS.Transport.announce_freq_rx
stats["atxf"] = RNS.Transport.announce_freq_tx
stats["prxb"] = RNS.Transport.pr_rxb
stats["ptxb"] = RNS.Transport.pr_txb
stats["prxs"] = RNS.Transport.pr_speed_rx
stats["ptxs"] = RNS.Transport.pr_speed_tx
stats["prxf"] = RNS.Transport.pr_freq_rx
stats["ptxf"] = RNS.Transport.pr_freq_tx
stats["rxpps"] = RNS.Transport.rx_pps
stats["txpps"] = RNS.Transport.tx_pps
stats["rxqt"] = qsnapshot[0]
stats["rxqd"] = qsnapshot[1][0]
stats["rxqa"] = qsnapshot[1][1]
stats["rxqp"] = qsnapshot[1][2]
stats["rxqil"] = qsnapshot[1][3]
stats["rxqtd"] = qsnapshot[2][0]+qsnapshot[2][1]+qsnapshot[2][2]+qsnapshot[2][3]
stats["rxqdd"] = qsnapshot[2][0]
stats["rxqad"] = qsnapshot[2][1]
stats["rxqpd"] = qsnapshot[2][2]
stats["rxqild"] = qsnapshot[2][3]
stats["tqpressure"] = qsnapshot[0]/tql if qsnapshot[0] else 0
stats["dqpressure"] = qsnapshot[1][0]/dql if qsnapshot[1][0] else 0
stats["aqpressure"] = qsnapshot[1][1]/aql if qsnapshot[1][1] else 0
stats["pqpressure"] = qsnapshot[1][2]/pql if qsnapshot[1][2] else 0
stats["ilqpressure"] = qsnapshot[1][3]/ilql if qsnapshot[1][3] else 0
stats["txq"] = None
if Reticulum.transport_enabled():
stats["transport_id"] = RNS.Transport.identity.hash
@ -1602,6 +1812,16 @@ class Reticulum:
return str(RNS.Transport.next_hop_interface(destination))
def get_first_hop_timeout(self, destination):
"""
Returns a best-effort estimate of a reasonable minimum
*first-hop* timeout value for a given destination hash.
If a path is known, this calculation takes the next-hop
interface's bitrate into account. If no path is currently
known, returns ``DEFAULT_PER_HOP_TIMEOUT``.
:param destination: A destination hash, as *bytes*.
:returns: First-hop timeout, in seconds.
"""
if self.is_connected_to_shared_instance:
try:
rpc_connection = self.get_rpc_client()
@ -1621,6 +1841,45 @@ class Reticulum:
else:
return RNS.Transport.first_hop_timeout(destination)
def get_lowest_interface_bitrate(self):
"""
Returns the bitrate of the slowest currently online
interface, or None if no online interface bitrate
:returns: Lowest online interface bitrate in bits per second, or ``None``.
"""
if self.is_connected_to_shared_instance:
try:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "lowest_interface_bitrate"}))
return mp.unpackb(rpc_connection.recv_bytes())
except Exception as e:
RNS.log("An error occurred while getting lowest interface bitrate from shared instance: "+str(e), RNS.LOG_ERROR)
return None
else:
return RNS.Transport.lowest_interface_bitrate
def get_medium_path_timeout(self):
"""
Returns an estimate of a reasonable minimum path request timeout covering
a full round trip for an MTU on the slowest currently online interface
plus per hop grace
:returns: Timeout in seconds or 0 if it's unknown.
"""
if self.is_connected_to_shared_instance:
try:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "medium_path_timeout"}))
return mp.unpackb(rpc_connection.recv_bytes())
except Exception as e:
RNS.log("An error occurred while getting medium path timeout from shared instance: "+str(e), RNS.LOG_ERROR)
return 0
else:
return RNS.Transport.medium_path_timeout()
def get_next_hop(self, destination):
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
@ -1640,7 +1899,17 @@ class Reticulum:
return response
else:
return len(RNS.Transport.link_table)
return RNS.Transport.link_count()
def get_active_link_count(self):
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "active_link_count"}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
else:
return RNS.Transport.active_link_count()
def get_packet_rssi(self, packet_hash):
if self.is_connected_to_shared_instance:
@ -1684,21 +1953,50 @@ class Reticulum:
return None
def halt_interface(self, interface):
pass
def get_profiling_results(self):
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "profiling_results"}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
def resume_interface(self, interface):
pass
else:
if RNS.Profiler.ran(): return RNS.Profiler.results()
else: return None
def reload_interface(self, interface):
pass
def attach_interface(self, interface_name):
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"manage": "attach_interface", "name": interface_name}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
else: return self._attach_interface(interface_name)
def detach_interface(self, interface_name):
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"manage": "detach_interface", "name": interface_name}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
else: return self._detach_interface(interface_name)
def reload_interface(self, interface_name):
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"manage": "reload_interface", "name": interface_name}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
else: return self._reload_interface(interface_name)
def get_blackholed_identities(self):
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "blackholed_identities"}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "blackholed_identities"}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
else: return RNS.Transport.blackholed_identities
@ -1709,10 +2007,10 @@ class Reticulum:
if len(identity_hash) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: raise ValueError("Invalid identity hash length for blackhole check")
if self.is_connected_to_shared_instance:
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "is_blackholed", "identity_hash": identity_hash}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
rpc_connection = self.get_rpc_client()
rpc_connection.send_bytes(mp.packb({"get": "is_blackholed", "identity_hash": identity_hash}))
response = mp.unpackb(rpc_connection.recv_bytes())
return response
else: return identity_hash in RNS.Transport.blackholed_identities
@ -1849,6 +2147,10 @@ class Reticulum:
def should_autoconnect_discovered_interfaces():
return Reticulum.__autoconnect_discovered_interfaces > 0
@staticmethod
def should_autoconnect_unverified_implementations():
return Reticulum.__autoconnect_unverified
@staticmethod
def autoconnect_interface_mode():
return Reticulum.__autoconnect_interface_mode
@ -1873,6 +2175,22 @@ class Reticulum:
def local_hops_delta():
return Reticulum.__local_hops_delta
@staticmethod
def default_data_queue_length():
return Reticulum.__inbound_data_queue_length
@staticmethod
def default_announce_queue_length():
return Reticulum.__inbound_announce_queue_length
@staticmethod
def default_pr_queue_length():
return Reticulum.__inbound_pr_queue_length
@staticmethod
def default_il_queue_length():
return Reticulum.__inbound_il_queue_length
# Default configuration file:
__default_rns_config__ = '''# This is the default Reticulum config file.
# You should probably edit it to include any additional,

File diff suppressed because it is too large Load diff

View file

@ -33,5 +33,7 @@ import glob
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))

View file

@ -401,7 +401,7 @@ def fetch(configdir, identitypath = None, verbosity = 0, quietness = 0, destinat
i = 0
syms = "⢄⢂⢁⡁⡈⡐⡠"
estab_timeout = time.time()+timeout
estab_timeout = time.time()+max(timeout, reticulum.get_medium_path_timeout())
while not RNS.Transport.has_path(destination_hash) and time.time() < estab_timeout:
if not silent:
time.sleep(0.1)
@ -431,6 +431,7 @@ def fetch(configdir, identitypath = None, verbosity = 0, quietness = 0, destinat
)
link = RNS.Link(listener_destination)
estab_timeout = max(estab_timeout, time.time()+link.establishment_timeout)
while link.status != RNS.Link.ACTIVE and time.time() < estab_timeout:
if not silent:
time.sleep(0.1)
@ -656,7 +657,7 @@ def send(configdir, identitypath = None, verbosity = 0, quietness = 0, destinati
i = 0
syms = "⢄⢂⢁⡁⡈⡐⡠"
estab_timeout = time.time()+timeout
estab_timeout = time.time()+max(timeout, reticulum.get_medium_path_timeout())
while not RNS.Transport.has_path(destination_hash) and time.time() < estab_timeout:
if not silent:
time.sleep(0.1)
@ -686,6 +687,7 @@ def send(configdir, identitypath = None, verbosity = 0, quietness = 0, destinati
)
link = RNS.Link(receiver_destination)
estab_timeout = max(estab_timeout, time.time()+link.establishment_timeout)
while link.status != RNS.Link.ACTIVE and time.time() < estab_timeout:
if not silent:
time.sleep(0.1)
@ -878,8 +880,8 @@ def main():
except KeyboardInterrupt:
print("")
if resource != None:
resource.cancel()
if current_resource != None:
current_resource.cancel()
if link != None:
link.teardown()
RNS.exit()

View file

@ -35,5 +35,7 @@ import glob
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))

View file

@ -40,6 +40,7 @@ import subprocess
from RNS._version import __version__
from RNS.Utilities.rngit import APP_NAME
from RNS.Utilities.rngit.util import medium_path_timeout
from RNS.vendor.configobj import ConfigObj
from tempfile import TemporaryDirectory
@ -223,7 +224,7 @@ class ReticulumGitClient():
RNS.log(f"Requesting path to {RNS.prettyhexrep(destination_hash)}", RNS.LOG_DEBUG)
sys.stderr.write(f"Requesting path..."); sys.stderr.flush()
if not RNS.Transport.await_path(destination_hash, timeout=self.path_timeout):
if not RNS.Transport.await_path(destination_hash, timeout=medium_path_timeout(self.path_timeout)):
sys.stderr.write(f"\n"); sys.stderr.flush()
self.abort(f"Could not resolve path to {RNS.prettyhexrep(destination_hash)}")
@ -291,6 +292,8 @@ class ReticulumGitClient():
if hasattr(self.request_response, "read") and callable(self.request_response.read):
response_path = self.request_response.name
try: self.request_response.close()
except Exception as e: RNS.log(f"Could not close resource file descriptor before retain: {e}", RNS.LOG_ERROR)
base_name = os.path.basename(response_path)
retained_path = os.path.join(self.tmp_dir.name, base_name)
shutil.move(response_path, retained_path)
@ -333,10 +336,10 @@ class ReticulumGitClient():
try: self.connect_server()
except Exception as e: self.abort(str(e))
timeout = self.link_timeout
timeout = max(self.link_timeout, self.link.establishment_timeout)
while not self.link_ready and not self.link_failed and timeout > 0:
time.sleep(0.5)
timeout -= 1
timeout -= 0.5
if not self.link_ready: self.abort("Failed to establish link")

View file

@ -0,0 +1,239 @@
# Reticulum License
#
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import time
import shutil
import subprocess
import tempfile
import RNS
CONVERSION_TIMEOUT = 8
# WebP encoding backends, in preference order. All supported
# encoder families handle the common still formats (PNG, JPEG,
# TIFF, BMP, GIF, WebP).
#
# Environment overrides:
# RNGIT_MEDIA_BACKEND force a specific backend
BACKENDS = ( ("magick", ["magick", "-", "webp:-"]),
("convert", ["convert", "-", "webp:-"]),
("gm", ["gm", "convert", "-", "webp:-"]),
("ffmpeg", ["ffmpeg", "-y", "-loglevel", "error", "-i", "-", "-f", "webp", "pipe:1"]),
("avconv", ["avconv", "-y", "-loglevel", "error", "-i", "-", "-f", "webp", "pipe:1"]) )
_ENV_BACKEND = os.environ.get("RNGIT_MEDIA_BACKEND")
_winner = None
_no_backend_logged = False
def available_backends():
out = []
for name, argv in BACKENDS: out.append((name, shutil.which(argv[0]) is not None))
return out
def _selected_backend():
global _winner
if _ENV_BACKEND:
for name, argv in BACKENDS:
if name == _ENV_BACKEND and shutil.which(argv[0]) is not None:
return (name, argv)
return None
order = list(BACKENDS)
if _winner is not None: order.sort(key=lambda backend: backend[0] != _winner)
for name, argv in order:
if shutil.which(argv[0]) is not None:
_winner = name
return (name, argv)
return None
def _terminate(proc):
try: proc.kill()
except Exception: pass
try: proc.wait()
except Exception: pass
def _stderr_tail(proc, limit=1024):
try:
data = proc.stderr.read(limit)
return data.decode("utf-8", "replace").strip()
except Exception: return ""
def _webp_info(data):
if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP": return None
fourcc = data[12:16]
if fourcc == b"VP8X":
width = int.from_bytes(data[24:27], "little") + 1
height = int.from_bytes(data[27:30], "little") + 1
elif fourcc == b"VP8 ":
width = int.from_bytes(data[26:28], "little") & 0x3FFF
height = int.from_bytes(data[28:30], "little") & 0x3FFF
elif fourcc == b"VP8L":
bits = int.from_bytes(data[21:25], "little")
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
else: return None
if width > 0 and height > 0: return (width, height)
return None
def _valid_webp(path):
try:
with open(path, "rb") as fh: return _webp_info(fh.read(30)) is not None
except Exception: return False
def _unlink_output(path):
try: os.unlink(path)
except Exception: pass
def _configured_backend(quality=None, max_dimension=None):
global _no_backend_logged
backend = _selected_backend()
if backend is None:
if not _no_backend_logged:
_no_backend_logged = True
RNS.log("No WebP encoding backend available for media conversion. You can Install ImageMagick or ffmpeg to enable image conversion.", RNS.LOG_WARNING)
return None
backend_name, encoder_argv = backend
_no_backend_logged = False
quality_arg = None
dimension_arg = None
if quality is not None:
try: quality_arg = max(1, min(100, int(quality)))
except (ValueError, TypeError): pass
if max_dimension is not None:
try: dimension_arg = int(max_dimension)
except (ValueError, TypeError): pass
if dimension_arg is not None and dimension_arg < 1: dimension_arg = None
encoder_argv = list(encoder_argv)
if backend_name in ("magick", "convert", "gm"):
options = []
if quality_arg is not None: options += ["-quality", str(quality_arg)]
if dimension_arg is not None: options += ["-resize", f"{dimension_arg}x{dimension_arg}>"]
encoder_argv = encoder_argv[:-1] + options + encoder_argv[-1:]
elif backend_name in ("ffmpeg", "avconv"):
options = []
if quality_arg is not None: options += ["-quality", str(quality_arg)]
if dimension_arg is not None: options += ["-vf", f"scale='min(iw,{dimension_arg})':'min(ih,{dimension_arg})':force_original_aspect_ratio=decrease"]
try: format_index = encoder_argv.index("-f")
except ValueError: format_index = len(encoder_argv)
encoder_argv = encoder_argv[:format_index] + options + encoder_argv[format_index:]
return backend_name, encoder_argv
def _await(backend_name, encoder_proc, input_proc=None, timeout=None):
if timeout is None: timeout = CONVERSION_TIMEOUT
deadline = time.time() + timeout
try:
encoder_proc.wait(timeout=max(0.0, deadline - time.time()))
if input_proc is not None: input_proc.wait(timeout=max(0.0, deadline - time.time()))
except subprocess.TimeoutExpired:
_terminate(input_proc)
_terminate(encoder_proc)
RNS.log(f"Media conversion via {backend_name} timed out after {timeout} seconds", RNS.LOG_WARNING)
return False
if encoder_proc.returncode != 0:
detail = ""
encoder_tail = _stderr_tail(encoder_proc)
input_tail = _stderr_tail(input_proc) if input_proc is not None else ""
if encoder_tail: detail += f" Encoder: {encoder_tail}"
if input_tail: detail += f" Input: {input_tail}"
RNS.log(f"Media conversion via {backend_name} failed.{detail}", RNS.LOG_WARNING)
return False
return True
def convert_to_webp(input_argv, output_path, cwd=None, timeout=None, quality=None, max_dimension=None):
configured = _configured_backend(quality=quality, max_dimension=max_dimension)
if configured is None: return False
backend_name, encoder_argv = configured
try:
with open(output_path, "wb") as output_fh:
input_proc = subprocess.Popen(input_argv, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
encoder_proc = subprocess.Popen(encoder_argv, stdin=input_proc.stdout, stdout=output_fh, stderr=subprocess.PIPE)
# The parent must not retain a copy of the input pipe's write
# end, or the input process would never see EOF.
input_proc.stdout.close()
if not _await(backend_name, encoder_proc, input_proc=input_proc, timeout=timeout): return False
if not _valid_webp(output_path):
RNS.log(f"Media conversion via {backend_name} produced invalid WebP output", RNS.LOG_WARNING)
return False
RNS.log(f"Media converted to WebP with {backend_name}", RNS.LOG_DEBUG)
return True
except Exception as e:
RNS.log(f"Error during media conversion: {e}", RNS.LOG_WARNING)
return False
def convert_file_to_webp(source_path, quality=85, max_dimension=None, timeout=None):
configured = _configured_backend(quality=quality, max_dimension=max_dimension)
if configured is None: return False
backend_name, encoder_argv = configured
tmp_path = None
success = False
try:
if not os.path.isfile(source_path):
RNS.log(f"Cannot convert media: source file does not exist: {source_path}", RNS.LOG_WARNING)
return False
fd, tmp_path = tempfile.mkstemp(prefix="rns_media_", suffix=".webp")
os.close(fd)
with open(source_path, "rb") as input_fh, open(tmp_path, "wb") as output_fh:
encoder_proc = subprocess.Popen(encoder_argv, stdin=input_fh, stdout=output_fh, stderr=subprocess.PIPE)
if not _await(backend_name, encoder_proc, timeout=timeout): return False
if not _valid_webp(tmp_path):
RNS.log(f"Invalid media conversion output from {backend_name}", RNS.LOG_WARNING)
return False
success = True
RNS.log(f"Media converted to WebP with {backend_name}", RNS.LOG_DEBUG)
return tmp_path
except Exception as e:
RNS.log(f"Error during media conversion: {e}", RNS.LOG_WARNING)
return False
finally:
if not success and tmp_path is not None: _unlink_output(tmp_path)

View file

@ -32,13 +32,14 @@ import os
import time
import threading
import subprocess
import tempfile
import urllib.parse
import RNS
import struct
import base64
from collections import deque
from datetime import datetime
from RNS.Utilities.rngit import APP_NAME
from RNS.Utilities.rngit import APP_NAME, media
from RNS.Utilities.rngit.util import MarkdownToMicron, san_sha
from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from RNS.vendor.configobj import ConfigObj
@ -50,6 +51,7 @@ from RNS.Utilities.rngit.commitsigs import unarmor_ssh_signature, parse_ssh_sign
class NomadNetworkNode():
APP_NAME = "nomadnetwork"
JOBS_INTERVAL = 5
LINK_CLEAN_INTERVAL = 60
PATH_INDEX = "/page/index.mu"
PATH_GROUP = "/page/group.mu"
@ -64,6 +66,7 @@ class NomadNetworkNode():
PATH_RELEASE = "/page/release.mu"
PATH_WORK = "/page/work.mu"
PATH_WORK_DOC = "/page/work_doc.mu"
PATH_MEDIA = "/media"
FILE_ARTIFACT = "/file/artifact"
FILE_DOWNLOAD = "/file/download"
FILE_WORKDOC = "/file/workdoc"
@ -120,8 +123,10 @@ class NomadNetworkNode():
# want to use tabs, three spaces is all you get.
TAB_WIDTH = " "
RENDERABLE_EXTS = [".md", ".mu"]
RENDER_DEFAULT = [".md", ".mu"]
CONVERTABLE_EXTS = [".md"]
RENDERABLE_EXTS = [".md", ".mu"]
RENDER_DEFAULT = [".md", ".mu"]
IMAGE_EXTS = [".webp", ".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".bmp"]
def __init__(self, owner=None):
if not owner: raise TypeError(f"Invalid owner {owner} for {self}")
@ -150,12 +155,16 @@ class NomadNetworkNode():
self.templates["stats"] = DEFAULT_STATS_TEMPLATE
self.templates["work"] = DEFAULT_WORK_TEMPLATE
self.templates["work_doc"] = DEFAULT_WORK_DOC_TEMPLATE
self.templates["no_ident"] = DEFAULT_NO_IDENT_TEMPLATE
self.templatesdir = self.owner.configdir+"/templates"
self.use_nerdfonts = self.USE_NERDFONTS
self.media_conversion = True
self.highlight_syntax = True
self.highlighter = SyntaxHighlighter()
self.mdc = MarkdownToMicron(max_width=self.MAX_RENDER_WIDTH, syntax_highlighter=self.highlighter)
self.thanks_deque = deque(maxlen=256)
self.active_links = {}
self.last_link_clean = 0
if not os.path.isdir(self.templatesdir):
try: os.makedirs(self.templatesdir)
@ -164,6 +173,8 @@ class NomadNetworkNode():
if "pages" in self.owner.config:
if "unicode_icons" in self.owner.config["pages"]:
if self.owner.config["pages"].as_bool("unicode_icons"): self.use_nerdfonts = False
if "media_conversion" in self.owner.config["pages"]:
self.media_conversion = self.owner.config["pages"].as_bool("media_conversion")
self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, self.APP_NAME, "node")
self.destination.set_link_established_callback(self.remote_connected)
@ -210,6 +221,10 @@ class NomadNetworkNode():
try:
if self.announce_interval and time.time() > self.last_announce + self.announce_interval: self.announce()
if time.time() > self.last_link_clean + self.LINK_CLEAN_INTERVAL:
self.clean_links()
self.last_link_clean = time.time()
except Exception as e: RNS.log(f"Error while running periodic jobs: {e}", RNS.LOG_ERROR)
def get_announce_app_data(self): return self.node_name.encode("utf-8")
@ -246,6 +261,7 @@ 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_MEDIA, response_generator=self.serve_media, allow=RNS.Destination.ALLOW_ALL, auto_compress=False)
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)
@ -372,6 +388,10 @@ class NomadNetworkNode():
self.owner.view_succeeded(None, None, remote_identity)
page_content = "".join(content_parts)
nav_content = "".join(nav_parts)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", nav_content=nav_content, st=st)
return self.render_template(page_content, nav_content=nav_content, template="front", st=st)
def serve_group_page(self, path, data, request_id, link_id, remote_identity, requested_at):
@ -392,6 +412,9 @@ class NomadNetworkNode():
nav_parts.append(breadcrumb + "\n")
nav_content = "".join(nav_parts)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", nav_content=nav_content, st=st)
accessible_repos = self.get_accessible_repositories(remote_identity, group_name)
if not group_name or group_name not in self.owner.groups or not accessible_repos:
@ -441,6 +464,9 @@ class NomadNetworkNode():
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", nav_content="".join(nav_parts), st=st)
if not repo:
content = self.m_heading("Not Found", 1) + "\nThe requested repository was not found.\n"
return self.render_template(content, nav_content="".join(nav_parts), st=st)
@ -554,6 +580,9 @@ class NomadNetworkNode():
except (ValueError, TypeError): page_num = 0
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Not Found", 1) + "\n\nThe requested repository does not exist or you do not have access to it.\n"
@ -691,6 +720,9 @@ class NomadNetworkNode():
render = True if render else False
raw = True if raw else False
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Not Found", 1) + "\n\nThe requested repository does not exist or you do not have access to it.\n"
@ -709,9 +741,10 @@ class NomadNetworkNode():
content = self.m_heading("Invalid Path", 1) + "\n\nNo file path specified.\n"
return self.render_template(content, st=st)
file_path = file_path.lstrip("./").replace("/./", "/")
file_path = file_path.removeprefix("./").replace("/./", "/")
file_ext = os.path.splitext(file_path)[1].lower()
renderable = file_ext in self.RENDERABLE_EXTS
convertable = file_ext in self.CONVERTABLE_EXTS
if not renderable: raw = True; render = False
else:
if raw: render = False
@ -738,14 +771,16 @@ class NomadNetworkNode():
nav_parts.append(">>\n" + breadcrumb + "\n")
sep = self.icon("sep")
dl_link = self.m_link("Download", self.FILE_DOWNLOAD, g=group_name, r=repo_name, ref=ref, path=file_path)
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:
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")
mu_link = self.m_link("as micron", self.FILE_DOWNLOAD, g=group_name, r=repo_name, ref=ref, path=file_path, fmt="mu")
if render: render_controls = f"Displaying Rendered {sep} {raw_link}"
else: render_controls = f"Displaying Raw {sep} {rnd_link}"
nav_parts.append(f"\n{render_controls} {sep} {dl_link}\n")
if convertable: nav_parts.append(f"\n{render_controls} {sep} {dl_link} {self.CLR_DIM}{mu_link}`f\n")
else: nav_parts.append(f"\n{render_controls} {sep} {dl_link}\n")
# Get blob info
blob_info = self.get_blob_info(repo_path, resolved_ref, file_path)
@ -771,8 +806,9 @@ class NomadNetworkNode():
content_parts.append(f"`*{self.m_escape(symlink_target or 'unknown')}`*\n")
elif is_binary:
content_parts.append("This file appears to be binary and cannot be displayed as text.\n")
# TODO: Implement raw file downloads
if file_ext in self.IMAGE_EXTS:
content_parts.append(f"`(Image file`w=n`a=c`:/media/{group_name}/{repo_name}/{ref}/{urllib.parse.quote_plus(file_path)})\n")
else: content_parts.append("This file appears to be binary and cannot be displayed as text.\n")
elif size > self.BLOB_SIZE_LIMIT:
content_parts.append(f"This file is {RNS.prettysize(size)}, which exceeds the display limit of {RNS.prettysize(self.BLOB_SIZE_LIMIT)}.\n")
@ -823,6 +859,9 @@ class NomadNetworkNode():
except (ValueError, TypeError): page_num = 0
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Not Found", 1) + "\n\nThe requested repository does not exist or you do not have access to it.\n"
@ -902,6 +941,9 @@ class NomadNetworkNode():
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n"
@ -1057,6 +1099,9 @@ class NomadNetworkNode():
nav_parts.append(">>\n" + breadcrumb + "\n")
nav_content = "".join(nav_parts)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
@ -1155,6 +1200,9 @@ class NomadNetworkNode():
if not group_name or not repo_name:
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
content_parts = []
nav_parts = []
@ -1256,6 +1304,9 @@ class NomadNetworkNode():
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n"
@ -1320,11 +1371,22 @@ class NomadNetworkNode():
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n"
return self.render_template(content, st=st)
content_parts = []
nav_parts = []
# Breadcrumb navigation
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('releases', self.PATH_RELEASES, g=group_name, r=repo_name)} / {tag}"
nav_parts.append(breadcrumb + "\n")
nav_content = "".join(nav_parts)
releases_path = f"{repo['path']}.releases"
if tag == "latest":
releases, latest_release = self.owner.releases_list_data(releases_path)
@ -1338,14 +1400,6 @@ class NomadNetworkNode():
else: tag = latest_release
content_parts = []
nav_parts = []
# Breadcrumb navigation
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('releases', self.PATH_RELEASES, g=group_name, r=repo_name)} / {tag}"
nav_parts.append(breadcrumb + "\n")
nav_content = "".join(nav_parts)
release_dir = os.path.join(releases_path, tag)
if not os.path.isdir(release_dir):
@ -1422,11 +1476,17 @@ class NomadNetworkNode():
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n"
return self.render_template(content, st=st)
work_path = f"{repo['path']}.work"
scopes_to_show = ["active", "completed", "proposed"] if scope == "all" else [scope]
content_parts = []
nav_parts = []
@ -1441,17 +1501,46 @@ class NomadNetworkNode():
cmplt_s = "`_" if scope == "completed" else ""
prpsd_s = "`_" if scope == "proposed" else ""
all_s = "`_" if scope == "all" else ""
# Count work documents
scope_counts = {"active": 0, "completed": 0, "proposed": 0}
for s in ["active", "completed", "proposed"]:
try:
folder_path = os.path.join(work_path, s)
if os.path.isdir(folder_path):
for entry in os.listdir(folder_path):
try:
doc_dir = os.path.join(folder_path, entry)
if not os.path.isdir(doc_dir): continue
doc_id = int(entry)
read_access = self.resolve_doc_permission(remote_identity, group_name, repo_name, doc_id, self.owner.PERM_READ)
if not read_access: continue
root_path = os.path.join(doc_dir, "root")
if not os.path.isfile(root_path): continue
scope_counts[s] += 1
except Exception as e: RNS.trace_exception(e)
except Exception as e: RNS.trace_exception(e)
force_counts = True
adc = scope_counts['active']
cdc = scope_counts['completed']
pdc = scope_counts['proposed']
tdc = adc+cdc+pdc
adc_str = f" ({adc})" if force_counts or (not scope == "active" and adc) else ""
cdc_str = f" ({cdc})" if force_counts or (not scope == "completed" and cdc) else ""
pdc_str = f" ({pdc})" if force_counts or (not scope == "proposed" and pdc) else ""
tdc_str = f" ({tdc})" if force_counts or (not scope == "all" and tdc) else ""
filter_links = []
filter_links.append(active_s+self.m_link("Active", self.PATH_WORK, g=group_name, r=repo_name, scope="active")+active_s)
filter_links.append(cmplt_s+self.m_link("Completed", self.PATH_WORK, g=group_name, r=repo_name, scope="completed")+cmplt_s)
filter_links.append(prpsd_s+self.m_link("Proposed", self.PATH_WORK, g=group_name, r=repo_name, scope="proposed")+prpsd_s)
filter_links.append(all_s+self.m_link("All", self.PATH_WORK, g=group_name, r=repo_name, scope="all")+all_s)
filter_links.append(active_s+self.m_link("Active", self.PATH_WORK, g=group_name, r=repo_name, scope="active")+active_s+adc_str)
filter_links.append(cmplt_s+self.m_link("Completed", self.PATH_WORK, g=group_name, r=repo_name, scope="completed")+cmplt_s+cdc_str)
filter_links.append(prpsd_s+self.m_link("Proposed", self.PATH_WORK, g=group_name, r=repo_name, scope="proposed")+prpsd_s+pdc_str)
filter_links.append(all_s+self.m_link("All", self.PATH_WORK, g=group_name, r=repo_name, scope="all")+all_s+tdc_str)
content_parts.append(f" {sep} ".join(filter_links) + "\n\n")
# Load work documents
work_path = f"{repo['path']}.work"
scopes_to_show = ["active", "completed", "proposed"] if scope == "all" else [scope]
for s in scopes_to_show:
folder_path = os.path.join(work_path, s)
@ -1483,11 +1572,11 @@ class NomadNetworkNode():
docs.sort(key=lambda x: max(x["created"], x["edited"]), reverse=True)
if not docs:
content_parts.append(self.m_heading(f"{s.capitalize()} ({len(docs)})", 2)+f"\n`*No {s} work documents`*\n")
content_parts.append(self.m_heading(f"{s.capitalize()}", 2)+f"\n`*No {s} work documents`*\n")
content_parts.append("\n")
else:
content_parts.append(self.m_heading(f"{s.capitalize()} ({len(docs)})", 2))
content_parts.append(self.m_heading(f"{s.capitalize()}", 2))
content_parts.append("\n")
for doc in docs:
@ -1522,6 +1611,9 @@ class NomadNetworkNode():
content = self.m_heading("Error", 2) + "\nInvalid request\n"
return self.render_template(content, st=st)
if not remote_identity and self.null_ident.hash in self.owner.blocked_identities:
return self.render_template("", template="no_ident", st=st)
try: doc_id = int(doc_id)
except:
content = self.m_heading("Error", 2) + "\nInvalid document ID\n"
@ -1671,7 +1763,7 @@ class NomadNetworkNode():
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
RNS.log(f"Repository not found or no access for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_WARNING)
RNS.log(f"Repository not found or no access for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_DEBUG)
return None
releases_path = f"{repo['path']}.releases"
@ -1715,6 +1807,69 @@ class NomadNetworkNode():
self.owner.release_download_succeeded(group_name, repo_name, remote_identity)
return [open(artifact_path, "rb"), {"name": artifact.encode("utf-8")}]
def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at):
st = time.time()
RNS.log(f"Media request from {remote_identity}", RNS.LOG_DEBUG)
if not data or not type(data) == dict: data = {}
if not "key" in data:
RNS.log(f"Missing request key in media request", RNS.LOG_DEBUG)
return False
media_path = data.get("path", None)
if not media_path:
RNS.log(f"Missing path in media request", RNS.LOG_DEBUG)
return False
comps = media_path.removeprefix("/media").lstrip("/").split("/")
if len(comps) < 4:
RNS.log(f"Insufficient path components in media request", RNS.LOG_DEBUG)
return False
group_name = comps[0]
repo_name = comps[1]
ref = comps[2]
file_path = "/".join(comps[3:])
file_path = urllib.parse.unquote_plus(file_path)
file_name = os.path.basename(file_path)
repo = self.get_accessible_repository(remote_identity, group_name, repo_name)
if not repo:
RNS.log(f"Repository not found or no access for media request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_DEBUG)
return False
repo_path = repo["path"]
resolved_ref = self.resolve_ref(repo_path, ref)
if not resolved_ref:
RNS.log(f"Ref not found for media request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING)
return False
if not file_path:
RNS.log(f"No file path for media request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING)
return False
blob_info = self.get_blob_info(repo_path, resolved_ref, file_path)
if blob_info is None:
RNS.log(f"File not found at ref for media request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING)
return False
else:
stream = None
response_name = file_name
file_ext = os.path.splitext(file_path)[1].lower()
if self.media_conversion and file_ext in self.IMAGE_EXTS and file_ext != ".webp":
converted = self.get_webp_stream(repo_path, resolved_ref, file_path, link_id)
if converted: stream, response_name = converted
if not stream: stream = self.get_blob_stream(repo_path, resolved_ref, file_path)
if stream: return [stream, {"name": response_name.encode("utf-8")}]
else:
RNS.log(f"Could not resolve blob stream for media request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING)
return None
return None
def serve_download(self, path, data, request_id, link_id, remote_identity, requested_at):
st = time.time()
RNS.log(f"File download request from {remote_identity}", RNS.LOG_DEBUG)
@ -1723,13 +1878,19 @@ class NomadNetworkNode():
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"
file_fmt = data.get("var_fmt", "") if data else ""
file_path = data.get("var_path", "") if data else ""
file_path = urllib.parse.unquote_plus(file_path)
file_name = os.path.basename(file_path)
file_ext = os.path.splitext(file_path)[1].lower()
convertable = file_ext in self.CONVERTABLE_EXTS
if file_fmt and not convertable:
RNS.log(f"Download conversion request for non-convertable file", RNS.LOG_DEBUG)
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 download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING)
RNS.log(f"Repository not found or no access for download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_DEBUG)
return None
repo_path = repo["path"]
@ -1752,7 +1913,47 @@ class NomadNetworkNode():
stream = self.get_blob_stream(repo_path, resolved_ref, file_path)
if stream is not None:
self.owner.download_succeeded(group_name, repo_name, remote_identity)
return [stream, {"name": file_name.encode("utf-8")}]
if not file_fmt: return [stream, {"name": file_name.encode("utf-8")}]
elif file_fmt == "mu":
link = self.active_links.get(link_id)
if not link:
RNS.log(f"Could not resolve link for file conversion of {file_path}", RNS.LOG_WARNING)
return None
if not hasattr(link, "temporary_directories"): link.temporary_directories = []
tmpdir = tempfile.TemporaryDirectory()
link.temporary_directories.append(tmpdir)
stem = os.path.splitext(os.path.basename(file_path))[0]
response_name = stem + ".mu"
try:
path_components = file_path.strip("/").split("/")
path = "/".join(path_components[:-1])+"/" if len(path_components) > 1 else ""
url_scope = f":/page/blob.mu`g={group_name}|r={repo_name}|ref={ref}|path={path}"
mdc = MarkdownToMicron(max_width=self.MAX_RENDER_WIDTH, syntax_highlighter=self.highlighter, url_scope=url_scope)
mu = mdc.format_block(stream.read().decode("utf-8")).rstrip().encode("utf-8")
if not mu:
if tmpdir in link.temporary_directories: link.temporary_directories.remove(tmpdir)
tmpdir.cleanup()
return None
fd, spool_path = tempfile.mkstemp(prefix=stem+".", suffix=".mu", dir=tmpdir.name)
os.close(fd)
with open(spool_path, "wb") as f: f.write(mu)
spool = open(spool_path, "rb")
return [spool, {"name": f"{os.path.splitext(file_name)[0]}.mu".encode("utf-8")}]
except Exception as e:
RNS.log(f"Error during file conversion of {file_path} for {link}: {e}", RNS.LOG_WARNING)
if tmpdir in link.temporary_directories: link.temporary_directories.remove(tmpdir)
try: tmpdir.cleanup()
except Exception: pass
return None
else: return None
else:
RNS.log(f"Could not resolve blob stream for download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING)
@ -1783,7 +1984,7 @@ class NomadNetworkNode():
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}", RNS.LOG_WARNING)
RNS.log(f"Repository not found or no access for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id}", RNS.LOG_DEBUG)
return None
doc_access = self.resolve_doc_permission(remote_identity, group_name, repo_name, doc_id, self.owner.PERM_READ)
@ -2084,6 +2285,40 @@ class NomadNetworkNode():
return None
def get_webp_stream(self, repo_path, ref, file_path, link_id):
file_path = file_path.strip("/")
link = self.active_links.get(link_id)
if not link:
RNS.log(f"Could not resolve link for media conversion of {file_path}", RNS.LOG_WARNING)
return None
if not hasattr(link, "temporary_directories"): link.temporary_directories = []
tmpdir = tempfile.TemporaryDirectory()
link.temporary_directories.append(tmpdir)
stem = os.path.splitext(os.path.basename(file_path))[0]
response_name = stem + ".webp"
try:
fd, spool_path = tempfile.mkstemp(prefix=stem+".", suffix=".webp", dir=tmpdir.name)
os.close(fd)
converted = media.convert_to_webp(["git", "show", f"{ref}:{file_path}"], spool_path, cwd=repo_path)
if not converted:
if tmpdir in link.temporary_directories: link.temporary_directories.remove(tmpdir)
tmpdir.cleanup()
return None
spool = open(spool_path, "rb")
return spool, response_name
except Exception as e:
RNS.log(f"Error during media conversion of {file_path} for {link}: {e}", RNS.LOG_WARNING)
if tmpdir in link.temporary_directories: link.temporary_directories.remove(tmpdir)
try: tmpdir.cleanup()
except Exception: pass
return None
def get_refs_info(self, repo_path, default_branch=None):
refs = {"heads": [], "tags": []}
@ -2695,11 +2930,38 @@ class NomadNetworkNode():
def remote_connected(self, link):
RNS.log(f"Peer connected to {self.destination}", RNS.LOG_DEBUG)
self.active_links[link.link_id] = link
link.set_remote_identified_callback(self.remote_identified)
link.set_link_closed_callback(self.remote_disconnected)
def remote_disconnected(self, link):
RNS.log(f"Peer disconnected from {self.destination}", RNS.LOG_DEBUG)
if link.link_id in self.active_links: self.active_links.pop(link.link_id)
self.cleanup_link_temporary_resources(link)
def clean_links(self):
stale_links = []
for link_id, link in self.active_links.items():
if not link.status == RNS.Link.ACTIVE: stale_links.append(link_id)
cleaned_links = 0
for link_id in stale_links:
link = self.active_links.pop(link_id, None)
if link:
self.cleanup_link_temporary_resources(link)
cleaned_links += 1
if cleaned_links > 0: RNS.log(f"Cleaned {cleaned_links} stale link{'s' if cleaned_links != 1 else ''}", RNS.LOG_DEBUG)
def cleanup_link_temporary_resources(self, link):
if hasattr(link, "temporary_directories"):
for tmpdir in link.temporary_directories:
try:
tmpdir.cleanup()
RNS.log(f"Cleaned up {tmpdir.name}", RNS.LOG_DEBUG)
except Exception as e: RNS.log(f"Error while cleaning temporary directory: {e}", RNS.LOG_ERROR)
link.temporary_directories = []
def remote_identified(self, link, identity):
RNS.log(f"Peer identified as {link.get_remote_identity()} on {link}", RNS.LOG_DEBUG)
@ -2795,5 +3057,8 @@ DEFAULT_WORK_TEMPLATE = """{PAGE_CONTENT}"""
DEFAULT_WORK_DOC_TEMPLATE = """{PAGE_CONTENT}"""
# Error state templates
DEFAULT_NO_IDENT_TEMPLATE = """>>No Identity\n\nThis page requires identification, and none was received.\n"""
# Fallback template
FALLBACK_TEMPLATE = """{PAGE_CONTENT}"""

View file

@ -46,7 +46,7 @@ from datetime import datetime, timezone
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.Utilities.rngit.util import san_ref, san_refs, san_sha, medium_path_timeout
from RNS.vendor.configobj import ConfigObj
from RNS.vendor import umsgpack as mp
from RNS.Utilities.rnid import create_rsg, validate_rsg, get_rsg_hash
@ -465,7 +465,7 @@ class ReticulumGitClient():
def connect_remote(self, remote):
destination_hash = self.parse_remote_destination_url(remote)
print(f"Requesting path... ", end="")
if not RNS.Transport.await_path(destination_hash, timeout=self.path_timeout):
if not RNS.Transport.await_path(destination_hash, timeout=medium_path_timeout(self.path_timeout)):
print(f"\n", end="")
self.abort(f"Could not resolve path to {RNS.prettyhexrep(destination_hash)}")
@ -479,6 +479,7 @@ class ReticulumGitClient():
self.link = RNS.Link(self.destination)
self.link.set_link_established_callback(self.link_established)
self.link.set_link_closed_callback(self.link_closed)
self.link_timeout = max(self.link_timeout, self.link.establishment_timeout)
def link_established(self, link):
print(f"\rLink established ", end="")
@ -1998,6 +1999,7 @@ class ReticulumGitNode():
self.active_links_lock = Lock()
self.stats_lock = Lock()
self.sync_lock = Lock()
self.perms_lock = Lock()
self.stats_ignored = {}
self.stats_push_ignored = {}
self.node_name = "Anonymous Git Node"
@ -2506,6 +2508,22 @@ class ReticulumGitNode():
return permissions
def load_allowed_permissions(self, allowed_path):
allowed_input = ""
dynamic_perms = False
if os.path.isfile(allowed_path):
if os.access(allowed_path, os.X_OK):
allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE)
allowed_input = allowed_result.stdout.decode("utf-8")
dynamic_perms = True
else:
fh = open(allowed_path, "rb")
allowed_input = fh.read().decode("utf-8")
fh.close()
return self.permissions_from_allowed_input(allowed_input), dynamic_perms
def load_repository_group(self, group_name, group_path):
if not group_name in self.groups: self.groups[group_name] = { "path": group_path, "name": group_name, "repositories": {}, "dynamic_perms": False,
"read": [], "write": [], "create": [], "stats": [], "release": [],
@ -2531,8 +2549,8 @@ class ReticulumGitNode():
RNS.log(f"Attempt to set group permissions for non-existing group {group_name}, aborting", RNS.LOG_WARNING)
return
# Clear permissions before update
for perm in self.ALL_PERMS: self.groups[group_name][perm] = []
new_permissions = {}
for perm in self.ALL_PERMS: new_permissions[perm] = []
# Apply permissions from allowed file if present
group_path = self.groups[group_name]["path"]
@ -2540,22 +2558,14 @@ class ReticulumGitNode():
if os.path.isfile(allowed_path):
RNS.log(f"Applying group permissions for {group_name} from {allowed_path}", RNS.LOG_DEBUG)
try:
allowed_input = ""
if os.access(allowed_path, os.X_OK):
allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE)
allowed_input = allowed_result.stdout.decode("utf-8")
self.groups[group_name]["dynamic_perms"] = True
else:
fh = open(allowed_path, "rb")
allowed_input = fh.read().decode("utf-8")
fh.close()
group_permissions = self.permissions_from_allowed_input(allowed_input)
for perm in group_permissions: self.groups[group_name][perm] = group_permissions[perm]
group_permissions, dynamic_perms = self.load_allowed_permissions(allowed_path)
for perm in self.ALL_PERMS: new_permissions[perm] = group_permissions[perm]
self.groups[group_name]["dynamic_perms"] = dynamic_perms
except Exception as e: RNS.log(f"Could not load group permissions from {allowed_path}: {e}", RNS.LOG_ERROR)
else: self.groups[group_name]["dynamic_perms"] = False
# Apply permissions from config file if present
if "access" in self.config:
section = self.config["access"]
@ -2579,14 +2589,33 @@ class ReticulumGitNode():
if perm == self.PERM_PROPOSE: propose = True
if perm == self.PERM_ADMIN: admin = True
if read and not target in self.groups[group_name]["read"]: self.groups[group_name]["read"].append(target)
if write and not target in self.groups[group_name]["write"]: self.groups[group_name]["write"].append(target)
if create and not target in self.groups[group_name]["create"]: self.groups[group_name]["create"].append(target)
if stats and not target in self.groups[group_name]["stats"]: self.groups[group_name]["stats"].append(target)
if release and not target in self.groups[group_name]["release"]: self.groups[group_name]["release"].append(target)
if interact and not target in self.groups[group_name]["interact"]: self.groups[group_name]["interact"].append(target)
if propose and not target in self.groups[group_name]["propose"]: self.groups[group_name]["propose"].append(target)
if admin and not target in self.groups[group_name]["admin"]: self.groups[group_name]["admin"].append(target)
if read and not target in new_permissions["read"]: new_permissions["read"].append(target)
if write and not target in new_permissions["write"]: new_permissions["write"].append(target)
if create and not target in new_permissions["create"]: new_permissions["create"].append(target)
if stats and not target in new_permissions["stats"]: new_permissions["stats"].append(target)
if release and not target in new_permissions["release"]: new_permissions["release"].append(target)
if interact and not target in new_permissions["interact"]: new_permissions["interact"].append(target)
if propose and not target in new_permissions["propose"]: new_permissions["propose"].append(target)
if admin and not target in new_permissions["admin"]: new_permissions["admin"].append(target)
for perm in self.ALL_PERMS: self.groups[group_name][perm] = new_permissions[perm]
def update_repository_permissions(self, group_name, repository_name):
if not group_name in self.groups:
RNS.log(f"Attempt to update permissions for non-existing group {group_name}, aborting", RNS.LOG_WARNING)
return
if not repository_name in self.groups[group_name]["repositories"]:
RNS.log(f"Attempt to update permissions for non-existing repository {group_name}/{repository_name}, aborting", RNS.LOG_WARNING)
return
repo = self.groups[group_name]["repositories"][repository_name]
allowed_path = repo["path"]+".allowed"
try:
repo_permissions, dynamic_perms = self.load_allowed_permissions(allowed_path)
for perm in self.ALL_PERMS: repo[perm] = repo_permissions[perm] if perm in repo_permissions else []
except Exception as e: RNS.log(f"Could not update repository permissions for {group_name}/{repository_name} from {allowed_path}: {e}", RNS.LOG_ERROR)
def load_repository(self, group, path):
if not group or not path: return False
@ -2600,27 +2629,15 @@ class ReticulumGitNode():
RNS.log(f"You can change it to a bare repository using \"git config --bool core.bare true\".", RNS.LOG_WARNING)
else:
repository_name = os.path.basename(path)
allowed_path = f"{path}.allowed"
allowed_input = ""
dynamic_perms = False
if os.path.isfile(allowed_path):
if os.access(allowed_path, os.X_OK):
allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE)
allowed_input = allowed_result.stdout.decode("utf-8")
dynamic_perms = True
else:
fh = open(allowed_path, "rb")
allowed_input = fh.read().decode("utf-8")
fh.close()
repository_name = os.path.basename(path)
allowed_path = f"{path}.allowed"
repo_permissions, dynamic_perms = self.load_allowed_permissions(allowed_path)
fork = self.__is_fork(path)
mirror = self.__is_mirror(path)
p = self.permissions_from_allowed_input(allowed_input)
group["repositories"][repository_name] = { "name": repository_name, "group": group_name, "path": path, "fork": fork, "mirror": mirror }
for perm in self.ALL_PERMS: group["repositories"][repository_name][perm] = p[perm] if perm in p else []
for perm in self.ALL_PERMS: group["repositories"][repository_name][perm] = repo_permissions[perm] if perm in repo_permissions else []
return True
@ -4230,6 +4247,7 @@ class ReticulumGitNode():
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_complete(self, work_path, data, remote_identity):
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
doc_id = data.get("doc_id")
if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified"
@ -4245,7 +4263,9 @@ class ReticulumGitNode():
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
is_author = doc.get("meta", {}).get("author") == remote_identity.hash
admin_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_ADMIN)
if not is_author and not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
try:
completed_dir = os.path.join(completed_base, str(doc_id))
@ -4259,26 +4279,35 @@ class ReticulumGitNode():
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error"
def _work_activate(self, work_path, data, remote_identity):
group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY])
doc_id = data.get("doc_id")
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"
completed_dir = os.path.join(work_path, "completed", str(doc_id))
active_base = os.path.join(work_path, "active")
if not os.path.isdir(completed_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
doc_dir = None
for scope in ["completed", "proposed"]:
d = os.path.join(work_path, scope, str(doc_id))
if os.path.isdir(d):
doc_dir = d
break
if not doc_dir: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found"
root_path = os.path.join(completed_dir, "root")
root_path = os.path.join(doc_dir, "root")
doc = self._work_load_document(root_path)
if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document"
if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author"
is_author = doc.get("meta", {}).get("author") == remote_identity.hash
admin_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_ADMIN)
if not is_author and not admin_access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed"
try:
active_dir = os.path.join(active_base, str(doc_id))
shutil.move(completed_dir, active_dir)
shutil.move(doc_dir, active_dir)
RNS.log(f"Activated work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00" + mp.packb({"id": doc_id, "scope": "active"})
@ -4504,10 +4533,17 @@ class ReticulumGitNode():
try:
group_path = self.groups[group_name]["path"]
allowed_path = group_path + ".allowed"
if os.access(allowed_path, os.X_OK): return self.RES_DISALLOWED.to_bytes(1, "big") + b"Executable permission resolvers can only be modified node-side"
tmp_path = allowed_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f: f.write(content)
os.rename(tmp_path, allowed_path)
with self.perms_lock:
try: self.update_group_permissions(group_name)
except Exception as e: RNS.log(f"Error while refreshing permissions for group {group_name}: {e}", RNS.LOG_ERROR)
RNS.log(f"Permissions for group {group_name} updated by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00"
@ -4579,10 +4615,17 @@ class ReticulumGitNode():
try:
repo_path = self.groups[group_name]["repositories"][repository_name]["path"]
allowed_path = repo_path + ".allowed"
if os.access(allowed_path, os.X_OK): return self.RES_DISALLOWED.to_bytes(1, "big") + b"Executable permission resolvers can only be modified node-side"
tmp_path = allowed_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f: f.write(content)
os.rename(tmp_path, allowed_path)
with self.perms_lock:
try: self.update_repository_permissions(group_name, repository_name)
except Exception as e: RNS.log(f"Error while refreshing permissions for repository {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
RNS.log(f"Permissions for repository {group_name}/{repository_name} updated by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_VERBOSE)
return b"\x00"
@ -4896,6 +4939,11 @@ announce_interval = 360
# blocked_identities = d31aeea49873006f13b3415520666a4e
# To make it easier to handle scrapers, crawlers, slopware
# and other annoyances, you can block unidentified peers by
# adding the null_ident hash to to blocked identities.
# blocked_identities = d7db22f63b453c23bb0688dde565b7c1
[repositories]
@ -4995,14 +5043,26 @@ internal = rw:9710b86ba12c42d1d8f30f74fe509286
# serve_nomadnet = no
# It is possible to disable Nerd Font icons and instead
# It is possible to disable Nerd Font icons and instead
# use simpler (but more compatible) unicode icons.
# unicode_icons = yes
# You can configure whether the page server should try
# to convert media files to WebP on the fly, for serving
# to nomadnet clients. Enabled by default, but will
# require an available encoding backend installed on
# your system. Supported backends utilities are "magick",
# "convert", "gm", "ffmpeg" and "avconv". If any one is
# installed, rngit will auto-detect and use it, but you
# can force a specific backend with the environment
# variable RNGIT_MEDIA_BACKEND.
# media_conversion = yes
[logging]
# Valid log levels are 0 through 7:
# Valid log levels are 0 through 8:
# 0: Log only critical information
# 1: Log errors and lower log levels
# 2: Log warnings and lower log levels
@ -5010,7 +5070,8 @@ internal = rw:9710b86ba12c42d1d8f30f74fe509286
# 4: Log info and lower (this is the default)
# 5: Verbose logging
# 6: Debug logging
# 7: Extreme logging
# 7: Pathing logging
# 8: Extreme logging
loglevel = 4

View file

@ -814,3 +814,6 @@ class MarkdownToMicron:
def convert_markdown_to_micron(text):
converter = MarkdownToMicron()
return converter.format_block(text)
def medium_path_timeout(default_timeout):
return max(default_timeout, RNS.Reticulum.get_instance().get_medium_path_timeout())

View file

@ -55,19 +55,12 @@ def main():
parser.add_argument("--config", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument('-v', '--verbose', action='count', default=0)
parser.add_argument('-q', '--quiet', action='count', default=0)
parser.add_argument("--exampleconfig", action='store_true', default=False, help="print verbose configuration example to stdout and exit")
parser.add_argument("--version", action="version", version="rnir {version}".format(version=__version__))
args = parser.parse_args()
if args.exampleconfig:
print(__example_rns_config__)
exit()
if args.config:
configarg = args.config
else:
configarg = None
if args.config: configarg = args.config
else: configarg = None
program_setup(configdir = configarg, verbosity=args.verbose, quietness=args.quiet)

View file

@ -3766,8 +3766,8 @@ def main():
print("\nDevice configuration:")
if ec_bt == 0x73: print(f" Bluetooth : Enabled")
else: print(f" Bluetooth : Disabled")
if ec_wifi == 0x01: print(f" WiFi : Enabled (Station)")
if ec_wifi == 0x02: print(f" WiFi : Enabled (AP)")
if ec_wifi == 0x01: print(f" WiFi : Enabled (Station)")
elif ec_wifi == 0x02: print(f" WiFi : Enabled (AP)")
else: print(f" WiFi : Disabled")
if ec_wifi == 0x01 or ec_wifi == 0x02:
if not ec_wchn: print(f" Channel : Unknown")

View file

@ -42,6 +42,7 @@ remote_link = None
output_rst_str = "\r \r"
def connect_remote(destination_hash, auth_identity, timeout, no_output = False, purpose="management"):
global remote_link, reticulum
timeout = max(timeout, reticulum.get_medium_path_timeout())
if not RNS.Transport.has_path(destination_hash):
if not no_output:
print("Path to "+RNS.prettyhexrep(destination_hash)+" requested", end=" ")
@ -451,7 +452,7 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
i = 0
syms = "⢄⢂⢁⡁⡈⡐⡠"
limit = time.time()+timeout
limit = time.time()+max(timeout, reticulum.get_medium_path_timeout())
while not RNS.Transport.has_path(destination_hash) and time.time()<limit:
time.sleep(0.1)
print(("\b\b"+syms[i]+" "), end="")

View file

@ -81,7 +81,7 @@ def program_setup(configdir, destination_hexhash, size=None, full_name = None, v
print("Path to "+RNS.prettyhexrep(destination_hash)+" requested ", end=" ")
sys.stdout.flush()
_timeout = time.time() + (timeout or DEFAULT_TIMEOUT+reticulum.get_first_hop_timeout(destination_hash))
_timeout = time.time() + (timeout or max(DEFAULT_TIMEOUT+reticulum.get_first_hop_timeout(destination_hash), reticulum.get_medium_path_timeout()))
i = 0
syms = "⢄⢂⢁⡁⡈⡐⡠"
while not RNS.Transport.has_path(destination_hash) and not time.time() > _timeout:
@ -131,7 +131,7 @@ def program_setup(configdir, destination_hexhash, size=None, full_name = None, v
print("\rSent probe "+str(sent)+" ("+str(size)+" bytes) to "+RNS.prettyhexrep(destination_hash)+more+" ", end=" ")
_timeout = time.time() + (timeout or DEFAULT_TIMEOUT+reticulum.get_first_hop_timeout(destination_hash))
_timeout = time.time() + (timeout or max(DEFAULT_TIMEOUT+reticulum.get_first_hop_timeout(destination_hash), reticulum.get_medium_path_timeout()))
i = 0
while receipt.status == RNS.PacketReceipt.SENT and not time.time() > _timeout:
time.sleep(0.1)

View file

@ -215,6 +215,14 @@ instance_name = default
# required_discovery_value = 14
# You can disable allowing interfaces to be attached, de-
# tached or reloaded via rnstatus, for example if running
# on a shared system where users with normal RPC access
# should not be allowed to control interface states.
# enable_interface_management = no
# You can configure Reticulum to panic and forcibly close
# if an unrecoverable interface error occurs, such as the
# hardware device for an interface disappearing. This is

View file

@ -41,7 +41,9 @@ module_dir = os.path.dirname(module_abs_filename)
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))
def _get_version(): return __version__

View file

@ -405,7 +405,7 @@ async def initiate(configdir: str, rnsconfigdir:str, identitypath: str, logfile:
chunk_segment = None
chunk_segment = None
max_data_len = channel.mdu - protocol.StreamDataMessage.OVERHEAD
max_data_len = channel.mdu - protocol.StreamDataMessage.HEADER_LEN
while chunk_len > 32 and comp_try < comp_tries:
chunk_segment_length = int(chunk_len/comp_try)
compressed_chunk = bz2.compress(buf[:chunk_segment_length])

View file

@ -32,6 +32,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import RNS
import asyncio
import threading
import time

View file

@ -235,7 +235,7 @@ class ListenerSession:
chunk_segment = None
chunk_segment = None
max_data_len = self.channel.mdu - protocol.StreamDataMessage.OVERHEAD
max_data_len = self.channel.mdu - protocol.StreamDataMessage.HEADER_LEN
while chunk_len > 32 and comp_try < comp_tries:
chunk_segment_length = int(chunk_len/comp_try)
compressed_chunk = bz2.compress(buf[:chunk_segment_length])

View file

@ -63,9 +63,10 @@ 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):
def get_remote_status(destination_hash, include_lstats, include_profiling, identity, no_output=False, timeout=RNS.Transport.PATH_REQUEST_TIMEOUT):
global request_result, request_concluded, first_remote_req, remote_destination, remote_link
link_count = None
profiling_results = None
if not RNS.Transport.has_path(destination_hash):
if not no_output:
@ -114,7 +115,10 @@ def get_remote_status(destination_hash, include_lstats, identity, no_output=Fals
if len(response) > 1: link_count = response[1]
else: link_count = None
request_result = (status, link_count)
if len(response) > 2: profiling_results = response[2]
else: profiling_results = None
request_result = (status, link_count, profiling_results)
request_concluded = True
@ -125,7 +129,7 @@ def get_remote_status(destination_hash, include_lstats, identity, no_output=Fals
print("Sending request...", end=" ")
sys.stdout.flush()
link.identify(identity)
link.request("/status", data = [include_lstats], response_callback = got_response, failed_callback = request_failed)
link.request("/status", data = [include_lstats, include_profiling], response_callback = got_response, failed_callback = request_failed)
first_remote_req = False
if not remote_link and not no_output:
@ -152,9 +156,11 @@ def get_remote_status(destination_hash, include_lstats, identity, no_output=Fals
return request_result
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):
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, must_exit=True, rns_instance=None,
traffic_totals=False, discovered_interfaces=False, config_entries=False, burst_filter=False, blocked_ips=False,
queue_stats=False, pps=False, profiling=False, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT,
attach=None, detach=None, reload=None, show_stale=False, show_unknown=False):
if remote: require_shared = False
else: require_shared = True
@ -171,8 +177,41 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if must_exit: exit(1)
else: return
if attach:
result = reticulum.attach_interface(attach)
if result == True: print(f"Interface {attach} was attached")
elif result == False: print(f"Could not attach interface {attach}")
elif result == None: print(f"The interface {attach} does not exist")
else: print(f"Unknown error while attaching interface {attach}")
if result == True: exit(0)
else: exit(1)
if detach:
result = reticulum.detach_interface(detach)
if result == True: print(f"Interface {detach} was detached")
elif result == False: print(f"Could not detach interface {detach}")
elif result == None: print(f"The interface {detach} does not exist")
else: print(f"Unknown error while detaching interface {detach}")
if result == True: exit(0)
else: exit(1)
if reload:
result = reticulum.reload_interface(reload)
if result == True: print(f"Interface {reload} was reloaded")
elif result == False: print(f"Could not reload interface {reload}")
elif result == None: print(f"The interface {reload} does not exist")
else: print(f"Unknown error while reloading interface {reload}")
if result == True: exit(0)
else: exit(1)
link_count = None
active_link_count = None
stats = None
profiling_results = None
details = False
if config_entries:
@ -204,12 +243,17 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
name = i["name"]
if_type = i["type"]
status = i["status"]
has_impl_info = "impl_name" in i and "version" in i and i["impl_name"] and i["version"]
impl_str = f"{i['impl_name']} {i['version']}" if has_impl_info else "Unknown"
if status == "available": status_display = "Available"
elif status == "unknown": status_display = "Unknown"
elif status == "stale": status_display = "Stale"
else: status_display = status
if status == "stale" and not show_stale: continue
if not has_impl_info and not show_unknown: continue
now = time.time()
dago = now-i["discovered"]
hago = now-i["last_heard"]
@ -225,18 +269,22 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
location = f"{lat}, {lon}{height}"
else: location = "Unknown"
lxmf_addr = None
transport_id = None
network = None
network = None
if "operator_lxmf_address" in i: lxmf_addr = i['operator_lxmf_address']
if "transport_id" in i: transport_id = i["transport_id"]
if "transport_id" in i and "network_id" in i and i["transport_id"] != i["network_id"]:
network = i["network_id"]
if idx > 0: print("\n"+"="*32+"\n")
if idx > 0: print("\n"+"="*47+"\n")
if network: print(f"Network ID : {network}")
if transport_id: print(f"Transport ID : {transport_id}")
if lxmf_addr: print(f"LXMF address : {lxmf_addr}")
print(f"Name : {name}")
print(f"Type : {if_type}")
print(f"Stack : {impl_str}")
print(f"Status : {status_display}")
print(f"Transport : {transport_str}")
print(f"Distance : {i['hops']} hop{'' if i['hops'] == 1 else 's'}")
@ -258,16 +306,18 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
config_lines = i["config_entry"].split('\n')
for line in config_lines: print(f" {line}")
except Exception as e:
pass
except Exception as e: pass
else:
print(f"{'Name':<25} {'Type':<12} {'Status':<12} {'Last Heard':<12} {'Value':<8} {'Location':<15}")
print("-" * 89)
print(f"{'Name':<25} {'Type':<10} {'Status':<10} {'Last Heard':<12} {'Value':<7} {'Running':<16} {'Location':<15}")
print("-" * 110)
for i in filtered_ifs:
try:
name = i["name"][:24] + "…" if len(i["name"]) > 24 else i["name"]
has_impl_info = "impl_name" in i and "version" in i and i["impl_name"] and i["version"]
impl_str = f"{i['impl_name']} {i['version']}" if has_impl_info else "Unknown"
if len(impl_str) > 16: impl_str = impl_str[:15]+"…"
if_type = i["type"].replace("Interface", "")
@ -276,6 +326,9 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
elif status == "unknown": status_display = "? Unknown"
elif status == "stale": status_display = "× Stale"
else: status_display = status
if status == "stale" and not show_stale: continue
if not has_impl_info and not show_unknown: continue
now = time.time()
last_heard = i["last_heard"]
@ -300,7 +353,7 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
location = f"{lat}, {lon}"
else: location = "N/A"
print(f"{name:<25} {if_type:<12} {status_display:<12} {last_heard_display:<12} {value:<8} {location:<15}")
print(f"{name:<25} {if_type:<10} {status_display:<12} {last_heard_display:<12} {value:<7} {impl_str:<16} {location:<15}")
except Exception as e:
pass
@ -323,8 +376,8 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
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
remote_status = get_remote_status(destination_hash, lstats, profiling, identity, no_output=json, timeout=remote_timeout)
if remote_status != None: stats, link_count, profiling_results = remote_status
except Exception as e: raise e
except Exception as e:
@ -336,6 +389,12 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if lstats:
try: link_count = reticulum.get_link_count()
except Exception as e: pass
try: active_link_count = reticulum.get_active_link_count()
except Exception as e: pass
if profiling:
try: profiling_results = reticulum.get_profiling_results()
except Exception as e: pass
try: stats = reticulum.get_interface_stats()
except Exception as e: pass
@ -361,34 +420,31 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
interfaces = stats["interfaces"]
if sorting != None and isinstance(sorting, str):
sorting = sorting.lower()
if sorting == "rate" or sorting == "bitrate":
interfaces.sort(key=lambda i: i["bitrate"], reverse=not sort_reverse)
if sorting == "rx":
interfaces.sort(key=lambda i: i["rxb"], reverse=not sort_reverse)
if sorting == "tx":
interfaces.sort(key=lambda i: i["txb"], reverse=not sort_reverse)
if sorting == "rxs":
interfaces.sort(key=lambda i: i["rxs"], reverse=not sort_reverse)
if sorting == "txs":
interfaces.sort(key=lambda i: i["txs"], reverse=not sort_reverse)
if sorting == "traffic":
interfaces.sort(key=lambda i: i["rxb"]+i["txb"], reverse=not sort_reverse)
if sorting == "announces" or sorting == "announce":
interfaces.sort(key=lambda i: i["incoming_announce_frequency"]+i["outgoing_announce_frequency"], reverse=not sort_reverse)
if sorting == "arx":
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)
if sorting == "gravity" or sorting == "g":
interfaces.sort(key=lambda i: i["gravity"], reverse=not sort_reverse)
if sorting == "rate" or sorting == "bitrate": interfaces.sort(key=lambda i: i["bitrate"], reverse=not sort_reverse)
if sorting == "rx": interfaces.sort(key=lambda i: i["rxb"], reverse=not sort_reverse)
if sorting == "tx": interfaces.sort(key=lambda i: i["txb"], reverse=not sort_reverse)
if sorting == "rxs": interfaces.sort(key=lambda i: i["rxs"], reverse=not sort_reverse)
if sorting == "txs": interfaces.sort(key=lambda i: i["txs"], reverse=not sort_reverse)
if sorting == "traffic": interfaces.sort(key=lambda i: i["rxb"]+i["txb"], reverse=not sort_reverse)
if sorting == "anns" or sorting == "announces": interfaces.sort(key=lambda i: i["incoming_announce_frequency"]+i["outgoing_announce_frequency"], reverse=not sort_reverse)
if sorting == "arx": 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 == "arxc": interfaces.sort(key=lambda i: i["arxc"], reverse=not sort_reverse)
if sorting == "atxc": interfaces.sort(key=lambda i: i["atxc"], 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 == "prxc": interfaces.sort(key=lambda i: i["prxc"], reverse=not sort_reverse)
if sorting == "ptxc": interfaces.sort(key=lambda i: i["ptxc"], reverse=not sort_reverse)
if sorting == "held": interfaces.sort(key=lambda i: i["held_announces"], reverse=not sort_reverse)
if sorting == "pvs": interfaces.sort(key=lambda i: i["protocol_violations"], reverse=not sort_reverse)
if sorting == "ivs": interfaces.sort(key=lambda i: i["ifac_violations"], reverse=not sort_reverse)
if sorting == "flt": interfaces.sort(key=lambda i: i["packet_filter_hits"], reverse=not sort_reverse)
if sorting == "gravity" or sorting == "g": interfaces.sort(key=lambda i: i["gravity"], reverse=not sort_reverse)
if sorting == "txdrp": interfaces.sort(key=lambda i: i["txdrp"], reverse=not sort_reverse)
if sorting == "txdrb": interfaces.sort(key=lambda i: i["txdrb"], reverse=not sort_reverse)
if sorting == "txbuf": interfaces.sort(key=lambda i: i["txbuffered"], reverse=not sort_reverse)
for ifstat in interfaces:
name = ifstat["name"]
@ -458,6 +514,9 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
p = ifstat["blocked_ips"] > 0
if p: clients_string += "\n Blocked : "+str(ifstat["blocked_ips"])+" IP"+"s" if p else ""
if blocked_ips and "blocked_ip_list" in ifstat and len(ifstat["blocked_ip_list"]) > 0:
for bip in ifstat["blocked_ip_list"]: clients_string += f"\n {bip}"
else:
clients = None
@ -477,8 +536,11 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if not (name.startswith("Shared Instance[") or name.startswith("TCPInterface[Client") or name.startswith("LocalInterface[")):
print(f" Mode : {modestr}")
if ifstat["txdrp"]:
print(f" TX Drops : {ifstat['txdrp']} ({RNS.prettysize(ifstat['txdrb'])})")
if "bitrate" in ifstat and ifstat["bitrate"] != None:
print(" Rate : {ss}".format(ss=speed_str(ifstat["bitrate"])))
print(f" Rate : {speed_str(ifstat['bitrate'])}, MTU {ifstat['mtu']}")
if "noise_floor" in ifstat:
if not "interference" in ifstat: nstr = ""
@ -572,13 +634,17 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
burst_str = ""
if "burst_active" in ifstat and ifstat["burst_active"]:
if "burst_count" in ifstat and ifstat["burst_count"]: bcstr = f"on {ifstat['burst_count']} "
else: bcstr = ""
for_str = RNS.prettytime(time.time()-ifstat["burst_activated"])
burst_str = f" burst for {for_str}"
burst_str = f" burst {bcstr}for {for_str}"
pburst_str = ""
if "pr_burst_active" in ifstat and ifstat["pr_burst_active"]:
if "pr_burst_count" in ifstat and ifstat["pr_burst_count"]: prbcstr = f"on {ifstat['pr_burst_count']} "
else: prbcstr = ""
for_str = RNS.prettytime(time.time()-ifstat["pr_burst_activated"])
pburst_str = f"burst for {for_str}"
pburst_str = f"burst {prbcstr}for {for_str}"
rxb_str = "↓"+RNS.prettysize(ifstat["rxb"])
txb_str = "↑"+RNS.prettysize(ifstat["txb"])
@ -593,8 +659,16 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
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}"
if clients != None and clients > 0: pc_str = f" {RNS.prettyfrequency(ifstat['outgoing_announce_frequency']/clients, d=1, lpf=True)}/{cspec}"
else: pc_str = ""
if "prxs" in ifstat and "rxs" in ifstat and "ptxs" in ifstat and "txs" in ifstat:
arxspct = min(100.0, (ifstat["arxs"] / ifstat["rxs"])*100.0) if ifstat["rxs"] and ifstat["arxs"] else 0.0
atxspct = min(100.0, (ifstat["atxs"] / ifstat["txs"])*100.0) if ifstat["txs"] and ifstat["atxs"] else 0.0
apctstr = f"(↓{int(arxspct)}% / ↑{int(atxspct)}% of flow)"
if pc_str: pc_str = f"{pc_str} {apctstr}"
else: pc_str = f"{apctstr}"
asr = True
psr = False
@ -610,8 +684,16 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
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}"
if clients != None and clients > 0: rpc_str = f" {RNS.prettyfrequency(ifstat['outgoing_pr_frequency']/clients, d=1, lpf=True)}/{cspec}"
else: rpc_str = ""
if "prxs" in ifstat and "rxs" in ifstat and "ptxs" in ifstat and "txs" in ifstat:
prxspct = min(100.0, (ifstat["prxs"] / ifstat["rxs"])*100.0) if ifstat["rxs"] and ifstat["prxs"] else 0.0
ptxspct = min(100.0, (ifstat["ptxs"] / ifstat["txs"])*100.0) if ifstat["txs"] and ifstat["ptxs"] else 0.0
ppctstr = f"(↓{int(prxspct)}% / ↑{int(ptxspct)}% of flow)"
if rpc_str: rpc_str = f"{rpc_str} {ppctstr}"
else: rpc_str = f"{ppctstr}"
psr = True
if not asr: iaf = ""; oaf = ""
@ -627,12 +709,30 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
rxb_str += (mlen-len(rxb_str))*" "
txb_str += (mlen-len(txb_str))*" "
if "protocol_violations" in ifstat and "ifac_violations" in ifstat:
pv = ifstat["protocol_violations"]; iv = ifstat["ifac_violations"]
if pv or iv:
pvstr = f"{pv} protocol{', ' if iv else ''}"
ivstr = f"{iv} IFAC" if iv else ""
print(f" Violatns. : {pvstr}{ivstr}")
if "packet_filter_hits" in ifstat and ifstat["packet_filter_hits"]:
print(f" Flt. Hits : {ifstat['packet_filter_hits']}")
if psr:
print(f" Path Rqs. : {opf} {rpc_str}")
if ifstat["prxc"] and ifstat["ptxc"]:
print(f" Path Rqs. : {ifstat['prxc']}↓ {ifstat['ptxc']}↑ total")
print(f" {opf} {rpc_str}")
else:
print(f" Path Rqs. : {opf} {rpc_str}")
print(f" {ipf} {pburst_str}")
if asr:
print(f" Announces : {oaf} {pc_str}")
if ifstat["arxc"] and ifstat["atxc"]:
print(f" Announces : {ifstat['arxc']}↓ {ifstat['atxc']}↑ total")
print(f" {oaf} {pc_str}")
else:
print(f" Announces : {oaf} {pc_str}")
print(f" {iaf} {art_str}{burst_str}")
rxstat = rxb_str
@ -640,6 +740,11 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if "rxs" in ifstat and "txs" in ifstat:
rxstat += " "+RNS.prettyspeed(ifstat["rxs"])
txstat += " "+RNS.prettyspeed(ifstat["txs"])
if ifstat["txbuffered"]:
if ifstat["txstalled"]: stlstr = ", stalled"
else: stlstr = ""
txstat += f" ({RNS.prettysize(ifstat['txbuffered'])} waiting{stlstr})"
print(f" Traffic : {txstat}\n {rxstat}")
@ -651,19 +756,96 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
else:
lstr = f" {link_count} entr{ms} in link table"
if active_link_count: lstr = f"{lstr} ({active_link_count} active)"
if traffic_totals:
rxb_str = "↓"+RNS.prettysize(stats["rxb"])
txb_str = "↑"+RNS.prettysize(stats["txb"])
strdiff = len(rxb_str)-len(txb_str)
if strdiff > 0:
txb_str += " "*strdiff
elif strdiff < 0:
rxb_str += " "*-strdiff
if strdiff > 0: txb_str += " "*strdiff
elif strdiff < 0: rxb_str += " "*-strdiff
rxstat = rxb_str+" "+RNS.prettyspeed(stats["rxs"])
txstat = txb_str+" "+RNS.prettyspeed(stats["txs"])
if pstats or astats:
if "prxs" in stats and "ptxs" in stats and "arxs" in stats and "atxs" in stats:
parxs = stats["prxs"]+stats["arxs"]
patxs = stats["ptxs"]+stats["atxs"]
drxpct = 100.0-min(100.0, (parxs / stats["rxs"])*100.0) if stats["rxs"] else 100.0
dtxpct = 100.0-min(100.0, (patxs / stats["txs"])*100.0) if stats["txs"] else 100.0
drxs = stats["rxs"]*(drxpct/100.0)
dtxs = stats["txs"]*(dtxpct/100.0)
if stats["rxs"] > 0: rxstat = f"{rxstat}, {int(drxpct)}% data ({RNS.prettyspeed(drxs)})"
if stats["txs"] > 0: txstat = f"{txstat}, {int(dtxpct)}% data ({RNS.prettyspeed(dtxs)})"
if pps:
rpps = RNS.prettysize(stats['rxpps'], suffix="pps")
tpps = RNS.prettysize(stats['txpps'], suffix="pps")
rxstat = f"{rxstat}, {rpps}"
txstat = f"{txstat}, {tpps}"
print(f"\n Totals : {txstat}\n {rxstat}")
if pstats:
if "prxb" in stats and "ptxb" in stats and "prxs" in stats and "ptxs" in stats:
prxb_str = "↓"+RNS.prettysize(stats["prxb"])
ptxb_str = "↑"+RNS.prettysize(stats["ptxb"])
strdiff = len(prxb_str)-len(ptxb_str)
if strdiff > 0: ptxb_str += " "*strdiff
elif strdiff < 0: prxb_str += " "*-strdiff
prxspct = min(100.0, (stats["prxs"] / stats["rxs"])*100.0) if stats["rxs"] and stats["prxs"] else 0.0
ptxspct = min(100.0, (stats["ptxs"] / stats["txs"])*100.0) if stats["txs"] and stats["ptxs"] else 0.0
prxfstr = RNS.prettyfrequency(stats["prxf"])
ptxfstr = RNS.prettyfrequency(stats["ptxf"])
prxstat = prxb_str+" "+RNS.prettyspeed(stats["prxs"])+f", {int(prxspct)}% of flow, {prxfstr}"
ptxstat = ptxb_str+" "+RNS.prettyspeed(stats["ptxs"])+f", {int(ptxspct)}% of flow, {ptxfstr}"
print(f"\n Path Rqs. : {ptxstat}\n {prxstat}")
if astats:
if "arxb" in stats and "atxb" in stats and "arxs" in stats and "atxs" in stats:
arxb_str = "↓"+RNS.prettysize(stats["arxb"])
atxb_str = "↑"+RNS.prettysize(stats["atxb"])
strdiff = len(arxb_str)-len(atxb_str)
if strdiff > 0: atxb_str += " "*strdiff
elif strdiff < 0: arxb_str += " "*-strdiff
arxspct = min(100.0, (stats["arxs"] / stats["rxs"])*100.0) if stats["rxs"] and stats["arxs"] else 0.0
atxspct = min(100.0, (stats["atxs"] / stats["txs"])*100.0) if stats["txs"] and stats["atxs"] else 0.0
arxfstr = RNS.prettyfrequency(stats["arxf"])
atxfstr = RNS.prettyfrequency(stats["atxf"])
arxstat = arxb_str+" "+RNS.prettyspeed(stats["arxs"])+f", {int(arxspct)}% of flow, {arxfstr}"
atxstat = atxb_str+" "+RNS.prettyspeed(stats["atxs"])+f", {int(atxspct)}% of flow, {atxfstr}"
print(f"\n Announces : {atxstat}\n {arxstat}")
if queue_stats:
tqdp = f", {stats['rxqtd']} dropped" if stats['rxqtd'] else ""
dqdp = f", {stats['rxqdd']} dropped" if stats['rxqdd'] else ""
aqdp = f", {stats['rxqad']} dropped" if stats['rxqad'] else ""
pqdp = f", {stats['rxqpd']} dropped" if stats['rxqpd'] else ""
ilqdp = f", {stats['rxqild']} dropped" if stats['rxqild'] else ""
tqpress = f"{round(stats['tqpressure']*100.0, 1)}% total, {stats['rxqt']} pkts{tqdp}"
dqpress = f"{round(stats['dqpressure']*100.0, 1)}% data, {stats['rxqd']} pkts{dqdp}"
aqpress = f"{round(stats['aqpressure']*100.0, 1)}% announce, {stats['rxqa']} pkts{aqdp}"
pqpress = f"{round(stats['pqpressure']*100.0, 1)}% path request, {stats['rxqp']} pkts{pqdp}"
ilpress = f"{round(stats['ilqpressure']*100.0, 1)}% ingress limiter, {stats['rxqil']} pkts{ilqdp}"
print(f"\n Qu. Pressure : {tqpress}")
print(f" {dqpress}")
print(f" {aqpress}")
print(f" {pqpress}")
print(f" {ilpress}")
if profiling_results:
print(f"\n Profiling :\n{RNS.Profiler.format_results(profiling_results)}")
if "transport_id" in stats and stats["transport_id"] != None:
print("\n Transport Instance "+RNS.prettyhexrep(stats["transport_id"])+" running")
if "network_id" in stats and stats["network_id"] != None:
@ -694,13 +876,21 @@ def main(must_exit=True, rns_instance=None):
parser.add_argument("--config", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("--version", action="version", version="rnstatus {version}".format(version=__version__))
parser.add_argument("--attach", action="store", metavar="name", help="Attach interface by name", default=None, type=str)
parser.add_argument("--detach", action="store", metavar="name", help="Detach interface by name", default=None, type=str)
parser.add_argument("--reload", action="store", metavar="name", help="Reload interface by name", default=None, type=str)
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("-b", "--blocked-ips", action="store_true", help="show blocked IPs per interface", 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, prx, ptx, held]", default=None, type=str)
parser.add_argument("-p", "--pps", action="store_true", help="display packets per second in totals", default=False)
parser.add_argument("-q", "--queues", action="store_true", help="display queue stats", default=False)
parser.add_argument("-z", "--profiling", action="store_true", help="display live profiling results", default=False)
parser.add_argument("-s", "--sort", action="store", help="sort interfaces by [rate, traffic, rx, tx, rxs, txs, anns, arx, atx, arxc, atxc, held, prx, ptx, prxc, ptxc, pvs, ivs, flt, txdrp, txdrb, txbuf]", 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)
@ -708,6 +898,8 @@ def main(must_exit=True, rns_instance=None):
parser.add_argument("-w", action="store", metavar="seconds", type=float, help="timeout before giving up on remote queries", default=RNS.Transport.PATH_REQUEST_TIMEOUT)
parser.add_argument("-d", "--discovered", action="store_true", help="list discovered interfaces", default=False)
parser.add_argument("-D", action="store_true", help="show details and config entries for discovered interfaces", default=False)
parser.add_argument("--show-stale", action="store_true", help="show stale discovery entries", default=False)
parser.add_argument("--show-unknown", action="store_true", help="show discovery entries without version info", default=False)
parser.add_argument("-m", "--monitor", action="store_true", help="continuously monitor status", default=False)
parser.add_argument("-I", "--monitor-interval", action="store", metavar="seconds", type=float, help="refresh interval for monitor mode (default: 1)", default=1.0)
parser.add_argument('-v', '--verbose', action='count', default=0)
@ -737,7 +929,9 @@ def main(must_exit=True, rns_instance=None):
program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
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)
traffic_totals=args.totals, discovered_interfaces=args.discovered, config_entries=args.D, burst_filter=args.burst,
blocked_ips=args.blocked_ips, queue_stats=args.queues, pps=args.pps, profiling=args.profiling,
show_stale=args.show_stale, show_unknown=args.show_unknown)
finally:
sys.stdout = old_stdout
@ -754,7 +948,9 @@ def main(must_exit=True, rns_instance=None):
program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
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)
traffic_totals=args.totals, discovered_interfaces=args.discovered, config_entries=args.D, burst_filter=args.burst,
blocked_ips=args.blocked_ips, queue_stats=args.queues, pps=args.pps, profiling=args.profiling,
attach=args.attach, detach=args.detach, reload=args.reload, show_stale=args.show_stale, show_unknown=args.show_unknown)
except KeyboardInterrupt:
print("")

View file

@ -347,7 +347,7 @@ def execute(configdir, identitypath = None, verbosity = 0, quietness = 0, detail
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
if not spin(until=lambda: RNS.Transport.has_path(destination_hash), msg="Path to "+RNS.prettyhexrep(destination_hash)+" requested", timeout=timeout):
if not spin(until=lambda: RNS.Transport.has_path(destination_hash), msg="Path to "+RNS.prettyhexrep(destination_hash)+" requested", timeout=max(timeout, reticulum.get_medium_path_timeout())):
print("Path not found")
exit(242)
@ -365,7 +365,7 @@ def execute(configdir, identitypath = None, verbosity = 0, quietness = 0, detail
link = RNS.Link(listener_destination)
link.did_identify = False
if not spin(until=lambda: link.status == RNS.Link.ACTIVE, msg="Establishing link with "+RNS.prettyhexrep(destination_hash), timeout=timeout):
if not spin(until=lambda: link.status == RNS.Link.ACTIVE, msg="Establishing link with "+RNS.prettyhexrep(destination_hash), timeout=max(timeout, link.establishment_timeout)):
print("Could not establish link with "+RNS.prettyhexrep(destination_hash))
exit(243)

View file

@ -35,32 +35,25 @@ import time
import datetime
import random
import threading
import math
import bisect
from ._version import __version__
from .Reticulum import Reticulum
from .Identity import Identity
from .Link import Link, RequestReceipt
from .Channel import MessageBase
from .Buffer import Buffer, RawChannelReader, RawChannelWriter
from .Transport import Transport
from .Discovery import InterfaceAnnouncer
from .Destination import Destination
from .Packet import Packet
from .Packet import PacketReceipt
from .Resolver import Resolver
from .Resource import Resource, ResourceAdvertisement
from .Cryptography import HKDF
from .Cryptography import Hashes
from collections import deque
from functools import partial, wraps
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))
import importlib.util
if importlib.util.find_spec("cython"): import cython; compiled = cython.compiled
else: compiled = False
try: from ._buildinfo import compiled
except ImportError:
if importlib.util.find_spec("cython"): import cython; compiled = cython.compiled
else: compiled = False
LOG_NONE = -1
LOG_CRITICAL = 0
@ -161,7 +154,6 @@ def log(msg, level=3, _override_destination = False, pt=False):
log("Exception occurred while calling external log handler: "+str(e), LOG_CRITICAL)
log("Dumping future log events to console!", LOG_CRITICAL)
log(msg, level)
def rand():
result = instance_random.random()
@ -291,7 +283,7 @@ def prettytime(time, verbose=False, compact=False):
if not neg: return tstr
else: return f"-{tstr}"
def prettyshorttime(time, verbose=False, compact=False):
def prettyshorttime(time, verbose=False, compact=False, tight=False):
neg = False
time = time*1e6
if time < 0:
@ -327,8 +319,8 @@ def prettyshorttime(time, verbose=False, compact=False):
for c in components:
i += 1
if i == 1: pass
elif i < len(components): tstr += ", "
elif i == len(components): tstr += " and "
elif i < len(components): tstr += ", " if not tight else " "
elif i == len(components): tstr += " and " if not tight else " "
tstr += c
@ -365,20 +357,50 @@ class Profiler:
profilers = {}
tags = {}
# Samples per tag
MAX_CAPTURES = 10000
@staticmethod
def get_profiler(tag=None, super_tag=None):
def get_profiler(tag=None, super_tag=None, max_captures=None):
if tag in Profiler.profilers: return Profiler.profilers[tag]
else:
profiler = Profiler(tag, super_tag)
if max_captures is None: max_captures = Profiler.MAX_CAPTURES
profiler = Profiler(tag, super_tag, max_captures)
Profiler.profilers[tag] = profiler
return profiler
def __init__(self, tag=None, super_tag=None):
@staticmethod
def profile(func=None, *, tag=None, max_captures=None):
"""
Decorator to profile a function. With no arguments, it can be
used as `@RNS.Profiler.profile`, in which case it will use the
qualified name of the function as the profiler tag. It can
also be used as `@RNS.Profiler.profile(tag="prof_tag")` to use
a specific profiler tag.
:param tag: Either a profiler tag as passed to `Profiler.get_profiler(...)` or a `Profiler` object.
:param max_captures: Maximum samples to capture for the specified profiler tag.
"""
if func is None:
return partial(Profiler.profile, tag=tag, max_captures=max_captures)
tag = func.__qualname__ if tag is None else tag
if isinstance(tag, Profiler): profiler = tag
else: profiler = Profiler.get_profiler(tag=tag, max_captures=max_captures)
@wraps(func)
def wrapper(*args, **kwargs):
with profiler:
return func(*args, **kwargs)
return wrapper
def __init__(self, tag=None, super_tag=None, max_captures=None):
self.paused = False
self.pause_time = 0
self.pause_started = None
self.tag = tag
self.super_tag = super_tag
self.max_captures = max_captures if max_captures is not None else Profiler.MAX_CAPTURES
if self.super_tag in Profiler.profilers:
self.super_profiler = Profiler.profilers[self.super_tag]
@ -396,11 +418,18 @@ 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, "captures": deque(maxlen=self.max_captures)}
if not thread_ident in Profiler.tags[tag]["threads"]:
Profiler.tags[tag]["threads"][thread_ident] = {"current_start": None, "captures": []}
Profiler.tags[tag]["threads"][thread_ident] = {"running": deque()}
Profiler.tags[tag]["threads"][thread_ident]["current_start"] = time.perf_counter()
# The tag deque stores a shared reference to the capture, and the end
# time isn't updated until the context manager exits. This leaves the
# deque sorted by start time, except for cases where the thread is
# preemted between getting the current time and appending. We also store
# a stack of start times per thread to support reentrancy.
capture = {"start": time.perf_counter(), "end": None, "thread_ident": thread_ident}
Profiler.tags[tag]["captures"].append(capture)
Profiler.tags[tag]["threads"][thread_ident]["running"].append(capture)
self.resume_super()
def __exit__(self, exc_type, exc_value, traceback):
@ -410,14 +439,15 @@ class Profiler:
end = time.perf_counter() - self.pause_time
self.pause_time = 0
thread_ident = threading.get_ident()
if tag in Profiler.tags and thread_ident in Profiler.tags[tag]["threads"]:
if Profiler.tags[tag]["threads"][thread_ident]["current_start"] != None:
begin = Profiler.tags[tag]["threads"][thread_ident]["current_start"]
Profiler.tags[tag]["threads"][thread_ident]["current_start"] = None
Profiler.tags[tag]["threads"][thread_ident]["captures"].append(end-begin)
try:
if tag in Profiler.tags and thread_ident in Profiler.tags[tag]["threads"]:
try: capture = Profiler.tags[tag]["threads"][thread_ident]["running"].pop()
except IndexError as e: return
capture["end"] = end
if not Profiler._ran:
Profiler._ran = True
self.resume_super()
finally:
self.resume_super()
def pause(self, pause_started=None):
if not self.paused:
@ -436,78 +466,153 @@ class Profiler:
@staticmethod
def results():
from statistics import mean, median, stdev
def find_window_start(captures, start_time, hi=None):
hi = len(captures) if hi is None else hi
idx = bisect.bisect_left(captures, start_time, hi=hi, key=lambda c: c["start"])
return idx if len(captures) - idx > 1 else None
# Fast one-pass calculation of summary statistics
def calc_stats(captures, start=0, end=None, key=lambda c: c, threads_key=None):
if end is None: end = len(captures)
while start > 0 and start < len(captures) and start < end and key(captures[start]) is None: start += 1
while end > 0 and end <= len(captures) and start < end and key(captures[end-1]) is None: end -= 1
count = end - start
if count <= 0: return None
elif count == 1:
capture = key(captures[start])
return { "count": 1,
"mean": capture,
"median": capture,
"min": capture,
"max": capture,
"stdev": None,
"sum": capture,
"threads": 1 if threads_key else None}
# Median is approximate if there are any incomplete captures in the
# middle of the range.
med_even = count % 2 == 0
med_idx = start + (count // 2 if med_even else (count - 1) // 2)
while key(captures[med_idx]) is None and med_idx < end: med_idx += 1
if med_idx == end: c_median = None
elif med_even: c_median = key(captures[med_idx])
else:
med_idx2 = med_idx + 1
while key(captures[med_idx2]) is None and med_idx2 < end: med_idx2 += 1
if med_idx2 == end: c_median = None
else:
med_cap1 = key(captures[med_idx])
med_cap2 = key(captures[med_idx+1])
if med_cap1 is None or med_cap2 is None: c_median = None
else: c_median = (med_cap1 + med_cap2) / 2
c_count = 0; c_sum = 0; c_min = key(captures[start]); c_max = key(captures[start]); ck = 0; ck2 = 0
uniq_threads = set()
for idx in range(start, end):
c = key(captures[idx])
if c is None: continue
else: c_count += 1
c_sum += c
if c < c_min: c_min = c
if c > c_max: c_max = c
ck += c - c_median
ck2 += (c - c_median) ** 2
if threads_key: uniq_threads.add(threads_key(captures[idx]))
c_mean = c_sum / c_count if c_count > 0 else None
c_std = math.sqrt((ck2 - (ck ** 2)/c_count) / (c_count - 1)) if c_count > 1 else None
return { "count": c_count,
"mean": c_mean,
"median": c_median,
"min": c_min,
"max": c_max,
"stdev": c_std,
"sum": c_sum,
"threads": len(uniq_threads) if threads_key else None }
def stats_key(capture):
end = capture["end"]
if end is None: return None
else: return end - capture["start"]
results = {}
for tag in Profiler.tags:
tag_captures = []
now = time.perf_counter()
for tag in sorted(Profiler.tags):
tag_entry = Profiler.tags[tag]
for thread_ident in tag_entry["threads"]:
thread_entry = tag_entry["threads"][thread_ident]
thread_captures = thread_entry["captures"]
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) }
elif sample_count == 1:
thread_results = { "count": sample_count,
"mean": mean(thread_captures),
"median": median(thread_captures),
"stdev": None }
tag_captures = sorted(tag_entry["captures"], key=lambda c: c["start"])
tag_captures.extend(thread_captures)
if len(tag_captures):
captures_1m = None; captures_5m = None; captures_30m = None; captures_60m = None
stats_1m = None; stats_5m = None; stats_30m = None; stats_60m = None
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) }
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 }
captures_1m = find_window_start(tag_captures, now - 1*60)
if captures_1m: captures_5m = find_window_start(tag_captures, now - 5*60, hi=captures_1m)
if captures_5m: captures_30m = find_window_start(tag_captures, now - 30*60, hi=captures_5m)
if captures_30m: captures_60m = find_window_start(tag_captures, now - 60*60, hi=captures_30m)
results[tag] = tag_results
stats_all = calc_stats(tag_captures, 0, None, key=stats_key, threads_key=lambda c: c["thread_ident"])
if captures_1m: stats_1m = calc_stats(tag_captures, captures_1m, None, key=stats_key)
if captures_5m: stats_5m = calc_stats(tag_captures, captures_5m, captures_1m, key=stats_key)
if captures_30m: stats_30m = calc_stats(tag_captures, captures_30m, captures_5m, key=stats_key)
if captures_60m: stats_60m = calc_stats(tag_captures, captures_60m, captures_30m, key=stats_key)
if stats_all["count"]:
results[tag] = { "name": tag,
"super": tag_entry["super"],
"stats_all": stats_all,
"stats_1m": stats_1m,
"stats_5m": stats_5m,
"stats_30m": stats_30m,
"stats_60m": stats_60m }
# Yield to avoid bogging down the instance
time.sleep(0.001)
return results
@staticmethod
def format_results(results):
def pst(time):
if time is not None: return prettyshorttime(time, tight=True)
else: return "-----"
def print_results_recursive(tag, results, level=0):
print_tag_results(tag, level+1)
results_str = print_tag_results(tag, level+1) + "\n"
for tag_name in results:
sub_tag = results[tag_name]
if sub_tag["super"] == tag["name"]:
print_results_recursive(sub_tag, results, level=level+1)
results_str += print_results_recursive(sub_tag, results, level=level+1)
return results_str
def print_tag_results(tag, level):
ind = " "*level
name = tag["name"]; count = tag["count"]
mean = tag["mean"]; median = tag["median"]; stdev = tag["stdev"]
print( f"{ind}{name}")
print( f"{ind} Samples : {count}")
if stdev != None:
print(f"{ind} Mean : {prettyshorttime(mean)}")
print(f"{ind} Median : {prettyshorttime(median)}")
print(f"{ind} St.dev. : {prettyshorttime(stdev)}")
print( f"{ind} Total : {prettyshorttime(mean*count)}")
print("")
name = tag["name"]
stats_all = tag["stats_all"]; stats_1m = tag["stats_1m"]; stats_5m = tag["stats_5m"]; stats_30m = tag["stats_30m"]; stats_60m = tag["stats_60m"]
results_str = f" {ind}{name}\n"
results_str += f" {ind} Samples : {stats_all['count']} from {stats_all['threads']} thread{'s' if stats_all['threads'] > 1 else ''}\n"
if stats_all != None:
results_str += f" {ind} {'Mean':^15} | {'Median':^15} | {'Min':^15} | {'Max':^15} | {'St. dev':^15} | {'Total':^15}\n"
results_str += f" {ind} Stats : ({pst(stats_all['mean']):^15} | {pst(stats_all['median']):^15} | {pst(stats_all['min']):^15} | {pst(stats_all['max']):^15} | {pst(stats_all['stdev']):^15} | {pst(stats_all['sum']):^15})\n"
if stats_1m != None:
results_str += f" {ind} 0-1m : ({pst(stats_1m['mean']):^15} | {pst(stats_1m['median']):^15} | {pst(stats_1m['min']):^15} | {pst(stats_1m['max']):^15} | {pst(stats_1m['stdev']):^15} | {pst(stats_1m['sum']):^15})\n"
if stats_5m != None:
results_str += f" {ind} 1-5m : ({pst(stats_5m['mean']):^15} | {pst(stats_5m['median']):^15} | {pst(stats_5m['min']):^15} | {pst(stats_5m['max']):^15} | {pst(stats_5m['stdev']):^15} | {pst(stats_5m['sum']):^15})\n"
if stats_30m != None:
results_str += f" {ind} 5-30m : ({pst(stats_30m['mean']):^15} | {pst(stats_30m['median']):^15} | {pst(stats_30m['min']):^15} | {pst(stats_30m['max']):^15} | {pst(stats_30m['stdev']):^15} | {pst(stats_30m['sum']):^15})\n"
if stats_60m != None:
results_str += f" {ind} 30-60m : ({pst(stats_60m['mean']):^15} | {pst(stats_60m['median']):^15} | {pst(stats_60m['min']):^15} | {pst(stats_60m['max']):^15} | {pst(stats_60m['stdev']):^15} | {pst(stats_60m['sum']):^15})\n"
return results_str
print("\nProfiler results:\n")
results_str = ""
for tag_name in results:
tag = results[tag_name]
if tag["super"] == None:
print_results_recursive(tag, results)
results_str += print_results_recursive(tag, results)
return results_str
profile = Profiler.get_profiler
@ -557,3 +662,19 @@ 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}")
from .Reticulum import Reticulum
from .Identity import Identity
from .Link import Link, RequestReceipt
from .Channel import MessageBase
from .Buffer import Buffer, RawChannelReader, RawChannelWriter
from .Transport import Transport
from .Discovery import InterfaceAnnouncer
from .Destination import Destination
from .Packet import Packet
from .Packet import PacketReceipt
from .Resolver import Resolver
from .Resource import Resource, ResourceAdvertisement
from .Cryptography import HKDF
from .Cryptography import Hashes

View file

@ -1 +1 @@
__version__ = "1.4.2"
__version__ = "1.5.5"

View file

@ -3,5 +3,7 @@ import glob
py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
so_modules = glob.glob(os.path.dirname(__file__)+"/*.so")
pyd_modules = glob.glob(os.path.dirname(__file__)+"/*.pyd")
modules = py_modules+pyc_modules+so_modules+pyd_modules
__all__ = list(set([os.path.basename(f).split(".")[0] for f in modules if not os.path.basename(f).split(".")[0] == "__init__"]))

233
RNS/vendor/umsgpack.py vendored
View file

@ -2,7 +2,7 @@
# https://github.com/vsergeev/u-msgpack-python
#
# u-msgpack-python is a lightweight MessagePack serializer and deserializer
# module, compatible with both Python 2 and 3, as well CPython and PyPy
# module, compatible with Python 3, as well CPython and PyPy
# implementations of Python. u-msgpack-python is fully compliant with the
# latest MessagePack specification.com/msgpack/msgpack/blob/master/spec.md). In
# particular, it supports the new binary, UTF-8 string, and application ext
@ -35,7 +35,7 @@ u-msgpack-python v2.7.1 - v at sergeev.io
https://github.com/vsergeev/u-msgpack-python
u-msgpack-python is a lightweight MessagePack serializer and deserializer
module, compatible with both Python 2 and 3, as well CPython and PyPy
module, compatible with Python 3, as well CPython and PyPy
implementations of Python. u-msgpack-python is fully compliant with the
latest MessagePack specification.com/msgpack/msgpack/blob/master/spec.md). In
particular, it supports the new binary, UTF-8 string, and application ext
@ -49,10 +49,7 @@ import datetime
import sys
import io
if sys.version_info[0:2] >= (3, 3):
from collections.abc import Hashable
else:
from collections import Hashable
from collections.abc import Hashable
__version__ = "2.7.1"
"Module version string"
@ -104,8 +101,6 @@ class Ext(object):
# Check data is type bytes or str
elif sys.version_info[0] == 3 and not isinstance(data, bytes):
raise TypeError("ext data is not type \'bytes\'")
elif sys.version_info[0] == 2 and not isinstance(data, str):
raise TypeError("ext data is not type \'str\'")
self.type = type
self.data = data
@ -129,7 +124,7 @@ class Ext(object):
"""
s = "Ext Object (Type: {:d}, Data: ".format(self.type)
s += " ".join(["0x{:02}".format(ord(self.data[i:i + 1]))
for i in xrange(min(len(self.data), 8))])
for i in range(min(len(self.data), 8))])
if len(self.data) > 8:
s += " ..."
s += ")"
@ -452,94 +447,6 @@ def _pack_map(obj, fp, options):
########################################
# Pack for Python 2, with 'unicode' type, 'str' type, and 'long' type
def _pack2(obj, fp, **options):
"""
Serialize a Python object into MessagePack bytes.
Args:
obj: a Python object
fp: a .write()-supporting file-like object
Kwargs:
ext_handlers (dict): dictionary of Ext handlers, mapping a custom type
to a callable that packs an instance of the type
into an Ext object
force_float_precision (str): "single" to force packing floats as
IEEE-754 single-precision floats,
"double" to force packing floats as
IEEE-754 double-precision floats.
Returns:
None.
Raises:
UnsupportedType(PackException):
Object type not supported for packing.
Example:
>>> f = open('test.bin', 'wb')
>>> umsgpack.pack({u"compact": True, u"schema": 0}, f)
>>>
"""
global compatibility
ext_handlers = options.get("ext_handlers")
if obj is None:
_pack_nil(obj, fp, options)
elif ext_handlers and obj.__class__ in ext_handlers:
_pack_ext(ext_handlers[obj.__class__](obj), fp, options)
elif obj.__class__ in _ext_class_to_type:
try:
_pack_ext(Ext(_ext_class_to_type[obj.__class__], obj.packb()), fp, options)
except AttributeError:
raise NotImplementedError("Ext serializable class {:s} is missing implementation of packb()".format(repr(obj.__class__)))
elif isinstance(obj, bool):
_pack_boolean(obj, fp, options)
elif isinstance(obj, (int, long)):
_pack_integer(obj, fp, options)
elif isinstance(obj, float):
_pack_float(obj, fp, options)
elif compatibility and isinstance(obj, unicode):
_pack_oldspec_raw(bytes(obj), fp, options)
elif compatibility and isinstance(obj, bytes):
_pack_oldspec_raw(obj, fp, options)
elif isinstance(obj, unicode):
_pack_string(obj, fp, options)
elif isinstance(obj, str):
_pack_binary(obj, fp, options)
elif isinstance(obj, (list, tuple)):
_pack_array(obj, fp, options)
elif isinstance(obj, dict):
_pack_map(obj, fp, options)
elif isinstance(obj, datetime.datetime):
_pack_ext_timestamp(obj, fp, options)
elif isinstance(obj, Ext):
_pack_ext(obj, fp, options)
elif ext_handlers:
# Linear search for superclass
t = next((t for t in ext_handlers.keys() if isinstance(obj, t)), None)
if t:
_pack_ext(ext_handlers[t](obj), fp, options)
else:
raise UnsupportedTypeException(
"unsupported type: {:s}".format(str(type(obj))))
elif _ext_class_to_type:
# Linear search for superclass
t = next((t for t in _ext_class_to_type if isinstance(obj, t)), None)
if t:
try:
_pack_ext(Ext(_ext_class_to_type[t], obj.packb()), fp, options)
except AttributeError:
raise NotImplementedError("Ext serializable class {:s} is missing implementation of packb()".format(repr(t)))
else:
raise UnsupportedTypeException("unsupported type: {:s}".format(str(type(obj))))
else:
raise UnsupportedTypeException("unsupported type: {:s}".format(str(type(obj))))
# Pack for Python 3, with unicode 'str' type, 'bytes' type, and no 'long' type
def _pack3(obj, fp, **options):
"""
@ -627,40 +534,6 @@ def _pack3(obj, fp, **options):
raise UnsupportedTypeException(
"unsupported type: {:s}".format(str(type(obj))))
def _packb2(obj, **options):
"""
Serialize a Python object into MessagePack bytes.
Args:
obj: a Python object
Kwargs:
ext_handlers (dict): dictionary of Ext handlers, mapping a custom type
to a callable that packs an instance of the type
into an Ext object
force_float_precision (str): "single" to force packing floats as
IEEE-754 single-precision floats,
"double" to force packing floats as
IEEE-754 double-precision floats.
Returns:
A 'str' containing serialized MessagePack bytes.
Raises:
UnsupportedType(PackException):
Object type not supported for packing.
Example:
>>> umsgpack.packb({u"compact": True, u"schema": 0})
'\x82\xa7compact\xc3\xa6schema\x00'
>>>
"""
fp = io.BytesIO()
_pack2(obj, fp, **options)
return fp.getvalue()
def _packb3(obj, **options):
"""
Serialize a Python object into MessagePack bytes.
@ -885,9 +758,9 @@ def _unpack_array(code, fp, options):
raise Exception("logic error, not array: 0x{:02x}".format(ord(code)))
if options.get('use_tuple'):
return tuple((_unpack(fp, options) for i in xrange(length)))
return tuple((_unpack(fp, options) for i in range(length)))
return [_unpack(fp, options) for i in xrange(length)]
return [_unpack(fp, options) for i in range(length)]
def _deep_list_to_tuple(obj):
@ -907,7 +780,7 @@ def _unpack_map(code, fp, options):
raise Exception("logic error, not map: 0x{:02x}".format(ord(code)))
d = {} if not options.get('use_ordered_dict') else collections.OrderedDict()
for _ in xrange(length):
for _ in range(length):
# Unpack key
k = _unpack(fp, options)
@ -938,53 +811,6 @@ def _unpack(fp, options):
########################################
def _unpack2(fp, **options):
"""
Deserialize MessagePack bytes into a Python object.
Args:
fp: a .read()-supporting file-like object
Kwargs:
ext_handlers (dict): dictionary of Ext handlers, mapping integer Ext
type to a callable that unpacks an instance of
Ext into an object
use_ordered_dict (bool): unpack maps into OrderedDict, instead of
unordered dict (default False)
use_tuple (bool): unpacks arrays into tuples, instead of lists (default
False)
allow_invalid_utf8 (bool): unpack invalid strings into instances of
InvalidString, for access to the bytes
(default False)
Returns:
A Python object.
Raises:
InsufficientDataException(UnpackException):
Insufficient data to unpack the serialized object.
InvalidStringException(UnpackException):
Invalid UTF-8 string encountered during unpacking.
UnsupportedTimestampException(UnpackException):
Unsupported timestamp format encountered during unpacking.
ReservedCodeException(UnpackException):
Reserved code encountered during unpacking.
UnhashableKeyException(UnpackException):
Unhashable key encountered during map unpacking.
The serialized map cannot be deserialized into a Python dictionary.
DuplicateKeyException(UnpackException):
Duplicate key encountered during map unpacking.
Example:
>>> f = open('test.bin', 'rb')
>>> umsgpack.unpackb(f)
{u'compact': True, u'schema': 0}
>>>
"""
return _unpack(fp, options)
def _unpack3(fp, **options):
"""
Deserialize MessagePack bytes into a Python object.
@ -1149,27 +975,11 @@ def __init():
global _utc_tzinfo
global _float_precision
global _unpack_dispatch_table
global xrange
# Compatibility mode for handling strings/bytes with the old specification
compatibility = False
if sys.version_info[0] == 3:
_utc_tzinfo = datetime.timezone.utc
else:
class UTC(datetime.tzinfo):
ZERO = datetime.timedelta(0)
def utcoffset(self, dt):
return UTC.ZERO
def tzname(self, dt):
return "UTC"
def dst(self, dt):
return UTC.ZERO
_utc_tzinfo = UTC()
_utc_tzinfo = datetime.timezone.utc
# Calculate an aware epoch datetime
_epoch = datetime.datetime(1970, 1, 1, tzinfo=_utc_tzinfo)
@ -1181,25 +991,14 @@ def __init():
_float_precision = "single"
# Map packb and unpackb to the appropriate version
if sys.version_info[0] == 3:
pack = _pack3
packb = _packb3
dump = _pack3
dumps = _packb3
unpack = _unpack3
unpackb = _unpackb3
load = _unpack3
loads = _unpackb3
xrange = range
else:
pack = _pack2
packb = _packb2
dump = _pack2
dumps = _packb2
unpack = _unpack2
unpackb = _unpackb2
load = _unpack2
loads = _unpackb2
pack = _pack3
packb = _packb3
dump = _pack3
dumps = _packb3
unpack = _unpack3
unpackb = _unpackb3
load = _unpack3
loads = _unpackb3
# Build a dispatch table for fast lookup of unpacking function

View file

@ -1,94 +0,0 @@
# Reticulum Development Roadmap
This document outlines the currently established development roadmap for Reticulum.
1. [Currently Active Work Areas](#currently-active-work-areas)
2. [Primary Efforts](#primary-efforts)
- [Comprehensibility](#comprehensibility)
- [Universality](#universality)
- [Functionality](#functionality)
- [Usability & Utility](#usability--utility)
- [Interfaceability](#interfaceability)
3. [Auxillary Efforts](#auxillary-efforts)
4. [Release History](#release-history)
## Currently Active Work Areas
For each release cycle of Reticulum, improvements and additions from the five [Primary Efforts](#primary-efforts) are selected as active work areas, and can be expected to be included in the upcoming releases within that cycle. While not entirely set in stone for each release cycle, they serve as a pointer of what to expect in the near future.
## Primary Efforts
The development path for Reticulum is currently laid out in five distinct areas: *Comprehensibility*, *Universality*, *Functionality*, *Usability & Utility* and *Interfaceability*. Conceptualising the development of Reticulum into these areas serves to advance the implementation and work towards the Foundational Goals & Values of Reticulum.
### Comprehensibility
These efforts are aimed at improving the ease of which Reticulum is understood, and lowering the barrier to entry for people who wish to start building systems on Reticulum.
- Improving [the manual](https://markqvist.github.io/Reticulum/manual/) with tutorials specifically for beginners
- Updating the documentation to reflect recent changes and improvements
- Update descriptions of protocol mechanics
- Update announce description
- Add in-depth explanation of the IFAC system
- Software
- Update software descriptions and screenshots
- Communications hardware section
- Add information about RNode external displays.
- Possibly add other relevant types here as well.
- Setup *Best Practices For...* / *Installation Examples* section.
- Home or office (example)
- Vehicles (example)
- No-grid/solar/remote sites (example)
### Universality
These efforts seek to broaden the universality of the Reticulum software and hardware ecosystem by continously diversifying platform support, and by improving the overall availability and ease of deployment of the Reticulum stack.
- Create a standalone RNS Daemon app for Android
- A lightweight and portable C implementation for microcontrollers, µRNS
- A portable, high-performance Reticulum implementation in C/C++, see [#21](https://github.com/markqvist/Reticulum/discussions/21)
- Bindings for other programming languages
### Functionality
These efforts aim to expand and improve the core functionality and reliability of Reticulum.
- Add interface hot-plug and live up/down control to running instances
- Add automatic retries to all use cases of the `Request` API
- Network-wide path balancing
- Distributed Destination Naming System
- Globally routable multicast
- Destination proxying
- [Metric-based path selection and multiple paths](https://github.com/markqvist/Reticulum/discussions/86)
### Usability & Utility
These efforts seek to make Reticulum easier to use and operate, and to expand the utility of the stack on deployed systems.
- Easy way to share interface configurations, see [#19](https://github.com/markqvist/Reticulum/discussions/19)
- Transit traffic display in `rnstatus`
- `rnsconfig` utility
### Interfaceability
These efforts aim to expand the types of physical and virtual interfaces that Reticulum can natively use to transport data.
- Plain ESP32 devices (ESP-Now, WiFi, Bluetooth, etc.)
- More LoRa transceivers
- AT-compatible modems
- Filesystem interface
- Direct SDR Support
- Optical mediums
- IR Transceivers
- AWDL / OWL
- HF Modems
- GNU Radio
- CAN-bus
- Raw SPI
- Raw i²c
- MQTT
- XBee
- Tor
## Auxillary Efforts
The Reticulum ecosystem is enriched by several other software and hardware projects, and the support and improvement of these, in symbiosis with the core Reticulum project helps expand the reach and utility of Reticulum itself.
This section lists, in no particular order, various important efforts that would be beneficial to the goals of Reticulum.
- The [RNode](https://unsigned.io/rnode/) project
- [x] Create a WebUSB-based bootstrapping utility, and integrate this directly into the [RNode Bootstrap Console](#), both on-device, and on an Internet-reachable copy. This will make it much easier to create new RNodes for average users.
## Release History
Please see the [Changelog](./Changelog.md) for a complete release history and changelog of Reticulum.

5
SECURITY.md Normal file
View file

@ -0,0 +1,5 @@
# Reporting Security Issues
It goes without saying: If you have discovered a security issue in Reticulum or any related project, please report it responsibly to the maintainer over LXMF at `<8dd57a738226809646089335a6b03695>`. If you wish to remain anonymous, please create a disposable identity to send from, and notify us of any such wishes.
If you are not able to use LXMF, you can send an email to the address currently posted on the maintainer's profile on this site.

View file

@ -2,7 +2,7 @@ Recently, and mostly from people who I've never seen before, the opinions about
- The decision to no longer mirror release notes on GitHub.
- Some people feeling there were too many "barriers to entry" to joining RNS development.
- The project not really being "open source" because random strangers couldn't just "contribute".
- The project not really being "[open source](https://reticulum.network/manual/brandolinis.html#open-source-means-open-source)" because random strangers couldn't just "contribute".
Joakim posted some very relevant observations about how Reticulum operates, along with the following quote:
@ -10,7 +10,7 @@ Joakim posted some very relevant observations about how Reticulum operates, alon
That E. F. Schumacher quote perfectly illustrates the ontological schism that makes it so tiresome to deal with stuff like this.
There is, in this day and age, between different people, widely different base conceptual integrations of what "open source" means. For many people, "open source" has become synonymous **not** with skilled people working together in a coordinated and careful way on complex engineering challenges, but a sort of growth- and attention-focused "free-for-all" *behavioral* codex that must be followed above all else; a *social* modus operandi of fake inclusivity where everyone "should have their voice heard", and adherence to that specific process is weighed much higher than the final results.
There is, in this day and age, between different people, widely different base conceptual integrations of what "open source" means. For many people, "open source" has become synonymous **not** with skilled people working together in a coordinated and careful way on complex engineering challenges, but a sort of growth- and attention-focused "free-for-all" *behavioral* codex that must be followed above all else; a *social* modus operandi of fake inclusivity where everyone "should have their voice heard", and adherence to that specific *process* is weighed much higher than the final results.
I do not subscribe to, and consequently do not operate the Reticulum project under *any* versions of that idea.
@ -28,7 +28,7 @@ I do not subscribe to, and consequently do not operate the Reticulum project und
Can you imagine how much time that wasted? Can you imagine what we could have accomplished with that time instead?
The only thing that this creates is *noise* and confusion. Clogging up the mental and physical workspaces, of people who are actually investing time and effort on the project with stuff like that is objectively just taking time that could have been used on development, and replacing it with *nothing*.
The only thing that this creates is *noise* and confusion. Clogging up the mental and physical workspaces, of people who are actually investing time and effort on the project, with stuff like that is objectively just taking time that could have been used on development, and replacing it with *nothing*.
I was receiving *actual* bug reports, pull requests, proper technical investigations and patches via methods outside GitHub and "public" internet-based channels *way* before GitHub interaction and similar was closed down. That was were almost *all* of the real contributions were coming from, anyway. Apparently, and not unsurprisingly, the people who has invested the time and effort to understand Reticulum also prefer to collaborate in this way. Since leaving the GitHub madhouse behind, the signal-to-noise ratio has **significantly** increased.
@ -46,7 +46,7 @@ When this pattern repeats, over and over, the only sensible, measured and constr
So, now it's your turn. Go look at the diffs for the last six months. What does it look like I have been doing?
But I will be damned straight with you all, and say that part of that solution is **absolutely** to erect barriers to entry. You can fucking bet your arse on that. I don't want opinionated man-babies running around in my living-room at 3am. I don't want to clean up the floor after a wannabe "dev-ops stars" with LLMs and a peripheral case of influencitis has puked all over the office.
But I will be damned straight with you all, and say that part of that solution is **absolutely** to erect barriers to entry. You can fucking bet your arse on that. I don't want opinionated man-babies running around in my living-room at 3am. I don't want to clean up the floor after a wannabe "dev-ops star" with LLMs and a peripheral case of influencitis has puked all over the office.
- If you want to join the fun of changing core networking code that thousands of people rely on for communication
daily, you better know what the fuck you're doing.

View file

@ -0,0 +1,339 @@
# Yes, I am Angry
Polite, but deeply malicious bullshit is a much worse evil than the coarse variant, so you will have to excuse my bluntness here.
Unfortunately, the only way to respond, as a normal, healthy human being, to something so astronomically tasteless and disgraceful as this, is not in kind, but in honest outrage, modulated to some degree with sarcasm and humor (the dark variant, yes).
Because *this* shit, this *is* outrageous.
The man that produced the "article" in question here is either so disposessed of intelligence that his head is teetering on the brink of collapsing into a singularity, or he is deeply malicious.
If any rational explanation can produce an alternative, I am all ears.
**To the footsoldiers of the "cause" the article represents**
Let's get this show on. Let's see the barrage of new smear pieces you can prompt forth from the fuel I provide you here. It'll fit straight into your theater production, so: Be my fucking guest, and dig your own hole of delusion, mistrust and ridicule even deeper than it already is. I can then hurl it right back at you, and we can all have a crap-fight till the end of days. *Or* we could take responsibility and ownership for our words and actions, and actually build something together. Ball's in your court.
**To anyone else**
Consider this a paranthetical, sarcastic (and for me, cathartic) aside in the whole of the corpus of writings I have published, and weigh it in the context of the rest of those 200,000 words or so, together with the frustration and exhaustion that inevitably entails from dealing with this same fabricated campaign and its regurgitated nonsense for *years* on end, while trying to do constructive and helpful work; and not expecting anything else than basic decency in return.
I am no angel, not perfect, flawed in many ways, but I certainly ain't afraid to show that I am a real human being, with the capacity for anger, and that in this case, the anger is fed by the most bitter form of disappointment and sadness in what people will stoop to for influence and attention.
[This is the article in question](https://gaggl.com/blogs/2026-09-05-enclosure-by-good-intentions/)
[This is the polite, measured analysis of the *generalized* phenomenon, all 18,000 words of it](https://reticulum.network/manual/brandolinis.html)
And here is a very short, but central point from that analysis:
> In whole, this is the description of three fronts of the same operation:
>
> * The license attack and other urgency-engineered narratives are the *cover*: They supply
> the ideological justification and battering ram for treating the reference as illegitimate,
> and needing replacement.
> * The ports are the *inventory*: they supply the “replacement” material that the narrative
> claims the ecosystem needs.
> * The takeover narrative is the *objective function*: the repeated, persistent, escalating claim that
> the reference should be replaced, and that the speakers themself are the natural heirs.
---
*This*, right here, is the real, human and honest response. And the only sane one there could ever be, lest madness be allowed to descend wholly.
While this is *written* as a response to you, Leo Gaggl, you absolute degenerate piece of fecal matter, it is really a response to the phenomenon *as a whole*.
I chose your article because it is such a complete and rich example of the madness. What I attack here is *conduct*.
Leo, according to his own evident preference, is the actor on the theatre stage, and I will treat him as such, in the play that follows.
---
> So when Mark Qvist, the person behind the [Reticulum](https://gaggl.com/blogs/2026-02-25-lpwan-meshes-reticulum-deep-dive/) mesh networking stack
Color me surprised, the link to "Reticulum" doesn't even point to the repo, but to *another* one of your sloporated "articles".
Don't wanna accidentially lead users to anywhere they could discover the truth about things now, would we? Like commit histories, the *actual* Reticulum community, that kind of stuff. Nah, nah...
> ... replaced its MIT licence with a custom one built to keep the project out of corporate AI training sets and out of weapons systems
Which happened 18 months ago. *Fuuuunny* timing bringing it up again now, right where it's [mighty fucking convenient](https://reticulum.network/manual/brandolinis.html#a-persistent-but-fragile-triad) for your slop-coding license-violating friends, don't you think?
Interesting you don't bring up *any* of the extensive argumentation made against the same old points you recycle here, but act as they simply never existed. Or maybe you just chose to completely ignore them in the service of your God-given mission?
> Reticulum is now marked non-free by Debian
Which is [excellent](https://reticulum.network/manual/brandolinis.html#the-distribution-argument), since debian users will not have to use old, and potentially outdated versions of a package that updates *fast* everytime it is required.
And "now"? Weasel wording.
> kept out of the main F-Droid repository
RNS? In the F-Droid repository? How would that even work? People install the base stack as an *app* and run it... how? Are we going to bundle a command line or `systemd` with that? Do you have any idea about how software actually works?
If people want to do that, open Termux and type `pip install rns`. Done. Has been possible and documented since the dawn of the project.
All actual *apps* that use Reticulum are distributed as APKs, ready to install and download, distribute offline, fetch from their websites, over Reticulum itself, or whatever platform they prefer. Commercially or for free. That's the point.
> It cannot be combined with GPL-licensed code
[Cry me a river](https://reticulum.network/manual/brandolinis.html#on-freedom). Not my fault that people choose to use viral licenses that absolutely must subsume everything and their aunt under their own restrictive terms.
> It is not eligible for most open-source grant funding.
Exactly, and I specifically rejected that, several times, to keep the license right and avoid political takeover. That's 50.000€ I said no to, right there, while I barely had enough to make it three months into the future. Because I value integrity and *real* sustainability a lot higher.
Do *you* have anything to show for it, in that regard? Who's footing your pay-check? A varied community of people from all walks of life who use the things you created? No? Didn't think so. I wonder who, then.
> And the clause that triggered all of this is very likely unenforceable.
"Very likely" is very likely a lot of handwaving, Leo. It's certainly not an argument. [This is](https://reticulum.network/manual/brandolinis.html#what-the-reticulum-license-is).
> As one Debian developer put it in the project’s own discussion thread, people who build harmful systems are not going to be stopped by a term they were always going to ignore.
It turns out [that most do](https://reticulum.network/manual/brandolinis.html#the-prediction-and-what-happened), because of a thing called "the law".
Or are you proposing we live in a state of complete and utter anarchy already? Or maybe that your cult is simply above the law?
> The moment you add a “not for harm” or “not for AI” condition, you have left open source behind
Strike up the organ, start the hymns.
> The Open Source Definition rules out licences that discriminate against people or groups, or against fields of endeavour
[Erratum](https://reticulum.network/manual/brandolinis.html#open-source-means-open-source): *Your* "open source" religion, not "The Open Source Definition" (Capitals Of Authority, bravo).
Yes, let's not discriminate against people, **BY FUCKING MURDERING THEM WITH DRONES**. Time for a party Leo!
Beauuuutiful way to hide [the true nonsense](https://reticulum.network/manual/brandolinis.html#what-the-argument-is-actually-doing) of the "fields of endeavour" by slyly insinuating *I* am being discriminatory.
Side-note: The overall patronizing, better-than-thy and fakely knowledgable tone is almost hilarious, if it wasn't so malicious.
> The tool was already on the shelf
Oh for Christ's sake, this one [again](https://reticulum.network/manual/brandolinis.html#recycled-wine-fermenting)...
> Kaonic tactical mesh radios are sold for military command and control, drone and autonomous-systems networking, and operation in “austere, contested environments”, and which has since been selected for NATO’s DIANA defence-innovation accelerator. It is MIT-licensed, it is an independent implementation of a protocol that is itself public domain, and its repository predates Qvist’s harm clause by nine months.
Those fuckers came to me and presented themselves as wanting to create a rust port for the good of the ecosystem, the world and the community. That was a blatant lie and front for their actual goal of acquiring my goodwill and expertise so they could quickly create a shallow clone, hardfork it with their own sloppy "Post-Quantum" crypto, and get big money from the military. They deliberately lied and manipulated to extract from a whole community and someone they believed to be a naive, vulnerable developer.
Why do you think a non-harm clause is needed? Their implementation is still not *working*, by the way, and has provided **nothing** to the community or world at large.
I cut ties with those bastards when I found out what their end-game was, but not until I had spent every ounce of energy I had slowing them down as much as humanly possible, while *I was still working on the actual Reticulum implementation and providing updates to users, managing a community, and living on next to nothing in a camper*.
Holy fuck I hate people like you Leo, you willfully ignorant, destructive, spoiled piece of shit.
> His fix trades a door nobody has worked out how to close for the loss of every distribution channel
Every distribution channel that [we never used, and never needed](https://reticulum.network/manual/brandolinis.html#the-distribution-argument)? I haven't seen PyPi acting up about our apparently infuriating conduct yet.
Moot point, even if they do, since we can distribute over Reticulum just fine. With full cryptographic authentication, and no centralized infrastructure or PKI required. Also, no governance required. *That's* resilience.
> and every downstream collaborator who needs a known-good licence.
We are definitely not short of downstream collaborators currently, so no thanks, you don't have much to sell me there.
> You pay the full price in distribution and collaboration
The quality of collaboration has gone up *significantly* since that decision. Most of the wannabes stay away and sob in their Stallman handkerchiefs, while we keep working, dilligently as always. Great success.
(Sorry Stallman, I love you)
Reticulum is available everywhere. It is one of the leanest, most dependency free software packages there is, and it ships an entire networking stack, a plethora of management utilities, a device flashing utility, a full GitHub replacement running over Reticulum, remote shell utilities, and supports a whole ecosystem of application-layer protocols and user-facing applications, including messaging (LXMF), voice (LXST), chat (RRC), content hosting and browsing (nomadnet), and many others.
*In a cross-platform, portable wheel of 537 kilobytes* that installs or embeds on every platform known to man. Drop it on a floppy, baby.
The delusions run so deep here I cannot even begin to fathom it.
> and you get an AI-training restriction a determined actor routes around in an afternoon.
In reality, of the 10 sampled language models (both proprietary and open-weights) that were tested for traces of Reticulum source material, *all* stopped training on the Reticulum source after the license change.
Turns out, *it worked*, Leo.
Did *you* check? No you didn't, handwaving and inventions again.
> A written answer to “what happens when the maintainer walks away”, because for Reticulum that stopped being hypothetical in December 2025.
Did you check the [commit logs](https://github.com/markqvist/Reticulum/commits/) you dimwit? No, you just randomly rolled around in the personal puddle of phantasmogoria you constructed from wishful thinking.
Have you seen what I have produced in those ten months? I'd wager it's a lot more than you, you lazy fuck, not even doing the most basic fact-checking of your smear-job propaganda pieces.
Try that on for a "written answer".
> There is now a Reticulum_CE, a “community edition” fork that exists mainly to carry a redistributable licence.
What psychosis are you getting these from? [Reticulum_CE](https://github.com/Reticulum-Community/Reticulum_CE) is published under the Reticulum License. Did you read their repo? No, you did not:
*"This is a community maintained fork of the Reticulum Network Stack and is intended as a public forum for discussion and issue reporting, as well as a repository to collect community enhancements to RNS."*
> There is RetiNet, an AGPL fork of the Python stack.
Which has been [defunct and abandoned for 8 months](https://codeberg.org/SkyGuy/RetiNet). Are you not equipped with visual perception or basic literacy?
> There are side-loaded F-Droid repositories so people can install the apps at all.
What nonsense is this? You are literally so horny to paint a picture of a dead system that you have to assume people cannot think for two seconds straight?
There has never been a point in time where people could not install Reticulum-based apps on Android. These have always, intentionally, been distributed as *re-distributable* APK files so people could share them offline. Sideband can host and share it's own APK. This is the point, Archimedes.
The F-droid repository was made as service to users by a volunteer in the *real* Reticulum community (not your abhorrent confabulation of what it is) who *has never once objected to the license and project directions*, and who just wanted to help make things easier!
Don't use them as ammunition in your perverse fantasies!
> Every one of those is real maintenance work, done by volunteers, spent entirely on the problem of being allowed to share the software.
A copy of my repo, then pasting my (Reticulum Licensed) work into four files, then abandoning the project is not "real maintenance work".
What I have been doing, for over a decade, is real maintenance work.
> four independent teams have rebuilt Reticulum from scratch in Rust.
Those [have not "rebuild"](https://reticulum.network/manual/brandolinis.html#copyright-the-two-legal-layers) anything ["from scratch"](https://reticulum.network/manual/brandolinis.html#the-clean-room-delusion).
They are feeding the source code of RNS directly into an LLM, running automated software loops, and copying it, blatantly, bit for bit, without any modicum of creative input, work or understanding.
Such a connoisseur of the commons you are.
> The people rebuilding the project to keep it alive
The project is already alive, and has been for a decade. You are lying so hard that it's fucking raining.
Those people, are in their own words, "Killing Reticulum" (quote, Lew Palm, "author" of "Leviculum"), so they can appropriate it for their own profit and control.
> The two that put enclosure resistance first, Leviculum and the rsReticulum stack behind Ratspeak, both chose AGPLv3.
And all of those are [void and null](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code), because what they *really* chose is **copyright violation**, and complete disrespect ([as is now becoming tradition](https://reticulum.network/manual/brandolinis.html#licensing-analysis)) for open source development practices, attribution and basic decency.
> Underneath the licence choice sits a harder problem, and it is the one this site keeps coming back to: a system can be decentralised in its architecture and completely centralised in who decides things.
Oooooh... Tension. Drama. Tell me more, let's have the juicy details, huh? Outrage incoming, I can feel it! Ramp up your audience like a good boy, Leo!
The only thing that keeps coming back here is nonsense.
The decisions follow those responsible, over years, and the actual work. Not cry-babies like you. Get used to it.
> Relicensing a codebase other people contributed to, under MIT terms, with a single unilateral commit, is itself a small act of enclosure.
I am responsible for over 98% of commits in that repository. Not a single one of the other contributors disagreed to the license decision.
But your ideology runs so deep now, *that we cannot ourselves decide how we license our own software*? Yes, let's consider for a moment **who the fucking dictator is here**...
It's *almost* like you've decided you already personally own Reticulum.
> The pattern shows up off the codebase too. In late August 2026, after a badly-behaved crawler hammered his NomadNet git node with tens of thousands of requests
Ah yes, so I got personally mad because it hit *my* node, and had a tantrum?
The scraper was hitting *every node across the entire fucking intermesh*, from a high-bandwidth VPS, with no throttling, no regards for people's radio links, airtime concerns, metered connections or limited compute resources.
For what reason? To gouge tens of **millions** of requests to a broken piece of shit software that an idiot deployed and left running without oversight, trying to infintely download **everything that everyone had ever put on their nodes several times over** on a recursive loop.
Who made that stop? I did, because I [contacted](https://github.com/roogle-dev/reticulum-phantom/issues/4) the idiot and talked him out of it.
Let's hear more of your brilliant ideas about how to "protect the commons" you fucking ape.
> Qvist removed the volunteer moderator of the Reticulum subreddit, deleted roughly three years of posts, and closed it with a notice that ended “Well done, vibecoders. You ruined it for everyone.”
The moderator was not moderating anything, and hadn't been for months. The precise original agreement with him for allowing him moderator rights was exactly the arrangement, that if the space was not moderated, it would be closed, and that I would maintain the final decision on that.
This is now being spun into yet another [bottle of piss](https://reticulum.network/manual/brandolinis.html#recycled-wine-fermenting) by the dipshits like you. Well done, moron.
> Around the same window the project’s Matrix room shut down and the GitHub issue tracker was hidden.
Does time not exist in your alternate universe? The events you allude to are separated by month or years, and the rationales were clearly stated. You deliberately ignored all of that.
Further, I did not manage or administrate the Matrix room. It was a community room, run by volunteers, flooded with spam and bad-faith actors. I had *zero* admin rights on it. The moderators of the room made that decision themselves, long after I had left - *to focus on actual work*.
The development was *never* centered on the GitHub issue tracker, it ran over Reticulum long before the preposterous madhouse of GitHub was left behind for good, as it continues to do today, as is documented and stated several places (including the front-page of the repository you refuse to link to).
Signal to noise ratio has gone up *significantly* because of that. The community is *more* alive, and more productive.
You are literally making things up as you go.
> One community member’s summary: for a supposedly decentralised network, the founder holds and uses a lot of centralised levers.
Yes, let's just [invent](https://reticulum.network/manual/brandolinis.html#the-movement-of-a-dozen) another concerned "community member". Who? Some random nutjob on reddit?
A random stranger of your choosing is not a "community member" that gets to decide how a highly complex engineering project is run. But damn man, you wish it worked that way. You wish it badly.
> None of this is meant as a pile-on.
Oh, *thanks*, you're SO *considerate* and full of empathy Leo!
> The Reticulum Licence is presented as a shield for the commons
**Because that is what it actually is.**
How about trying to actually *prove* otherwise, with *a coherent argument*, instead of getting an LLM to spin a load of high-strung nonsense to further your deranged religion?
> If your users are European public bodies
Oh, those poor administrative organizations! Do you think I dedicated my life to empowering European Public Bodies, or actual people?
> Then spend the effort you saved on the part that is actually hard: governance that survives the founder losing interest.
[Ze Governance](https://reticulum.network/manual/brandolinis.html#a-history-of-what-works), again, I see.
Maybe when you've worked 12 hours a day on a project for ten years, which is persistently flooded with this kind of profound idiocy, and kept going, can you begin to consider expounding your finer points on "losing interest".
> The commons is not protected by a clause forbidding bad uses.
It sure as hell ain't protected by pretending the world is made of candy-floss either, like you seem to prefer.
> It is protected by how many people can fork it, ship it, and keep it running without asking anyone’s permission.
Oh isn't that convenient now? How many can do that today? 99.999999% of the worlds population. What's the equation if we change to your beloved AGPL again, you moron?
> Reticulum’s licence made that number smaller.
[Mathematical failure](https://reticulum.network/manual/brandolinis.html#on-freedom).
> The intention was to guard the commons. The effect was to fence off a piece of it.
On the intent, [correct](https://reticulum.network/manual/brandolinis.html#what-the-reticulum-license-is).
On the effect, no: The effect apparently was to induce the uncomfortable feeling of grinding against your own inconsistent dogmas in dishonest, propagandistic nutjobs like you, because *you* now feel *fenced out*.
There's an important difference there.
The self-referential circle-jerk you've got going on here is *exactly* the definition of a fence; one made of hysteria and propaganda with the ulterior motive of usurping power away from a community of thousands of people, and putting it, squarely, into your own, trembling, wanting and lazy little hands.
> Reticulum is also not a toy. People run it over LoRa and packet radio for emergency and off-grid communication, so “someone degrades the network” is a safety question, not an inconvenience.
**Bingo**, Leo.
Which is why you need a *real* [responsible, accountable and competent person](https://reticulum.network/manual/brandolinis.html#a-history-of-what-works), with a decade-long track-record to run the project, not a bunch of kids with a fixation on Claude, and their sugardaddies like you.
> Here is the part that frustrates me.
Yes, you must *surely* be frustrated Leo, how very frustrating this must be for you and your ChatGPT subscription to have to go through all of this.
Tell me about frustration once you have actually done something that required more than pillow-fucking your own sense of grandiosity.
All your github repos are minor personal projects with up to... count them... coooount them... five stars. No users. You must surely know a lot about software development, distribution, and these commons you are protecting so heroically.
I can see you have a lot of skin in the game here, and a lot of work and experience on your back to bolster your authority.
Fuck off, you fraud.
> Every few years a piece of infrastructure I care about gets a new licence
You don't care one fucking bit about infrastructure Leo. *You don't even know what it is*, least of all what it takes to create it.
I do. Because I did it.
Go home, you're drunk.
Thanks for the material, though. You've provided a textbook example of the incessant bullshit, appropriation, deliberate enclosure and intentionally weaponized manipulation that [this project is up against](https://reticulum.network/manual/brandolinis.html#intent-consequence).
*In ideology and war, self-justification wears the same coat, and feeds the same destruction, perpetually.*
Yours truly,
Mark Qvist
September 18th, 2026
---
That it is, I am no longer angry. Back to work.

View file

@ -0,0 +1,253 @@
## Void Grants: Legal Foundations of Machine-Generated Code
*From the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the Reticulum Manual.*
A grant of rights is only as real as the rights behind it. A license conveys what its issuer holds, and nothing more. A derivative may be distributed only under whatever license covers what it took. And over a work that a machine originated, no human holds anything at all.
Each of these statements is a matter of settled statute and case law, and this document sets out the authorities. The conclusions they support are:
1. A software implementation generated from a licensed work by a machine, published under its operator's claimed copyright and license, **conveys rights the operator never possessed**.
2. **Its grant is void**. The projects, products, and promises built upon such grants stand on nothing. A field of void grants is a minefield for everyone who crosses it.
This document is a general examination of copyright law as it applies to machine-generated code. It is not legal advice.
### I. The Foundational Statute: Translation Is Copying
The starting point is the statutory definition of the class of works at issue. Title 17, United States Code, section 101 provides the definition of a derivative work:
> A "derivative work" is a work based upon one or more preexisting works, such as a *translation*, musical arrangement, dramatization, fictionalization, motion picture version, sound recording, art reproduction, abridgment, condensation, or *any other form in which a work may be recast, transformed, or adapted*.
Section 101 further provides that *"the terms 'including' and 'such as' are illustrative and not limitative"*. As such, the list is a list of *examples*, and the very first example is translation. The definition also reaches, in its own words, *"any other form in which a work may be recast, transformed, or adapted"*.
The exclusive right at issue is defined in section 106:
> Subject to sections 107 through 122, the owner of copyright under this title has the exclusive rights to do and to authorize any of the following:
> (1) to reproduce the copyrighted work in copies or phonorecords;
> (2) to prepare derivative works based upon the copyrighted work;
> (3) to distribute copies or phonorecords of the copyrighted work to the public by sale or other transfer of ownership, or by rental, lease, or lending;
Violation of any of these rights is infringement. 17 U.S.C. § 501(a).
Taken together, the provisions yield a construction that is difficult to improve upon and impossible to escape:
1. **A port is a translation.** A work that takes a program written in one language and from it produces a program written in another language, implementing the same behavior, is a translation of the original - whatever mechanism performed it, and however the surface presentation differs.
2. **A translation is a derivative work.** Section 101 says so, explicitly.
3. **Preparing a derivative work is the copyright owner's exclusive right.** Section 106(2) says so explicitly. A derivative work prepared without authorization, or outside the scope of any authorization, infringes that right.
The common objection - "the code is in a different language, so it is completely different" - is not a legal argument; it is the definition of what the statute already classifies.
Translation *is the paradigm* of derivative authorship, not an exception to it. If translating a work without permission was a defense, the derivative-work right would be meaningless; every infringing adaptation would simply need to be "different enough" to count as a translation of its source.
Nor does the identity of the translator change the analysis. The statute asks whether the work is "based upon" a preexisting work. It does not ask who or what performed the translation. A machine that ingests a licensed work and reproduces a translated transformation of it has performed mechanical translation; the output is a derivative of the ingested work, and the law governing derivative works applies to it exactly as it applies to a translation made by hand.
### II. Ideas Are Free, Expressions Protected
The second foundation is the distinction between what a work *is* - its ideas, its processes, its systems - and the *expression* in which those ideas are embodied. The distinction is codified in section 102(b):
> In no case does copyright protection for an original work of authorship extend to any idea, procedure, process, system, method of operation, concept, principle, or discovery, regardless of the form in which it is described, explained, illustrated, or embodied in such work.
The principle is older than the statute, and its application to software is settled. An author may own the particular code in which a method is expressed; the method itself, the process, the algorithm, belongs to no one - because *"the sine qua non of copyright is originality"* (*Feist Publications, Inc. v. Rural Telephone Service Co.*, 499 U.S. 340, 345. 1991). And ideas are not original to anyone in particular, *Baker v. Selden*, 101 U.S. 99 (1879), established the line: Copyright in a book describing a system does not prevent others from practicing the system.
Applied to a communications protocol, the structure is clean:
---
**An implementation of a public-domain protocol, honestly reasoned and written by humans, is welcome and unencumbered, and owes nothing to any license. An implementation generated from a licensed implementation is a derivative of licensed work, subject to its conditions and to the ordinary copyright of its source.**
---
The public-domain status of the protocol's ideas changes nothing about the copyright in an implementation's expression. A person who copies an implementation - by machine or by hand - cannot defend the copying by pointing to the protocol, because the protocol covers the ideas, not the symbols in which someone expressed them.
A further misconception can be dismissed here; the recently popularized "clean-room" claim about the absence of copying. A [clean-room](https://reticulum.network/manual/brandolinis.html#the-clean-room-delusion) is a not a magic label achievable by LLM, but a specific, institutionalized procedure for proving to a court that copying did not occur: Physically separated teams, written functional specifications, attestable non-exposure, and no access to the original by the implementers (*Altai*, 982 F.2d at 709–11).
*"I created a clean-room implementation by feeding the original source code into a machine"* attempts to *claim* the certified, legally established procedure for proving the absence of copying by describing, in the same breath, a process **composed entirely of copying**. The word is deployed because the procedure it names is what the claimant would *need to have happened*.
### III. Machine Output Has No Author
The third foundation is the human authorship requirement. It is settled at both levels of the federal courts and in the Copyright Office's administering practice.
In *Thaler v. Perlmutter*, the district court held that *"copyright law protects only works of human creation,"* and that *"human authorship is a bedrock requirement of copyright"* (687 F. Supp. 3d 140, 146, D.D.C. 2023). On appeal, the United States Court of Appeals for the District of Columbia Circuit affirmed, holding that the Copyright Act *"requires all eligible works to be authored in the first instance by a human being"* (No. 23-5233, D.C. Cir. Mar. 18, 2025).
The requirement traces to the ordinary meaning of the statutory term. In *Burrow-Giles Lithographic Co. v. Sarony*, the Supreme Court defined an "author" as *"he to whom anything owes its origin; originator; maker; one who completes a work of science or literature"* (111 U.S. 53, 57–58, 1884), and more recently described the author as *"the person who translates an idea into a fixed, tangible expression entitled to copyright protection"* (*Community for Creative Non-Violence v. Reid*, 490 U.S. 730, 737, 1989).
---
**A machine does not owe expression its origin; it reproduces it. No court has recognized copyright in material created by non-humans, and the courts that have addressed the question have rejected it. If you create by machine generation, you do not own the output.**
---
The Copyright Office's registration guidance applies the requirement to generative systems in terms that are directly on point. The Office asks:
> whether the "work" is basically one of human authorship, with the computer [or other device] merely being an assisting instrument, or whether the traditional elements of authorship in the work (literary, artistic, or musical expression or elements of selection, arrangement, etc.) were actually conceived and executed not by man but by a machine.
- *Copyright Registration Guidance: Works Containing Material Generated by Artificial Intelligence*, 88 Fed. Reg. 16190, 16192 (Mar. 16, 2023)
And it states the consequence: *"If a work's traditional elements of authorship were produced by a machine, the work lacks human authorship and the Office will not register it"* (*Id.*).
On the role of prompts, the Office is equally explicit: when a system receives a prompt and produces a work in response, *"these prompts function more like instructions to a commissioned artist - they identify what the prompter wishes to have depicted, but the machine determines how those instructions are implemented in its output"* (*Id.*).
The agency applied this standard and excluded machine-originated material from registration in the leading administrative case. In *Zarya of the Dawn* (Cancellation Decision, Feb. 21, 2023, Registration No. VAu001480196), the Office cancelled registration of images generated by the Midjourney service, holding that *"it was Midjourney-not [the author]-that originated the 'traditional elements of authorship' in the images"*, while preserving protection for the human-authored text and the human selection and arrangement of the work.
The Copyright Office's 2025 report on AI copyrightability reaches the same conclusion:
> Based on the functioning of current generally available technology, prompts do not alone provide sufficient control.
- U.S. Copyright Office, *Copyright and Artificial Intelligence, Part 2: Copyrightability*, at iii (Jan. 29, 2025)
The report's analysis is explicit: *"The Office concludes that, given current generally available technology, prompts alone do not provide sufficient human control to make users of an AI system the authors of the output. Prompts essentially function as instructions that convey unprotectible ideas"* (*Id.* at 18).
Its guiding distinction is stated plainly: *"The use of AI tools to assist rather than stand in for human creativity does not affect the availability of copyright protection for the output"* while *"Copyright does not extend to purely AI-generated material, or material where there is insufficient human control over the expressive elements"* (*Id.* at iii).
The boundary is therefore not "AI or no AI". It is *assistance or substitution*: Whether a human exercised sufficient creative control over the expressive elements and can answer for them, or whether the machine originated them and the human merely supplied instructions and a signature. That is a question of fact in each case, determined from the record of how the work was made.
Two facts follow, and stated together, they form the base legal reality:
1. **A work whose expressive elements were originated by a machine contains no valid new copyright: It has no human author. To the extent that the same work reproduces a preexisting copyrighted work without explicit license, it is at the same time an unauthorized derivative. It is unowned for anything new it contains, and unlicensed for everything taken.**
2. **Its operator therefore holds no copyright in it and no grant over it - and a person who holds neither copyright nor grant can convey neither.**
The first fact makes the work **unownable** by its operator. The second makes it infringing against its source. Neither replaces the other:
- The absence of copyright in machine output does not make the output free for anyone to take; it makes it unowned.
- This is a different thing entirely, especially when what it contains is someone else's expression.
### IV. License Conditions Bound the Grant
The fourth foundation governs what a license *is*. A copyright owner may grant permission to use the work on conditions. The conditions define the scope of the permission. Use outside the scope is use that was never authorized.
In *Jacobsen v. Katzer*, 535 F.3d 1373 (Fed. Cir. 2008), the Federal Circuit held that the conditions of an open-source license are *conditions of the copyright license itself*, not mere contractual promises, and that a licensee operating outside them is an infringer:
> Copyright holders who engage in open source licensing have the right to control the modification and distribution of copyrighted material.
> The choice to exact consideration in the form of compliance with the open source requirements of disclosure and explanation of changes, rather than as a dollar-denominated fee, is entitled to no less legal recognition.
> It is outside the scope of the [license] to modify and distribute the copyrighted materials without copyright notices and a tracking of modifications from the original computer files.
- *Id.* at 1381–82
The doctrinal mechanism is settled: *"A copyright owner who grants a nonexclusive license to use his copyrighted material waives his right to sue the licensee for copyright infringement"* within the scope of the license. If, however *"the licensee acts outside the scope, the licensor can bring an action for copyright infringement"*: An act outside the granted scope **is infringement** (*Sun Microsystems, Inc. v. Microsoft Corp.*, 188 F.3d 1115, 1121, 9th Cir. 1999; *Graham v. James*, 144 F.3d 229, 236, 2d Cir. 1998; *S.O.S., Inc. v. Payday, Inc.*, 886 F.2d 1081, 1087, 9th Cir. 1989).
Whether a given license term is a condition of the grant or a mere covenant is determined from the license's own language; conditions that limit what may be copied, modified, and distributed - the attribution terms, the notice terms, the modification-tracking terms, the field-of-use terms - are the boundaries of the copyright grant (*MDY Industries, LLC v. Blizzard Entertainment, Inc.*, 629 F.3d 928, 940, 9th Cir. 2010).
Three consequences follow for the analysis at hand. They are not hypothetical.
**First, the conditions are measured at the moment of copying**
A work is licensed under whatever conditions applied when the reproduction, adaptation, and distribution occurred. There is **no** period in which copying a licensed work is "free" because the copy was made by a machine; the machine is the *licensee's tool*, and the licensee is bound by the conditions regardless. A derivative generated without satisfying the conditions - without the attribution, without authorization for the manner of use - was never within the scope of the license, from the first instant of its existence.
**Second, breach does not restore rights that never attached**
The district court in *Artifex Software, Inc. v. Hancom, Inc.*, No. 16-cv-06982-JSC (N.D. Cal. Sept. 12, 2017), analyzing the GPL's termination provisions, observed that *"the language of the GPL suggests that [the licensee's] obligations persisted beyond termination of its rights to propagate software using Ghostscript,"* because the source-code obligation recurs each time a covered work is conveyed. The same structure operates a fortiori where a derivative was produced *outside* the license entirely: No grant ever attached, and no subsequent event creates one.
**Third, the settled conditions are the enforceable core**
The attribution and notice conditions rest on the settled doctrine above. Novel conditions (restrictions on field of use, restrictions on how the work may be consumed) are enforceable as conditions of the grant when clearly drafted, but their enforcement history is thinner. The argument that a derivative is unlicensed does *not* depend on the novel conditions being vindicated; it needs **only** the settled ones, and the ordinary law of derivative works, to stand.
### V. The Void Grant
The preceding sections assemble into a single structure. Let us apply it to an example situation:
- A project presented as an independent implementation of a protocol; "ground-up", "from scratch" or "clean-room" work.
- The actual history shows continuous, machine-mediated ingestion of a licensed reference implementation.
- The output is generated at a scale no human could produce or review, with the provenance of the process obscured.
**Step One: The derivative**
The project's output is a translation of the licensed implementation: A derivative work, produced by a copy process. 17 U.S.C. §§ 101, 106(2). Preparing it without authorization infringed from the first moment of its existence. *Id.* § 501(a).
**Step Two: The license**
The derivative was produced outside the conditions of the license; without the notices, without the attribution. The license never attached (*Jacobsen*, 535 F.3d at 1381–82).
**Step Three: Authorship**
The expression was originated by a machine. No human can account for its creative decisions, and no human authored it. It contains no valid new copyright (*Thaler*, 687 F. Supp. 3d at 146; No. 23-5233, D.C. Cir. 2025; 88 Fed. Reg. at 16192).
**Consequence: Void Grant**
The statute supplies the consequence. Section 103(a) provides:
> The subject matter of copyright as specified by section 102 includes compilations and derivative works, but protection for a work employing preexisting material in which copyright subsists does not extend to any part of the work in which such material has been used unlawfully.
And section 103(b) provides that copyright in a derivative work *"extends only to the material contributed by the author of such work."* The legislative history states the point without ambiguity: *"An unauthorized translation of a novel could not be copyrighted at all"* (H.R. Rep. No. 94-1476, at 57, 1976).
In our example, the operator of the derivative's copy process decides to assert copyright over the output, and licenses it to the world under terms of the operator's choosing, for example the Apache License.
But the operator holds no copyright in machine-originated content (section III) and no license over the source from which it was derived (sections I and IV).
For the derived material, the operator's copyright claim **is an assertion of a right that never existed**. The material was used unlawfully, and section 103(a) withholds protection from *every* part of the work in which it appears. For the machine-originated remainder, the operator holds no copyright either, for lack of any human author.
The operator's license to the world is a promise the operator had no power to make. Every person who receives the work believing they received a valid grant has received **a void grant**, and stands, as a matter of settled law, in the position of a downstream user of an unauthorized derivative, whatever they were told at the time.
This is a cascading consequence, and it is why the pattern is a hazard. No combination of self-granted licenses - "MIT," "Apache," "public domain," anything at all - can transfer rights the operator never held. Every downstream project, product, or deployment built on the derivative is built on unlicensed reproduction of a third party's work, whether or not it knew, whether or not it was told, and whether or not the marketing was plausible.
The longer the derivative circulates unexamined, the more it becomes embedded, forked, vendored, depended upon, and the more expensive its correction becomes for everyone who trusted it. A field of superficially real projects, repositories, and licenses that **in reality confer nothing on anyone who relies on them is a minefield**, in the most precise sense the law allows: The rights they purport to grant *were never possessed*.
The law defines what may be asserted, not what will be. The copyright owner may choose whether and how to assert these rights, and at what point in time. The exposure exists regardless, and for *every* downstream that trusted the void grant. This is the scope of the hazard that the conduct exemplified here produces.
### VI. Fair Use Does Not Rescue This Pattern
Every defendant raises fair use. It is an essential and important limitation in the service of free speech, accountability, criticism, education and other important domains. Therefore, it must be considered, and in the case exemplified here, it fails.
Fair use is a limitation on the exclusive rights, evaluated case by case under four factors: The purpose and character of the use; the nature of the copyrighted work; the amount and substantiality of the portion used; and the effect of the use on the potential market for or value of the work (17 U.S.C. § 107).
On the first factor, the governing standard is *Andy Warhol Foundation for the Visual Arts, Inc. v. Goldsmith*, 598 U.S. 508 (2023), in which the Supreme Court held that:
> In sum, the first fair use factor considers whether the use of a copyrighted work has a further purpose or different character, which is a matter of degree, and the degree of difference must be balanced against the commercial nature of the use
The question is whether the use serves a further purpose, not whether the surface changed. A derivative that exists to serve as the same thing as the original, marketed to the people who would otherwise use the original, has not acquired a further purpose by being translated; the surface changed, the function did not, and the use is commercial.
The remaining factors resolve the same way. Source code is expressive creative work, not bare fact. The pattern under examination copies the whole; a complete implementation, ingested and translated in its entirety. And the market effect is the decisive factor, because it is engineered: A derivative marketed as a drop-in replacement for the work it was copied from is not a complement to the original's market, it is a substitute for it. The Supreme Court has long held that use of a work to satisfy the market demand for the original is the paradigm of an *unfair* use (*Harper & Row, Publishers, Inc. v. Nation Enterprises*, 471 U.S. 539, 566–67, 1985).
The one case that is always raised in response, *Google LLC v. Oracle America, Inc.*, 593 U.S. 1 (2021), holds less than it is believed to. The Court there assumed *"for argument's sake, that the material was copyrightable"* and held *"that the copying here at issue nonetheless constituted a fair use"* (*Id.* at 11).
The material in question was a thin slice of *declaring code*, which the Court found sits, *"if copyrightable at all, further than are most computer programs (such as the implementing code) from the core of copyright"* (*id.*), in a use that built a new platform for a new environment, with the copier **having written the vast majority of its own code**. By comparison: A wholesale translation of an entire implementation, marketed as a substitute for it, is **not** that case; it is the other end of every factor.
The valuable zones of fair use remain, and they are important. **Commentary and criticism**, quoting from a work to review, discuss or criticize it. **Teaching and scholarship**, explaining concepts and quoting excerpts, is its traditional beneficiary.
### Conclusion
The law is neither obscure nor new. The conclusion it yields can now be stated:
- A machine translation of a protected implementation **is a derivative work**, and an unauthorized derivative **is an infringement**, whatever language it speaks and whatever machinery produced it.
- A work whose expression a machine originated has no human author, no valid new copyright, and without valid copyright **no license can be granted on it**.
- § 103 withholds protection from every part of a derivative in which protected material was used unlawfully. A license is a grant with conditions, and a derivative made outside the conditions **never received a grant**.
- A person who holds no copyright and no grant **can convey neither**, and *every* downstream consumer who received from them received a **void grant**.
This is a *catastrophic* failure state for all who want to create things good and useful: The projects that inherit only void grants and liability believing they have inherited foundations, the users who depend on code that no one on earth can answer for, the honest builders whose work is buried under a field of plausible-looking impostors.
### Sources and Authorities
Statutory references are to Title 17, United States Code. Case citations are to the official or generally cited reporters. Online links are for reference only. Section numbers refer to the sections of this document in which each authority is principally relied upon.
**Statutes**
- [17 U.S.C. § 101 (definitions: "derivative work," "including," "publication") - I, II](https://www.law.cornell.edu/uscode/text/17/101)
- [17 U.S.C. § 102(b) (idea/expression) - II](https://www.law.cornell.edu/uscode/text/17/102)
- [17 U.S.C. § 103(a)–(b) (derivative works employing unlawfully used preexisting material) - V](https://www.law.cornell.edu/uscode/text/17/103)
- [17 U.S.C. § 106(1)–(3) (exclusive rights: reproduction, derivative works, distribution) - I](https://www.law.cornell.edu/uscode/text/17/106)
- [17 U.S.C. § 107 (fair use) - VI](https://www.law.cornell.edu/uscode/text/17/107)
- [17 U.S.C. § 501(a) (infringement) - I, V](https://www.law.cornell.edu/uscode/text/17/501)
**Judicial decisions**
- [*Jacobsen v. Katzer*, 535 F.3d 1373 (Fed. Cir. 2008) - IV, V](https://www.casemine.com/judgement/us/5914b290add7b04934760a54)
- [*Sun Microsystems, Inc. v. Microsoft Corp.*, 188 F.3d 1115 (9th Cir. 1999) - IV](https://law.justia.com/cases/federal/appellate-courts/F3/188/1115/629501/)
- [*Graham v. James*, 144 F.3d 229 (2d Cir. 1998) - IV](https://caselaw.findlaw.com/court/us-2nd-circuit/1011522.html)
- [*S.O.S., Inc. v. Payday, Inc.*, 886 F.2d 1081 (9th Cir. 1989) - IV](https://law.justia.com/cases/federal/appellate-courts/F2/886/1081/19091/)
- [*MDY Industries, LLC v. Blizzard Entertainment, Inc.*, 629 F.3d 928 (9th Cir. 2010) - IV](https://law.justia.com/cases/federal/appellate-courts/ca9/09-15932/09-15932-2011-02-25.html)
- [*Artifex Software, Inc. v. Hancom, Inc.*, No. 16-cv-06982-JSC (N.D. Cal. Sept. 12, 2017) (settled; dismissed with prejudice Jan. 2018) - IV](https://storage.courtlistener.com/recap/gov.uscourts.cand.305835/gov.uscourts.cand.305835.54.0_1.pdf)
- [*Thaler v. Perlmutter*, 687 F. Supp. 3d 140 (D.D.C. 2023), aff'd, No. 23-5233 (D.C. Cir. Mar. 18, 2025) - III, V](https://www.bitlaw.com/source/cases/copyright/Thaler-Perlmutter-Dct.html)
- [*Burrow-Giles Lithographic Co. v. Sarony*, 111 U.S. 53 (1884) - III](https://supreme.justia.com/cases/federal/us/111/53/)
- [*Community for Creative Non-Violence v. Reid*, 490 U.S. 730 (1989) - III](https://supreme.justia.com/cases/federal/us/490/730/)
- [*Feist Publications, Inc. v. Rural Telephone Service Co.*, 499 U.S. 340 (1991) - II](https://supreme.justia.com/cases/federal/us/499/340/)
- [*Baker v. Selden*, 101 U.S. 99 (1879) - II](https://supreme.justia.com/cases/federal/us/101/99/)
- [*Computer Associates International, Inc. v. Altai, Inc.*, 982 F.2d 693 (2d Cir. 1992) - II](https://law.justia.com/cases/federal/appellate-courts/F2/982/693/137252/)
- [*Andy Warhol Foundation for the Visual Arts, Inc. v. Goldsmith*, 598 U.S. 508 (2023) - VI](https://www.supremecourt.gov/opinions/22pdf/21-869_87ad.pdf)
- [*Google LLC v. Oracle America, Inc.*, 593 U.S. 1 (2021) - VI](https://supreme.justia.com/cases/federal/us/593/18-956/)
- [*Harper & Row, Publishers, Inc. v. Nation Enterprises*, 471 U.S. 539 (1985) - VI](https://supreme.justia.com/cases/federal/us/471/539/)
**Administrative and policy authorities**
- [*Copyright Registration Guidance: Works Containing Material Generated by Artificial Intelligence*, 88 Fed. Reg. 16190 (Mar. 16, 2023) - III](https://www.federalregister.gov/documents/2023/03/16/2023-05321/copyright-registration-guidance-works-containing-material-generated-by-artificial-intelligence)
- [*Zarya of the Dawn*, U.S. Copyright Office (Cancellation Decision, Feb. 21, 2023), Registration No. VAu001480196 - III](https://www.copyright.gov/docs/zarya-of-the-dawn.pdf)
- [U.S. Copyright Office, *Copyright and Artificial Intelligence, Part 2: Copyrightability* (Jan. 29, 2025) - III](https://www.copyright.gov/ai/Copyright-and-Artificial-Intelligence-Part-2-Copyrightability-Report.pdf)
- [H.R. Rep. No. 94-1476 (1976) - V](https://law.resource.org/pub/us/works/aba/ibr/H.Rep.94-1476.pdf)

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: 4e2f0f7390586c35b5c4b57cd1fc8b5b
config: a83095d108892439ad6027acd44f7c0a
tags: 645f666f9bcd5a90fca523b33c5a78b7

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,16 @@ This chapter of the manual serves as the technical reference for the distributed
A set of utilities for distributed collaborative software development and publishing are included in RNS.
.. only:: html
.. image:: screenshots/rngit.webp
:align: center
.. only:: latex
.. image:: screenshots/rngit.png
:align: center
The system consists of two parts: The ``rngit`` node that hosts repositories, and the ``git-remote-rns`` helper that enables Git to communicate with rngit nodes. As soon as you have RNS installed on your system, you can transparently use Git with Reticulum-hosted repositories just like any other type of remote. Git over Reticulum uses URLs in the following format: ``rns://DESTINATION_HASH/group/repo``.
If you set a branch to track a Reticulum remote as the default upstream, you can simply use ``git`` as you normally would; all commands work transparently and as expected.
@ -133,6 +143,9 @@ To create a new empty repository on a remote node:
This creates a bare Git repository at the specified path. You must have ``create`` permission for the target group. When a repository is created, the creator automatically receives ``adm`` (admin) permissions on the repository through an auto-generated ``.allowed`` file.
.. tip::
If you want to use a different primary branch name than ``master``, you can change it inside the bare git repository directory with ``git symbolic-ref HEAD refs/heads/name_of_other_branch``.
**All Command-Line Options (rngit create)**
.. code:: text
@ -683,6 +696,7 @@ The following template files are supported:
- ``commit.mu`` - Individual commit detail page
- ``refs.mu`` - Branches and tags listing
- ``stats.mu`` - Statistics page
- ``no_ident.mu`` - Displayed to non-identified initiators if blocked
Templates can include the following variables:

View file

@ -32,6 +32,7 @@ to participate in the development of Reticulum itself.
support
examples
license
brandolinis
.. toctree::
:maxdepth: 2

View file

@ -998,6 +998,9 @@ When ``discoverable`` is enabled, a variety of additional options become availab
``discovery_name``
A human-readable name for the interface. This name will be displayed to users on remote systems when they list discovered interfaces. If not specified, the interface name (the section header) will be used.
``discovery_lxmf_address``
An optional LXMF address for contacting the operator of this interface. The address will be displayed to users on remote systems when they view details for discovered interfaces. Useful for getting in touch with other operators to interconnect networks with and coordinating network coverage.
``announce_interval``
The interval in minutes between successive discovery announces for this interface. Default is 360 minutes (6 hours). For stable, long-running infrastructure, higher intervals (12 to 22 hours) are usually sufficient and reduce network load. Minimum allowed value is 5 minutes (but expect to have your announces throttled if using intervals below one hour).
@ -1567,4 +1570,34 @@ but all the parameters are exposed for configuration if needed.
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.
interface basis under the relevant interface configuration section.
Tuning Ingress Queues
=====================
Reticulum uses separate, prioritized queues for processing inbound packets. The default four queues are drained in the following order:
1. Data and general traffic
2. Announces
3. Path requests
4. Ingress-limited traffic
You can tune the queue sizes from the defaults, for example if you're running a setup where you want to simply drop most traffic from ingress-limited interfaces. The queue sizes are configured in the ``[reticulum]`` section of the config
* | The ``qlen_in_data`` option sets the maximum size of the data
queue. Defaults to ``1024``.
* | The ``qlen_in_announce`` option sets the maximum size of the announce
queue. Defaults to ``128``.
* | The ``qlen_in_pr`` option sets the maximum size of the path request
queue. Defaults to ``128``.
* | The ``qlen_in_il`` option sets the maximum size of the ingress limiter
queue. Defaults to ``8``.
If an inbound packet arrives and its target queue is full, the packet is dropped. Reticulum will always completely drain higher-priority queues before starting to drain a lower-priority one. As an example, the ingress limiter queue will only start draining once all other queues are empty.

View file

@ -4,146 +4,225 @@
Programs Using Reticulum
************************
This chapter provides a non-exhaustive list of notable programs, systems and application-layer
protocols that have been built using Reticulum.
This chapter provides a non-exhaustive list of notable programs, systems and application-layer protocols that have been built using Reticulum.
These programs will let you get a feel for how Reticulum works. Most of them have been designed
to run well even over slow networks based on LoRa or packet radio, but all can also be used over fast
links, such as local WiFi, wired Ethernet, the Internet, or any combination.
Many different applications using Reticulum already exist, serving a wide variety of purposes from day-to-day communication and information sharing to systems administration and tackling advanced networking and communications challenges.
As such, it is easy to get started experimenting, without having to set up any radio
transceivers or infrastructure just to try it out. Launching the programs on separate
devices connected to the same WiFi network is enough to get started, and physical
radio interfaces can then be added later.
.. tip::
For ease of discovery while reading this manual over the web or as an e-book, links provided in this chapter primarily point to repositories and pages located on internet servers. In many cases, these are public web mirrors, with the upstream repositories residing on Reticulum nodes. For the latest source, updates and information regarding any project, be sure to check their Nomad Network or ``rngit`` nodes.
Programs & Utilities
====================
These programs will let you get a feel for how Reticulum works. Most of them have been designed to run well even over slow networks based on LoRa or packet radio, but all can also be used over fast links, such as local WiFi, wired Ethernet, the Internet, or any combination.
Many different applications using Reticulum already exist, serving a wide variety of purposes
from day-to-day communication and information sharing to systems administration and tackling
advanced networking and communications challenges.
As such, it is easy to get started experimenting, without having to set up any radio transceivers or infrastructure just to try it out. Launching the programs on separate devices connected to the same WiFi network is enough to get started, and physical radio interfaces can then be added later.
Development of Reticulum-based applications and systems is ongoing, so consider this list
a non-exhaustive starting point of *some* of the options available. With a bit of searching,
primarily over Reticulum itself, you will find many more interesting things.
Development of Reticulum-based applications and systems is ongoing, so consider this list a non-exhaustive starting point of *some* of the options available. With a bit of searching, primarily over Reticulum itself, you will find many more interesting things.
Remote Shell
^^^^^^^^^^^^
Browsing & Publishing
=====================
The `rnsh <https://github.com/acehoss/rnsh>`_ program lets you establish fully interactive
remote shell sessions over Reticulum. It also allows you to pipe any program to or from a
remote system, and is similar to how ``ssh`` works. The ``rnsh`` program is very efficient, and
can facilitate fully interactive shell sessions, even over extremely low-bandwidth links,
such as LoRa or packet radio.
In addition to the programs listed in this section, other Nomad Network clients include:
In addition to the default, fully interactive terminal mode,
for extremely limited links, ``rnsh`` offers line-interactive mode, allowing you to interact
with remote systems, even when link throughput is counted in a few hundreds of bits per second.
.. raw:: latex
\newpage
- `MeshChatX <https://meshchatx.com/>`_
- `Columba <https://github.com/torlando-tech/columba/>`_
Nomad Network
^^^^^^^^^^^^^
The terminal-based program `Nomad Network <https://github.com/markqvist/nomadnet>`_
provides a complete encrypted communications suite built with Reticulum. It features
encrypted messaging (both direct and delayed-delivery for offline users), file sharing,
and has a built-in text-browser and page server with support for dynamically rendered pages,
user authentication and more.
The terminal-based program `Nomad Network <https://github.com/markqvist/nomadnet>`_ provides a complete encrypted communications suite built with Reticulum.
.. image:: screenshots/nomadnet_3.png
:target: https://github.com/markqvist/nomadnet
.. only:: html
`Nomad Network <https://github.com/markqvist/nomadnet>`_ is a user-facing client
for the messaging and information-sharing protocol LXMF.
.. image:: screenshots/nomadnet_6.webp
:align: center
:target: https://github.com/markqvist/nomadnet
.. only:: latex
.. image:: screenshots/nomadnet_6.png
:align: center
:target: https://github.com/markqvist/nomadnet
It features encrypted LXMF messaging (both direct and delayed-delivery for offline users), file sharing, and has a built-in text-browser and page server with support for dynamically rendered pages, user authentication and more. It also includes an RRC client and a suite of network utilities.
Ren Browser
^^^^^^^^^^^
`Ren Browser <https://github.com/Quad4-Software/Ren-Browser>`_ is a modern, standalone browser for Nomad Network using the `Reticulum-Go <https://github.com/Quad4-Software/Reticulum-Go>`_ implementation. The project is actively being developed, and should be considered alpha level software, but much of the functionality is already working. If you want to help expanding the ecosystem, this is an excellent project to take a look at.
.. only:: html
.. image:: screenshots/ren_browser.webp
:align: center
:target: https://github.com/fr33n0w/rBrowser
.. only:: latex
.. image:: screenshots/ren_browser.png
:align: center
:target: https://github.com/fr33n0w/rBrowser
Ren Browser is great example of an application aiming for true, seamless interoperability with the existing software ecosystem. It can operate fully standalone using it's own embedded `Go implementation <https://github.com/Quad4-Software/Reticulum-Go>`_ of Reticulum, or seamlessly utilise a shared RNS instance already running on the system.
RNS Page Node
^^^^^^^^^^^^^
`RNS Page Node <https://git.quad4.io/RNS-Things/rns-page-node>`_ is a simple way to serve pages and files to any other Nomad Network compatible client. Drop-in replacement for NomadNet nodes that primarily serve pages and files.
`RNS Page Node <https://github.com/Quad4-Software/rns-page-node>`_ is a simple way to serve pages and files to any other Nomad Network compatible client. Drop-in replacement for NomadNet nodes that primarily serve pages and files.
Retipedia
^^^^^^^^^
You can host the entirity of Wikipedia (or any ``.zim``) file to other Nomad Network clients using `Retipedia <https://github.com/RFnexus/Retipedia>`_.
You can host the entirity of Wikipedia, StackExchange or any other ``.zim`` file, to Nomad Network clients using `Retipedia <https://github.com/RFnexus/Retipedia>`_.
.. only:: html
.. image:: screenshots/retipedia.webp
:align: center
:target: https://github.com/RFnexus/Retipedia
.. only:: latex
.. image:: screenshots/retipedia.png
:align: center
:target: https://github.com/RFnexus/Retipedia
Git Over Reticulum
^^^^^^^^^^^^^^^^^^
The included :ref:`rngit<git-main>` system provides a fully distributed solution for managing, hosting, collaborating on and interacting with Git repositories over Reticulum.
.. only:: html
.. image:: screenshots/rngit.webp
:align: center
.. only:: latex
.. image:: screenshots/rngit.png
:align: center
.. raw:: latex
\newpage
Messaging & Voice
=================
Sideband
^^^^^^^^
If you would rather use an LXMF client with a graphical user interface, you can take
a look at `Sideband <https://unsigned.io/sideband>`_, which is available for Android,
Linux, macOS and Windows. Sideband is an advanced LXMF and LXST client, and a multi-purpose Reticulum
utility, with features and functionality targeted at advanced users.
`Sideband <https://github.com/markqvist/Sideband>`_ is a technical LXMF and LXST client, for Android, Linux, macOS and Windows. It also serves as a multi-purpose Reticulum utility, with features and functionality targeted at advanced users.
.. only:: html
.. image:: screenshots/sideband_devices.webp
:align: center
:target: https://unsigned.io/sideband
:target: https://github.com/markqvist/Sideband
.. only:: latex
.. image:: screenshots/sideband_devices.png
:align: center
:target: https://unsigned.io/sideband
:target: https://github.com/markqvist/Sideband
Sideband allows you to communicate with other people or LXMF-compatible
systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, Encrypted QR
Paper Messages, or anything else Reticulum supports.
Sideband allows you to communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, Encrypted QR Paper Messages, or anything else Reticulum supports.
It also interoperates with all other LXMF clients, and provides advanced features such as voice messaging,
real-time voice calls, file attachments, private telemetry sharing, and a full
plugin system for expandability.
It interoperates with all other LXMF clients, and provides advanced features such as PTT and voice messaging, real-time voice calls, file attachments, private telemetry sharing, and a full plugin system for expandability.
.. raw:: latex
\newpage
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 <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.
`MeshChatX <https://meshchatx.com/>`_ is an all-in-one Reticulum client with messaging, calling, and a NomadNet browser, providing everything you need for Reticulum, LXMF, and LXST in one beautiful and feature-rich application.
.. only:: html
.. image:: screenshots/meshchatx.webp
:align: center
:target: https://git.quad4.io/RNS-Things/MeshChatX
:target: https://meshchatx.com/
.. only:: latex
.. image:: screenshots/meshchatx.png
:align: center
:target: https://git.quad4.io/RNS-Things/MeshChatX
:target: https://meshchatx.com/
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.
Features include full LXST support, custom voicemail, phonebook, contact sharing, ringtone support, multi-identity handling, modern UI/UX, offline documentation, expanded tools, page archiving, integrated maps, telemetry and improved application security.
.. raw:: latex
Columba
^^^^^^^
\newpage
`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. It also includes LXST voice call functionality, and a mobile Nomad Network page browser.
.. only:: html
.. image:: screenshots/columba.webp
:align: center
:target: https://github.com/torlando-tech/columba/
.. only:: latex
.. image:: screenshots/columba.png
:align: center
:target: https://github.com/torlando-tech/columba/
Ren TUI
^^^^^^^
`Ren TUI <https://github.com/Quad4-Software/Ren-TUI>`_ is an early prototype terminal LXMF client for Reticulum built with Odin on librns (Reticulum-Go). It aims for NomadNet-like messaging/browsing without urwid or ncurses.
.. only:: html
.. image:: screenshots/rentui.webp
:align: center
:target: https://github.com/Quad4-Software/Ren-TUI
.. only:: latex
.. image:: screenshots/rentui.png
:align: center
:target: https://github.com/Quad4-Software/Ren-TUI
Chat
====
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.
The `rrcd <https://github.com/kc1awv/rrcd>`_ program provides a functional, reference RRC hub-server daemon implementation. RRC user clients include `rrc-gui <https://github.com/kc1awv/rrc-gui>`_ and `rrc-web <https://github.com/kc1awv/rrc-web>`_.
.. only:: html
.. image:: screenshots/rrc.webp
:align: center
.. only:: latex
.. image:: screenshots/rrc.png
:align: center
RRC is closer in spirit to IRC than to modern “everything platforms.” You connect, you join a room, you talk, and then you leave. If you were present, you saw the conversation. If you were not, the conversation did not wait for you. This is not an accident. This is the entire design.
The `rrcd <https://github.com/kc1awv/rrcd>`_ program provides a functional, reference RRC hub-server daemon implementation. RRC user clients include:
- `Nomad Network <https://github.com/markqvist/NomadNet/>`_
- `MeshChatX <https://meshchatx.com/>`_
- `Eridanus <https://github.com/torlando-tech/eridanus>`_
- `rrc-gui <https://github.com/kc1awv/rrc-gui>`_
- `rrc-web <https://github.com/kc1awv/rrc-web>`_
RetiBBS
^^^^^^^
`RetiBBS <https://github.com/kc1awv/RetiBBS>`_ is a bulletin board system implementation for Reticulum networks.
`RetiBBS <https://github.com/kc1awv/RetiBBS>`_ is an old-school bulletin board system implementation for Reticulum networks.
.. only:: html
@ -163,29 +242,14 @@ RetiBBS allows users to communicate through message boards in a secure manner.
\newpage
RBrowser
^^^^^^^^
Voice & Telephony
=================
The `rBrowser <https://github.com/fr33n0w/rBrowser>`_ program is a cross-platform, standalone, web-based browser for exploring NomadNetwork Nodes over Reticulum Network. It automatically discovers NomadNet nodes through network announces and provides a user-friendly interface for browsing distributed content with Micron markup support.
In addition to the programs listed in this section, other LXST voice clients include:
.. only:: html
.. image:: screenshots/rbrowser.webp
:align: center
:target: https://github.com/fr33n0w/rBrowser
.. only:: latex
.. image:: screenshots/rbrowser.png
:align: center
:target: https://github.com/fr33n0w/rBrowser
Includes useful features like automatic listening for announce, adding nodes to favorites, browsing and rendering any kind of NomadNet links, downloading files from remote nodes, a unique local NomadNet Search Engine and more.
.. raw:: latex
\newpage
- `Sideband <https://github.com/markqvist/Sideband>`_
- `MeshChatX <https://meshchatx.com/>`_
- `Columba <https://github.com/torlando-tech/columba/>`_
Reticulum Network Telephone
^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -206,14 +270,31 @@ The ``rnphone`` program, included as part of the `LXST <https://github.com/markq
It supports interfacing directly with hardware peripherals such as GPIO keypads and LCD displays, providing a modular system for building secure hardware telephones.
.. raw:: latex
\newpage
Partyline
^^^^^^^^^
`Partyline <https://github.com/RFnexus/partyline>`_ is a group voice application for Reticulum, built on `LXST <https://github.com/markqvist/LXST>`_, inspired by `Mumble <https://github.com/mumble-voip/mumble>`_.
.. only:: html
.. image:: screenshots/partyline.webp
:align: center
:target: https://github.com/markqvist/LXST
.. only:: latex
.. image:: screenshots/partyline.png
:align: center
:target: https://github.com/markqvist/LXST
It provides both realtime group voice over any Reticulum transport capable of >6 kilobits per second, LXST dial-in support, listen-only music/broadcasting rooms, and many other useful functions.
LXST Phone
^^^^^^^^^^
The `LXST Phone <https://github.com/kc1awv/lxst_phone>`_ program is a cross-platform desktop application for performing LXST voice calls over Reticulum.
The `LXST Phone <https://github.com/kc1awv/lxst_phone>`_ program is a cross-platform desktop application for performing LXST voice calls over Reticulum. It supports various advanced features such as SAS verification, peer blocking, rate limiting, encrypted call history storage and contact management.
.. only:: html
@ -227,28 +308,60 @@ The `LXST Phone <https://github.com/kc1awv/lxst_phone>`_ program is a cross-plat
:align: center
:target: https://github.com/kc1awv/lxst_phone
It supports various advanced features such as SAS verification, peer blocking, rate limiting, encrypted call history storage and contact management.
.. raw:: latex
\newpage
LXMFy
^^^^^
System Administration
=====================
`LXMFy <https://lxmfy.quad4.io/>`_ is a comprehensive and advanced bot creation framework for LXMF, that allows building any kind of automation or bot system running over LXMF and Reticulum. `Bot implementations exist <https://github.com/lxmfy/awesome-lxmfy-bots>`_ for Home Assistant control, LLM integrations, and various other purposes.
Remote Shell
^^^^^^^^^^^^
The included :ref:`rnsh<using-rnsh>` program lets you establish fully interactive
remote shell sessions over Reticulum. It also allows you to pipe any program to or from a
remote system, and is similar to how ``ssh`` works. The ``rnsh`` program is very efficient, and
can facilitate fully interactive shell sessions, even over extremely low-bandwidth links,
such as LoRa or packet radio.
LXMF Interactive Client
^^^^^^^^^^^^^^^^^^^^^^^
`LXMF Interactive Client <https://github.com/fr33n0w/lxmf-cli>`_ is a feature-rich, terminal-based LXMF messaging client with many advanced features and an extensible plugin architecture.
In addition to the default, fully interactive terminal mode, for extremely limited links,
``rnsh`` offers line-interactive mode, allowing you to interact with remote systems, even
when link throughput is counted in a few hundreds of bits per second.
RNS FileSync
^^^^^^^^^^^^
The `RNS FileSync <https://git.quad4.io/RNS-Things/RNS-Filesync>`_ program enables automatic file synchronization between devices without requiring central servers, internet connectivity, or cloud services. It works over any network medium supported by Reticulum, including radio, LoRa, WiFi, or the internet, making it ideal for off-grid, privacy-focused, and resilient file sharing.
The `RNS FileSync <https://github.com/Quad4-Software/RNS-Filesync>`_ program enables automatic file synchronization between devices without requiring central servers, internet connectivity, or cloud services. It works over any network medium supported by Reticulum, including radio, LoRa, WiFi, or the internet, making it ideal for off-grid, privacy-focused, and resilient file sharing.
.. raw:: latex
\newpage
Utilities & Components
======================
Modem73
^^^^^^^
`MODEM73 <https://github.com/RFnexus/modem73>`_ and its companion `Modem73Interface <https://github.com/RFnexus/modem73interface>`_ is an open source software modem that works with any HF, VHF, or UHF radio capable of 2400 Hz of bandwidth. All you need is a sound card and audio cable for your radio.
.. only:: html
.. image:: screenshots/modem73.webp
:align: center
:target: https://github.com/RFnexus/modem73
.. only:: latex
.. image:: screenshots/modem73.jpg
:align: center
:target: https://github.com/RFnexus/modem73
LXMFy
^^^^^
`LXMFy <https://lxmfy.quad4.io/>`_ is a comprehensive and advanced bot creation framework for LXMF, that allows building any kind of automation or bot system running over LXMF and Reticulum. `Bot implementations exist <https://lxmfy.quad4.io/latest/creating-bots/>`_ for Home Assistant control, LLM integrations, and various other purposes.
Micron Parser JS
@ -256,6 +369,25 @@ Micron Parser JS
`Micron Parser JS <https://github.com/RFnexus/micron-parser-js>`_ is the JavaScript-based parser for the Micron markup language, that most web-based Nomad Network browsers use. If you want to make utilities or tools that display Micron pages, this library is essential.
Reticulated
^^^^^^^^^^^
`Reticulated <https://github.com/RFnexus/Reticulated>`_ is a Reticulum Network simulator that spawns live, interactive Reticulum instances controllable from a node-based Web UI.
.. only:: html
.. image:: screenshots/reticulated.webp
:align: center
:target: https://github.com/RFnexus/Reticulated
.. only:: latex
.. image:: screenshots/reticulated.jpg
:align: center
:target: https://github.com/RFnexus/Reticulated
You can create networks of any topology and set bitrate, MTU, and configure path loss and propagation behavior. It provides a top-down view to a live network. You can connect any Reticulum application to the simulated network. It also has built in utilities like a basic LXMF chat function and Resource transfer included.
RNMon
^^^^^
@ -267,6 +399,16 @@ RNMon
\newpage
Device Flashers
===============
In addition to the included command-line ``rnodeconf`` flasher, several community-provided RNode device flashers exist:
- `RNS Moscow <https://flasher.rns.moscow/>`_
- `Aetherlab <https://flasher.aetherlab.org/>`_
Several programs, such as `Sideband <https://github.com/markqvist/Sideband>`_ and `MeshChatX <https://meshchatx.com/>`_ also include built-in device flashers that can be used fully offline.
Protocols
=========
@ -298,8 +440,8 @@ Interface Modules & Connectivity Resources
This section provides a list of various community-provided interface modules, guides and resources for creating Reticulum networks over special or challenging mediums.
* Custom interface module for running `RNS over HTTP <https://git.quad4.io/RNS-Things/RNS-over-HTTP>`_
* `Modem73 <https://github.com/RFnexus/modem73>`_ is an efficient software modem that can be used with Reticulum
* Custom interface module for running `RNS over HTTP <https://github.com/Quad4-Software/RNS-over-HTTP>`_
* Guide for running `Reticulum over ICMP <https://github.com/matvik22000/rns-over-icmp>`_ using ``PipeInterface``
* Guide for running `Reticulum over DNS <https://github.com/markqvist/Reticulum/discussions/1002>`_ with Iodine
* Guide for running `Reticulum over HF radio <https://github.com/RFnexus/reticulum-over-hf>`_
* `Modem73 <https://github.com/RFnexus/modem73>`_ is a KISS TNC OFDM modem frontend that can be used with Reticulum
* Guide for running `Reticulum over HF radio <https://github.com/RFnexus/reticulum-over-hf>`_

View file

@ -28,9 +28,6 @@ Donations are gratefully accepted via the following channels:
Ko-Fi:
https://ko-fi.com/markqvist
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

View file

@ -274,8 +274,7 @@ You can easily add ``rnsd`` as an always-on service by :ref:`configuring a servi
The rnstatus Utility
====================
Using the ``rnstatus`` utility, you can view the status of configured Reticulum
interfaces, similar to the ``ifconfig`` program.
Using the ``rnstatus`` utility, you can view the status of and control configured Reticulum interfaces, similar to the ``ifconfig`` program.
**Usage Examples**
@ -337,8 +336,8 @@ Filter output to only show some interfaces:
.. code:: text
usage: rnstatus [-h] [--config CONFIG] [--version] [-a] [-A]
[-l] [-t] [-s SORT] [-r] [-j] [-R hash] [-i path]
usage: rnstatus [-h] [--config CONFIG] [--version] [-a] [-A] [-P] [-l]
[-B] [-b] [-t] [-q] [-s SORT] [-r] [-j] [-R hash] [-i path]
[-w seconds] [-d] [-D] [-m] [-I seconds] [-v] [filter]
Reticulum Network Stack Status
@ -350,12 +349,19 @@ Filter output to only show some interfaces:
-h, --help show this help message and exit
--config CONFIG path to alternative Reticulum config directory
--version show program's version number and exit
--attach name Attach interface by name
--detach name Detach interface by name
--reload name Reload interface by name
-a, --all show all interfaces
-A, --announce-stats show announce stats
-P, --pr-stats show path request stats
-l, --link-stats show link stats
-B, --burst only show interfaces with active bursts
-b, --blocked-ips show blocked IPs per interface
-t, --totals display traffic totals
-q, --queues display queue stats
-s, --sort SORT sort interfaces by [rate, traffic, rx, tx, rxs, txs,
announces, arx, atx, held]
announces, arx, atx, prx, ptx, held]
-r, --reverse reverse sorting
-j, --json output in JSON format
-R hash transport identity hash of remote instance to get status from
@ -363,6 +369,8 @@ Filter output to only show some interfaces:
-w seconds timeout before giving up on remote queries
-d, --discovered list discovered interfaces
-D show details and config entries for discovered interfaces
--show-stale show stale discovery entries
--show-unknown show discovery entries without version info
-m, --monitor continuously monitor status
-I, --monitor-interval seconds
refresh interval for monitor mode (default: 1)
@ -701,7 +709,7 @@ The rnx Utility
The ``rnx`` utility is a basic remote command execution program. It allows you to
execute commands on remote systems over Reticulum, and to view returned command
output. For a fully interactive remote shell solution, be sure to also take a look
at the `rnsh <https://github.com/acehoss/rnsh>`_ program.
at the :ref:`rnsh<using-rnsh>` program.
**Usage Examples**
@ -767,6 +775,8 @@ another one, which will be created if it does not already exist
--version show program's version number and exit
.. _using-rnsh:
The rnsh Utility
================
@ -1226,6 +1236,8 @@ For more fine-grained control over how discovered interfaces are auto-connected,
* The ``autoconnect_announces_to_internal`` option allows you to specify that auto-connected interfaces should propagate announces to ``internal`` mode interfaces, even if the auto-connected interface's mode would normally not allow for this.
* The ``autoconnect_unverified_implementations`` option will allow the interface discovery system to auto-connect to *any* announced interface, even those coming from unverified, experimental or potentially broken implementations. Be very careful with this option.
Remote Management
-----------------

View file

@ -1,5 +1,5 @@
const DOCUMENTATION_OPTIONS = {
VERSION: '1.4.2',
VERSION: '1.5.5',
LANGUAGE: 'en',
COLLAPSE_INDEX: false,
BUILDER: 'html',

1611
docs/manual/brandolinis.html Normal file

File diff suppressed because it is too large Load diff

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>Distributed Development - Reticulum Network Stack 1.4.2 documentation</title>
<title>Distributed Development - Reticulum Network Stack 1.5.5 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.4.2 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.5.5 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.4.2 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.5.5 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">
@ -227,6 +227,7 @@
<li class="toctree-l1"><a class="reference internal" href="support.html">Support Reticulum</a></li>
<li class="toctree-l1"><a class="reference internal" href="examples.html">Code Examples</a></li>
<li class="toctree-l1"><a class="reference internal" href="license.html">Reticulum License</a></li>
<li class="toctree-l1"><a class="reference internal" href="brandolinis.html">Brandolini’s Reference</a></li>
</ul>
<ul>
<li class="toctree-l1"><a class="reference internal" href="reference.html">API Reference</a></li>
@ -431,7 +432,7 @@
</aside>
</div>
</div><script src="_static/documentation_options.js?v=02f2166e"></script>
</div><script src="_static/documentation_options.js?v=b8aeccfa"></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>

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.4.2 documentation</title>
<title>Code Examples - Reticulum Network Stack 1.5.5 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.4.2 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.5.5 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.4.2 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.5.5 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">
@ -227,6 +227,7 @@
<li class="toctree-l1"><a class="reference internal" href="support.html">Support Reticulum</a></li>
<li class="toctree-l1 current current-page"><a class="current reference internal" href="#">Code Examples</a></li>
<li class="toctree-l1"><a class="reference internal" href="license.html">Reticulum License</a></li>
<li class="toctree-l1"><a class="reference internal" href="brandolinis.html">Brandolini’s Reference</a></li>
</ul>
<ul>
<li class="toctree-l1"><a class="reference internal" href="reference.html">API Reference</a></li>
@ -3648,7 +3649,7 @@
</aside>
</div>
</div><script src="_static/documentation_options.js?v=02f2166e"></script>
</div><script src="_static/documentation_options.js?v=b8aeccfa"></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>

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.4.2 documentation</title>
<title>An Explanation of Reticulum for Human Beings - Reticulum Network Stack 1.5.5 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.4.2 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.5.5 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.4.2 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.5.5 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">
@ -227,6 +227,7 @@
<li class="toctree-l1"><a class="reference internal" href="support.html">Support Reticulum</a></li>
<li class="toctree-l1"><a class="reference internal" href="examples.html">Code Examples</a></li>
<li class="toctree-l1"><a class="reference internal" href="license.html">Reticulum License</a></li>
<li class="toctree-l1"><a class="reference internal" href="brandolinis.html">Brandolini’s Reference</a></li>
</ul>
<ul>
<li class="toctree-l1"><a class="reference internal" href="reference.html">API Reference</a></li>
@ -296,7 +297,7 @@
</aside>
</div>
</div><script src="_static/documentation_options.js?v=02f2166e"></script>
</div><script src="_static/documentation_options.js?v=b8aeccfa"></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>

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.4.2 documentation</title>
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 --><title>Index - Reticulum Network Stack 1.5.5 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.4.2 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.5.5 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.4.2 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.5.5 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">
@ -225,6 +225,7 @@
<li class="toctree-l1"><a class="reference internal" href="support.html">Support Reticulum</a></li>
<li class="toctree-l1"><a class="reference internal" href="examples.html">Code Examples</a></li>
<li class="toctree-l1"><a class="reference internal" href="license.html">Reticulum License</a></li>
<li class="toctree-l1"><a class="reference internal" href="brandolinis.html">Brandolini’s Reference</a></li>
</ul>
<ul>
<li class="toctree-l1"><a class="reference internal" href="reference.html">API Reference</a></li>
@ -365,10 +366,12 @@
<li><a href="reference.html#RNS.Identity.decrypt">(RNS.Identity method)</a>
</li>
</ul></li>
<li><a href="reference.html#RNS.Transport.deregister_announce_handler">deregister_announce_handler() (RNS.Transport static method)</a>
<li><a href="reference.html#RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT">DEFAULT_PER_HOP_TIMEOUT (RNS.Reticulum attribute)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="reference.html#RNS.Transport.deregister_announce_handler">deregister_announce_handler() (RNS.Transport static method)</a>
</li>
<li><a href="reference.html#RNS.Destination.deregister_request_handler">deregister_request_handler() (RNS.Destination method)</a>
</li>
<li><a href="reference.html#RNS.Destination">Destination (class in RNS)</a>
@ -434,12 +437,18 @@
<li><a href="reference.html#RNS.Link.get_establishment_rate">get_establishment_rate() (RNS.Link method)</a>
</li>
<li><a href="reference.html#RNS.Link.get_expected_rate">get_expected_rate() (RNS.Link method)</a>
</li>
<li><a href="reference.html#RNS.Reticulum.get_first_hop_timeout">get_first_hop_timeout() (RNS.Reticulum method)</a>
</li>
<li><a href="reference.html#RNS.Resource.get_hash">get_hash() (RNS.Resource method)</a>
</li>
<li><a href="reference.html#RNS.Reticulum.get_instance">get_instance() (RNS.Reticulum static method)</a>
</li>
<li><a href="reference.html#RNS.Reticulum.get_lowest_interface_bitrate">get_lowest_interface_bitrate() (RNS.Reticulum method)</a>
</li>
<li><a href="reference.html#RNS.Link.get_mdu">get_mdu() (RNS.Link method)</a>
</li>
<li><a href="reference.html#RNS.Reticulum.get_medium_path_timeout">get_medium_path_timeout() (RNS.Reticulum method)</a>
</li>
<li><a href="reference.html#RNS.Link.get_mode">get_mode() (RNS.Link method)</a>
</li>
@ -459,10 +468,10 @@
<li><a href="reference.html#RNS.Resource.get_progress">(RNS.Resource method)</a>
</li>
</ul></li>
<li><a href="reference.html#RNS.Identity.get_public_key">get_public_key() (RNS.Identity method)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="reference.html#RNS.Identity.get_public_key">get_public_key() (RNS.Identity method)</a>
</li>
<li><a href="reference.html#RNS.Link.get_q">get_q() (RNS.Link method)</a>
<ul>
@ -848,7 +857,7 @@
</aside>
</div>
</div><script src="_static/documentation_options.js?v=02f2166e"></script>
</div><script src="_static/documentation_options.js?v=b8aeccfa"></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>

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.4.2 documentation</title>
<title>Getting Started Fast - Reticulum Network Stack 1.5.5 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.4.2 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.5.5 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.4.2 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.5.5 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">
@ -227,6 +227,7 @@
<li class="toctree-l1"><a class="reference internal" href="support.html">Support Reticulum</a></li>
<li class="toctree-l1"><a class="reference internal" href="examples.html">Code Examples</a></li>
<li class="toctree-l1"><a class="reference internal" href="license.html">Reticulum License</a></li>
<li class="toctree-l1"><a class="reference internal" href="brandolinis.html">Brandolini’s Reference</a></li>
</ul>
<ul>
<li class="toctree-l1"><a class="reference internal" href="reference.html">API Reference</a></li>
@ -776,7 +777,7 @@ section of this manual.</p>
</aside>
</div>
</div><script src="_static/documentation_options.js?v=02f2166e"></script>
</div><script src="_static/documentation_options.js?v=b8aeccfa"></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>

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.4.2 documentation</title>
<title>Git Over Reticulum - Reticulum Network Stack 1.5.5 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.4.2 documentation</div></a>
<a href="index.html"><div class="brand">Reticulum Network Stack 1.5.5 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.4.2 documentation</span>
<span class="sidebar-brand-text">Reticulum Network Stack 1.5.5 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">
@ -227,6 +227,7 @@
<li class="toctree-l1"><a class="reference internal" href="support.html">Support Reticulum</a></li>
<li class="toctree-l1"><a class="reference internal" href="examples.html">Code Examples</a></li>
<li class="toctree-l1"><a class="reference internal" href="license.html">Reticulum License</a></li>
<li class="toctree-l1"><a class="reference internal" href="brandolinis.html">Brandolini’s Reference</a></li>
</ul>
<ul>
<li class="toctree-l1"><a class="reference internal" href="reference.html">API Reference</a></li>
@ -266,6 +267,7 @@
<span id="git-main"></span><h1>Git Over Reticulum<a class="headerlink" href="#git-over-reticulum" title="Link to this heading"></a></h1>
<p>This chapter of the manual serves as the technical reference for the distributed software development and project collaboration tools included in RNS. For a conceptual overview, see the <a class="reference internal" href="distributed.html#distributed-development"><span class="std std-ref">Distributed Development</span></a> chapter.</p>
<p>A set of utilities for distributed collaborative software development and publishing are included in RNS.</p>
<img alt="_images/rngit.webp" class="align-center" src="_images/rngit.webp" />
<p>The system consists of two parts: The <code class="docutils literal notranslate"><span class="pre">rngit</span></code> node that hosts repositories, and the <code class="docutils literal notranslate"><span class="pre">git-remote-rns</span></code> helper that enables Git to communicate with rngit nodes. As soon as you have RNS installed on your system, you can transparently use Git with Reticulum-hosted repositories just like any other type of remote. Git over Reticulum uses URLs in the following format: <code class="docutils literal notranslate"><span class="pre">rns://DESTINATION_HASH/group/repo</span></code>.</p>
<p>If you set a branch to track a Reticulum remote as the default upstream, you can simply use <code class="docutils literal notranslate"><span class="pre">git</span></code> as you normally would; all commands work transparently and as expected.</p>
<div class="admonition important">
@ -356,6 +358,10 @@ Repository public/myrepo created
</pre></div>
</div>
<p>This creates a bare Git repository at the specified path. You must have <code class="docutils literal notranslate"><span class="pre">create</span></code> permission for the target group. When a repository is created, the creator automatically receives <code class="docutils literal notranslate"><span class="pre">adm</span></code> (admin) permissions on the repository through an auto-generated <code class="docutils literal notranslate"><span class="pre">.allowed</span></code> file.</p>
<div class="admonition tip">
<p class="admonition-title">Tip</p>
<p>If you want to use a different primary branch name than <code class="docutils literal notranslate"><span class="pre">master</span></code>, you can change it inside the bare git repository directory with <code class="docutils literal notranslate"><span class="pre">git</span> <span class="pre">symbolic-ref</span> <span class="pre">HEAD</span> <span class="pre">refs/heads/name_of_other_branch</span></code>.</p>
</div>
<p><strong>All Command-Line Options (rngit create)</strong></p>
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>usage: rngit create [-h] [--config CONFIG] [--rnsconfig RNSCONFIG]
[-i PATH] [-v] [-q] [--version]
@ -816,6 +822,7 @@ def hello_world():
<li><p><code class="docutils literal notranslate"><span class="pre">commit.mu</span></code> - Individual commit detail page</p></li>
<li><p><code class="docutils literal notranslate"><span class="pre">refs.mu</span></code> - Branches and tags listing</p></li>
<li><p><code class="docutils literal notranslate"><span class="pre">stats.mu</span></code> - Statistics page</p></li>
<li><p><code class="docutils literal notranslate"><span class="pre">no_ident.mu</span></code> - Displayed to non-identified initiators if blocked</p></li>
</ul>
<p>Templates can include the following variables:</p>
<ul class="simple">
@ -1594,7 +1601,7 @@ done
</aside>
</div>
</div><script src="_static/documentation_options.js?v=02f2166e"></script>
</div><script src="_static/documentation_options.js?v=b8aeccfa"></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>

Some files were not shown because too many files have changed in this diff Show more