Compare commits

..

492 Commits

Author SHA1 Message Date
Mark Qvist 4eb5dbc633 Prepare release 2026-01-04 12:27:34 +01:00
Mark Qvist a1e6ce2357 Updated docs 2026-01-04 12:22:08 +01:00
Mark Qvist 16e833ddb7 Updated changelog 2026-01-04 01:49:52 +01:00
Mark Qvist 4af35bd7ea Updated docs 2026-01-04 01:12:48 +01:00
Mark Qvist 7d305527e9 Updated manual 2026-01-04 00:52:31 +01:00
Mark Qvist 1d84dc94a0 Implemented external IP resolution for interface discovery announcer 2026-01-04 00:50:35 +01:00
Mark Qvist f825ba38a0 Updated docs 2026-01-04 00:49:58 +01:00
Mark Qvist f076c2d143 Updated docs 2026-01-04 00:19:32 +01:00
Mark Qvist 58a20fffb5 Updated docs 2026-01-04 00:00:02 +01:00
Mark Qvist 15a123875f Implemented bootstrap interface handling 2026-01-03 22:29:26 +01:00
Mark Qvist 7cadb3af8b Added bootstrap_only interface option 2026-01-03 20:00:22 +01:00
Mark Qvist 01984a33eb Updated docs 2026-01-03 19:59:01 +01:00
Mark Qvist 7329817d95 Updated docs 2026-01-03 19:58:49 +01:00
Mark Qvist ad4af7dd50 Sanitize mode configuration for discovery-enabled interfaces 2026-01-03 02:58:47 +01:00
Mark Qvist f2a778ffa4 Implemented discovery announce encryption 2026-01-03 02:20:24 +01:00
Mark Qvist 1a77b5752c Added auto-connect of discovered interfaces on instance start 2026-01-03 00:52:39 +01:00
Mark Qvist 2b3d6a0989 Added auto-connect option for discovered interfaces 2026-01-03 00:36:50 +01:00
Mark Qvist 0b508a04b8 Added interface discovery source filtering by network identity 2026-01-02 21:03:18 +01:00
Mark Qvist 13aebeecf9 Implemented network identity handling 2026-01-02 17:16:24 +01:00
Mark Qvist 47d3c640d6 Cleanup 2026-01-02 13:34:05 +01:00
Mark Qvist 19f27598d9 Updated version 2026-01-01 23:32:28 +01:00
Mark Qvist f2ef22e1a0 Updated config descriptions 2026-01-01 23:32:02 +01:00
Mark Qvist 251e1b8a35 Implemented remote blackhole list updater 2026-01-01 23:12:40 +01:00
Mark Qvist 5de4e24a9f Added await_path method to transport API 2026-01-01 21:37:56 +01:00
Mark Qvist 5e4d32c4c0 Added ability to view published blackhole list 2026-01-01 20:13:00 +01:00
Mark Qvist e1327842b1 Added ability to specify duration and reason to blackhole entries 2026-01-01 18:07:19 +01:00
Mark Qvist c13412369a Implemented blackhole management 2026-01-01 17:35:41 +01:00
Mark Qvist 18e4e66db8 Cleanup 2026-01-01 15:13:53 +01:00
Mark Qvist 5392d635dd Improved announce processing 2026-01-01 14:51:33 +01:00
Mark Qvist e56e80aade Added discovery support for Weave interface 2026-01-01 14:49:40 +01:00
Mark Qvist 994c4fd699 Support interface discovery on Weave interface 2026-01-01 14:01:57 +01:00
Mark Qvist ef64fefa96 Cleanup 2026-01-01 14:01:05 +01:00
Mark Qvist 344ff21c1e Cleanup 2026-01-01 02:27:45 +01:00
Mark Qvist d34e06cb8c Cleanup 2025-12-31 19:27:12 +01:00
Mark Qvist 8f65a0320b Updated docs 2025-12-31 17:29:45 +01:00
Mark Qvist b42e1c93da Cleanup 2025-12-31 17:24:08 +01:00
Mark Qvist e0ca14eb21 Cleanup 2025-12-31 17:20:28 +01:00
Mark Qvist 48fe97291b Cleanup 2025-12-31 17:12:15 +01:00
Mark Qvist f400fd7b60 Added interface discovery output to rnstatus 2025-12-31 16:46:51 +01:00
Mark Qvist fd1d464f06 Added discovery configuration to configuration options 2025-12-31 15:23:43 +01:00
Mark Qvist 28afdb36fe Discovery listing and state information 2025-12-31 14:51:56 +01:00
Mark Qvist 6c7db096fc Fixed typo 2025-12-31 14:22:15 +01:00
Mark Qvist 5a7fcb0ec3 Fixed typo 2025-12-31 14:19:49 +01:00
Mark Qvist d647da7a4a Implemented discovery handler 2025-12-31 13:50:49 +01:00
Mark Qvist d7df390bb4 Added input sanitization to discovery data 2025-12-31 11:25:20 +01:00
Mark Qvist 9d36ff48dd Implemented on-network global interface discovery 2025-12-31 01:07:08 +01:00
Mark Qvist 8743388263 Cleanup 2025-12-30 21:34:36 +01:00
Mark Qvist 58486654d5 Added FUNDING.json 2025-12-29 16:45:16 +01:00
Mark Qvist 326d719a49 Force synchronous processing for entire announce logic flow 2025-12-28 23:46:39 +01:00
Mark Qvist c9b6dc007a Added MIRROR.md 2025-12-28 00:44:33 +01:00
Mark Qvist 1bcac5e234 Updated readme 2025-12-28 00:39:27 +01:00
Mark Qvist dad58e14e2 Added MIRROR.md 2025-12-28 00:37:30 +01:00
Mark Qvist db85939322 Restored rncp corrupt identity error indication 2025-12-27 11:00:38 +01:00
markqvist 4f4eb1fce5 Merge pull request #1023 from MikelCalvo/feature/rncp-custom-identity
Added custom identity support to rncp utility
2025-12-27 10:57:42 +01:00
Mark Qvist e55000ee1a Updated docs 2025-12-27 10:49:38 +01:00
Mark Qvist 9c2bf9fba8 Updated rnstatus monitor mode arguments 2025-12-27 10:49:22 +01:00
Mark Qvist 563784573b Merge branch 'master' of github.com:markqvist/Reticulum 2025-12-27 10:46:46 +01:00
markqvist e2903f18da Merge pull request #1024 from MikelCalvo/feature/rnstatus_monitor_mode
Added monitor mode to rnstatus
2025-12-27 10:46:32 +01:00
Mark Qvist 2f47456668 Enabled ingress limiting on per-peer Weave and Auto sub-interfaces 2025-12-27 10:43:28 +01:00
Mark Qvist 79b3101fe0 Updated docs 2025-12-22 23:56:58 +01:00
Mark Qvist 9788675934 Updated docs 2025-12-22 19:00:07 +01:00
Mark Qvist 10c63fcaa2 Updated rnodeconf version and urls 2025-12-22 18:57:36 +01:00
Mark Qvist 707c012318 Updated docs 2025-12-22 16:39:10 +01:00
Mark Qvist 3f30e17eb4 Updated docs 2025-12-22 14:23:36 +01:00
Mark Qvist 9eff138c3c Added fixed MTU configuration to TCPClientInterface 2025-12-22 14:23:27 +01:00
Mark Qvist b0fb5d1898 Merge branch 'master' of github.com:markqvist/Reticulum 2025-12-22 11:36:54 +01:00
Mark Qvist d542da38b2 Added descriptive error message on corrupt ratchet file 2025-12-22 11:36:21 +01:00
markqvist c8b446ecaf Update Contributing.md 2025-12-22 00:29:52 +01:00
markqvist 6ed6af5b98 Update Contributing.md 2025-12-21 14:23:01 +01:00
markqvist 12d39916b9 Update Contributing.md 2025-12-21 11:39:26 +01:00
markqvist 12d4de0619 Update Contributing.md 2025-12-21 11:38:23 +01:00
markqvist 7ab87f688a Update Contributing.md 2025-12-21 11:35:45 +01:00
markqvist 9024a277ac Update Contributing.md 2025-12-21 11:32:08 +01:00
Mark Qvist fc00d9a5aa Cleanup 2025-12-20 14:00:42 +01:00
markqvist 106a773f22 Update Contributing.md 2025-12-19 23:05:00 +01:00
Mark Qvist 93d9cb3b69 Added reverse unicast AutoInterface discovery packets for improving peer discovery on Android devices with broken WiFi multicast handling. 2025-12-19 14:16:03 +01:00
Mark Qvist 99504b7f7d Reverted AutoInterface ingress limit 2025-12-12 00:22:41 +01:00
Mark Qvist 72c1995551 Enabled ingress limiting on per-peer AutoInterface sub-interfaces 2025-12-02 21:24:43 +01:00
Mark Qvist 3d8c6c3839 Merge branch 'master' of github.com:markqvist/Reticulum 2025-12-02 21:18:32 +01:00
Mark Qvist 0a06ffd074 Updated manual 2025-12-02 21:18:11 +01:00
Mark Qvist 12abb544bf Cleanup 2025-12-02 21:18:02 +01:00
markqvist 78fe132cc2 Merge pull request #1026 from wincentbalin/master
Fixed typo
2025-12-01 23:50:57 +01:00
Wincent Balin b516d7f092 Fixed typo 2025-12-01 18:57:48 +01:00
Mikel Calvo 0961df316f Added monitor mode to rnstatus 2025-12-01 02:14:27 +01:00
Mikel Calvo 8ad2986877 Added custom identity support to rncp utility 2025-12-01 01:29:54 +01:00
Mark Qvist 6214487fb3 Updated manual 2025-11-27 22:18:46 +01:00
Mark Qvist 2219a5454c Updated makefile 2025-11-27 22:18:39 +01:00
Mark Qvist 712a5d1b06 Cleanup 2025-11-26 20:28:21 +01:00
Mark Qvist cbc3b800fb Updated manual 2025-11-24 02:06:56 +01:00
Mark Qvist e7348d0812 Merge branch 'master' of https://git.unsigned.io/markqvist/Reticulum 2025-11-23 14:57:04 +01:00
Mark Qvist 59e638402c Corrected logic regression. Fixes #1014. 2025-11-23 14:56:47 +01:00
Mark Qvist bcd6de015d Corrected logic regression. Fixes #1004. 2025-11-23 14:55:32 +01:00
Mark Qvist b798c84160 Updated changelog 2025-11-22 15:04:44 +01:00
Mark Qvist 708f666787 Updated changelog 2025-11-22 15:00:17 +01:00
Mark Qvist 4f03302ae2 Cleanup 2025-11-22 14:57:50 +01:00
Mark Qvist d8f6ab206b Updated docs 2025-11-22 12:06:58 +01:00
Mark Qvist 472e69fe9a Updated docs 2025-11-22 11:21:33 +01:00
Mark Qvist aeed5279f8 Fixed formatting 2025-11-22 11:21:05 +01:00
Mark Qvist f3b8965fa6 Fixed formatting 2025-11-22 11:16:29 +01:00
Mark Qvist 1bbaab1db9 Updated version 2025-11-21 17:10:13 +01:00
Mark Qvist bf2fcbba37 Added interference detection status and history to rnstatus output for RNode interfaces 2025-11-21 15:56:17 +01:00
Mark Qvist a63dd67a07 Updated changelog 2025-11-19 15:40:03 +01:00
Mark Qvist b27f9836ae Updated docs and manual 2025-11-19 15:39:30 +01:00
Mark Qvist 9504c5b863 Updated docs 2025-11-19 15:37:24 +01:00
Mark Qvist 9767b3453e Updated docs 2025-11-19 15:23:35 +01:00
Mark Qvist 643fbbbc84 Fixed broken links. Thanks @symbioquine in #999. 2025-11-19 15:21:55 +01:00
Mark Qvist 2a5bcd5f52 Updated docs 2025-11-19 15:16:46 +01:00
Mark Qvist 237c3160eb Updated RNode TCP read timeouts 2025-11-19 14:37:47 +01:00
Mark Qvist fcdcf1a2a8 RNode TCP connection on Android 2025-11-18 12:27:36 +01:00
Mark Qvist 7c99aca1d0 Improved reconnect/hotplug reliability and responsiveness for RNodes connected over WiFi 2025-11-18 03:12:42 +01:00
Mark Qvist 309f1999e7 Cleanup 2025-11-17 21:13:21 +01:00
Mark Qvist fa6de7ff79 Updated docs 2025-11-17 19:00:13 +01:00
Mark Qvist 47dfcab170 Added ability to configure RNode IP settings to rnodeconf 2025-11-17 18:46:36 +01:00
Mark Qvist 8abd19800f Updated version 2025-11-17 17:16:29 +01:00
Mark Qvist b2d6ed733d Added support for configuring RNode WiFi settings to rnodeconf 2025-11-17 17:16:06 +01:00
Mark Qvist 1179757893 Added support for connecting RNode devices over TCP connections 2025-11-17 00:31:37 +01:00
Mark Qvist d328ef5ce0 Cleanup 2025-11-15 14:20:43 +01:00
Mark Qvist f577d3018f Updated manual 2025-11-15 13:43:36 +01:00
Mark Qvist e6db629915 Updated manual 2025-11-15 13:42:14 +01:00
Mark Qvist acaab30b91 Updated manual 2025-11-15 13:35:00 +01:00
Mark Qvist 76cedeed07 Updated BLE connection read timeouts on Android 2025-11-15 12:11:45 +01:00
Mark Qvist 5beea74eb3 Handle serial port never being opened due to failure on interface detach for RNodeInterface 2025-11-11 10:29:06 +01:00
Mark Qvist 1f91a8f6f2 Updated manual 2025-11-10 19:05:36 +01:00
Mark Qvist 080216bd55 Updated version 2025-11-10 18:58:27 +01:00
Mark Qvist aa37172293 Added support for CPU temperature reporting from RNode devices 2025-11-10 18:44:57 +01:00
Mark Qvist 5836d7f8ba Added support for RNodes with PAs up to 37 dBm. Corrected Heltec V4 board parameters in rnodeconf. 2025-11-07 19:34:16 +01:00
Mark Qvist a699d7c110 Run github workflow tests on Python 3.11 2025-11-02 22:43:12 +01:00
Mark Qvist 8eedbb9d91 Run github workflow tests on Python 3.11 2025-11-02 22:41:25 +01:00
Mark Qvist 2afa85db60 Updated manual 2025-11-02 22:37:34 +01:00
Mark Qvist a5672e7afe Updated manual 2025-11-02 22:36:40 +01:00
Mark Qvist 77d40215c8 Updated documentation 2025-11-02 22:27:27 +01:00
Mark Qvist 0896df05b6 Workaround for https://github.com/python/cpython/issues/138720 2025-11-02 11:50:17 +01:00
Mark Qvist cf0b1c6237 AutoInterface peering timing 2025-11-01 17:29:53 +01:00
Mark Qvist 08b129c8e0 Cleanup 2025-11-01 17:23:40 +01:00
Mark Qvist e2ea397715 Added Heltec v4 to rnodeconf 2025-11-01 15:23:31 +01:00
Mark Qvist 25a73e6ef9 Added detection and logging of multicast echoes never arriving on AutoInterface system interfaces. Implements #905. 2025-11-01 14:51:50 +01:00
markqvist f1c4bba3f2 Merge pull request #599 from faragher/master
Ported rncp allow files to rnx
2025-11-01 14:18:03 +01:00
Mark Qvist 704019ded8 Fixed typo 2025-11-01 14:13:17 +01:00
markqvist 79f5b92bae Merge pull request #911 from SCWhite/patch-1
minor error in hardware.html
2025-11-01 14:12:51 +01:00
Mark Qvist 5aaa743ef8 Updated rnid output 2025-11-01 14:11:35 +01:00
markqvist 9721c0bf85 Merge pull request #901 from jacobeva/cmd_interfaces_fix
Change CMD_INTERFACES value to be 0x71, fix #900
2025-11-01 14:10:34 +01:00
Mark Qvist 56848cdb63 Ensure default destination app data can be generated and sent even on first system-internal discovery announce 2025-10-31 21:25:28 +01:00
Mark Qvist 41ad089ff7 Added path response status signalling to announce handlers 2025-10-30 15:00:39 +01:00
Mark Qvist 2df355d7b4 Updated version 2025-10-30 14:11:06 +01:00
Mark Qvist 39a63b0643 WeaveInterface compatibility on Android 2025-10-29 15:44:47 +01:00
Mark Qvist ddf14e5636 Updated WeaveInterface. Added support for Weave devices to rnstatus. 2025-10-29 12:43:20 +01:00
Mark Qvist 7138749307 Fixed RNodeInterface BLE reconnection hang on Android 2025-10-28 12:31:52 +01:00
Mark Qvist af7697f223 Fixed string formatting for Android 2025-10-28 12:31:30 +01:00
Mark Qvist 0bcb4b8573 Cleanup 2025-10-28 10:59:58 +01:00
Mark Qvist 6d47b59b1e Added WeaveInterface 2025-10-28 02:31:33 +01:00
Mark Qvist 3d8eaffe9a Added WeaveInterface 2025-10-28 02:20:22 +01:00
Mark Qvist d8039aca17 Changed CMD_INTERFACES to 0x71. Fixes #900. 2025-10-28 02:10:02 +01:00
Mark Qvist 4e4d379486 Fixed Android BLE MTU for RNodeInterface 2025-10-28 02:05:59 +01:00
SCWhite 87faffa785 minor error in hardware.html
manual/hareware.html: correct T114 device platform "ESP32" → "nRF52"
2025-09-04 02:59:53 +08:00
jacob.eva adef3f80f0 Change CMD_INTERFACES value to be 0x71, fix #900 2025-08-16 16:10:13 +01:00
Mark Qvist 319c798f78 Updated readme 2025-07-15 02:28:54 +02:00
Mark Qvist 8579a7f2a5 Updated changelog 2025-07-14 16:37:41 +02:00
Mark Qvist ffbbba7395 Updated manual 2025-07-14 16:37:06 +02:00
Mark Qvist e66745c9ef Updated documentation 2025-07-14 16:07:07 +02:00
Mark Qvist 45fc9338a7 Improved BLE MTU on Android 2025-07-13 23:45:50 +02:00
Mark Qvist f2969bd1b0 Improved BLE device discovery on Android 2025-07-13 22:07:47 +02:00
Mark Qvist e0f1f3f947 Merge branch 'master' of github.com:markqvist/Reticulum 2025-07-13 19:17:13 +02:00
Mark Qvist e3827f2e25 Updated manual 2025-07-13 19:17:00 +02:00
Mark Qvist fad1d4972c Fixed potential EPOLL hang on interface failure 2025-07-13 19:16:51 +02:00
markqvist 2c33ce6c98 Merge pull request #839 from wincentbalin/master
Fixed typo
2025-07-13 15:02:45 +02:00
Mark Qvist c0d7f42f17 Cleanup 2025-07-13 14:51:24 +02:00
Mark Qvist d5a8e4b056 Trace exception on public key load failure 2025-07-13 13:58:09 +02:00
Mark Qvist 76dd50a060 Fixed potential AutoInterface peer discovery add before final init complete 2025-07-13 13:14:18 +02:00
Mark Qvist 6f9a9a7ad9 Fixed link request handling with invalid link mode 2025-07-13 13:05:37 +02:00
Mark Qvist eaec9a493b Updated readme 2025-07-13 12:28:50 +02:00
Mark Qvist d3c8555b39 Updated documentation 2025-07-13 12:26:24 +02:00
Mark Qvist 446f5c0989 Merge branch 'master' of github.com:markqvist/Reticulum 2025-07-13 12:05:59 +02:00
markqvist f3b72a8a3c Merge pull request #844 from Aareon/patch-2
fix(rnstatus): Add validation for missing -i flag when using -R
2025-07-13 12:06:20 +02:00
markqvist d2c5a1f34b Merge pull request #856 from jacobeva/multi_transport_fix
Fix announce cap crash when transport mode enabled
2025-07-13 11:56:59 +02:00
markqvist 182b49cc04 Merge pull request #843 from Aareon/patch-1
docs: Document -R and -i flag dependency in rnstatus usage
2025-07-13 11:54:59 +02:00
Mark Qvist cc8bd34cd4 Merge branch 'master' of github.com:markqvist/Reticulum 2025-07-13 11:54:54 +02:00
Mark Qvist 957ece7394 Fixed typo 2025-07-13 11:53:25 +02:00
Mark Qvist 762343adf9 Updated version 2025-07-13 11:49:44 +02:00
Mark Qvist 8d32b378d9 Fixed log statements 2025-07-13 11:49:12 +02:00
jacob.eva 41e816d299 Fix announce cap crash when transport mode enabled 2025-07-01 18:16:24 +01:00
Aareon Sullivan 4226a62f23 fix(rnstatus): Add validation for missing -i flag when using -R
Add check to ensure `management_identity` is provided when using remote
query flag (`-R`). Prevents `TypeError` and provides clear error message
when user forgets to specify identity file with `-i` flag.

Before: `expected str, bytes or os.PathLike object, not NoneType`
After: `Remote management requires an identity file. Use -i to specify the path to a management identity.`

Fixes #792
2025-06-08 19:52:46 -05:00
Aareon Sullivan 5dda28559b docs: Document -R and -i flag dependency in rnstatus usage
Add usage example and clarify that -R requires -i flag for remote
transport instance status queries. Fixes confusion where users get
"expected str, bytes or os.PathLike object, not NoneType" error
when using -R without -i.

- Add remote status query example to Usage Examples section
- Update -R flag description to indicate -i requirement
- Add explanatory note about management identity authorization

Addresses user reported issue where documentation was unclear about
mandatory flag combination for remote management functionality.
2025-06-08 19:33:42 -05:00
Wincent Balin d055ca50d6 Fixed typo 2025-05-31 01:12:25 +02:00
Mark Qvist 799bcfc7aa Updated version 2025-05-26 19:08:03 +02:00
Mark Qvist 045cb662ef Removed legacy AES-128 handlers 2025-05-26 19:04:30 +02:00
Mark Qvist 51e3983bf8 Updated readme 2025-05-17 10:39:52 +02:00
Mark Qvist 95fdc41845 Updated readme 2025-05-17 10:39:30 +02:00
Mark Qvist d795fbeaf3 Updated documentation 2025-05-17 10:37:11 +02:00
Mark Qvist 13037d68ed Updated documentation 2025-05-17 10:35:49 +02:00
Mark Qvist 6da5df9f21 Merge branch 'master' of github.com:markqvist/Reticulum 2025-05-17 10:25:24 +02:00
Mark Qvist 8128f573ef Updated manual 2025-05-17 10:25:09 +02:00
markqvist accf104553 Update FUNDING.yml 2025-05-17 10:24:38 +02:00
Mark Qvist 5387264dcb Updated changelog 2025-05-15 22:24:33 +02:00
Mark Qvist 308a6906db Updated manual 2025-05-15 15:32:31 +02:00
Mark Qvist 96ce7e3f47 Updated changelog 2025-05-15 15:32:20 +02:00
Mark Qvist f186b6266b Implemented dynamic keepalive and link timeout calculation 2025-05-15 12:50:16 +02:00
Mark Qvist 756029e5af Added option to specify shared instance type 2025-05-15 01:14:55 +02:00
Mark Qvist c1673f39b6 Updated changelog 2025-05-13 19:46:18 +02:00
Mark Qvist 30a08c4192 Updated manual 2025-05-13 18:01:13 +02:00
Mark Qvist d680f4d411 Cleanup 2025-05-13 17:59:26 +02:00
Mark Qvist 29a52e19cf Cleanup 2025-05-13 17:25:00 +02:00
Mark Qvist 11511168dc Cleanup 2025-05-13 13:32:35 +02:00
Mark Qvist d4ea698236 Cleanup 2025-05-13 13:29:20 +02:00
Mark Qvist 11e06b477e Cleanup 2025-05-13 13:26:26 +02:00
Mark Qvist 4e4c68071f Removed legacy encryption modes. Default to AES-256 for links and packets. 2025-05-13 13:18:44 +02:00
Mark Qvist 5f502746a4 Updated tests 2025-05-13 13:16:37 +02:00
Mark Qvist 17bbb9c0b4 Updated docs 2025-05-12 20:20:29 +02:00
Mark Qvist 8b13d6e08b Fixed announce handlers not triggering after shared instance disappearance/reappearance 2025-05-12 11:41:06 +02:00
Mark Qvist efa512be32 Cleanup 2025-05-11 16:40:14 +02:00
Mark Qvist 594f5fba1e Added ability to return file resources for request responses. Added option to specify request response auto-compression limits. 2025-05-11 16:37:57 +02:00
Mark Qvist 2912fb2184 Added option to specify resource auto-compression limits 2025-05-11 16:37:19 +02:00
Mark Qvist 02496f39f7 Removed completed tasks from roadmap 2025-05-11 11:30:06 +02:00
Mark Qvist 4e31f113c6 Optimised hardware MTU autoconfig 2025-05-10 23:15:43 +02:00
Mark Qvist 9aded3e1da Updated readme 2025-05-10 23:09:21 +02:00
Mark Qvist 3337d18e9a Added allow overwrite option to rncp 2025-05-10 21:44:42 +02:00
Mark Qvist 2cb6d019f9 Improved rncp memory utilisation and performance 2025-05-10 21:19:57 +02:00
Mark Qvist 3dc260a300 Updated link tests 2025-05-10 20:59:23 +02:00
Mark Qvist 4d7f5b8ca6 Let shared instance handle packet hashlist 2025-05-10 20:58:54 +02:00
Mark Qvist 48be5f65d8 Faster link cleanup on close 2025-05-10 20:58:01 +02:00
Mark Qvist b5d854a55c Resource performance and memory optimisations 2025-05-10 20:57:32 +02:00
Mark Qvist 552663c625 Fixed offset 2025-05-10 17:00:27 +02:00
Mark Qvist e6f0b92464 Updated example 2025-05-10 17:00:02 +02:00
Mark Qvist 08a6820aa0 Updated descriptions 2025-05-10 15:42:22 +02:00
Mark Qvist cc1faa55be Updated readme 2025-05-10 15:39:51 +02:00
Mark Qvist 840966f3e6 Updated version 2025-05-10 15:38:28 +02:00
Mark Qvist 763078a1ae Added ability to include metadata on resource transfers 2025-05-10 15:38:06 +02:00
Mark Qvist 5fb6abd019 Added resource example 2025-05-10 15:32:06 +02:00
Mark Qvist 7065856229 Added resource with metadata tests 2025-05-10 15:31:35 +02:00
Mark Qvist 668ef9253a Cleanup 2025-05-09 12:07:00 +02:00
Mark Qvist 6f333b8234 Cleanup 2025-05-09 12:04:55 +02:00
Mark Qvist 32c839f497 Updated changelog 2025-05-09 11:31:54 +02:00
Mark Qvist cbdef1d538 Updated manual 2025-05-09 11:21:13 +02:00
Mark Qvist c398b34dd8 Fixed potential unhandled exception on fast-flapping connections 2025-05-08 10:57:34 +02:00
Mark Qvist 9a1884cfec Updated manual 2025-05-07 12:10:53 +02:00
Mark Qvist 378dc1e931 Added link mode get method to Link API 2025-05-06 19:09:40 +02:00
Mark Qvist be821b6927 Added instance_name option and description to default config file 2025-05-06 19:09:20 +02:00
Mark Qvist af46e98865 Improved ratchet persist reliability 2025-05-06 18:18:05 +02:00
Mark Qvist 65b1667ae7 Updated readme 2025-05-06 17:57:08 +02:00
Mark Qvist 5bc1fc2bde Updated readme 2025-05-06 17:55:45 +02:00
Mark Qvist 4ae0f28aa0 Cleanup 2025-05-06 17:48:38 +02:00
Mark Qvist 62ecc0549d Updated readme 2025-05-06 17:25:53 +02:00
Mark Qvist cbf4c71a73 Added pure-python AES-256 implementation 2025-05-06 17:20:55 +02:00
Mark Qvist 1d27fae370 Updated github test workflow 2025-05-06 16:45:16 +02:00
Mark Qvist 05b9a80a07 Path MTU clamping handling with link mode signalling 2025-05-06 16:37:04 +02:00
Mark Qvist 38241452d3 Dynamic link mode establishment 2025-05-06 16:31:36 +02:00
Mark Qvist 40e040807a Link mode signalling generators 2025-05-06 16:12:54 +02:00
Mark Qvist 437da99d63 Handle AES-256 compatibility in Identity 2025-05-06 16:12:15 +02:00
Mark Qvist 3cbcbec942 Updated tests for link modes 2025-05-06 14:23:42 +02:00
Mark Qvist bc7a8cd09f Updated documentation 2025-05-06 14:14:42 +02:00
Mark Qvist ab0ac46d5a Enabled AES_256_CBC link mode 2025-05-06 12:10:34 +02:00
Mark Qvist d7791c60e2 Implemented compatibility handling for AES-256 migration 2025-05-06 12:08:17 +02:00
Mark Qvist 5dc8cdc6dc Merge branch 'master' of github.com:markqvist/Reticulum 2025-04-29 11:42:23 +02:00
Mark Qvist cdc33a25c5 Updated readme 2025-04-29 11:42:07 +02:00
markqvist 2b6766f68a Merge pull request #801 from LinuxinaBit/master
Generative AI Policy
2025-04-28 15:29:32 +02:00
Linux in a Bit e871bbdc07 Add Generative AI Policy reminder to 🐛-bug-report.md 2025-04-20 15:04:55 -05:00
Linux in a Bit 6a98158ba6 [Contributing.md] Add LLM Policy; Emphasize CLA 2025-04-20 14:59:23 -05:00
Mark Qvist ef8d44c257 Updated changelog 2025-04-20 21:23:08 +02:00
Mark Qvist 6a48a4d1c0 Updated version 2025-04-18 12:25:47 +02:00
Mark Qvist 4d2ba28934 Docs build 2025-04-17 23:04:00 +02:00
Mark Qvist 98d4f1c69e Fixed instance name from config being overwritten if option was not last in section 2025-04-17 15:02:08 +02:00
Mark Qvist a0f0d73204 Improved ratchet persist 2025-04-17 14:25:24 +02:00
Mark Qvist 1dbb1a6a35 Merge branch 'linkmodes' 2025-04-16 14:11:14 +02:00
Mark Qvist cc50ca82b8 Added todo note 2025-04-16 14:09:43 +02:00
Mark Qvist 373790c890 Added AES-256 support to Link 2025-04-16 14:05:34 +02:00
Mark Qvist ef30d21b58 Added AES_256_CBC support to Token 2025-04-16 14:03:40 +02:00
Mark Qvist c4cafed6aa Added AES_128_CBC and AES_256_CBC mode proxies 2025-04-16 14:01:26 +02:00
Mark Qvist 828eec5e0d Cleanup 2025-04-16 01:30:11 +02:00
Mark Qvist a8c50fe7d4 Link mode signalling fields 2025-04-16 00:44:30 +02:00
Mark Qvist ab9fc7b370 Updated docs 2025-04-16 00:43:35 +02:00
Mark Qvist 0dc972f7c9 Updated docs 2025-04-16 00:41:50 +02:00
Mark Qvist 796cffe29d Updated docs 2025-04-16 00:40:29 +02:00
Mark Qvist a0f6c99fb5 Updated docs 2025-04-16 00:37:11 +02:00
Mark Qvist eff0c91ed0 Updated docs 2025-04-16 00:35:56 +02:00
Mark Qvist dba6cd8393 Updated license 2025-04-15 20:48:12 +02:00
Mark Qvist e7daceec82 Updated license 2025-04-15 20:19:33 +02:00
Mark Qvist a65473f6ab Updated docs 2025-04-15 18:57:12 +02:00
Mark Qvist 1851fda9e0 Fixed interface string representation 2025-04-15 18:51:52 +02:00
Mark Qvist 80eec131f8 Updated docs 2025-04-15 18:25:12 +02:00
Mark Qvist bfe5b876de Fixed occasional io thread hang on shutdown 2025-04-15 18:04:32 +02:00
Mark Qvist da8a0ee5e9 Updated docs 2025-04-15 17:45:01 +02:00
Mark Qvist 3269384439 Cleanup 2025-04-15 17:44:52 +02:00
Mark Qvist 9a766eac8c Add init to interface utils 2025-04-15 14:04:02 +02:00
Mark Qvist 9d2456500a Added rnodeconf autoinstaller support for XIAO ESP32S3 boards 2025-04-13 03:42:47 +02:00
Mark Qvist df85beac3e Merge branch 'master' of github.com:markqvist/Reticulum 2025-04-12 21:30:41 +02:00
Mark Qvist 3dd020cb86 Fix string representation 2025-04-12 21:30:36 +02:00
markqvist 67da6be040 Merge pull request #769 from easytarget/xiao-esp32s3-wio
add rnodeconf support for SeeedStudio XIAO esp32s3 wio
2025-04-12 21:29:47 +02:00
Mark Qvist d2efd6c3e4 Allow AP mode on Backbone and TCP interfaces 2025-04-12 11:01:57 +02:00
Mark Qvist ea4a525db6 Cleanup 2025-04-11 12:40:22 +02:00
Mark Qvist c83043b087 Cleanup 2025-04-11 12:38:46 +02:00
Mark Qvist c07e968218 Cleanup 2025-04-11 12:30:22 +02:00
Mark Qvist a6eeac14d2 Added internal netinfo implementation 2025-04-11 12:28:36 +02:00
Mark Qvist a65bc3bc7b Added internal netinfo implementation 2025-04-11 12:25:52 +02:00
Mark Qvist 8e4b0b3b16 Use internal netinfo implementation 2025-04-11 12:22:58 +02:00
Mark Qvist d34cefe31d Updated readme 2025-04-11 12:22:24 +02:00
Mark Qvist 3a68a3fc02 Removed ifaddr library 2025-04-11 12:15:27 +02:00
Mark Qvist a4b6a64611 Fixed typo 2025-04-10 13:26:44 +02:00
Mark Qvist 4f189f5319 Updated manual 2025-04-10 00:23:53 +02:00
Mark Qvist cb69085280 Updated hardware section of docs 2025-04-10 00:16:01 +02:00
Mark Qvist f4d13986af Disable AP mode on BackboneInterface 2025-04-09 23:47:49 +02:00
Mark Qvist 6125c835f7 Updated interface documentation 2025-04-09 23:45:14 +02:00
Mark Qvist 3049049d5b Use abstract domain sockets for RPC 2025-04-09 17:15:38 +02:00
Mark Qvist 628c4984a3 Added IPv6 support to BackboneInterface 2025-04-09 14:23:39 +02:00
Mark Qvist b58cb3c0ed Cache clean interval 2025-04-09 00:09:17 +02:00
Mark Qvist b267687c7f Announce cache handling 2025-04-09 00:01:08 +02:00
Mark Qvist 581b16f87c Improved link and reverse table culling 2025-04-08 16:25:18 +02:00
Mark Qvist f9d42082a2 Clean up importlib imports 2025-04-08 15:23:44 +02:00
Mark Qvist f8925eaed1 Exclude built documentation from sdist 2025-04-08 14:36:59 +02:00
Mark Qvist f4c1ece10a Updated manual 2025-04-08 14:36:30 +02:00
Mark Qvist d13b034cab Cleanup 2025-04-08 14:06:07 +02:00
Mark Qvist 008afd88d1 Cleanup 2025-04-08 14:04:21 +02:00
Mark Qvist 68ca903db4 Updated Identity API docstring 2025-04-08 14:02:10 +02:00
Mark Qvist 8f4b4fa82d Add ability to search for identity by identity hash 2025-04-08 13:54:22 +02:00
Mark Qvist 768f562437 Fixed compact log format output 2025-04-08 13:48:48 +02:00
Mark Qvist 9f0a4bfe69 Don't reference interface instances in tunnel path lists 2025-04-08 13:20:02 +02:00
Mark Qvist 13b4291840 Epoll backend switch 2025-04-08 02:33:32 +02:00
Mark Qvist 6dc33126a5 Remove null byte from abstract socket name 2025-04-08 02:09:44 +02:00
Mark Qvist fa31dced22 Tunnel table indices 2025-04-08 01:35:59 +02:00
Mark Qvist 194f6aef1d Clean BackboneInterface file descriptor refs immediately 2025-04-07 20:22:20 +02:00
Mark Qvist a12b630a4e Only collect when necessary 2025-04-07 19:03:19 +02:00
Mark Qvist c3ff73591a Fix addr_info property 2025-04-07 18:48:12 +02:00
Mark Qvist 1967811d68 Error logging 2025-04-07 17:55:34 +02:00
Mark Qvist 0e24a0d8bb Cleanup 2025-04-07 17:17:30 +02:00
Mark Qvist 5913f61e7d Cleanup 2025-04-07 15:31:27 +02:00
Mark Qvist 9a7e517c73 Updated version 2025-04-07 15:04:19 +02:00
Mark Qvist 99af71de75 Store only announce packet hashes in path table instead of full announce 2025-04-07 15:03:37 +02:00
Mark Qvist 06848b6731 Added missing none check on interface socket 2025-04-07 15:02:32 +02:00
Mark Qvist 4ece3a6140 Cleanup 2025-04-07 14:30:34 +02:00
Mark Qvist ae92432878 Added transport table index specifiers 2025-04-07 13:54:14 +02:00
Mark Qvist a4468da9b1 Refactored destination_table to path_table 2025-04-07 12:47:41 +02:00
Mark Qvist 187931a0ea Added interactive shell option to rnsd 2025-04-07 12:41:17 +02:00
Mark Qvist d3533e17e8 Cleanup 2025-04-07 01:42:49 +02:00
Mark Qvist b0944429db Merge branch 'master' of github.com:markqvist/Reticulum 2025-04-07 01:09:23 +02:00
Mark Qvist 7170573da7 Cleanup 2025-04-07 01:04:37 +02:00
Mark Qvist 4cd94c776a Added ability to run local shared instance over abstract domain sockets 2025-04-07 00:46:40 +02:00
Mark Qvist 3483de1fc2 Use epoll backend for LocalInterface 2025-04-06 22:50:43 +02:00
Mark Qvist df3c2cffb3 Work on BackboneInterface 2025-04-06 21:42:54 +02:00
markqvist f0e3bc0c14 Merge pull request #783 from LinuxinaBit/patch-1
Wording fixes in Contributing.md
2025-04-06 19:29:34 +02:00
Mark Qvist b4d1d54ccb Cleanup 2025-04-06 18:45:36 +02:00
Mark Qvist de3438248f Run all BackboneInterface I/O on single epoll instance 2025-04-06 18:17:37 +02:00
Mark Qvist 456eea9c13 Log instead of raise on outbound on closed link 2025-04-05 14:40:00 +02:00
Mark Qvist 3cdebb6e8a Work on BackboneInterface 2025-04-05 14:06:05 +02:00
Mark Qvist e0a9dad114 Docs 2025-04-05 14:05:43 +02:00
Mark Qvist b1aa355d5b Cleanup 2025-04-05 00:02:54 +02:00
Mark Qvist 129591392f Updated configobj and removed six dependency 2025-04-04 23:28:04 +02:00
Linux in a Bit e51f0f14d9 Wording fixes in Contributing.md 2025-04-03 16:14:28 -05:00
Mark Qvist 2c520bb936 Cleanup 2025-04-03 17:50:21 +02:00
Mark Qvist d3bccb2b4e Detach on BackboneInterface 2025-04-03 17:48:26 +02:00
Mark Qvist e28f44cfe5 Interface compat notice 2025-04-03 17:43:24 +02:00
Mark Qvist 45e5c85868 Added BackboneInterface skeleton 2025-04-03 17:39:32 +02:00
Mark Qvist c5bc92e4ea Added loader for BackboneInterface 2025-04-03 17:38:00 +02:00
Mark Qvist ebb8a35129 Improved rncp stats. Added no-compress option to listener. 2025-04-03 17:37:36 +02:00
Mark Qvist f2046b2453 Slots on packet 2025-04-03 17:36:37 +02:00
Mark Qvist f7351a3eb5 Fixed missing none check on TCPInterface 2025-04-03 17:36:09 +02:00
Mark Qvist 28d55279d8 Merge branch 'master' of github.com:markqvist/Reticulum 2025-03-31 16:37:22 +02:00
markqvist 8104db4fcc Merge pull request #713 from jacobeva/multi-spec
Update multi interface interaction spec
2025-03-31 16:37:04 +02:00
Mark Qvist b8658cd47c Fixed IF mode warnings 2025-03-31 16:36:53 +02:00
markqvist ecaa8d53e0 Merge pull request #770 from qbit/doc-fixes
A few fixes for the documentation
2025-03-24 14:36:25 +01:00
Aaron Bieber ca1ec1acef docs: remove stray "both" from networks document 2025-03-24 07:30:35 -06:00
Aaron Bieber 13283cb8e2 docs: fix spelling in examples page 2025-03-24 07:28:59 -06:00
Owen 5a42adb05b add XIAO esp32s3 wio 868Mhz board 2025-03-24 02:55:02 +01:00
Mark Qvist 98afe98870 Cleanup 2025-03-13 20:11:44 +01:00
Mark Qvist f5420d3be3 Updated changelog 2025-03-13 19:36:39 +01:00
Mark Qvist 50b5ab80c4 Updated manual and changelog 2025-03-13 19:36:17 +01:00
Mark Qvist e6371d74b5 Updated manual 2025-03-13 19:31:38 +01:00
Mark Qvist 0ab38faeac Merge branch 'master' of github.com:markqvist/Reticulum 2025-03-13 19:25:29 +01:00
Mark Qvist b0444104cc Wait for announce timebase tick when announcing via rnid. Fixes #752. 2025-03-13 19:25:10 +01:00
Mark Qvist 4757d6ee87 Remove corrupt ratchet files when cleaning ratchets 2025-03-13 18:59:03 +01:00
markqvist 1780965ef8 Update README.md 2025-03-12 11:29:08 +01:00
Mark Qvist aaa88e9b7d Fixed AutoInterface deferred init 2025-03-09 19:01:54 +01:00
Mark Qvist 17ce91a4a2 Fixed AutoInterface deferred init 2025-03-09 18:39:23 +01:00
Mark Qvist 08751a762a Add timing deviation stats to encrypt/decrypt tests 2025-02-25 12:31:29 +01:00
Mark Qvist 77c0beecf2 Fix X25519 base-point according to RFC7748 2025-02-25 12:27:15 +01:00
Mark Qvist 28bcf6a8ac Cleanup 2025-02-24 12:19:28 +01:00
Mark Qvist 61004b4dfb Updated docs 2025-02-24 12:05:23 +01:00
Mark Qvist e5c22b8a3f Run tests against CRNS 2025-02-24 12:04:10 +01:00
Mark Qvist 001d0f30aa Enabled link MTU discovery 2025-02-24 12:03:54 +01:00
Mark Qvist fbe4bb03d1 Run rnprobe via CRNS shim 2025-02-24 11:48:09 +01:00
Mark Qvist 3469b6beb8 Run rnid via CRNS shim 2025-02-24 11:46:35 +01:00
Mark Qvist c696efe0bc Run rnstatus via CRNS shim 2025-02-24 11:45:19 +01:00
Mark Qvist d0ca61f373 Implemented child interface spawning on AutoInterface 2025-02-24 01:57:39 +01:00
Mark Qvist 350687eda9 Link MTU upgrade on AutoInterface 2025-02-23 22:40:36 +01:00
Mark Qvist d898641e6a Added link API functions 2025-02-23 20:01:40 +01:00
Mark Qvist db576d73bb Cleanup 2025-02-23 15:18:12 +01:00
Mark Qvist 5fcdd17665 Added on-demand object code compilation and loader 2025-02-23 14:45:24 +01:00
Mark Qvist e8f2bd9b0c Updated manual 2025-02-22 21:21:51 +01:00
Mark Qvist 0ff51fed44 Updated docs 2025-02-17 22:11:43 +01:00
Mark Qvist 6e25f96024 Updated changelog 2025-02-17 22:11:38 +01:00
Mark Qvist ad228fb3b3 Copy list structures on persist 2025-02-17 19:29:23 +01:00
Mark Qvist a61b20a066 Updated version 2025-02-17 18:21:58 +01:00
Mark Qvist a49b04af21 Added missing check for path announce emission timestamp in lower hop-count announce processing. Closes #717. 2025-02-17 18:21:38 +01:00
jacob.eva 3002023a70 Update multi interface interaction spec 2025-02-10 18:31:46 +00:00
Mark Qvist f030cf6f22 Fixed potential daemon thread IO buffer deadlock on externally mediated shutdown signal 2025-02-09 17:52:49 +01:00
Mark Qvist 9e7641d2d3 Trace application-originated exception frames on LocalInterface 2025-01-27 12:29:28 +01:00
Mark Qvist c909871fb7 Updated issue template 2025-01-27 10:25:46 +01:00
Mark Qvist 47f60b0320 Fixed missing rx/tx stat assignment 2025-01-26 01:13:58 +01:00
Mark Qvist 6797909d90 Updated logging 2025-01-24 21:04:48 +01:00
Mark Qvist fd6d8ffff8 Updated logging 2025-01-21 23:55:49 +01:00
Mark Qvist 06de7f4a3d Updated logging 2025-01-21 23:22:39 +01:00
Mark Qvist 7221becd35 Updated manual 2025-01-19 21:09:50 +01:00
Mark Qvist a51f5f2eaf Updated changelog 2025-01-19 21:09:40 +01:00
Mark Qvist 9e8d71ddaf Updated examples 2025-01-19 20:38:41 +01:00
Mark Qvist 9bc55a9047 Updated manual 2025-01-19 12:56:32 +01:00
Mark Qvist 3e7ab5136e Better thread configuration 2025-01-19 00:57:36 +01:00
Mark Qvist d2cf3c2a7e Added error on configured radio parameter mismatch on Android 2025-01-19 00:10:35 +01:00
Mark Qvist 77519f1a0c Updated version 2025-01-18 21:38:13 +01:00
Mark Qvist e869b3cac9 Added resource reject signalling 2025-01-18 21:37:17 +01:00
Mark Qvist a2878f1722 Cleanup 2025-01-17 12:48:10 +01:00
Mark Qvist 748a7290a9 Updated docs 2025-01-17 12:47:02 +01:00
Mark Qvist 6e80a553c8 Updated changelog 2025-01-17 12:46:54 +01:00
Mark Qvist ec7aa44a17 Updated version 2025-01-17 12:41:48 +01:00
Mark Qvist 4fa335639c Added T3S3 support to rnodeconf 2025-01-16 19:10:49 +01:00
Mark Qvist 67195c0b14 Improved logging 2025-01-16 17:49:50 +01:00
Mark Qvist ad1e6a41ee Improved daemon restart time on systems with many interfaces 2025-01-16 17:48:16 +01:00
Mark Qvist a56d93fc1e Fixed potential logging deadlock 2025-01-16 17:37:47 +01:00
Mark Qvist b8aa6a3e44 Improved LocalInterface detach 2025-01-16 15:57:43 +01:00
Mark Qvist 1709cd929a Improved interface detach on shared instance shutdown 2025-01-16 14:12:30 +01:00
Mark Qvist 4f4961257c Improved interface detach on shared instance shutdown 2025-01-16 14:09:18 +01:00
Mark Qvist 1b48f43a0d Corrected T3S3 SX1280 model codes in rnodeconf 2025-01-16 12:04:54 +01:00
Mark Qvist e5d446a54e Retry ratchet reload on potential I/O conflicts 2025-01-16 12:04:29 +01:00
Mark Qvist 0af768e742 Cleanup 2025-01-14 19:36:49 +01:00
Mark Qvist 1a7d20a8d6 Cleanup 2025-01-14 19:02:15 +01:00
Mark Qvist ec4f4d5a83 Cleanup 2025-01-14 18:57:02 +01:00
Mark Qvist 8cefa4b2a9 Improved resource transfer timing 2025-01-14 18:24:56 +01:00
Mark Qvist 2331f1ea3e Fixed link MTU clamping 2025-01-14 18:21:31 +01:00
Mark Qvist be7dafa30c Added MTU autoconfiguration on interfaces 2025-01-14 18:19:51 +01:00
Mark Qvist 3e20cb1b67 Added resource EIFR continuity to split resource handling 2025-01-14 18:19:07 +01:00
Mark Qvist 097e136662 Fixed rnstatus display bug 2025-01-14 18:18:27 +01:00
Mark Qvist e3a716224d Implemented MTU autoconfiguration on interfaces 2025-01-14 18:17:53 +01:00
Mark Qvist 80dc567a53 Handle negative time in time formatters 2025-01-14 12:45:17 +01:00
Mark Qvist c6576d6504 Added link MTU discovery configuration option 2025-01-14 00:13:56 +01:00
Mark Qvist 89d5d9517d Added print device config option to rnodeconf 2025-01-13 21:48:35 +01:00
Mark Qvist dc315653c0 Added interference status to RNodeInterface 2025-01-13 21:06:24 +01:00
Mark Qvist 746b403890 Noise floor output formatting 2025-01-13 16:37:18 +01:00
Mark Qvist fc619460f0 Updated manual 2025-01-13 15:42:46 +01:00
Mark Qvist cd0f82d9ad Updated tests 2025-01-13 15:42:32 +01:00
Mark Qvist 330c2aacac Fixed incorrect resource SDU calculation when link MTU is set 2025-01-13 14:42:03 +01:00
Mark Qvist 63da084bbe Updated docs 2025-01-13 14:41:38 +01:00
Mark Qvist cbbd8221ee Fixed typo 2025-01-13 14:41:21 +01:00
Mark Qvist 1d18d53052 Updated speedtest example 2025-01-12 23:48:56 +01:00
Mark Qvist ceccf3153b Correct link MDU calculation 2025-01-12 23:48:21 +01:00
Mark Qvist bde33e7d84 Added support for dynamic link MTU to Channel and Buffer 2025-01-12 23:26:18 +01:00
Mark Qvist 93330d96a0 Updated manual 2025-01-12 20:56:13 +01:00
Mark Qvist d93ce62878 Updated HW MTUs 2025-01-12 20:56:06 +01:00
Mark Qvist eafa4aefbb Added log format 2025-01-12 18:51:27 +01:00
Mark Qvist 53df2fa5e0 Improved profiler 2025-01-12 17:51:02 +01:00
Mark Qvist abc657806d Added cumulative utilisation to profiler 2025-01-12 17:32:11 +01:00
Mark Qvist a0f219f7f4 Last-hop LR MTU clamping 2025-01-12 17:31:17 +01:00
Mark Qvist 47eba03a4b Single HW_MTU field 2025-01-12 17:29:06 +01:00
Mark Qvist 3289cd1299 Cleanup 2025-01-12 17:28:32 +01:00
Mark Qvist ab5fcd7a5b Added live traffic stats counting and output to rnstatus 2025-01-11 19:30:00 +01:00
Mark Qvist 45494f21aa Allow IFAC bitmask generation for large packet sizes 2025-01-11 17:26:51 +01:00
Mark Qvist 5d677d2fb7 Set correct hardware MTU 2025-01-11 17:25:03 +01:00
Mark Qvist 808082e300 Link proof MTU 2025-01-11 17:24:39 +01:00
Mark Qvist 97cfdfd023 Unify link ID across versions regardless of MTU discovery support 2025-01-11 16:58:09 +01:00
Mark Qvist 9b15cf2295 Check link MTU discovery is enabled 2025-01-11 15:52:40 +01:00
Mark Qvist eaa68c2d04 Updated docs 2025-01-11 14:56:45 +01:00
Mark Qvist ac5ca78c77 Improved split resource transfer performance 2025-01-11 14:25:27 +01:00
Mark Qvist 5b17dbdfd6 Improved packet filter performance 2025-01-11 14:24:40 +01:00
Mark Qvist d4ed20c7d5 Improved rncp status output 2025-01-11 14:23:53 +01:00
Mark Qvist a5093ea8f0 Updated version 2025-01-11 13:22:49 +01:00
Mark Qvist f5cf438abd Improve resource transfer throughput on high-MTU links 2025-01-11 13:22:18 +01:00
Mark Qvist bf6e73e163 Path MTU discovery for links 2025-01-11 11:43:47 +01:00
Mark Qvist 503f475ca5 Read link MTU from link request packet 2025-01-11 03:12:31 +01:00
Mark Qvist 8506118aee Cleanup 2025-01-11 01:45:09 +01:00
Mark Qvist dfa295a90a Cleanup 2025-01-11 01:31:57 +01:00
Mark Qvist 3ace1583da Packets go brrrr 2025-01-11 01:26:46 +01:00
Mark Qvist c62b66195d Optimised profiler timing overhead 2025-01-10 21:37:45 +01:00
Mark Qvist b724836d2b Changed profiler to context manager 2025-01-10 20:07:17 +01:00
Mark Qvist 1e1b9dc79e Fixed missing check for dict entry existence 2025-01-10 12:40:11 +01:00
Mark Qvist c668a51e39 Cleanup 2025-01-10 12:39:25 +01:00
Mark Qvist 09b34d34c6 Only persist ratchets when new 2025-01-10 12:39:04 +01:00
Mark Qvist 54e18e41c5 Updated changelog 2025-01-10 11:42:30 +01:00
Mark Qvist 5550bca040 Updated manual 2025-01-10 11:42:06 +01:00
Mark Qvist f7a02351d4 Added interference avoidance configuration to rnodeconf 2025-01-09 17:46:12 +01:00
Mark Qvist 3125b99043 Cleanup 2025-01-09 15:21:59 +01:00
Mark Qvist 158765abb7 Added noise floor stat output to rnodeconf 2025-01-09 15:18:29 +01:00
Mark Qvist 81aa9ac5b6 Added Heltec T114 to rnodeconf 2025-01-09 15:17:41 +01:00
Mark Qvist 55f5842587 Added new channel stat and CSMA parameters to RNodeInterface 2025-01-09 15:15:54 +01:00
Mark Qvist 38dd63a99a Updated issue template 2025-01-06 11:38:37 +01:00
Mark Qvist 558cd6c4a7 Updated version 2025-01-06 11:38:29 +01:00
Mark Qvist 15e6a1bfde Add support for SX1280 with PA 2025-01-03 22:35:01 +01:00
Mark Qvist c1087e62fd Added ability to initiate display reconditioning to rnodeconf 2024-12-31 14:14:14 +01:00
Mark Qvist 9d924dcd6d Added ability to set display rotation to rnodeconf 2024-12-31 13:22:57 +01:00
Mark Qvist 163d2ed157 Fixed missing console image install on Heltec V3 2024-12-12 13:06:52 +01:00
Mark Qvist 68f07ddd38 Updated manual 2024-12-11 22:26:48 +01:00
Mark Qvist d956b93c13 Updated changelog 2024-12-11 22:26:41 +01:00
Mark Qvist 3036305662 Cleanup 2024-12-11 22:17:58 +01:00
Mark Qvist ee603ce68e Updated manual 2024-12-11 19:56:37 +01:00
Mark Qvist 989513cb46 Updated version 2024-12-11 19:41:35 +01:00
Mark Qvist 7e52c37580 Allow announce handler to receive announce packet hash 2024-12-11 19:18:02 +01:00
Mark Qvist 0984f92fa2 Fixed typo 2024-12-11 19:17:14 +01:00
faragher 95cea24527 Ported rncp allow files to rnx 2024-11-02 23:06:43 -05:00
170 changed files with 18128 additions and 21559 deletions
+8 -3
View File
@@ -12,10 +12,15 @@ Before creating a bug report on this issue tracker, you **must** read the [Contr
- The issue tracker is used by developers of this project. **Do not use it to ask general questions, or for support requests**.
- Ideas and feature requests can be made on the [Discussions](https://github.com/markqvist/Reticulum/discussions). **Only** feature requests accepted by maintainers and developers are tracked and included on the issue tracker. **Do not post feature requests here**.
- After reading the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md), delete this section from your bug report.
- Do not submit code written using large language models (LLMs) or other generative 'AI' programs (see the [Generative AI Policy](/Contributing.md#generative-ai-policy) for details).
- After reading the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md), **delete this section only** (*"Read the Contribution Guidelines"*) from your bug report, **and fill in all the other sections**.
**Describe the Bug**
A clear and concise description of what the bug is.
First of all: Is this really a bug? Is it reproducible?
If this is a request for help because something is not working as you expected, stop right here, and go to the [discussions](https://github.com/markqvist/Reticulum/discussions) instead, where you can post your questions and get help from other users.
If this really is a bug or issue with the software, remove this section of the template, and provide **a clear and concise description of what the bug is**.
**To Reproduce**
Describe in detail how to reproduce the bug.
@@ -24,7 +29,7 @@ Describe in detail how to reproduce the bug.
A clear and concise description of what you expected to happen.
**Logs & Screenshots**
Please include any relevant log output. If applicable, also add screenshots to help explain your problem.
Please include any relevant log output. If applicable, also add screenshots to help explain your problem. In most cases, without any relevant log output, we will not be able to determine the cause of the bug, or reproduce it.
**System Information**
- OS and version
+7 -5
View File
@@ -28,8 +28,10 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.x
- run: make test
python-version: 3.11
- run: |
python -m pip install -q cryptography
make test
package:
needs: test
@@ -41,7 +43,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.x
python-version: 3.11
- run: |
python -m pip install -q build wheel setuptools
make remove_symlinks
@@ -87,10 +89,10 @@ jobs:
- uses: softprops/action-gh-release@v2
with:
files: |
.artifacts/package/**.whl
# .artifacts/package/**.whl
# .artifacts/documentation/latex/reticulumnetworkstack.pdf
# .artifacts/documentation/epub/ReticulumNetworkStack.epub
draft: true
generate_release_notes: true
generate_release_notes: false
prerelease: ${{ contains(github.ref, '-') }}
fail_on_unmatched_files: true
+1
View File
@@ -13,3 +13,4 @@ tests/rnsconfig/storage
tests/rnsconfig/logfile*
*.data
*.result
.buildinfo.bak
+26
View File
@@ -0,0 +1,26 @@
compiling = False
noticed = False
notice_delay = 0.3
import time
import sys
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]
def notice_job():
global noticed
started = time.time()
while compiling:
if time.time() > started+notice_delay and compiling:
noticed = True
print("Compiling RNS object code... ", end="")
sys.stdout.flush()
break
time.sleep(0.1)
compiling = True
threading.Thread(target=notice_job, daemon=True).start()
import RNS; compiling = False
if noticed: print("Done."); sys.stdout.flush()
+329
View File
@@ -1,3 +1,332 @@
### 2026-01-04: RNS 1.1.0
Enjoy.
**Changes**
- Added on-network global interface discovery. Hello world.
- Added discovered interface auto-connection. Robotic.
- Added external IP resolution for discovery-enabled interfaces. Snip-snip.
- Added encrypted interface discovery announces. Welcome home.
- Added bootstrap interface functionality. Decent.
- Added blackhole handling and management. Thank the Chinese guy.
- Added distributed blackhole list publishing and updating. Spammers go home.
- Added foundational network identity implementation. All your base.
- Added `await_path` method to API. Tick-tock.
- Added reverse-unicast peer discovery packet mechanism to AutoInterface. Ping-pong.
- Added custom identity support to `rncp`, thanks MikelCalvo!
- Added monitor mode to `rnstatus`, thanks MikelCalvo!
- Improved announce processing. Swoosh.
- Updated documentation quite a bit. Looky.
- Enabled per-peer ingress limiting on Weave and Auto interfaces. Hammertime.
- Fixed **the** typo, yes it's the olny one I'm sure.
- Fixed bugs. Squish.
**Release Hashes**
```
180b8baec2ec7d21abe2cec25ff763e70b2129c012fb02fc23c2fd654f94c1f5 dist/rns-1.1.0-py3-none-any.whl
d9e32caf66a9c53199e901d2c173e1de1bf50f1f0c9d5250e5d1b3b07bedcd7c dist/rnspure-1.1.0-py3-none-any.whl
```
### 2025-11-19: RNS 1.0.4
This maintenance release adds improved handling for RNodes with a PA/LNA combo.
**Changes**
- Improved handling for RNodes with PA/LNA combo
- Added interference detection stats to `rnstatus` output for RNode interfaces
- Updated documentation
**Release Hashes**
```
7a2b7893410833b42c0fa7f9a9e3369cebb085cdd26bd83f3031fa6c1051653c rns-1.0.4-py3-none-any.whl
ee647e7b3b94abdf1fab618a861390531a4aacc93eecce12c9e97280195c0e2d rnspure-1.0.4-py3-none-any.whl
```
### 2025-11-19: RNS 1.0.3
This release includes updates to RNode BLE reliability, and adds support for connecting RNodes to a host over WiFi and Ethernet.
**Changes**
- Added support for connecting RNode devices over WiFi and Ethernet
- Added support for configuring RNode WiFi and IP settings to `rnodeconf`
- Updated BLE connection read timeouts on Android, fixes intermittent BLE connection resets in areas with high 2.4GHz spectrum utilization
- Added handling for edge case where RNode serial port was never opened due to failure on interface detach
- Fixed broken links in documentation
**Release Hashes**
```
6bafde4c838ad778bf6878967e84c798e34d6ca621b255f59a60f38cb04ac138 rns-1.0.3-py3-none-any.whl
f277899f95c1189c6bf3beb40ac656c8b36dfd3d7e4cfb2bc3b4a1e6dc3484fa rnspure-1.0.3-py3-none-any.whl
```
### 2025-11-10: RNS 1.0.2
This maintenance release adds support for high-power RNodes with a LoRa PA and/or LNA.
**Changes**
- Added support for RNodes with a PA and/or LNA
- Added support for monitoring RNode CPU temperature via `rnodeconf`
**Release Hashes**
```
723bcf0a839025060ff680c4202b09fa766b35093a4a08506bb85485b8a1f154 rns-1.0.2-py3-none-any.whl
b02de8aeb1381ed2610f27f78799bab031367ed7bf500951fb8d5c2542d4a409 rnspure-1.0.2-py3-none-any.whl
```
### 2025-11-02: RNS 1.0.1
This release brings a number of bugfixes, as well as stability and reliability improvements. It also adds support for using Weave devices as Reticulum interfaces, fixes long-standing Bluetooth Low Energy connection issues on Android, and includes several API and usability improvements.
**Changes**
- Added path response signalling to announce handler API
- Added interface module for Weave devices
- Added support for connecting to Weave devices over serial/USB on Android
- Added support for allow files to `rnx`
- Added detection and logging of multicast echoes never arriving on AutoInterface system devices.
- Added Heltec v4 support to `rnodeconf`
- Implemented handler for ensuring dynamic destination app data can be generated and sent even on first system-internal discovery announce
- Updated documentation and manual
- Improved `AutoInterface` peering timing
- Fixed RNodeInterface Bluetooth Low Energy connection hangs on Android
- Fixed RNodeInterface Bluetooth Low Energy MTU not being configured correctly on Android
- Fixed command byte collision in RNodeInterface and RNodeMultiInterface
- Fixed string formatting for Android log output
- Updated output formatting for `rnid`
**Release Hashes**
```
aa77b4c8e1b6899117666e1e55b05b3250416ab5fea2826254358ae320e8b3ed rns-1.0.1-py3-none-any.whl
b3ddfa0b533631d9f1213043a0282952ae6e9f72c3072bbca053ac48e0483f7e rnspure-1.0.1-py3-none-any.whl
```
### 2025-07-14: RNS 1.0.0
We're out of beta. Thanks to **everyone** who helped make it this far.
This release brings a number of bugfixes, as well as stability and reliability improvements.
**Changes**
- Improved BLE device discovery on Android
- Improved BLE MTU configuration on Android
- Fixed a bug in handling of link requests with invalid link mode bytes
- Fixed potential AutoInterface peer discovery add before final init complete
- Fixed a potential EPOLL backend hang on interface failure
- Fixed various log statements
- Fixed announce cap crash for `RNodeMultiInterface` with transport mode enabled
- Updated documentation
- Removed legacy AES-128 handlers
**Release Hashes**
```
5a9f18840510b69f89c6706d130177e2843c9e19c774707ae2661030d693dfc1 rns-1.0.0-py3-none-any.whl
acfd52af9bf41f78be017579ca06c0abe748d0b98dbdc9baacf140a0606599ec rnspure-1.0.0-py3-none-any.whl
```
### 2025-05-15: RNS β 0.9.6
This release activates AES-256 as the default encryption mode for all communication. It is the last release that will support the old AES-128 based modes, which will be entirely phased out in the next release.
This release also includes a number of API and resource consumption improvements, and fixes a bug.
**Changes**
- Enabled AES-256 as default encryption mode for all traffic
- Added dynamic link keepalive and timeout calculation
- Added ability to efficiently transfer files as responses in the `Request` API
- Added ability to include metadata on `Resource` transfers
- Added option to specify `Resource` auto-compression limits
- Added option to specify `Request` response auto-compression limits
- Added `Resource` transfer example
- Added allow overwrite option to `rncp`
- Improved hardware MTU auto-configuration
- Improved handling of file transfers using the `Resource` API
- Improved `Resource` transfer memory consumption
- Improved memory consumption of applications connected to a shared instance
- Improved `rncp` memory consumption for large files
- Fixed announce handlers not triggering after shared instance disappearance
**Release Hashes**
```
a23c64a04c1e83fd0ab449f564ac904da7fd4f61c0faf68a063f486cc48b44bd rns-0.9.6-py3-none-any.whl
4544882dea902b18b00d8a04c9ab93201974573b7b63c3db06cb310b0acec240 rnspure-0.9.6-py3-none-any.whl
```
### 2025-05-09: RNS β 0.9.5
This release initiates migration of Reticulum from AES-128 to AES-256 as the default link and packet cipher mode. It is a compatibility/migration release, that while supporting AES-256 doesn't use it by default. It will work with both the old AES-128 based modes, and the new AES-256 based modes. There's a very slight penalty in performance to support both the old and new modes at the same time, but only for single packet APIs (not links), and it really shouldn't be noticeable in any everyday use.
In the next release, version `0.9.6`, Reticulum will transition fully to AES-256 and use it by default for all communications. That means that both single packets and links will use AES-256 by default. The old AES-128 link mode may or may not be available for a few releases, but will ultimately be phased out entirely.
The update requires no intervention, configuration changes or anything similar from a users or developers perspective. Everything should simply work. This goes both for the `0.9.5` update, and the next `0.9.6` update that transitions fully to AES-256.
**Changes**
- Added support for AES-256 mode to links and packets
- Added dynamic link mode support
- Added temporary backwards compatibility for AES-128 link and packet modes
- Added `get_mode()` method to link API
- Added tests for all enabled link modes
- Added `instance_name` option and description to default config file
- Improved ratchet persist reliability if Reticulum is force killed while persisting ratchets
- Fixed interface string representation for some interfaces
- Fixed instance name config option being overwritten if option was not last in section
- Fixed unhandled potential exception on fast-flapping `BackboneInterface` connections
**Release Hashes**
```
ae6587c86c98cae0df73567af093cc92fe204e71bb01f2506da9aec626a27e97 rns-0.9.5-py3-none-any.whl
96208c1d1234e3e4b1c18ca986bad5d4693aeb431453efd7ade33b87f35600e1 rnspure-0.9.5-py3-none-any.whl
```
### 2025-04-15: RNS β 0.9.4
This release significantly improves memory utilisation and performance. It also includes a few new features and general improvements to the included utilities and programs.
**Changes**
- Significantly improved memory utilisation, thread count and performance on nodes with many interfaces or clients
- Switched local instance communication to run over abstract domain sockets on Linux and Android
- Switched instance IPC to run over abstract domain sockets on Linux and Android
- Added kernel event based I/O backend on Linux and Android
- Added fast `BackboneInterface` type
- Added support for XIAO-ESP32S3 to `rnodeconf`
- Added interactive shell option to `rnsd`
- Added API option to search for identity by identity hash
- Added option to run TCP and Backbone interfaces in AP mode
- Improved `RNodeMultiInterface` host communications specification
- Improved `rncp` statistics output
- Improved link and reverse-table culling
- Fixed an occasional I/O thread hang on instance shutdown, that would result in an error printed to the console
- Fixed various minor interface logging inconsistencies
- Fixed various minor interface checking inconsistencies
- Updated internal `configobj` implementation
- Refactored various parts of the transport core code
- Swicthed to using internal `netinfo` implementation instead of including full `ifaddr` library
- Cleaned out unneeded dependencies
**Release Hashes**
```
737294f29e013f9fa9c8c1326006d0547497607156828fee3dc2a0d3ddd754e7 rns-0.9.4-py3-none-any.whl
0bd8a908af115c27733484853d779574d6383ebc1d78160e5a72c14ed9692a13 rnspure-0.9.4-py3-none-any.whl
```
### 2025-03-13: RNS β 0.9.3
This maintenance release improves performance and fixes a number of bugs.
**Changes**
- Enabled link MTU discovery by default
- Added on-demand object code compilation and loader shim
- Added link API methods
- Added child interface spawning for AutoInterface
- Fixed corrupt ratchet files not being removed on maintenance cleaning
- Fixed `rnid` not waiting for announce timebase tick before announcing
**Release Hashes**
```
0270c988a2b898b28348cd78138667115d4ef3f7e09c86531baaefbee35ef851 rns-0.9.3-py3-none-any.whl
eee1a6c4c9c0f04bb17b12b8fb37b9c4cec12a99c87a046730eb7c9a6ffd999f rnspure-0.9.3-py3-none-any.whl
```
### 2025-01-19: RNS β 0.9.2
This maintenance release fixes a number of bugs.
**Changes**
- Fixed missing RX/TX bytes statistics assignment
- Fixed potential daemon thread IO buffer deadlock on externally mediated shutdown signal
- Fixed missing check for path announce emission timestamp in lower hop-count announce processing
**Release Hashes**
```
068eb4408b332ea6eec1a58fb4644fba3531c9ca10dcd79ecf893aaaf40e720d rns-0.9.2-py3-none-any.whl
1e7c123d244cc14c287568f3a99953cc11ffc1e79a72a029aa1be72fa8eff24e rnspure-0.9.2-py3-none-any.whl
```
### 2025-01-19: RNS β 0.9.1
This maintenance release adds reject signalling mechanism to resource transfers, fixes inconsistencies in the code examples, and improves thread configuration in the transport core.
**Changes**
- Added resource reject signalling
- Added error reporting on configured radio parameter mismatch on Android
- Improved thread configuration for transport core threads
- Updated examples
**Release Hashes**
```
49288a562ad6d4b5647c3afec051a6bb6497b75e3f165a972436134d4a93ad76 rns-0.9.1-py3-none-any.whl
abd6c4bdead2fc25d0b9b2cda5708586e8cb776b088f2a901a5f262e2ed901ae rnspure-0.9.1-py3-none-any.whl
```
### 2025-01-17: RNS β 0.9.0
This release lays the groundwork for future performance and resource utilisation optimisations. Most importantly, this release adds **link MTU autodiscovery**, which allow established links to use much higher MTUs than the base MTU of 500 bytes.
**Please note!** To actually use link MTU discovery, all transport nodes along the path must be upgraded to at least version `0.9.0`. Since this is the first release to add support for this feature, *it is currently **not** activated by default*, and no clients or applications will use it yet. Using link MTU autodiscovery by default will be enabled by default in RNS version `0.9.1`. Please upgrade your nodes!
Additionally, this release adds several new features, performance improvements and bug fixes, as well as support for RNodes running firmware version `1.81`.
**Changes**
- Added MTU autoconfiguration on interfaces that support higher MTUs
- Added link MTU autodiscovery and path clamping
- Added dynamic SDU calculations based on link MTU to `Resource`, `Channel` and `Buffer`
- Added resource EIFR continuity to split resource handling
- Added interference status to `RNodeInterface`
- Fixed a display bug in `rnstatus`
- Added live traffic stats to `rnstatus`
- Added T3S3 support to `rnodeconf`
- Added Heltec T114 support to `rnodeconf`
- Added LilyGO T-Echo support to `rnodeconf`
- Added option to print device configuration to `rnodeconf`
- Improved CPU utilisation and memory consumption
- Improved `rnsd` restart time on systems with many interfaces
- Improved `rncp` status output
- Improved packet filter performance
- Improved interface detachment handling
- Improved resource transfer timing and performance
- Improved Transport core efficiency
- Improved reliability of ratchet reloads if I/O conflicts occur
- Improved logging
- Improved built-in profiler
- Fixed a potential deadlock in logging
- Fixed time formatters not handling negative times
- Updated example code
**Release Hashes**
```
1ee60634cf0627c45b93f4e6c9adaf1fcdf9c1a8dfd4dd3dcd499e029554ab4f rns-0.9.0-py3-none-any.whl
b67eec583fdb224ba8174b317e66b8f7344e338e93760ed1a90f0bafea8cf09e rnspure-0.9.0-py3-none-any.whl
```
### 2025-01-09: RNS β 0.8.9
This maintenance release adds a number of configuration options to `rnodeconf`.
**Changes**
- Added noise floor output to `rnstatus` for supported interfaces
- Added channel noise floor and CSMA parameter reporting to `RNodeInterface`
- Added ability to set display rotation in `rnodeconf`
- Added ability to configure interference avoidance to `rnodeconf`
- Fixed missing console image install on Heltec V3 in `rnodeconf`
**Release Hashes**
```
b54fe8bc296f83a3a70569c9d1e9db3096249789c18f8d0217671479fa6881a1 rns-0.8.9-py3-none-any.whl
52fd992e5f9478d5a1f61f8f37dc0ee2d268fdd0b8a4e6656d33d632490afc5a rnspure-0.8.9-py3-none-any.whl
```
### 2024-12-11: RNS β 0.8.8
This maintenance release adds a single API function and fixes a bug.
**Changes**
- Allow announce handlers to receive announce packet hash
- Fix packet RSSI/SNR/Q cache not being available on standalone instances
**Release Hashes**
```
9c1755a81049c67b051ecb9fe4b2c5f7d98bf09d20ed52d6ce6a410298b0527b rns-0.8.8-py3-none-any.whl
d8871d69cde4b0a0b99b383f324d651dc77a2f44ec9641be828902c778a8d128 rnspure-0.8.8-py3-none-any.whl
```
### 2024-12-09: RNS β 0.8.7
This maintenance release adds support for OpenWRT packaging, and brings several minor improvements and bugfixes.
+26 -11
View File
@@ -6,23 +6,30 @@ Apart from writing code, there are many ways in which you can contribute. Before
## 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** - also 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 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.
In order to keep the discussion forums and issue trackers navigable and useful, the following types of posts will be deleted without notice:
- 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.
- 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 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
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.
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).
Instead, ask away on the [discussions](https://github.com/markqvist/Reticulum/discussions) or on the [Reticulum Matrix channel](https://matrix.to/#/#reticulum:matrix.org) at `#reticulum:matrix.org`
## Providing Feedback & Ideas
Likewise, feedback, ideas and feature requests are a very welcome way to contribute, and should also be posted on the [discussions](https://github.com/markqvist/Reticulum/discussions), or on the [Reticulum Matrix channel](https://matrix.to/#/#reticulum:matrix.org) at `#reticulum:matrix.org`.
Please 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.
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). If at all possible, be sure to include details on how to reproduce the bug.
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.
@@ -40,4 +47,12 @@ Pull requests have a high chance of being accepted if they are:
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.
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 [MIT license](./LICENSE).
## Generative AI Policy
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.
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.
## 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).
+2 -1
View File
@@ -6,6 +6,7 @@
import argparse
import random
import sys
import RNS
# Let's define an app name. We'll use this for all
@@ -168,4 +169,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+1 -1
View File
@@ -118,4 +118,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+3 -4
View File
@@ -157,7 +157,7 @@ def client(destination_hexhash, configpath):
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -254,9 +254,8 @@ def link_closed(link):
else:
RNS.log("Link closed, exiting now")
RNS.Reticulum.exit_handler()
time.sleep(1.5)
os._exit(0)
sys.exit(0)
# When the buffer has new data, read it and write it to the terminal.
def client_buffer_ready(ready_bytes: int):
@@ -320,4 +319,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+5 -6
View File
@@ -124,7 +124,7 @@ def server(configpath):
def server_loop(destination):
# Let the user know that everything is ready
RNS.log(
"Link example "+
"Channel example "+
RNS.prettyhexrep(destination.hash)+
" running, waiting for a connection."
)
@@ -212,7 +212,7 @@ def client(destination_hexhash, configpath):
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -276,7 +276,7 @@ def client_loop():
packed_size = len(message.pack())
channel = server_link.get_channel()
if channel.is_ready_to_send():
if packed_size <= channel.MDU:
if packed_size <= channel.mdu:
channel.send(message)
else:
RNS.log(
@@ -321,9 +321,8 @@ def link_closed(link):
else:
RNS.log("Link closed, exiting now")
RNS.Reticulum.exit_handler()
time.sleep(1.5)
os._exit(0)
sys.exit(0)
# When a packet is received over the channel, we
# simply print out the data.
@@ -387,4 +386,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+3 -2
View File
@@ -6,6 +6,7 @@
##########################################################
import argparse
import sys
import RNS
# Let's define an app name. We'll use this for all
@@ -130,7 +131,7 @@ def client(destination_hexhash, configpath, timeout=None):
except Exception as e:
RNS.log("Invalid destination entered. Check your input!")
RNS.log(str(e)+"\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -328,4 +329,4 @@ if __name__ == "__main__":
client(args.destination, configarg, timeout=timeoutarg)
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
-2
View File
@@ -1,5 +1,3 @@
# MIT License - Copyright (c) 2024 Mark Qvist / unsigned.io
# This example illustrates creating a custom interface
# definition, that can be loaded and used by Reticulum at
# runtime. Any number of custom interfaces can be created
+4 -5
View File
@@ -224,7 +224,7 @@ def client(destination_hexhash, configpath):
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -462,7 +462,7 @@ def filelist_timeout_job():
global server_files
if len(server_files) == 0:
RNS.log("Timed out waiting for filelist, exiting")
os._exit(0)
sys.exit(0)
# When a link is closed, we'll inform the
@@ -475,9 +475,8 @@ def link_closed(link):
else:
RNS.log("Link closed, exiting now")
RNS.Reticulum.exit_handler()
time.sleep(1.5)
os._exit(0)
sys.exit(0)
# When RNS detects that the download has
# started, we'll update our menu state
@@ -601,4 +600,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+3 -4
View File
@@ -133,7 +133,7 @@ def client(destination_hexhash, configpath):
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -245,9 +245,8 @@ def link_closed(link):
else:
RNS.log("Link closed, exiting now")
RNS.Reticulum.exit_handler()
time.sleep(1.5)
os._exit(0)
sys.exit(0)
# When a packet is received over the link, we
# simply print out the data.
@@ -311,4 +310,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+3 -4
View File
@@ -119,7 +119,7 @@ def client(destination_hexhash, configpath):
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -222,9 +222,8 @@ def link_closed(link):
else:
RNS.log("Link closed, exiting now")
RNS.Reticulum.exit_handler()
time.sleep(1.5)
os._exit(0)
sys.exit(0)
# When a packet is received over the link, we
# simply print out the data.
@@ -288,4 +287,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+2 -1
View File
@@ -5,6 +5,7 @@
##########################################################
import argparse
import sys
import RNS
# Let's define an app name. We'll use this for all
@@ -98,4 +99,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+3 -5
View File
@@ -5,6 +5,7 @@
##########################################################
import argparse
import sys
import RNS
# Let's define an app name. We'll use this for all
@@ -25,9 +26,6 @@ def server(configpath):
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
# TODO: Remove
RNS.loglevel = RNS.LOG_DEBUG
# Randomly create a new identity for our echo server
server_identity = RNS.Identity()
@@ -141,7 +139,7 @@ def client(destination_hexhash, configpath, timeout=None):
except Exception as e:
RNS.log("Invalid destination entered. Check your input!")
RNS.log(str(e)+"\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -340,4 +338,4 @@ if __name__ == "__main__":
client(args.destination, configarg, timeout=timeoutarg)
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+5 -6
View File
@@ -1,6 +1,6 @@
##########################################################
# This RNS example demonstrates how to set perform #
# requests and receive responses over a link. #
# This RNS example demonstrates how to perform requests #
# and receive responses over a link. #
##########################################################
import os
@@ -119,7 +119,7 @@ def client(destination_hexhash, configpath):
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -226,9 +226,8 @@ def link_closed(link):
else:
RNS.log("Link closed, exiting now")
RNS.Reticulum.exit_handler()
time.sleep(1.5)
os._exit(0)
sys.exit(0)
##########################################################
@@ -284,4 +283,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+294
View File
@@ -0,0 +1,294 @@
##########################################################
# This RNS example demonstrates how to transfer a #
# resource over an established link #
##########################################################
import os
import sys
import time
import random
import argparse
import RNS
# Let's define an app name. We'll use this for all
# destinations we create. Since this echo example
# is part of a range of example utilities, we'll put
# them all within the app namespace "example_utilities"
APP_NAME = "example_utilities"
##########################################################
#### Server Part #########################################
##########################################################
# A reference to the latest client link that connected
latest_client_link = None
# This initialisation is executed when the users chooses
# to run as a server
def server(configpath):
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
# Randomly create a new identity for our link example
server_identity = RNS.Identity()
# We create a destination that clients can connect to. We
# want clients to create links to this destination, so we
# need to create a "single" destination type.
server_destination = RNS.Destination(
server_identity,
RNS.Destination.IN,
RNS.Destination.SINGLE,
APP_NAME,
"resourceexample"
)
# We configure a function that will get called every time
# a new client creates a link to this destination.
server_destination.set_link_established_callback(client_connected)
# Everything's ready!
# Let's Wait for client resources or user input
server_loop(server_destination)
def server_loop(destination):
# Let the user know that everything is ready
RNS.log(
"Resource example "+
RNS.prettyhexrep(destination.hash)+
" running, waiting for a connection."
)
RNS.log("Hit enter to manually send an announce (Ctrl-C to quit)")
# We enter a loop that runs until the users exits.
# If the user hits enter, we will announce our server
# destination on the network, which will let clients
# know how to create messages directed towards it.
while True:
entered = input()
destination.announce()
RNS.log("Sent announce from "+RNS.prettyhexrep(destination.hash))
# When a client establishes a link to our server
# destination, this function will be called with
# a reference to the link.
def client_connected(link):
global latest_client_link
RNS.log("Client connected")
# We configure the link to accept all resources
# and set a callback for completed resources
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
link.set_resource_concluded_callback(resource_concluded)
link.set_link_closed_callback(client_disconnected)
latest_client_link = link
def client_disconnected(link):
RNS.log("Client disconnected")
def resource_concluded(resource):
if resource.status == RNS.Resource.COMPLETE:
RNS.log(f"Resource {resource} received")
RNS.log(f"Metadata: {resource.metadata}")
RNS.log(f"Data length: {os.stat(resource.data.name).st_size}")
RNS.log(f"Data can be read directly from: {resource.data}")
RNS.log(f"Data can be moved or copied from: {resource.data.name}")
RNS.log(f"First 32 bytes of data: {RNS.hexrep(resource.data.read(32))}")
else:
RNS.log(f"Receiving resource {resource} failed")
##########################################################
#### Client Part #########################################
##########################################################
# A reference to the server link
server_link = None
def random_text_generator():
texts = ["They looked up", "On each full moon", "Becky was upset", "Ill stay away from it", "The pet shop stocks everything"]
return texts[random.randint(0, len(texts)-1)]
# This initialisation is executed when the users chooses
# to run as a client
def client(destination_hexhash, configpath):
# We need a binary representation of the destination
# hash that was entered on the command line
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(destination_hexhash) != dest_len:
raise ValueError(
"Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)
)
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
# Check if we know a path to the destination
if not RNS.Transport.has_path(destination_hash):
RNS.log("Destination is not yet known. Requesting path and waiting for announce to arrive...")
RNS.Transport.request_path(destination_hash)
while not RNS.Transport.has_path(destination_hash):
time.sleep(0.1)
# Recall the server identity
server_identity = RNS.Identity.recall(destination_hash)
# Inform the user that we'll begin connecting
RNS.log("Establishing link with server...")
# When the server identity is known, we set
# up a destination
server_destination = RNS.Destination(
server_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
APP_NAME,
"resourceexample"
)
# And create a link
link = RNS.Link(server_destination)
# We'll set up functions to inform the
# user when the link is established or closed
link.set_link_established_callback(link_established)
link.set_link_closed_callback(link_closed)
# Everything is set up, so let's enter a loop
# for the user to interact with the example
client_loop()
def client_loop():
global server_link
# Wait for the link to become active
while not server_link:
time.sleep(0.1)
should_quit = False
while not should_quit:
try:
print("> ", end=" ")
text = input()
# Check if we should quit the example
if text == "quit" or text == "q" or text == "exit":
should_quit = True
server_link.teardown()
else:
# Generate 32 megabytes of random data
data = os.urandom(32*1024*1024)
RNS.log(f"Data length: {len(data)}")
RNS.log(f"First 32 bytes of data: {RNS.hexrep(data[:32])}")
# Generate some metadata
metadata = {"text": random_text_generator(), "numbers": [1,2,3,4], "blob": os.urandom(16)}
# Send the resource
resource = RNS.Resource(data, server_link, metadata=metadata, callback=resource_concluded_sending, auto_compress=False)
# Alternatively, you can stream data
# directly from an open file descriptor
# with open("/path/to/file", "rb") as data_file:
# resource = RNS.Resource(data_file, server_link, metadata=metadata, callback=resource_concluded_sending, auto_compress=False)
except Exception as e:
RNS.log("Error while sending resource over the link: "+str(e))
should_quit = True
server_link.teardown()
def resource_concluded_sending(resource):
if resource.status == RNS.Resource.COMPLETE: RNS.log(f"The resource {resource} was sent successfully")
else: RNS.log(f"Sending the resource {resource} failed")
# This function is called when a link
# has been established with the server
def link_established(link):
# We store a reference to the link
# instance for later use
global server_link
server_link = link
# Inform the user that the server is
# connected
RNS.log("Link established with server, hit enter to sand a resource, or type in \"quit\" to quit")
# When a link is closed, we'll inform the
# user, and exit the program
def link_closed(link):
if link.teardown_reason == RNS.Link.TIMEOUT:
RNS.log("The link timed out, exiting now")
elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED:
RNS.log("The link was closed by the server, exiting now")
else:
RNS.log("Link closed, exiting now")
time.sleep(1.5)
sys.exit(0)
##########################################################
#### Program Startup #####################################
##########################################################
# This part of the program runs at startup,
# and parses input of from the user, and then
# starts up the desired program mode.
if __name__ == "__main__":
try:
parser = argparse.ArgumentParser(description="Simple resource example")
parser.add_argument(
"-s",
"--server",
action="store_true",
help="wait for incoming resources from clients"
)
parser.add_argument(
"--config",
action="store",
default=None,
help="path to alternative Reticulum config directory",
type=str
)
parser.add_argument(
"destination",
nargs="?",
default=None,
help="hexadecimal hash of the server destination",
type=str
)
args = parser.parse_args()
if args.config:
configarg = args.config
else:
configarg = None
if args.server:
server(configarg)
else:
if (args.destination == None):
print("")
parser.print_help()
print("")
else:
client(args.destination, configarg)
except KeyboardInterrupt:
print("")
sys.exit(0)
+10 -20
View File
@@ -149,8 +149,6 @@ def server_packet_received(message, packet):
time.sleep(0.2)
rc = 0
received_data = 0
# latest_client_link.teardown()
# os._exit(0)
##########################################################
@@ -159,6 +157,7 @@ def server_packet_received(message, packet):
# A reference to the server link
server_link = None
should_quit = False
# This initialisation is executed when the users chooses
# to run as a client
@@ -175,7 +174,7 @@ def client(destination_hexhash, configpath):
destination_hash = bytes.fromhex(destination_hexhash)
except:
RNS.log("Invalid destination entered. Check your input!\n")
exit()
sys.exit(0)
# We must first initialise Reticulum
reticulum = RNS.Reticulum(configpath)
@@ -216,7 +215,7 @@ def client(destination_hexhash, configpath):
client_loop()
def client_loop():
global server_link
global server_link, should_quit
# Wait for the link to become active
while not server_link:
@@ -224,16 +223,7 @@ def client_loop():
should_quit = False
while not should_quit:
try:
text = input()
# Check if we should quit the example
if text == "quit" or text == "q" or text == "exit":
should_quit = True
server_link.teardown()
except Exception as e:
raise e
time.sleep(0.2)
# This function is called when a link
# has been established with the server
@@ -246,8 +236,8 @@ def link_established(link):
# Inform the user that the server is
# connected
RNS.log("Link established with server,sending...")
rd = os.urandom(RNS.Link.MDU)
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:
RNS.Packet(server_link, rd, create_receipt=False).send()
@@ -276,17 +266,17 @@ def link_established(link):
# When a link is closed, we'll inform the
# user, and exit the program
def link_closed(link):
global should_quit
if link.teardown_reason == RNS.Link.TIMEOUT:
RNS.log("The link timed out, exiting now")
elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED:
RNS.log("The link was closed by the server, exiting now")
else:
RNS.log("Link closed, exiting now")
RNS.Reticulum.exit_handler()
should_quit = True
time.sleep(1.5)
os._exit(0)
sys.exit(0)
def client_packet_received(message, packet):
pass
@@ -344,4 +334,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("")
exit()
sys.exit(0)
+7
View File
@@ -0,0 +1,7 @@
{
"drips": {
"ethereum": {
"ownedBy": "0xae89F3B94fC4AD6563F0864a55F9a697a90261ff"
}
}
}
+2 -1
View File
@@ -1,2 +1,3 @@
liberapay: Reticulum
ko_fi: markqvist
custom: "https://unsigned.io/donate"
custom: "https://unsigned.io/donate"
+12 -4
View File
@@ -1,6 +1,6 @@
MIT License, unless otherwise noted
Reticulum License
Copyright (c) 2016-2024 Mark Qvist / unsigned.io
Copyright (c) 2016-2025 Mark Qvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@ 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 above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
- 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,
+33
View File
@@ -0,0 +1,33 @@
This repository is a public mirror. All potential future development is happening elsewhere.
I am stepping back from all public-facing interaction with this project. Reticulum has always been primarily my work, and continuing in the current public, internet-facing model is no longer sustainable.
The software remains available for use as-is. Occasional updates may appear at unpredictable intervals, but there will be no support, no responses to issues, no discussions, and no community management in this or any other public venue. If it doesn't work for you, it doesn't work. That is the entire extent of available troubleshooting assistance I can offer you.
If you've followed this project for a while, you already know what this means. You know who designed, wrote and tested this, and you know how many years of my life it took. You'll also know about both my particular challenges and strengths, and how I believe anything worth building needs to be built and maintained with our own hands.
Seven months ago, I said I needed to step back, that I was exhausted, and that I needed to recover. I believed a public resolve would be enough to effectuate that, but while striving to get just a few more useful features and protocols out, the unproductive requests and demands also ramped up, and I got pulled back into the same patterns and draining interactions that I'd explicitly said I couldn't sustain anymore.
So here's what you might have already guessed: I'm done playing the game by rules I can't win at.
Everything you need is right here, and by any sensible measure, it's done. Anyone who wants to invest the time, skill and persistence can build on it, or completely re-imagine it with different priorities. That was always the point.
The people who actually contributed - you know who you are, and you know I mean it when I say: Thank you. All of you who've used this to build something real - that was the goal, and you did it without needing me to hold your hand.
The rest of you: You have what you need. Use it or don't. I am not going to be the person who explains it to you anymore.
This is not a temporary break. It's not "see you after some rest", but a recognition that the current model is fundamentally incompatible with my life, my health, and my reality.
If you want to support continued work, you can do so at the donation links listed in this repository. But please understand, that this is not purchasing support or guaranteeing updates. It is support for work that happens on my timeline, according to my capacity, which at the moment is not what it was.
If you want Reticulum to continue evolving, you have the power to make that happen. The protocol is public domain. The code is open source. Everything you need is right here. I've provided the tools, but building what comes next is not my responsibility anymore. It's yours.
To the small group of people who has actually been here, and understood what this work was and what it cost - you already know where to find me if it actually matters.
To everyone else: This is where we part ways. No hard feelings. It's just time.
---
असतो मा सद्गमय
तमसो मा ज्योतिर्गमय
मृत्योर्मा अमृतं गमय
+12 -4
View File
@@ -24,6 +24,12 @@ clean:
@make -C docs clean
@echo Done
purge_docs:
@echo Purging documentation build...
@-rm -rf ./docs/manual
@-rm -rf ./docs/*.pdf
@-rm -rf ./docs/*.epub
remove_symlinks:
@echo Removing symlinks for build...
-rm Examples/RNS
@@ -34,14 +40,14 @@ create_symlinks:
-ln -s ../RNS ./Examples/
-ln -s ../../RNS ./RNS/Utilities/
build_sdist_only:
build_sdist: purge_docs
python3 setup.py sdist
build_wheel:
python3 setup.py sdist bdist_wheel
python3 setup.py bdist_wheel
build_pure_wheel:
python3 setup.py sdist bdist_wheel --pure
python3 setup.py bdist_wheel --pure
documentation:
make -C docs html
@@ -49,7 +55,9 @@ documentation:
manual:
make -C docs latexpdf epub
release: test remove_symlinks build_wheel build_pure_wheel documentation manual create_symlinks
build_spkg: remove_symlinks build_sdist create_symlinks
release: test remove_symlinks build_sdist build_wheel build_pure_wheel documentation manual create_symlinks
debug: remove_symlinks build_wheel build_pure_wheel create_symlinks
+43 -33
View File
@@ -1,8 +1,10 @@
Reticulum Network Stack β <img align="right" src="https://static.pepy.tech/personalized-badge/rns?period=total&units=international_system&left_color=grey&right_color=blue&left_text=Installs" 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>
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>
==========
<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.*
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
@@ -52,7 +54,7 @@ For more info, see [reticulum.network](https://reticulum.network/) and [the FAQ
- Forward Secrecy is available for all communication types, both for single packets and over links
- Reticulum uses the following format for encrypted tokens:
- Ephemeral per-packet and link keys and derived from an ECDH key exchange on Curve25519
- AES-128 in CBC mode with PKCS7 padding
- AES-256 in CBC mode with PKCS7 padding
- HMAC using SHA256 for authentication
- IVs are generated through os.urandom()
- Unforgeable packet delivery confirmations
@@ -62,7 +64,7 @@ For more info, see [reticulum.network](https://reticulum.network/) and [the FAQ
- Easily create your own custom interfaces for communicating over anything
- Authentication and virtual network segmentation on all supported interface types
- An intuitive and easy-to-use API
- Simpler and easier to use than sockets APIs and simpler, but more powerful
- Simpler and easier to use than sockets APIs, but more powerful
- Makes building distributed and decentralised applications much simpler
- Reliable and efficient transfer of arbitrary amounts of data
- Reticulum can handle a few bytes of data or files of many gigabytes
@@ -86,9 +88,10 @@ following resources.
- You can use the [rnsh](https://github.com/acehoss/rnsh) program to establish remote shell sessions over Reticulum.
- [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.
- For an off-grid, encrypted and resilient mesh communications platform, see [Nomad Network](https://github.com/markqvist/NomadNet)
- The Android, Linux, macOS and Windows app [Sideband](https://github.com/markqvist/Sideband) has a graphical interface and focuses on ease of use.
- [MeshChat](https://github.com/liamcottle/reticulum-meshchat) is a user-friendly LXMF client, that also supports voice calls.
- 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.
- [MeshChat](https://github.com/liamcottle/reticulum-meshchat) is a user-friendly LXMF client with a web-based interface, that also supports image and voice messages, as well as file transfers. It also includes a built-in page browser for browsing Nomad Network nodes.
## Where can Reticulum be used?
Over practically any medium that can support at least a half-duplex channel
@@ -205,15 +208,14 @@ provide a dynamic performance envelope from 250 bits per second, to 1 gigabit
per second on normal hardware.
Currently, the usable performance envelope is approximately 150 bits per second
to 40 megabits per second, with physical mediums faster than that not being
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.
## Current Status
Reticulum should currently be considered beta software. All core protocol
features are implemented and functioning, but additions will probably occur as
real-world use is explored. There will be bugs. The API and wire-format can be
considered relatively stable at the moment, but could change if warranted.
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 the dependencies listed
@@ -280,14 +282,9 @@ file:
target_host = reticulum.betweentheborders.com
target_port = 4242
# Interface to Testnet I2P Hub
[[RNS Testnet I2P Hub]]
type = I2PInterface
enabled = yes
peers = g3br23bvx3lq5uddcsjii74xgmn6y5q325ovrkq2zw2wbzbqgbuq.b32.i2p
```
The testnet also contains a number of [Nomad Network](https://github.com/markqvist/nomadnet) nodes, and LXMF propagation nodes.
As the amount of global Reticulum nodes and entrypoints have grown to a substantial quantity, the public Amsterdam Testnet entrypoint is slated for de-commisioning in the first quarter of 2026. If your own instances rely on this entrypoint for connectivity, it is high time to start configuring alternatives. 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.
## Support Reticulum
You can help support the continued development of open, free and private communications systems by donating via one of the following channels:
@@ -296,23 +293,30 @@ You can help support the continued development of open, free and private communi
```
84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w
```
- Ethereum
```
0xFDabC71AC4c0C78C95aDDDe3B4FA19d6273c5E73
```
- Bitcoin
```
35G9uWVzrpJJibzUwpNUQGQNFzLirhrYAH
bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6
```
- Ko-Fi: https://ko-fi.com/markqvist
- Ethereum
```
0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E
```
- Liberapay: https://liberapay.com/Reticulum/
Are certain features in the development roadmap are important to you or your
organisation? Make them a reality quickly by sponsoring their implementation.
- 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. The utilised primitives are:
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.
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
@@ -320,15 +324,15 @@ general-purpose CPUs and on microcontrollers. The utilised primitives are:
- HKDF for key derivation
- Encrypted tokens are based on the [Fernet spec](https://github.com/fernet/spec/)
- Ephemeral keys derived from an ECDH key exchange on Curve25519
- AES-128 in CBC mode with PKCS7 padding
- HMAC using SHA256 for message authentication
- IVs are generated through os.urandom()
- IVs must be generated through `os.urandom()` or better
- AES-256 in CBC mode with PKCS7 padding
- No Fernet version and timestamp metadata fields
- SHA-256
- SHA-512
In the default installation configuration, the `X25519`, `Ed25519` and
`AES-128-CBC` primitives are provided by [OpenSSL](https://www.openssl.org/)
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`,
@@ -342,13 +346,19 @@ provided by the following internal implementations:
Reticulum also includes a complete implementation of all necessary primitives
in pure Python. If OpenSSL & PyCA are not available on the system when
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.
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.
@@ -372,12 +382,12 @@ 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*
- [Pysha2](https://github.com/thomdixon/pysha2) by [Thom Dixon](https://github.com/thomdixon), *MIT License*
- [Python-AES](https://github.com/orgurar/python-aes) by [Or Gur Arie](https://github.com/orgurar), *MIT License*
- [Python AES-128](https://github.com/orgurar/python-aes) by [Or Gur Arie](https://github.com/orgurar), *MIT License*
- [Python AES-256](https://github.com/boppreh/aes) by [BoppreH](https://github.com/boppreh), *MIT License*
- [Curve25519.py](https://gist.github.com/nickovs/cc3c22d15f239a2640c185035c06f8a3#file-curve25519-py) by [Nicko van Someren](https://gist.github.com/nickovs), *Public Domain*
- [I2Plib](https://github.com/l-n-s/i2plib) by [Viktor Villainov](https://github.com/l-n-s)
- [PySerial](https://github.com/pyserial/pyserial) by Chris Liechti, *BSD License*
- [Configobj](https://github.com/DiffSK/configobj) by Michael Foord, Nicola Larosa, Rob Dennis & Eli Courtwright, *BSD License*
- [Six](https://github.com/benjaminp/six) by [Benjamin Peterson](https://github.com/benjaminp), *MIT License*
- [ifaddr](https://github.com/pydron/ifaddr) by [Pydron](https://github.com/pydron), *MIT License*
- [ifaddr](https://github.com/pydron/ifaddr) by Stefan C. Mueller, *MIT License*
- [Umsgpack.py](https://github.com/vsergeev/u-msgpack-python) by [Ivan A. Sergeev](https://github.com/vsergeev)
- [Python](https://www.python.org)
+15 -5
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors.
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -45,7 +53,8 @@ class StreamDataMessage(MessageBase):
The stream id is limited to 2 bytes - 2 bit
"""
MAX_DATA_LEN = RNS.Link.MDU - 2 - 6 # 2 for stream data message header, 6 for channel envelope
OVERHEAD = 2 + 6 # 2 for stream data message header, 6 for channel envelope
MAX_DATA_LEN = RNS.Link.MDU - OVERHEAD
"""
When the Buffer package is imported, this value is
calculcated based on the value of OVERHEAD
@@ -215,6 +224,7 @@ class RawChannelWriter(RawIOBase, AbstractContextManager):
self._stream_id = stream_id
self._channel = channel
self._eof = False
self._mdu = channel.mdu - StreamDataMessage.OVERHEAD
def write(self, __b: bytes) -> int | None:
try:
+18 -7
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors.
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -602,7 +610,7 @@ class Channel(contextlib.AbstractContextManager):
return envelope
@property
def MDU(self):
def mdu(self):
"""
Maximum Data Unit: the number of bytes available
for a message to consume in a single send. This
@@ -611,7 +619,10 @@ class Channel(contextlib.AbstractContextManager):
:return: number of bytes available
"""
return self._outlet.mdu - 6 # sizeof(msgtype) + sizeof(length) + sizeof(sequence)
mdu = self._outlet.mdu - 6 # sizeof(msgtype) + sizeof(length) + sizeof(sequence)
if mdu > 0xFFFF:
mdu = 0xFFFF
return mdu
class LinkChannelOutlet(ChannelOutletBase):
@@ -639,7 +650,7 @@ class LinkChannelOutlet(ChannelOutletBase):
@property
def mdu(self):
return self.link.MDU
return self.link.mdu
@property
def rtt(self):
+55 -12
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -24,23 +32,57 @@ import RNS.Cryptography.Provider as cp
import RNS.vendor.platformutils as pu
if cp.PROVIDER == cp.PROVIDER_INTERNAL:
from .aes import AES
from .aes import AES128
from .aes import AES256
elif cp.PROVIDER == cp.PROVIDER_PYCA:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
if pu.cryptography_old_api():
from cryptography.hazmat.backends import default_backend
if pu.cryptography_old_api(): from cryptography.hazmat.backends import default_backend
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 cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES(key)
cipher = AES128(key)
return cipher.encrypt(plaintext, iv)
elif cp.PROVIDER == cp.PROVIDER_PYCA:
if not pu.cryptography_old_api():
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
else:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
return ciphertext
@staticmethod
def decrypt(ciphertext, key, iv):
if len(key) != 16: raise ValueError(f"Invalid key length {len(key)*8} for {self}")
if cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES128(key)
return cipher.decrypt(ciphertext, iv)
elif cp.PROVIDER == cp.PROVIDER_PYCA:
if not pu.cryptography_old_api():
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
else:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
return plaintext
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 cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES256(key)
return cipher.encrypt_cbc(plaintext, iv)
elif cp.PROVIDER == cp.PROVIDER_PYCA:
if not pu.cryptography_old_api():
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
@@ -53,9 +95,10 @@ class AES_128_CBC:
@staticmethod
def decrypt(ciphertext, key, iv):
if len(key) != 32: raise ValueError(f"Invalid key length {len(key)*8} for {self}")
if cp.PROVIDER == cp.PROVIDER_INTERNAL:
cipher = AES(key)
return cipher.decrypt(ciphertext, iv)
cipher = AES256(key)
return cipher.decrypt_cbc(ciphertext, iv)
elif cp.PROVIDER == cp.PROVIDER_PYCA:
if not pu.cryptography_old_api():
+30
View File
@@ -1,3 +1,33 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
from .pure25519 import ed25519_oop as ed25519
+13 -5
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -48,7 +56,7 @@ def hkdf(length=None, derive_from=None, salt=None, context=None):
derived = b""
for i in range(ceil(length / hash_len)):
block = hmac_sha256(pseudorandom_key, block + context + bytes([i + 1]))
block = hmac_sha256(pseudorandom_key, block + context + bytes([(i + 1)%(0xFF+1)]))
derived += block
return derived[:length]
+31 -1
View File
@@ -1,4 +1,34 @@
import importlib
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import importlib.util
if importlib.util.find_spec('hashlib') != None:
import hashlib
else:
+12 -4
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
+33 -2
View File
@@ -1,16 +1,47 @@
import importlib
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import importlib.util
PROVIDER_NONE = 0x00
PROVIDER_INTERNAL = 0x01
PROVIDER_PYCA = 0x02
FORCE_INTERNAL = False
PROVIDER = PROVIDER_NONE
pyca_v = None
use_pyca = False
try:
if importlib.util.find_spec('cryptography') != None:
if not FORCE_INTERNAL and importlib.util.find_spec('cryptography') != None:
import cryptography
pyca_v = cryptography.__version__
v = pyca_v.split(".")
+30
View File
@@ -1,3 +1,33 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
+48 -44
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -25,7 +33,9 @@ import time
from RNS.Cryptography import HMAC
from RNS.Cryptography import PKCS7
from RNS.Cryptography import AES
from RNS.Cryptography.AES import AES_128_CBC
from RNS.Cryptography.AES import AES_256_CBC
class Token():
"""
@@ -40,71 +50,65 @@ class Token():
TOKEN_OVERHEAD = 48 # Bytes
@staticmethod
def generate_key():
return os.urandom(32)
def generate_key(mode=AES_256_CBC):
if mode == AES_128_CBC: return os.urandom(32)
elif mode == AES_256_CBC: return os.urandom(64)
else: raise TypeError(f"Invalid token mode: {mode}")
def __init__(self, key = None):
if key == None:
raise ValueError("Token key cannot be None")
def __init__(self, key=None, mode=AES):
if key == None: raise ValueError("Token key cannot be None")
if len(key) != 32:
raise ValueError("Token key must be 32 bytes, not "+str(len(key)))
self._signing_key = key[:16]
self._encryption_key = key[16:]
if mode == AES:
if len(key) == 32:
self.mode = AES_128_CBC
self._signing_key = key[:16]
self._encryption_key = key[16:]
elif len(key) == 64:
self.mode = AES_256_CBC
self._signing_key = key[:32]
self._encryption_key = key[32:]
else: raise ValueError("Token key must be 128 or 256 bits, not "+str(len(key)*8))
else: raise TypeError(f"Invalid token mode: {mode}")
def verify_hmac(self, token):
if len(token) <= 32:
raise ValueError("Cannot verify HMAC on token of only "+str(len(token))+" bytes")
if len(token) <= 32: raise ValueError("Cannot verify HMAC on token of only "+str(len(token))+" bytes")
else:
received_hmac = token[-32:]
expected_hmac = HMAC.new(self._signing_key, token[:-32]).digest()
if received_hmac == expected_hmac:
return True
else:
return False
if received_hmac == expected_hmac: return True
else: return False
def encrypt(self, data = None):
if not isinstance(data, bytes): raise TypeError("Token plaintext input must be bytes")
iv = os.urandom(16)
current_time = int(time.time())
if not isinstance(data, bytes):
raise TypeError("Token plaintext input must be bytes")
ciphertext = AES_128_CBC.encrypt(
ciphertext = self.mode.encrypt(
plaintext = PKCS7.pad(data),
key = self._encryption_key,
iv = iv,
)
iv = iv)
signed_parts = iv+ciphertext
return signed_parts + HMAC.new(self._signing_key, signed_parts).digest()
def decrypt(self, token = None):
if not isinstance(token, bytes):
raise TypeError("Token must be bytes")
if not self.verify_hmac(token):
raise ValueError("Token HMAC was invalid")
if not isinstance(token, bytes): raise TypeError("Token must be bytes")
if not self.verify_hmac(token): raise ValueError("Token HMAC was invalid")
iv = token[:16]
ciphertext = token[16:-32]
try:
plaintext = PKCS7.unpad(
AES_128_CBC.decrypt(
ciphertext,
self._encryption_key,
iv,
)
)
return PKCS7.unpad(
self.mode.decrypt(
ciphertext = ciphertext,
key = self._encryption_key,
iv = iv))
return plaintext
except Exception as e:
raise ValueError("Could not decrypt token")
except Exception as e: raise ValueError(f"Could not decrypt token: {e}")
+4 -1
View File
@@ -82,10 +82,13 @@ def _fix_secret(n):
n |= 64 << 8 * 31
return n
def _fix_base_point(n):
n &= ~(2**255)
return n
def curve25519(base_point_raw, secret_raw):
"""Raise the base point to a given power"""
base_point = _unpack_number(base_point_raw)
base_point = _fix_base_point(_unpack_number(base_point_raw))
secret = _fix_secret(_unpack_number(secret_raw))
return _pack_number(_raw_curve25519(base_point, secret))
+30
View File
@@ -1,3 +1,33 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import glob
+2 -1
View File
@@ -1 +1,2 @@
from .aes import AES
from .aes128 import AES128
from .aes256 import AES256
-271
View File
@@ -1,271 +0,0 @@
# MIT License
# Copyright (c) 2021 Or Gur Arie
# 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 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 .utils import *
class AES:
# AES-128 block size
block_size = 16
# AES-128 encrypts messages with 10 rounds
_rounds = 10
# initiate the AES objecy
def __init__(self, key):
"""
Initializes the object with a given key.
"""
# make sure key length is right
assert len(key) == AES.block_size
# ExpandKey
self._round_keys = self._expand_key(key)
# will perform the AES ExpandKey phase
def _expand_key(self, master_key):
"""
Expands and returns a list of key matrices for the given master_key.
"""
# Initialize round keys with raw key material.
key_columns = bytes2matrix(master_key)
iteration_size = len(master_key) // 4
# Each iteration has exactly as many columns as the key material.
i = 1
while len(key_columns) < (self._rounds + 1) * 4:
# Copy previous word.
word = list(key_columns[-1])
# Perform schedule_core once every "row".
if len(key_columns) % iteration_size == 0:
# Circular shift.
word.append(word.pop(0))
# Map to S-BOX.
word = [s_box[b] for b in word]
# XOR with first byte of R-CON, since the others bytes of R-CON are 0.
word[0] ^= r_con[i]
i += 1
elif len(master_key) == 32 and len(key_columns) % iteration_size == 4:
# Run word through S-box in the fourth iteration when using a
# 256-bit key.
word = [s_box[b] for b in word]
# XOR with equivalent word from previous iteration.
word = bytes(i^j for i, j in zip(word, key_columns[-iteration_size]))
key_columns.append(word)
# Group key words in 4x4 byte matrices.
return [key_columns[4*i : 4*(i+1)] for i in range(len(key_columns) // 4)]
# encrypt a single block of data with AES
def _encrypt_block(self, plaintext):
"""
Encrypts a single block of 16 byte long plaintext.
"""
# length of a single block
assert len(plaintext) == AES.block_size
# perform on a matrix
state = bytes2matrix(plaintext)
# AddRoundKey
add_round_key(state, self._round_keys[0])
# 9 main rounds
for i in range(1, self._rounds):
# SubBytes
sub_bytes(state)
# ShiftRows
shift_rows(state)
# MixCols
mix_columns(state)
# AddRoundKey
add_round_key(state, self._round_keys[i])
# last round, w/t AddRoundKey step
sub_bytes(state)
shift_rows(state)
add_round_key(state, self._round_keys[-1])
# return the encrypted matrix as bytes
return matrix2bytes(state)
# decrypt a single block of data with AES
def _decrypt_block(self, ciphertext):
"""
Decrypts a single block of 16 byte long ciphertext.
"""
# length of a single block
assert len(ciphertext) == AES.block_size
# perform on a matrix
state = bytes2matrix(ciphertext)
# in reverse order, last round is first
add_round_key(state, self._round_keys[-1])
inv_shift_rows(state)
inv_sub_bytes(state)
for i in range(self._rounds - 1, 0, -1):
# nain rounds
add_round_key(state, self._round_keys[i])
inv_mix_columns(state)
inv_shift_rows(state)
inv_sub_bytes(state)
# initial AddRoundKey phase
add_round_key(state, self._round_keys[0])
# return bytes
return matrix2bytes(state)
# will encrypt the entire data
def encrypt(self, plaintext, iv):
"""
Encrypts `plaintext` using CBC mode and PKCS#7 padding, with the given
initialization vector (iv).
"""
# iv length must be same as block size
assert len(iv) == AES.block_size
assert len(plaintext) % AES.block_size == 0
ciphertext_blocks = []
previous = iv
for plaintext_block in split_blocks(plaintext):
# in CBC mode every block is XOR'd with the previous block
xorred = xor_bytes(plaintext_block, previous)
# encrypt current block
block = self._encrypt_block(xorred)
previous = block
# append to ciphertext
ciphertext_blocks.append(block)
# return as bytes
return b''.join(ciphertext_blocks)
# will decrypt the entire data
def decrypt(self, ciphertext, iv):
"""
Decrypts `ciphertext` using CBC mode and PKCS#7 padding, with the given
initialization vector (iv).
"""
# iv length must be same as block size
assert len(iv) == AES.block_size
plaintext_blocks = []
previous = iv
for ciphertext_block in split_blocks(ciphertext):
# in CBC mode every block is XOR'd with the previous block
xorred = xor_bytes(previous, self._decrypt_block(ciphertext_block))
# append plaintext
plaintext_blocks.append(xorred)
previous = ciphertext_block
return b''.join(plaintext_blocks)
def test():
# modules and classes requiered for test only
import os
class bcolors:
OK = '\033[92m' #GREEN
WARNING = '\033[93m' #YELLOW
FAIL = '\033[91m' #RED
RESET = '\033[0m' #RESET COLOR
# will test AES class by performing an encryption / decryption
print("AES Tests")
print("=========")
# generate a secret key and print details
key = os.urandom(AES.block_size)
_aes = AES(key)
print(f"Algorithm: AES-CBC-{AES.block_size*8}")
print(f"Secret Key: {key.hex()}")
print()
# test single block encryption / decryption
iv = os.urandom(AES.block_size)
single_block_text = b"SingleBlock Text"
print("Single Block Tests")
print("------------------")
print(f"iv: {iv.hex()}")
print(f"plain text: '{single_block_text.decode()}'")
ciphertext_block = _aes._encrypt_block(single_block_text)
plaintext_block = _aes._decrypt_block(ciphertext_block)
print(f"Ciphertext Hex: {ciphertext_block.hex()}")
print(f"Plaintext: {plaintext_block.decode()}")
assert plaintext_block == single_block_text
print(bcolors.OK + "Single Block Test Passed Successfully" + bcolors.RESET)
print()
# test a less than a block length phrase
iv = os.urandom(AES.block_size)
short_text = b"Just Text"
print("Short Text Tests")
print("----------------")
print(f"iv: {iv.hex()}")
print(f"plain text: '{short_text.decode()}'")
ciphertext_short = _aes.encrypt(short_text, iv)
plaintext_short = _aes.decrypt(ciphertext_short, iv)
print(f"Ciphertext Hex: {ciphertext_short.hex()}")
print(f"Plaintext: {plaintext_short.decode()}")
assert short_text == plaintext_short
print(bcolors.OK + "Short Text Test Passed Successfully" + bcolors.RESET)
print()
# test an arbitrary length phrase
iv = os.urandom(AES.block_size)
text = b"This Text is longer than one block"
print("Arbitrary Length Tests")
print("----------------------")
print(f"iv: {iv.hex()}")
print(f"plain text: '{text.decode()}'")
ciphertext = _aes.encrypt(text, iv)
plaintext = _aes.decrypt(ciphertext, iv)
print(f"Ciphertext Hex: {ciphertext.hex()}")
print(f"Plaintext: {plaintext.decode()}")
assert text == plaintext
print(bcolors.OK + "Arbitrary Length Text Test Passed Successfully" + bcolors.RESET)
print()
if __name__ == "__main__":
# test AES class
test()
+326
View File
@@ -0,0 +1,326 @@
# MIT License
# Copyright (c) 2021 Or Gur Arie
# 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 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.
## AES lookup tables
# resource: https://en.wikipedia.org/wiki/Rijndael_S-box
s_box = (
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)
inv_s_box = (
0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB,
0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB,
0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E,
0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25,
0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92,
0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84,
0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06,
0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B,
0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73,
0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E,
0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B,
0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4,
0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F,
0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF,
0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61,
0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D,
)
## AES AddRoundKey
# Round constants https://en.wikipedia.org/wiki/AES_key_schedule#Round_constants
r_con = (
0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40,
0x80, 0x1B, 0x36, 0x6C, 0xD8, 0xAB, 0x4D, 0x9A,
0x2F, 0x5E, 0xBC, 0x63, 0xC6, 0x97, 0x35, 0x6A,
0xD4, 0xB3, 0x7D, 0xFA, 0xEF, 0xC5, 0x91, 0x39,
)
def add_round_key(s, k):
for i in range(4):
for j in range(4):
s[i][j] ^= k[i][j]
## AES SubBytes
def sub_bytes(s):
for i in range(4):
for j in range(4):
s[i][j] = s_box[s[i][j]]
def inv_sub_bytes(s):
for i in range(4):
for j in range(4):
s[i][j] = inv_s_box[s[i][j]]
## AES ShiftRows
def shift_rows(s):
s[0][1], s[1][1], s[2][1], s[3][1] = s[1][1], s[2][1], s[3][1], s[0][1]
s[0][2], s[1][2], s[2][2], s[3][2] = s[2][2], s[3][2], s[0][2], s[1][2]
s[0][3], s[1][3], s[2][3], s[3][3] = s[3][3], s[0][3], s[1][3], s[2][3]
def inv_shift_rows(s):
s[0][1], s[1][1], s[2][1], s[3][1] = s[3][1], s[0][1], s[1][1], s[2][1]
s[0][2], s[1][2], s[2][2], s[3][2] = s[2][2], s[3][2], s[0][2], s[1][2]
s[0][3], s[1][3], s[2][3], s[3][3] = s[1][3], s[2][3], s[3][3], s[0][3]
## AES MixColumns
# learned from http://cs.ucsb.edu/~koc/cs178/projects/JT/aes.c
xtime = lambda a: (((a << 1) ^ 0x1B) & 0xFF) if (a & 0x80) else (a << 1)
def mix_single_column(a):
# see Sec 4.1.2 in The Design of Rijndael
t = a[0] ^ a[1] ^ a[2] ^ a[3]
u = a[0]
a[0] ^= t ^ xtime(a[0] ^ a[1])
a[1] ^= t ^ xtime(a[1] ^ a[2])
a[2] ^= t ^ xtime(a[2] ^ a[3])
a[3] ^= t ^ xtime(a[3] ^ u)
def mix_columns(s):
for i in range(4):
mix_single_column(s[i])
def inv_mix_columns(s):
# see Sec 4.1.3 in The Design of Rijndael
for i in range(4):
u = xtime(xtime(s[i][0] ^ s[i][2]))
v = xtime(xtime(s[i][1] ^ s[i][3]))
s[i][0] ^= u
s[i][1] ^= v
s[i][2] ^= u
s[i][3] ^= v
mix_columns(s)
## AES Bytes
def bytes2matrix(text):
""" Converts a 16-byte array into a 4x4 matrix. """
return [list(text[i:i+4]) for i in range(0, len(text), 4)]
def matrix2bytes(matrix):
""" Converts a 4x4 matrix into a 16-byte array. """
return bytes(sum(matrix, []))
def xor_bytes(a, b):
""" Returns a new byte array with the elements xor'ed. """
return bytes(i^j for i, j in zip(a, b))
def split_blocks(message, block_size=16, require_padding=True):
assert len(message) % block_size == 0 or not require_padding
return [message[i:i+16] for i in range(0, len(message), block_size)]
class AES128:
# AES-128 block size
block_size = 16
# AES-128 encrypts messages with 10 rounds
_rounds = 10
# initiate the AES objecy
def __init__(self, key):
"""
Initializes the object with a given key.
"""
# make sure key length is right
assert len(key) == AES128.block_size
# ExpandKey
self._round_keys = self._expand_key(key)
# will perform the AES ExpandKey phase
def _expand_key(self, master_key):
"""
Expands and returns a list of key matrices for the given master_key.
"""
# Initialize round keys with raw key material.
key_columns = bytes2matrix(master_key)
iteration_size = len(master_key) // 4
# Each iteration has exactly as many columns as the key material.
i = 1
while len(key_columns) < (self._rounds + 1) * 4:
# Copy previous word.
word = list(key_columns[-1])
# Perform schedule_core once every "row".
if len(key_columns) % iteration_size == 0:
# Circular shift.
word.append(word.pop(0))
# Map to S-BOX.
word = [s_box[b] for b in word]
# XOR with first byte of R-CON, since the others bytes of R-CON are 0.
word[0] ^= r_con[i]
i += 1
elif len(master_key) == 32 and len(key_columns) % iteration_size == 4:
# Run word through S-box in the fourth iteration when using a
# 256-bit key.
word = [s_box[b] for b in word]
# XOR with equivalent word from previous iteration.
word = bytes(i^j for i, j in zip(word, key_columns[-iteration_size]))
key_columns.append(word)
# Group key words in 4x4 byte matrices.
return [key_columns[4*i : 4*(i+1)] for i in range(len(key_columns) // 4)]
# encrypt a single block of data with AES
def _encrypt_block(self, plaintext):
"""
Encrypts a single block of 16 byte long plaintext.
"""
# length of a single block
assert len(plaintext) == AES128.block_size
# perform on a matrix
state = bytes2matrix(plaintext)
# AddRoundKey
add_round_key(state, self._round_keys[0])
# 9 main rounds
for i in range(1, self._rounds):
# SubBytes
sub_bytes(state)
# ShiftRows
shift_rows(state)
# MixCols
mix_columns(state)
# AddRoundKey
add_round_key(state, self._round_keys[i])
# last round, w/t AddRoundKey step
sub_bytes(state)
shift_rows(state)
add_round_key(state, self._round_keys[-1])
# return the encrypted matrix as bytes
return matrix2bytes(state)
# decrypt a single block of data with AES
def _decrypt_block(self, ciphertext):
"""
Decrypts a single block of 16 byte long ciphertext.
"""
# length of a single block
assert len(ciphertext) == AES128.block_size
# perform on a matrix
state = bytes2matrix(ciphertext)
# in reverse order, last round is first
add_round_key(state, self._round_keys[-1])
inv_shift_rows(state)
inv_sub_bytes(state)
for i in range(self._rounds - 1, 0, -1):
# nain rounds
add_round_key(state, self._round_keys[i])
inv_mix_columns(state)
inv_shift_rows(state)
inv_sub_bytes(state)
# initial AddRoundKey phase
add_round_key(state, self._round_keys[0])
# return bytes
return matrix2bytes(state)
# will encrypt the entire data
def encrypt(self, plaintext, iv):
"""
Encrypts `plaintext` using CBC mode and PKCS#7 padding, with the given
initialization vector (iv).
"""
# iv length must be same as block size
assert len(iv) == AES128.block_size
assert len(plaintext) % AES128.block_size == 0
ciphertext_blocks = []
previous = iv
for plaintext_block in split_blocks(plaintext):
# in CBC mode every block is XOR'd with the previous block
xorred = xor_bytes(plaintext_block, previous)
# encrypt current block
block = self._encrypt_block(xorred)
previous = block
# append to ciphertext
ciphertext_blocks.append(block)
# return as bytes
return b''.join(ciphertext_blocks)
# will decrypt the entire data
def decrypt(self, ciphertext, iv):
"""
Decrypts `ciphertext` using CBC mode and PKCS#7 padding, with the given
initialization vector (iv).
"""
# iv length must be same as block size
assert len(iv) == AES128.block_size
plaintext_blocks = []
previous = iv
for ciphertext_block in split_blocks(ciphertext):
# in CBC mode every block is XOR'd with the previous block
xorred = xor_bytes(previous, self._decrypt_block(ciphertext_block))
# append plaintext
plaintext_blocks.append(xorred)
previous = ciphertext_block
return b''.join(plaintext_blocks)
@@ -1,17 +1,17 @@
# MIT License
# Copyright (c) 2021 Or Gur Arie
#
# Copyright (c) 2024 BoppreH
#
# 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 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
@@ -20,12 +20,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
'''
Utils class for AES encryption / decryption
'''
## AES lookup tables
# resource: https://en.wikipedia.org/wiki/Rijndael_S-box
s_box = (
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
@@ -64,53 +58,33 @@ inv_s_box = (
0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D,
)
## AES AddRoundKey
# Round constants https://en.wikipedia.org/wiki/AES_key_schedule#Round_constants
r_con = (
0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40,
0x80, 0x1B, 0x36, 0x6C, 0xD8, 0xAB, 0x4D, 0x9A,
0x2F, 0x5E, 0xBC, 0x63, 0xC6, 0x97, 0x35, 0x6A,
0xD4, 0xB3, 0x7D, 0xFA, 0xEF, 0xC5, 0x91, 0x39,
)
def add_round_key(s, k):
for i in range(4):
for j in range(4):
s[i][j] ^= k[i][j]
## AES SubBytes
def sub_bytes(s):
for i in range(4):
for j in range(4):
s[i][j] = s_box[s[i][j]]
def inv_sub_bytes(s):
for i in range(4):
for j in range(4):
s[i][j] = inv_s_box[s[i][j]]
## AES ShiftRows
def shift_rows(s):
s[0][1], s[1][1], s[2][1], s[3][1] = s[1][1], s[2][1], s[3][1], s[0][1]
s[0][2], s[1][2], s[2][2], s[3][2] = s[2][2], s[3][2], s[0][2], s[1][2]
s[0][3], s[1][3], s[2][3], s[3][3] = s[3][3], s[0][3], s[1][3], s[2][3]
def inv_shift_rows(s):
s[0][1], s[1][1], s[2][1], s[3][1] = s[3][1], s[0][1], s[1][1], s[2][1]
s[0][2], s[1][2], s[2][2], s[3][2] = s[2][2], s[3][2], s[0][2], s[1][2]
s[0][3], s[1][3], s[2][3], s[3][3] = s[1][3], s[2][3], s[3][3], s[0][3]
def add_round_key(s, k):
for i in range(4):
for j in range(4):
s[i][j] ^= k[i][j]
## AES MixColumns
# learned from http://cs.ucsb.edu/~koc/cs178/projects/JT/aes.c
xtime = lambda a: (((a << 1) ^ 0x1B) & 0xFF) if (a & 0x80) else (a << 1)
def mix_single_column(a):
# see Sec 4.1.2 in The Design of Rijndael
t = a[0] ^ a[1] ^ a[2] ^ a[3]
@@ -120,12 +94,10 @@ def mix_single_column(a):
a[2] ^= t ^ xtime(a[2] ^ a[3])
a[3] ^= t ^ xtime(a[3] ^ u)
def mix_columns(s):
for i in range(4):
mix_single_column(s[i])
def inv_mix_columns(s):
# see Sec 4.1.3 in The Design of Rijndael
for i in range(4):
@@ -138,22 +110,127 @@ def inv_mix_columns(s):
mix_columns(s)
## AES Bytes
def bytes2matrix(text):
""" Converts a 16-byte array into a 4x4 matrix. """
return [list(text[i:i+4]) for i in range(0, len(text), 4)]
def matrix2bytes(matrix):
""" Converts a 4x4 matrix into a 16-byte array. """
return bytes(sum(matrix, []))
r_con = (
0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40,
0x80, 0x1B, 0x36, 0x6C, 0xD8, 0xAB, 0x4D, 0x9A,
0x2F, 0x5E, 0xBC, 0x63, 0xC6, 0x97, 0x35, 0x6A,
0xD4, 0xB3, 0x7D, 0xFA, 0xEF, 0xC5, 0x91, 0x39,
)
def xor_bytes(a, b):
""" Returns a new byte array with the elements xor'ed. """
return bytes(i^j for i, j in zip(a, b))
def bytes2matrix(text): return [list(text[i:i+4]) for i in range(0, len(text), 4)]
def matrix2bytes(matrix): return bytes(sum(matrix, []))
def xor_bytes(a, b): return bytes(i^j for i, j in zip(a, b))
def inc_bytes(a):
out = list(a)
for i in reversed(range(len(out))):
if out[i] == 0xFF:
out[i] = 0
else:
out[i] += 1
break
return bytes(out)
def split_blocks(message, block_size=16, require_padding=True):
assert len(message) % block_size == 0 or not require_padding
return [message[i:i+16] for i in range(0, len(message), block_size)]
assert len(message) % block_size == 0 or not require_padding
return [message[i:i+16] for i in range(0, len(message), block_size)]
class AES256:
rounds_by_key_size = {32: 14}
def __init__(self, master_key):
assert len(master_key) in AES256.rounds_by_key_size
self.n_rounds = AES256.rounds_by_key_size[len(master_key)]
self._key_matrices = self._expand_key(master_key)
def _expand_key(self, master_key):
# Initialize round keys with raw key material.
key_columns = bytes2matrix(master_key)
iteration_size = len(master_key) // 4
i = 1
while len(key_columns) < (self.n_rounds + 1) * 4:
# Copy previous word.
word = list(key_columns[-1])
# Perform schedule_core once every "row".
if len(key_columns) % iteration_size == 0:
# Circular shift.
word.append(word.pop(0))
# Map to S-BOX.
word = [s_box[b] for b in word]
# XOR with first byte of R-CON, since the others bytes of R-CON are 0.
word[0] ^= r_con[i]
i += 1
elif len(master_key) == 32 and len(key_columns) % iteration_size == 4:
# Run word through S-box in the fourth iteration when using a
# 256-bit key.
word = [s_box[b] for b in word]
# XOR with equivalent word from previous iteration.
word = xor_bytes(word, key_columns[-iteration_size])
key_columns.append(word)
# Group key words in 4x4 byte matrices.
return [key_columns[4*i : 4*(i+1)] for i in range(len(key_columns) // 4)]
def encrypt_block(self, plaintext):
assert len(plaintext) == 16
plain_state = bytes2matrix(plaintext)
add_round_key(plain_state, self._key_matrices[0])
for i in range(1, self.n_rounds):
sub_bytes(plain_state)
shift_rows(plain_state)
mix_columns(plain_state)
add_round_key(plain_state, self._key_matrices[i])
sub_bytes(plain_state)
shift_rows(plain_state)
add_round_key(plain_state, self._key_matrices[-1])
return matrix2bytes(plain_state)
def decrypt_block(self, ciphertext):
assert len(ciphertext) == 16
cipher_state = bytes2matrix(ciphertext)
add_round_key(cipher_state, self._key_matrices[-1])
inv_shift_rows(cipher_state)
inv_sub_bytes(cipher_state)
for i in range(self.n_rounds - 1, 0, -1):
add_round_key(cipher_state, self._key_matrices[i])
inv_mix_columns(cipher_state)
inv_shift_rows(cipher_state)
inv_sub_bytes(cipher_state)
add_round_key(cipher_state, self._key_matrices[0])
return matrix2bytes(cipher_state)
def encrypt_cbc(self, plaintext, iv):
if len(iv) != 16: raise ValueError(f"Invalid IV length: {len(iv)}")
blocks = []
previous = iv
for plaintext_block in split_blocks(plaintext):
block = self.encrypt_block(xor_bytes(plaintext_block, previous))
blocks.append(block)
previous = block
return b''.join(blocks)
def decrypt_cbc(self, ciphertext, iv):
if len(iv) != 16: raise ValueError(f"Invalid IV length: {len(iv)}")
blocks = []
previous = iv
for ciphertext_block in split_blocks(ciphertext):
blocks.append(xor_bytes(previous, self.decrypt_block(ciphertext_block)))
previous = ciphertext_block
return b''.join(blocks)
__all__ = ["AES256"]
+47 -18
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -192,7 +200,7 @@ class Destination:
"""
:returns: A human-readable representation of the destination including addressable hash and full name.
"""
return "<"+self.name+"/"+self.hexhash+">"
return "<"+self.name+":"+self.hexhash+">"
def _clean_ratchets(self):
if self.ratchets != None:
@@ -202,12 +210,16 @@ class Destination:
def _persist_ratchets(self):
try:
with self.ratchet_file_lock:
temp_write_path = self.ratchets_path+".tmp"
packed_ratchets = umsgpack.packb(self.ratchets)
persisted_data = {"signature": self.sign(packed_ratchets), "ratchets": packed_ratchets}
ratchets_file = open(self.ratchets_path, "wb")
ratchets_file = open(temp_write_path, "wb")
ratchets_file.write(umsgpack.packb(persisted_data))
ratchets_file.close()
if os.path.isfile(self.ratchets_path): os.unlink(self.ratchets_path)
os.rename(temp_write_path, self.ratchets_path)
except Exception as e:
RNS.trace_exception(e)
self.ratchets = None
self.ratchets_path = None
raise OSError("Could not write ratchet file contents for "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
@@ -365,7 +377,7 @@ class Destination:
else:
self.proof_strategy = proof_strategy
def register_request_handler(self, path, response_generator = None, allow = ALLOW_NONE, allowed_list = None):
def register_request_handler(self, path, response_generator = None, allow = ALLOW_NONE, allowed_list = None, auto_compress = True):
"""
Registers a request handler.
@@ -373,17 +385,15 @@ class Destination:
:param response_generator: A function or method with the signature *response_generator(path, data, request_id, link_id, remote_identity, requested_at)* to be called. Whatever this funcion returns will be sent as a response to the requester. If the function returns ``None``, no response will be sent.
:param allow: One of ``RNS.Destination.ALLOW_NONE``, ``RNS.Destination.ALLOW_ALL`` or ``RNS.Destination.ALLOW_LIST``. If ``RNS.Destination.ALLOW_LIST`` is set, the request handler will only respond to requests for identified peers in the supplied list.
:param allowed_list: A list of *bytes-like* :ref:`RNS.Identity<api-identity>` hashes.
:param auto_compress: If ``True`` or ``False``, determines whether automatic compression of responses should be carried out. If set to an integer value, responses will only be auto-compressed if under this size in bytes. If omitted, the default compression settings will be followed.
:raises: ``ValueError`` if any of the supplied arguments are invalid.
"""
if path == None or path == "":
raise ValueError("Invalid path specified")
elif not callable(response_generator):
raise ValueError("Invalid response generator specified")
elif not allow in Destination.request_policies:
raise ValueError("Invalid request policy")
if path == None or path == "": raise ValueError("Invalid path specified")
elif not callable(response_generator): raise ValueError("Invalid response generator specified")
elif not allow in Destination.request_policies: raise ValueError("Invalid request policy")
else:
path_hash = RNS.Identity.truncated_hash(path.encode("utf-8"))
request_handler = [path, response_generator, allow, allowed_list]
request_handler = [path, response_generator, allow, allowed_list, auto_compress]
self.request_handlers[path_hash] = request_handler
def deregister_request_handler(self, path):
@@ -407,7 +417,8 @@ class Destination:
else:
plaintext = self.decrypt(packet.data)
packet.ratchet_id = self.latest_ratchet_id
if plaintext != None:
if plaintext == None: return False
else:
if packet.packet_type == RNS.Packet.DATA:
if self.callbacks.packet != None:
try:
@@ -415,6 +426,8 @@ class Destination:
except Exception as e:
RNS.log("Error while executing receive callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
return True
def incoming_link_request(self, data, packet):
if self.accept_link_requests:
link = RNS.Link.validate_request(self, data, packet)
@@ -424,7 +437,7 @@ class Destination:
def _reload_ratchets(self, ratchets_path):
if os.path.isfile(ratchets_path):
with self.ratchet_file_lock:
try:
def load_attempt():
ratchets_file = open(ratchets_path, "rb")
persisted_data = umsgpack.unpackb(ratchets_file.read())
if "signature" in persisted_data and "ratchets" in persisted_data:
@@ -433,11 +446,28 @@ class Destination:
self.ratchets_path = ratchets_path
else:
raise KeyError("Invalid ratchet file signature")
try:
try:
load_attempt()
except Exception as e:
RNS.trace_exception(e)
RNS.log(f"First ratchet reload attempt for {self} failed. Possible I/O conflict. Retrying in 500ms.", RNS.LOG_ERROR)
time.sleep(0.5)
load_attempt()
RNS.log(f"Ratchet reload retry succeeded", RNS.LOG_DEBUG)
except Exception as e:
self.ratchets = None
self.ratchets_path = None
RNS.trace_exception(e)
RNS.log(f"The ratchet file located at {ratchets_path} could not be loaded. This could indicate that the ratchet file has become corrupt.", RNS.LOG_CRITICAL)
RNS.log(f"You can attempt to manually recover the ratchet file, or simply remove it to have Reticulum recreate it on the next use.", RNS.LOG_CRITICAL)
RNS.log(f"If re-initialize this ratchet file, make sure to send an announce for the relevant destination as soon as possible,", RNS.LOG_CRITICAL)
RNS.log(f"so that the new ratchet information is synchronized to the network.", RNS.LOG_CRITICAL)
raise OSError("Could not read ratchet file contents for "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
else:
RNS.log("No existing ratchet data found, initialising new ratchet file for "+str(self), RNS.LOG_DEBUG)
self.ratchets = []
@@ -463,7 +493,6 @@ class Destination:
self.latest_ratchet_time = 0
self._reload_ratchets(ratchets_path)
# TODO: Remove at some point
RNS.log("Ratchets enabled on "+str(self), RNS.LOG_DEBUG)
return True
@@ -617,7 +646,7 @@ class Destination:
RNS.log(f"Decryption still failing after ratchet reload. The contained exception was: {e}", RNS.LOG_ERROR)
raise e
RNS.log("Decryption succeeded after ratchet reload", RNS.LOG_NOTICE)
if decrypted: RNS.log("Decryption succeeded after ratchet reload", RNS.LOG_NOTICE)
return decrypted
+672
View File
@@ -0,0 +1,672 @@
import os
import RNS
import time
import threading
import subprocess
from .vendor import umsgpack as msgpack
NAME = 0xFF
TRANSPORT_ID = 0xFE
INTERFACE_TYPE = 0x00
TRANSPORT = 0x01
REACHABLE_ON = 0x02
LATITUDE = 0x03
LONGITUDE = 0x04
HEIGHT = 0x05
PORT = 0x06
IFAC_NETNAME = 0x07
IFAC_NETKEY = 0x08
FREQUENCY = 0x09
BANDWIDTH = 0x0A
SPREADINGFACTOR = 0x0B
CODINGRATE = 0x0C
MODULATION = 0x0D
CHANNEL = 0x0E
APP_NAME = "rnstransport"
class InterfaceAnnouncer():
JOB_INTERVAL = 60
DEFAULT_STAMP_VALUE = 14
WORKBLOCK_EXPAND_ROUNDS = 20
DISCOVERABLE_INTERFACE_TYPES = ["BackboneInterface", "TCPServerInterface", "TCPClientInterface",
"RNodeInterface", "WeaveInterface", "I2PInterface", "KISSInterface"]
def __init__(self, owner):
import importlib.util
if importlib.util.find_spec('LXMF') != None: from LXMF import LXStamper
else:
RNS.log("Using on-network interface discovery requires the LXMF module to be installed.", RNS.LOG_CRITICAL)
RNS.log("You can install it with the command: pip install lxmf", RNS.LOG_CRITICAL)
RNS.panic()
self.owner = owner
self.should_run = False
self.job_interval = self.JOB_INTERVAL
self.stamper = LXStamper
self.stamp_cache = {}
if self.owner.has_network_identity(): identity = self.owner.network_identity
else: identity = self.owner.identity
self.discovery_destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE,
APP_NAME, "discovery", "interface")
def start(self):
if not self.should_run:
self.should_run = True
threading.Thread(target=self.job, daemon=True).start()
def stop(self): self.should_run = False
def job(self):
while self.should_run:
time.sleep(self.job_interval)
try:
now = time.time()
due_interfaces = [i for i in self.owner.interfaces if i.supports_discovery and i.discoverable and now > (i.last_discovery_announce+i.discovery_announce_interval)]
due_interfaces.sort(key=lambda i: now-i.last_discovery_announce, reverse=True)
if len(due_interfaces) > 0:
selected_interface = due_interfaces[0]
selected_interface.last_discovery_announce = time.time()
RNS.log(f"Preparing interface discovery announce for {selected_interface.name}", RNS.LOG_DEBUG)
app_data = self.get_interface_announce_data(selected_interface)
if not app_data: RNS.log(f"Could not generate interface discovery announce data for {selected_interface.name}", RNS.LOG_ERROR)
else:
RNS.log(f"Sending interface discovery announce for {selected_interface.name} with {len(app_data)}B payload", RNS.LOG_DEBUG)
self.discovery_destination.announce(app_data=app_data)
except Exception as e:
RNS.log(f"Error while preparing interface discovery announces: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
def sanitize(self, in_str):
sanitized = in_str.replace("\n", "")
sanitized = sanitized.replace("\r", "")
sanitized = sanitized.strip()
return sanitized
def get_interface_announce_data(self, interface):
interface_type = type(interface).__name__
stamp_value = interface.discovery_stamp_value if interface.discovery_stamp_value else self.DEFAULT_STAMP_VALUE
if not interface_type in self.DISCOVERABLE_INTERFACE_TYPES: return None
else:
flags = 0x00
info = {INTERFACE_TYPE: interface_type,
TRANSPORT: RNS.Reticulum.transport_enabled(),
TRANSPORT_ID: RNS.Transport.identity.hash,
NAME: self.sanitize(interface.discovery_name),
LATITUDE: interface.discovery_latitude,
LONGITUDE: interface.discovery_longitude,
HEIGHT: interface.discovery_height}
reachable_on = self.sanitize(interface.reachable_on)
if not RNS.vendor.platformutils.is_windows():
try:
exec_path = os.path.expanduser(reachable_on)
if os.path.isfile(exec_path) and os.access(exec_path, os.X_OK):
RNS.log(f"Evaluating reachable_on from executable at {exec_path}", RNS.LOG_DEBUG)
exec_result = subprocess.run([exec_path], stdout=subprocess.PIPE)
exec_stdout = exec_result.stdout.decode("utf-8")
if exec_result.returncode != 0: raise ValueError("Non-zero exit code from subprocess")
reachable_on = self.sanitize(exec_stdout)
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"Aborting discovery announce", RNS.LOG_ERROR)
return None
if interface_type in ["BackboneInterface", "TCPServerInterface"]:
info[REACHABLE_ON] = reachable_on
info[PORT] = interface.bind_port
if interface_type == "I2PInterface" and interface.connectable and interface.b32:
info[REACHABLE_ON] = interface.b32
if interface_type == "RNodeInterface":
info[FREQUENCY] = interface.frequency
info[BANDWIDTH] = interface.bandwidth
info[SPREADINGFACTOR] = interface.sf
info[CODINGRATE] = interface.cr
if interface_type == "WeaveInterface":
info[FREQUENCY] = interface.discovery_frequency
info[BANDWIDTH] = interface.discovery_bandwidth
info[CHANNEL] = interface.discovery_channel
info[MODULATION] = interface.discovery_modulation
if interface_type == "KISSInterface" or (interface_type == "TCPClientInterface" and interface.kiss_framing):
info[INTERFACE_TYPE] = "KISSInterface"
info[FREQUENCY] = interface.discovery_frequency
info[BANDWIDTH] = interface.discovery_bandwidth
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)
packed = msgpack.packb(info)
infohash = RNS.Identity.full_hash(packed)
if infohash in self.stamp_cache: stamp = self.stamp_cache[infohash]
else: stamp, v = self.stamper.generate_stamp(infohash, stamp_cost=stamp_value, expand_rounds=self.WORKBLOCK_EXPAND_ROUNDS)
if not stamp: return None
else: self.stamp_cache[infohash] = stamp
if interface.discovery_encrypt:
flags |= InterfaceAnnounceHandler.FLAG_ENCRYPTED
if not self.owner.has_network_identity():
RNS.log(f"Discovery encryption requested for {interface}, but no network identity configured. Aborting discovery announce.", RNS.LOG_ERROR)
return None
else: payload = self.owner.network_identity.encrypt(packed+stamp)
else: payload = packed+stamp
return bytes([flags])+payload
class InterfaceAnnounceHandler:
FLAG_SIGNED = 0b00000001
FLAG_ENCRYPTED = 0b00000010
def __init__(self, required_value=InterfaceAnnouncer.DEFAULT_STAMP_VALUE, callback=None):
import importlib.util
if importlib.util.find_spec('LXMF') != None: from LXMF import LXStamper
else:
RNS.log("Using on-network interface discovery requires the LXMF module to be installed.", RNS.LOG_CRITICAL)
RNS.log("You can install it with the command: pip install lxmf", RNS.LOG_CRITICAL)
RNS.panic()
self.aspect_filter = APP_NAME+".discovery.interface"
self.required_value = required_value
self.callback = callback
self.stamper = LXStamper
def received_announce(self, destination_hash, announced_identity, app_data):
try:
discovery_sources = RNS.Reticulum.interface_discovery_sources()
if discovery_sources and not announced_identity.hash in discovery_sources:
RNS.log(f"Interface discovered from non-authorized network identity {RNS.prettyhexrep(announced_identity.hash)}, ignoring", RNS.LOG_DEBUG)
return
if app_data and len(app_data) > self.stamper.STAMP_SIZE+1:
flags = app_data[0]
app_data = app_data[1:]
signed = flags & self.FLAG_SIGNED
encrypted = flags & self.FLAG_ENCRYPTED
if encrypted:
if not RNS.Transport.has_network_identity(): return
app_data = RNS.Transport.network_identity.decrypt(app_data)
if not app_data: return
stamp = app_data[-self.stamper.STAMP_SIZE:]
packed = app_data[:-self.stamper.STAMP_SIZE]
infohash = RNS.Identity.full_hash(packed)
workblock = self.stamper.stamp_workblock(infohash, expand_rounds=InterfaceAnnouncer.WORKBLOCK_EXPAND_ROUNDS)
value = self.stamper.stamp_value(workblock, stamp)
valid = self.stamper.stamp_valid(stamp, self.required_value, workblock)
if not valid:
RNS.log(f"Ignored discovered interface with invalid stamp", RNS.LOG_DEBUG)
return
if value < self.required_value: RNS.log(f"Ignored discovered interface with stamp value {value}", RNS.LOG_DEBUG)
else:
info = None
unpacked = msgpack.unpackb(packed)
if INTERFACE_TYPE in unpacked:
interface_type = unpacked[INTERFACE_TYPE]
info = {"type": interface_type,
"transport": unpacked[TRANSPORT],
"name": unpacked[NAME] or f"Discovered {interface_type}",
"received": time.time(),
"stamp": stamp,
"value": value,
"transport_id": RNS.hexrep(unpacked[TRANSPORT_ID], delimit=False),
"network_id": RNS.hexrep(announced_identity.hash, delimit=False),
"hops": RNS.Transport.hops_to(destination_hash),
"latitude": unpacked[LATITUDE],
"longitude": unpacked[LONGITUDE],
"height": unpacked[HEIGHT]}
if IFAC_NETNAME in unpacked: info["ifac_netname"] = unpacked[IFAC_NETNAME]
if IFAC_NETKEY in unpacked: info["ifac_netkey"] = unpacked[IFAC_NETKEY]
if interface_type in ["BackboneInterface", "TCPServerInterface"]:
backbone_support = not RNS.vendor.platformutils.is_windows()
info["reachable_on"] = unpacked[REACHABLE_ON]
info["port"] = unpacked[PORT]
connection_interface = "BackboneInterface" if backbone_support else "TCPClientInterface"
remote_str = "remote" if backbone_support else "target_host"
cfg_name = info["name"]
cfg_remote = info["reachable_on"]
cfg_port = info["port"]
cfg_identity = info["transport_id"]
cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
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 = {connection_interface}\n enabled = yes\n {remote_str} = {cfg_remote}\n target_port = {cfg_port}{cfg_identity_str}{cfg_netname_str}{cfg_netkey_str}"
if interface_type == "I2PInterface":
info["reachable_on"] = unpacked[REACHABLE_ON]
cfg_name = info["name"]
cfg_remote = info["reachable_on"]
cfg_identity = info["transport_id"]
cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
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}"
if interface_type == "RNodeInterface":
info["frequency"] = unpacked[FREQUENCY]
info["bandwidth"] = unpacked[BANDWIDTH]
info["sf"] = unpacked[SPREADINGFACTOR]
info["cr"] = unpacked[CODINGRATE]
cfg_name = info["name"]
cfg_frequency = info["frequency"]
cfg_bandwidth = info["bandwidth"]
cfg_sf = info["sf"]
cfg_cr = info["cr"]
cfg_identity = info["transport_id"]
cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
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 = RNodeInterface\n enabled = yes\n port = \n frequency = {cfg_frequency}\n bandwidth = {cfg_bandwidth}\n spreadingfactor = {cfg_sf}\n codingrate = {cfg_cr}\n txpower = {cfg_netname_str}{cfg_netkey_str}"
if interface_type == "WeaveInterface":
info["frequency"] = unpacked[FREQUENCY]
info["bandwidth"] = unpacked[BANDWIDTH]
info["channel"] = unpacked[CHANNEL]
info["modulation"] = unpacked[MODULATION]
cfg_name = info["name"]
cfg_identity = info["transport_id"]
cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
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 = WeaveInterface\n enabled = yes\n port = {cfg_netname_str}{cfg_netkey_str}"
if interface_type == "KISSInterface":
info["frequency"] = unpacked[FREQUENCY]
info["bandwidth"] = unpacked[BANDWIDTH]
info["modulation"] = unpacked[MODULATION]
cfg_name = info["name"]
cfg_frequency = info["frequency"]
cfg_bandwidth = info["bandwidth"]
cfg_modulation = info["modulation"]
cfg_identity = info["transport_id"]
cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
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 = KISSInterface\n enabled = yes\n port = \n # Frequency: {cfg_frequency}\n # Bandwidth: {cfg_bandwidth}\n # Modulation: {cfg_modulation}{cfg_identity_str}{cfg_netname_str}{cfg_netkey_str}"
discovery_hash_material = info["transport_id"]+info["name"]
info["discovery_hash"] = RNS.Identity.full_hash(discovery_hash_material.encode("utf-8"))
if self.callback and callable(self.callback): self.callback(info)
except Exception as e:
RNS.log(f"An error occurred while trying to decode discovered interface. The contained exception was: {e}", RNS.LOG_ERROR)
class InterfaceDiscovery():
THRESHOLD_UNKNOWN = 24*60*60
THRESHOLD_STALE = 3*24*60*60
THRESHOLD_REMOVE = 7*24*60*60
MONITOR_INTERVAL = 5
DETACH_THRESHOLD = 12
STATUS_STALE = 0
STATUS_UNKNOWN = 100
STATUS_AVAILABLE = 1000
STATUS_CODE_MAP = {"available": STATUS_AVAILABLE, "unknown": STATUS_UNKNOWN, "stale": STATUS_STALE}
AUTOCONNECT_TYPES = ["BackboneInterface", "TCPServerInterface"]
def __init__(self, required_value=InterfaceAnnouncer.DEFAULT_STAMP_VALUE, callback=None, discover_interfaces=True):
if not required_value: required_value = InterfaceAnnouncer.DEFAULT_STAMP_VALUE
self.required_value = required_value
self.discovery_callback = callback
self.rns_instance = RNS.Reticulum.get_instance()
self.monitored_interfaces = []
self.monitoring_autoconnects = False
self.monitor_interval = self.MONITOR_INTERVAL
self.detach_threshold = self.DETACH_THRESHOLD
if not self.rns_instance: raise SystemError("Attempt to start interface discovery listener without an active RNS instance")
self.storagepath = os.path.join(RNS.Reticulum.storagepath, "discovery", "interfaces")
if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath)
if discover_interfaces:
self.handler = InterfaceAnnounceHandler(callback=self.interface_discovered, required_value=self.required_value)
RNS.Transport.register_announce_handler(self.handler)
threading.Thread(target=self.connect_discovered, daemon=True).start()
def list_discovered_interfaces(self):
now = time.time()
discovered_interfaces = []
discovery_sources = RNS.Reticulum.interface_discovery_sources()
for filename in os.listdir(self.storagepath):
try:
filepath = os.path.join(self.storagepath, filename)
with open(filepath, "rb") as f: info = msgpack.unpackb(f.read())
should_remove = False
heard_delta = now-info["last_heard"]
if heard_delta > self.THRESHOLD_REMOVE: should_remove = True
elif discovery_sources and not "network_id" in info: should_remove = True
elif discovery_sources and not bytes.fromhex(info["network_id"]) in discovery_sources: should_remove = True
if should_remove:
os.unlink(filepath)
continue
else:
if heard_delta > self.THRESHOLD_STALE: info["status"] = "stale"
elif heard_delta > self.THRESHOLD_UNKNOWN: info["status"] = "unknown"
else: info["status"] = "available"
info["status_code"] = self.STATUS_CODE_MAP[info["status"]]
discovered_interfaces.append(info)
except Exception as e:
RNS.log(f"Error while loading discovered interface data: {e}", RNS.LOG_ERROR)
RNS.log(f"The interface data file {os.path.join(self.storagepath, filename)} may be corrupt", RNS.LOG_ERROR)
RNS.trace_exception(e)
discovered_interfaces.sort(key=lambda info: (info["status_code"], info["value"], info["last_heard"]), reverse=True)
return discovered_interfaces
def interface_discovered(self, info):
try:
name = info["name"]
value = info["value"]
interface_type = info["type"]
discovery_hash = info["discovery_hash"]
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)
if not os.path.isfile(filepath):
try:
with open(filepath, "wb") as f:
info["discovered"] = info["received"]
info["last_heard"] = info["received"]
info["heard_count"] = 0
f.write(msgpack.packb(info))
except Exception as e:
RNS.log(f"Error while persisting discovered interface data: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return
else:
discovered = None
heard_count = None
try:
with open(filepath, "rb") as f:
last_info = msgpack.unpackb(f.read())
discovered = last_info["discovered"]
heard_count = last_info["heard_count"]
if discovered == None: discovered = info["discovered"]
if heard_count == None: heard_count = 0
with open(filepath, "wb") as f:
info["discovered"] = discovered
info["last_heard"] = info["received"]
info["heard_count"] = heard_count+1
f.write(msgpack.packb(info))
except Exception as e:
RNS.log(f"Error while persisting discovered interface data: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return
except Exception as e:
RNS.log(f"Error processing discovered interface data: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return
self.autoconnect(info)
try:
if self.discovery_callback and callable(self.discovery_callback): self.discovery_callback(info)
except Exception as e: RNS.log(f"Error while processing external interface discovery callback: {e}", RNS.LOG_ERROR)
def monitor_interface(self, interface):
if not interface in self.monitored_interfaces:
self.monitored_interfaces.append(interface)
if not self.monitoring_autoconnects:
self.monitoring_autoconnects = True
threading.Thread(target=self.__monitor_job, daemon=True).start()
def __monitor_job(self):
while self.monitoring_autoconnects:
time.sleep(self.monitor_interval)
detached_interfaces = []
online_interfaces = 0
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
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()
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)
if online_interfaces >= RNS.Reticulum.max_autoconnected_interfaces():
for interface in RNS.Transport.interfaces:
if hasattr(interface, "bootstrap_only") and interface.bootstrap_only == True:
RNS.log(f"Tearing down bootstrap-only {interface} since target connected auto-discovered interface count has been reached", RNS.LOG_INFO)
if not interface in detached_interfaces: detached_interfaces.append(interface)
if online_interfaces == 0:
if self.bootstrap_interface_count() == 0:
RNS.log(f"No auto-discovered interfaces connected, re-enabling bootstrap interfaces", RNS.LOG_NOTICE)
for config in RNS.Reticulum.get_instance().bootstrap_configs:
RNS.Reticulum.get_instance()._synthesize_interface(config, config["name"])
for interface in detached_interfaces:
try: self.teardown_interface(interface)
except Exception as e:
RNS.log(f"Error while de-registering auto-connected interface from transport: {e}", RNS.LOG_ERROR)
def teardown_interface(self, interface):
interface.detach()
if interface in RNS.Transport.interfaces: RNS.Transport.interfaces.remove(interface)
if interface in self.monitored_interfaces: self.monitored_interfaces.remove(interface)
def autoconnect_count(self):
return len([i for i in RNS.Transport.interfaces if hasattr(i, "autoconnect_hash")])
def bootstrap_interface_count(self):
return len([i for i in RNS.Transport.interfaces if hasattr(i, "bootstrap_only") and i.bootstrap_only == True])
def connect_discovered(self):
if RNS.Reticulum.should_autoconnect_discovered_interfaces():
try:
discovered_interfaces = self.list_discovered_interfaces()
for info in discovered_interfaces:
if self.autoconnect_count() >= RNS.Reticulum.max_autoconnected_interfaces(): break
self.autoconnect(info)
except Exception as e:
RNS.log(f"Error while reconnecting discovered interfaces: {e}", RNS.LOG_ERROR)
def autoconnect(self, info):
try:
if RNS.Reticulum.should_autoconnect_discovered_interfaces():
autoconnected_count = self.autoconnect_count()
if autoconnected_count < RNS.Reticulum.max_autoconnected_interfaces():
interface_type = info["type"]
if interface_type in self.AUTOCONNECT_TYPES:
endpoint_specifier = ""
if "reachable_on" in info: endpoint_specifier += str(info["reachable_on"])
if "port" in info: endpoint_specifier += ":"+str(info["port"])
endpoint_hash = RNS.Identity.full_hash(endpoint_specifier.encode("utf-8"))
exists = False
for interface in RNS.Transport.interfaces:
if hasattr(interface, "autoconnect_hash") and interface.autoconnect_hash == endpoint_hash:
exists = True
break
else:
dest_match = "reachable_on" in info and hasattr(interface, "target_ip") and interface.target_ip == info["reachable_on"]
port_match = not "port" in info or (hasattr(interface, "target_port") and "port" in info and interface.target_port == info["port"])
b32d_match = "reachable_on" in info and hasattr(interface, "b32") and interface.b32 == info["reachable_on"]
if (dest_match and port_match) or b32d_match:
exists = True
break
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)
return
interface_name = info["name"]
RNS.log(f"Auto-connecting discovered {interface_type} {interface_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 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 interface:
interface.autoconnect_hash = endpoint_hash
interface.autoconnect_source = info["network_id"]
RNS.Reticulum.get_instance()._add_interface(interface, ifac_netname=ifac_netname, ifac_netkey=ifac_netkey, configured_bitrate=5E6)
self.monitor_interface(interface)
except Exception as e:
RNS.log(f"Error while auto-connecting discovered interface: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
class BlackholeUpdater():
INITIAL_WAIT = 20
JOB_INTERVAL = 60
UPDATE_INTERVAL = 1*60*60
SOURCE_TIMEOUT = 25
def __init__(self):
self.last_updates = {}
self.should_run = False
self.job_interval = self.JOB_INTERVAL
self.update_lock = threading.Lock()
def start(self):
if not self.should_run:
source_count = len(RNS.Reticulum.blackhole_sources())
ms = "" if source_count == 1 else "s"
RNS.log(f"Starting blackhole updater with {source_count} source{ms}", RNS.LOG_DEBUG)
self.should_run = True
threading.Thread(target=self.job, daemon=True).start()
def stop(self): self.should_run = False
def update_link_established(self, link):
remote_identity = link.get_remote_identity()
RNS.log(f"Link established for blackhole list update from {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
receipt = link.request("/list")
while not receipt.concluded(): time.sleep(0.2)
response = receipt.get_response()
link.teardown()
if type(response) == dict: blackhole_list = response
else: blackhole_list = None
if blackhole_list:
added = 0
for identity_hash in blackhole_list:
entry = blackhole_list[identity_hash]
if not identity_hash in RNS.Transport.blackholed_identities:
RNS.Transport.blackholed_identities[identity_hash] = entry
added += 1
if added > 0:
spec = "identity" if added == 1 else "identities"
RNS.log(f"Added {added} blackholed {spec} from {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG)
try:
sourcelistpath = os.path.join(RNS.Reticulum.blackholepath, RNS.hexrep(remote_identity.hash, delimit=False))
tmppath = f"{sourcelistpath}.tmp"
with open(tmppath, "wb") as f: f.write(msgpack.packb(blackhole_list))
if os.path.isfile(sourcelistpath): os.unlink(sourcelistpath)
os.rename(tmppath, sourcelistpath)
except Exception as e:
RNS.log(f"Error while persisting blackhole list from {RNS.prettyhexrep(remote_identity.hash)}: {e}", RNS.LOG_ERROR)
RNS.log(f"Blackhole list update from {RNS.prettyhexrep(remote_identity.hash)} completed", RNS.LOG_DEBUG)
def job(self):
time.sleep(self.INITIAL_WAIT)
while self.should_run:
try:
now = time.time()
for identity_hash in RNS.Reticulum.blackhole_sources():
if identity_hash in self.last_updates: last_update = self.last_updates[identity_hash]
else: last_update = 0
if now > last_update+self.UPDATE_INTERVAL:
try:
destination_hash = RNS.Destination.hash_from_name_and_identity("rnstransport.info.blackhole", identity_hash)
RNS.log(f"Attempting blackhole list update from {RNS.prettyhexrep(identity_hash)}...", RNS.LOG_DEBUG)
if not RNS.Transport.await_path(destination_hash): RNS.log(f"No path available for blackhole list update from {RNS.prettyhexrep(identity_hash)}, retrying later", RNS.LOG_VERBOSE)
else:
remote_identity = RNS.Identity.recall(destination_hash)
destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "info", "blackhole")
RNS.Link(destination, established_callback=self.update_link_established)
self.last_updates[identity_hash] = time.time()
except Exception as e:
RNS.log(f"Error while establishing link for blackhole list update from {RNS.prettyhexrep(identity_hash)}: {e}", RNS.LOG_ERROR)
except Exception as e:
RNS.log(f"Error in blackhole list updater job: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
time.sleep(self.job_interval)
+104 -62
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors.
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -71,13 +79,16 @@ class Identity:
HASHLENGTH = 256 # In bits
SIGLENGTH = KEYSIZE # In bits
NAME_HASH_LENGTH = 80
TRUNCATED_HASHLENGTH = RNS.Reticulum.TRUNCATED_HASHLENGTH
NAME_HASH_LENGTH = 80
TRUNCATED_HASHLENGTH = RNS.Reticulum.TRUNCATED_HASHLENGTH
"""
Constant specifying the truncated hash length (in bits) used by Reticulum
for addressable hashes and other purposes. Non-configurable.
"""
DERIVED_KEY_LENGTH = 512//8
DERIVED_KEY_LENGTH_LEGACY = 256//8
# Storage
known_destinations = {}
known_ratchets = {}
@@ -93,29 +104,47 @@ class Identity:
@staticmethod
def recall(destination_hash):
def recall(target_hash, from_identity_hash=False):
"""
Recall identity for a destination hash.
Recall identity for a destination or identity hash. By default, this function
will return the identity associated with a given *destination* hash. As an
example, if you know the ``lxmf.delivery`` destination hash of an endpoint,
this function will return the associated underlying identity. You can also
search for an identity from a known *identity hash*, by setting the
``from_identity_hash`` argument.
:param destination_hash: Destination hash as *bytes*.
:param target_hash: Destination or identity hash as *bytes*.
:param from_identity_hash: Whether to search based on identity hash instead of destination hash as *bool*.
:returns: An :ref:`RNS.Identity<api-identity>` instance that can be used to create an outgoing :ref:`RNS.Destination<api-destination>`, or *None* if the destination is unknown.
"""
if destination_hash in Identity.known_destinations:
identity_data = Identity.known_destinations[destination_hash]
identity = Identity(create_keys=False)
identity.load_public_key(identity_data[2])
identity.app_data = identity_data[3]
return identity
else:
for registered_destination in RNS.Transport.destinations:
if destination_hash == registered_destination.hash:
if from_identity_hash:
for destination_hash in Identity.known_destinations:
if target_hash == Identity.truncated_hash(Identity.known_destinations[destination_hash][2]):
identity_data = Identity.known_destinations[destination_hash]
identity = Identity(create_keys=False)
identity.load_public_key(registered_destination.identity.get_public_key())
identity.app_data = None
identity.load_public_key(identity_data[2])
identity.app_data = identity_data[3]
return identity
return None
else:
if target_hash in Identity.known_destinations:
identity_data = Identity.known_destinations[target_hash]
identity = Identity(create_keys=False)
identity.load_public_key(identity_data[2])
identity.app_data = identity_data[3]
return identity
else:
for registered_destination in RNS.Transport.destinations:
if target_hash == registered_destination.hash:
identity = Identity(create_keys=False)
identity.load_public_key(registered_destination.identity.get_public_key())
identity.app_data = None
return identity
return None
@staticmethod
def recall_app_data(destination_hash):
"""
@@ -266,30 +295,34 @@ class Identity:
@staticmethod
def _remember_ratchet(destination_hash, ratchet):
# TODO: Remove at some point, and only log new ratchets
RNS.log(f"Remembering ratchet {RNS.prettyhexrep(Identity._get_ratchet_id(ratchet))} for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_EXTREME)
try:
Identity.known_ratchets[destination_hash] = ratchet
if destination_hash in Identity.known_ratchets and Identity.known_ratchets[destination_hash] == ratchet:
ratchet_exists = True
else:
ratchet_exists = False
if not RNS.Transport.owner.is_connected_to_shared_instance:
def persist_job():
with Identity.ratchet_persist_lock:
hexhash = RNS.hexrep(destination_hash, delimit=False)
ratchet_data = {"ratchet": ratchet, "received": time.time()}
if not ratchet_exists:
RNS.log(f"Remembering ratchet {RNS.prettyhexrep(Identity._get_ratchet_id(ratchet))} for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_EXTREME)
Identity.known_ratchets[destination_hash] = ratchet
if not RNS.Transport.owner.is_connected_to_shared_instance:
def persist_job():
with Identity.ratchet_persist_lock:
hexhash = RNS.hexrep(destination_hash, delimit=False)
ratchet_data = {"ratchet": ratchet, "received": time.time()}
ratchetdir = RNS.Reticulum.storagepath+"/ratchets"
if not os.path.isdir(ratchetdir):
os.makedirs(ratchetdir)
ratchetdir = RNS.Reticulum.storagepath+"/ratchets"
if not os.path.isdir(ratchetdir):
os.makedirs(ratchetdir)
outpath = f"{ratchetdir}/{hexhash}.out"
finalpath = f"{ratchetdir}/{hexhash}"
with open(outpath, "wb") as ratchet_file:
ratchet_file.write(umsgpack.packb(ratchet_data))
os.replace(outpath, finalpath)
outpath = f"{ratchetdir}/{hexhash}.out"
finalpath = f"{ratchetdir}/{hexhash}"
with open(outpath, "wb") as ratchet_file:
ratchet_file.write(umsgpack.packb(ratchet_data))
os.replace(outpath, finalpath)
threading.Thread(target=persist_job, daemon=True).start()
threading.Thread(target=persist_job, daemon=True).start()
except Exception as e:
RNS.log(f"Could not persist ratchet for {RNS.prettyhexrep(destination_hash)} to storage.", RNS.LOG_ERROR)
@@ -306,12 +339,19 @@ class Identity:
for filename in os.listdir(ratchetdir):
try:
expired = False
corrupted = False
with open(f"{ratchetdir}/{filename}", "rb") as rf:
ratchet_data = umsgpack.unpackb(rf.read())
if now > ratchet_data["received"]+Identity.RATCHET_EXPIRY:
expired = True
# TODO: Remove individual ratchet file if corrupt
try:
ratchet_data = umsgpack.unpackb(rf.read())
if now > ratchet_data["received"]+Identity.RATCHET_EXPIRY:
expired = True
if expired:
except Exception as e:
RNS.log(f"Corrupted ratchet data while reading {ratchetdir}/{filename}, removing file", RNS.LOG_ERROR)
corrupted = True
if expired or corrupted:
os.unlink(f"{ratchetdir}/{filename}")
except Exception as e:
@@ -390,6 +430,11 @@ class Identity:
announced_identity = Identity(create_keys=False)
announced_identity.load_public_key(public_key)
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)
return False
if announced_identity.pub != None and announced_identity.validate(signature, signed_data):
if only_validate_signature:
del announced_identity
@@ -640,7 +685,7 @@ class Identity:
shared_key = ephemeral_key.exchange(target_public_key)
derived_key = RNS.Cryptography.hkdf(
length=32,
length=Identity.DERIVED_KEY_LENGTH,
derive_from=shared_key,
salt=self.get_salt(),
context=self.get_context(),
@@ -654,6 +699,16 @@ class Identity:
else:
raise KeyError("Encryption failed because identity does not hold a public key")
def __decrypt(self, shared_key, ciphertext):
derived_key = RNS.Cryptography.hkdf(
length=Identity.DERIVED_KEY_LENGTH,
derive_from=shared_key,
salt=self.get_salt(),
context=self.get_context())
token = Token(derived_key)
plaintext = token.decrypt(ciphertext)
return plaintext
def decrypt(self, ciphertext_token, ratchets=None, enforce_ratchets=False, ratchet_id_receiver=None):
"""
@@ -663,6 +718,7 @@ class Identity:
:returns: Plaintext as *bytes*, or *None* if decryption fails.
:raises: *KeyError* if the instance does not hold a private key.
"""
if self.prv != None:
if len(ciphertext_token) > Identity.KEYSIZE//8//2:
plaintext = None
@@ -677,15 +733,7 @@ class Identity:
ratchet_prv = X25519PrivateKey.from_private_bytes(ratchet)
ratchet_id = Identity._get_ratchet_id(ratchet_prv.public_key().public_bytes())
shared_key = ratchet_prv.exchange(peer_pub)
derived_key = RNS.Cryptography.hkdf(
length=32,
derive_from=shared_key,
salt=self.get_salt(),
context=self.get_context(),
)
token = Token(derived_key)
plaintext = token.decrypt(ciphertext)
plaintext = self.__decrypt(shared_key, ciphertext)
if ratchet_id_receiver:
ratchet_id_receiver.latest_ratchet_id = ratchet_id
@@ -702,15 +750,8 @@ class Identity:
if plaintext == None:
shared_key = self.prv.exchange(peer_pub)
derived_key = RNS.Cryptography.hkdf(
length=32,
derive_from=shared_key,
salt=self.get_salt(),
context=self.get_context(),
)
plaintext = self.__decrypt(shared_key, ciphertext)
token = Token(derived_key)
plaintext = token.decrypt(ciphertext)
if ratchet_id_receiver:
ratchet_id_receiver.latest_ratchet_id = None
@@ -719,7 +760,8 @@ class Identity:
if ratchet_id_receiver:
ratchet_id_receiver.latest_ratchet_id = None
return plaintext;
return plaintext
else:
RNS.log("Decryption failed because the token size was invalid.", RNS.LOG_DEBUG)
return None
+13 -5
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -70,7 +78,7 @@ class AX25KISSInterface(Interface):
serial = None
def __init__(self, owner, configuration):
import importlib
import importlib.util
if importlib.util.find_spec('serial') != None:
import serial
else:
+13 -5
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -63,7 +71,7 @@ class KISSInterface(Interface):
serial = None
def __init__(self, owner, configuration):
import importlib
import importlib.util
if RNS.vendor.platformutils.is_android():
self.on_android = True
if importlib.util.find_spec('usbserial4a') != None:
+371 -90
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -24,6 +32,7 @@ from RNS.Interfaces.Interface import Interface
from time import sleep
import sys
import threading
import socket
import time
import math
import RNS
@@ -64,6 +73,8 @@ class KISS():
CMD_STAT_CHTM = 0x25
CMD_STAT_PHYPRM = 0x26
CMD_STAT_BAT = 0x27
CMD_STAT_CSMA = 0x28
CMD_STAT_TEMP = 0x29
CMD_BLINK = 0x30
CMD_RANDOM = 0x40
CMD_FB_EXT = 0x41
@@ -84,15 +95,16 @@ class KISS():
RADIO_STATE_ON = 0x01
RADIO_STATE_ASK = 0xFF
CMD_ERROR = 0x90
ERROR_INITRADIO = 0x01
ERROR_TXFAILED = 0x02
ERROR_EEPROM_LOCKED = 0x03
ERROR_QUEUE_FULL = 0x04
ERROR_MEMORY_LOW = 0x05
ERROR_MODEM_TIMEOUT = 0x06
CMD_ERROR = 0x90
ERROR_INITRADIO = 0x01
ERROR_TXFAILED = 0x02
ERROR_EEPROM_LOCKED = 0x03
ERROR_QUEUE_FULL = 0x04
ERROR_MEMORY_LOW = 0x05
ERROR_MODEM_TIMEOUT = 0x06
ERROR_INVALID_FIRMWARE = 0x10
ERROR_INVALID_BLE_MTU = 0x20
ERROR_INVALID_CONFIG = 0x40
PLATFORM_AVR = 0x90
PLATFORM_ESP32 = 0x80
@@ -242,7 +254,7 @@ class RNodeInterface(Interface):
DEFAULT_IFAC_SIZE = 8
FREQ_MIN = 137000000
FREQ_MAX = 1020000000
FREQ_MAX = 3000000000
RSSI_OFFSET = 157
@@ -353,7 +365,9 @@ class RNodeInterface(Interface):
target_device_address = c["target_device_address"] if "target_device_address" in c else None
ble_name = c["ble_name"] if "ble_name" in c else None
ble_addr = c["ble_addr"] if "ble_addr" in c else None
tcp_host = c["tcp_host"] if "tcp_host" in c else None
force_ble = c["force_ble"] if "force_ble" in c else False
force_tcp = c["force_tcp"] if "force_tcp" in c else False
frequency = int(c["frequency"]) if "frequency" in c else 0
bandwidth = int(c["bandwidth"]) if "bandwidth" in c else 0
txpower = int(c["txpower"]) if "txpower" in c else 0
@@ -366,7 +380,7 @@ class RNodeInterface(Interface):
lt_alock = float(c["airtime_limit_long"]) if "airtime_limit_long" in c and c["airtime_limit_long"] != None else None
port = c["port"] if "port" in c else None
import importlib
import importlib.util
if RNS.vendor.platformutils.is_android():
self.on_android = True
if importlib.util.find_spec('usbserial4a') != None:
@@ -425,6 +439,14 @@ class RNodeInterface(Interface):
self.ble_rx_queue= b""
self.ble_tx_queue= b""
self.tcp = None
self.use_tcp = False
self.tcp_host = tcp_host
self.tcp_rx_queue= b""
self.tcp_tx_queue= b""
self.tcp_rx_lock = threading.Lock()
self.tcp_tx_lock = threading.Lock()
self.frequency = frequency
self.bandwidth = bandwidth
self.txpower = txpower
@@ -434,6 +456,7 @@ class RNodeInterface(Interface):
self.bitrate = 0
self.st_alock = st_alock
self.lt_alock = lt_alock
self.cpu_temp = None
self.platform = None
self.display = None
self.mcu = None
@@ -446,6 +469,7 @@ class RNodeInterface(Interface):
self.first_tx = None
self.reconnect_w = RNodeInterface.RECONNECT_WAIT
self.reconnect_lock = threading.Lock()
self.awaiting_ble_reset = False
self.r_frequency = None
self.r_bandwidth = None
@@ -465,10 +489,21 @@ class RNodeInterface(Interface):
self.r_airtime_long = 0.0
self.r_channel_load_short = 0.0
self.r_channel_load_long = 0.0
self.r_symbol_time_ms = None
self.r_symbol_rate = None
self.r_preamble_symbols = None
self.r_premable_time_ms = None
self.r_symbol_time_ms = None
self.r_symbol_rate = None
self.r_preamble_symbols = None
self.r_premable_time_ms = None
self.r_csma_slot_time_ms = None
self.r_csma_difs_ms = None
self.r_csma_cw_band = None
self.r_csma_cw_min = None
self.r_csma_cw_max = None
self.r_current_rssi = None
self.r_noise_floor = None
self.r_interference = None
self.r_interference_l = None
self.r_temperature = None
self.r_battery_state = RNodeInterface.BATTERY_STATE_UNKNOWN
self.r_battery_percent = 0
self.r_framebuffer = b""
@@ -489,15 +524,15 @@ class RNodeInterface(Interface):
self.port_io_timeout = RNodeInterface.PORT_IO_TIMEOUT
self.last_imagedata = None
if force_ble or self.ble_addr != None or self.ble_name != None:
self.use_ble = True
if force_ble or self.ble_addr != None or self.ble_name != None: self.use_ble = True
if force_tcp or self.tcp_host != None: self.use_tcp = True
self.validcfg = True
if (self.frequency < RNodeInterface.FREQ_MIN or self.frequency > RNodeInterface.FREQ_MAX):
RNS.log("Invalid frequency configured for "+str(self), RNS.LOG_ERROR)
self.validcfg = False
if (self.txpower < 0 or self.txpower > 22):
if (self.txpower < 0 or self.txpower > 37):
RNS.log("Invalid TX power configured for "+str(self), RNS.LOG_ERROR)
self.validcfg = False
@@ -540,10 +575,8 @@ class RNodeInterface(Interface):
self.open_port()
if self.serial != None:
if self.serial.is_open:
self.configure_device()
else:
raise IOError("Could not open serial port")
if self.serial.is_open: self.configure_device()
else: raise IOError("Could not open serial port")
elif self.bt_manager != None:
if self.bt_manager.connected:
self.configure_device()
@@ -561,12 +594,9 @@ class RNodeInterface(Interface):
def read_mux(self, len=None):
if self.serial != None:
return self.serial.read()
elif self.bt_manager != None:
return self.bt_manager.read()
else:
raise IOError("No ports available for reading")
if self.serial != None: return self.serial.read()
elif self.bt_manager != None: return self.bt_manager.read()
else: raise IOError("No ports available for reading")
def write_mux(self, data):
if self.serial != None:
@@ -581,17 +611,19 @@ class RNodeInterface(Interface):
else:
raise IOError("No ports available for writing")
# def reset_ble(self):
# RNS.log(f"Clearing previous connection instance: "+str(self.ble))
# del self.ble
# self.ble = None
# self.serial = None
# self.ble = BLEConnection(owner=self, target_name=self.ble_name, target_bt_addr=self.ble_addr)
# self.serial = self.ble
# RNS.log(f"New connection instance: "+str(self.ble))
def reset_ble(self):
if not self.awaiting_ble_reset: return
else:
RNS.log(f"Clearing previous connection instance: "+str(self.ble), RNS.LOG_DEBUG)
self.ble = None
self.serial = None
self.ble = BLEConnection(owner=self, target_name=self.ble_name, target_bt_addr=self.ble_addr)
self.serial = self.ble
self.awaiting_ble_reset = False
RNS.log(f"New connection instance: "+str(self.ble), RNS.LOG_DEBUG)
def open_port(self):
if not self.use_ble:
if not self.use_ble and not self.use_tcp:
if self.port != None:
RNS.log("Opening serial port "+self.port+"...")
# Get device parameters
@@ -659,7 +691,7 @@ class RNodeInterface(Interface):
if self.bt_manager != None:
self.bt_manager.connect_any_device()
else:
elif self.use_ble:
if self.ble == None:
self.ble = BLEConnection(owner=self, target_name=self.ble_name, target_bt_addr=self.ble_addr)
self.serial = self.ble
@@ -668,6 +700,24 @@ class RNodeInterface(Interface):
while not self.ble.connected and time.time() < open_time + self.ble.CONNECT_TIMEOUT:
time.sleep(1)
elif self.use_tcp:
RNS.log(f"Opening TCP connection for {self}...")
if self.tcp != None and self.tcp.running == False:
self.tcp.close()
self.tcp.cleanup()
self.tcp = None
if self.tcp == None:
self.tcp = TCPConnection(owner=self, target_host=self.tcp_host)
self.serial = self.tcp
open_time = time.time()
while not self.tcp.connected and time.time() < open_time + self.tcp.CONNECT_TIMEOUT:
time.sleep(1)
else:
raise TypeError("No valid device connection type defined for RNode interface")
def configure_device(self):
self.resetRadioState()
@@ -675,17 +725,19 @@ class RNodeInterface(Interface):
thread = threading.Thread(target=self.readLoop, daemon=True).start()
self.detect()
if not self.use_ble:
sleep(0.5)
else:
if self.use_tcp:
tcp_detect_timeout = 5.0
detect_time = time.time()
while not self.detected and time.time() < detect_time + tcp_detect_timeout: time.sleep(0.1)
if not self.detected: RNS.log(f"RNode detect timed out over TCP", RNS.LOG_ERROR)
elif self.use_ble:
ble_detect_timeout = 5
detect_time = time.time()
while not self.detected and time.time() < detect_time + ble_detect_timeout:
time.sleep(0.1)
if self.detected:
detect_time = RNS.prettytime(time.time()-detect_time)
else:
RNS.log(f"RNode detect timed out over {self.port}", RNS.LOG_ERROR)
while not self.detected and time.time() < detect_time + ble_detect_timeout: time.sleep(0.1)
if self.detected: detect_time = RNS.prettytime(time.time()-detect_time)
else: RNS.log(f"RNode detect timed out over {self.port}", RNS.LOG_ERROR)
else:
sleep(0.2)
if not self.detected:
raise IOError("Could not detect device")
@@ -698,11 +750,19 @@ class RNodeInterface(Interface):
if self.serial != None and self.port != None:
self.timeout = 200
RNS.log("Serial port "+self.port+" is now open")
RNS.log(f"Serial port {self.port} is now open")
if self.bt_manager != None and self.bt_manager.connected:
self.timeout = 1500
RNS.log("Bluetooth connection to RNode now open")
RNS.log(f"Bluetooth connection to RNode now open")
if self.ble != None and self.ble.connected:
self.timeout = 1500
RNS.log(f"BLE connection {self.port} to RNode now open")
if self.tcp != None and self.tcp.connected:
self.timeout = 1500
RNS.log(f"TCP connection tcp://{self.tcp_host} to RNode now open")
RNS.log("Configuring RNode interface...", RNS.LOG_VERBOSE)
self.initRadio()
@@ -715,6 +775,7 @@ class RNodeInterface(Interface):
RNS.log("After configuring "+str(self)+", the reported radio parameters did not match your configuration.", RNS.LOG_ERROR)
RNS.log("Make sure that your hardware actually supports the parameters specified in the configuration", RNS.LOG_ERROR)
RNS.log("Aborting RNode startup", RNS.LOG_ERROR)
self.hw_errors.append({"error": KISS.ERROR_INVALID_CONFIG, "description": "The configuration parameters were not validated by the device. Make sure that the device actually supports the TX power, frequency, bandwidth, spreading factor and coding rate you configured."})
if self.serial != None:
self.serial.close()
@@ -953,10 +1014,8 @@ class RNodeInterface(Interface):
def validateRadioState(self):
RNS.log("Waiting for radio configuration validation for "+str(self)+"...", RNS.LOG_VERBOSE)
if not self.platform == KISS.PLATFORM_ESP32:
sleep(1.00);
else:
sleep(2.00);
if not self.platform == KISS.PLATFORM_ESP32: sleep(1.00);
else: sleep(2.00);
self.validcfg = True
if (self.r_frequency != None and abs(self.frequency - int(self.r_frequency)) > 100):
@@ -1238,16 +1297,33 @@ class RNodeInterface(Interface):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 8):
if (len(command_buffer) == 11):
ats = command_buffer[0] << 8 | command_buffer[1]
atl = command_buffer[2] << 8 | command_buffer[3]
cus = command_buffer[4] << 8 | command_buffer[5]
cul = command_buffer[6] << 8 | command_buffer[7]
crs = command_buffer[8]
nfl = command_buffer[9]
ntf = command_buffer[10]
self.r_airtime_short = ats/100.0
self.r_airtime_long = atl/100.0
self.r_channel_load_short = cus/100.0
self.r_channel_load_long = cul/100.0
self.r_current_rssi = crs-RNodeInterface.RSSI_OFFSET
self.r_noise_floor = nfl-RNodeInterface.RSSI_OFFSET
if ntf == 0xFF:
self.r_interference = None
else:
self.r_interference = ntf-RNodeInterface.RSSI_OFFSET
self.r_interference_l = [time.time(), self.r_interference]
if self.r_interference != None:
RNS.log(f"{self} Radio detected interference at {self.r_interference} dBm", RNS.LOG_DEBUG)
# TODO: Remove debug
# RNS.log(f"RSSI: {self.r_current_rssi}, Noise floor: {self.r_noise_floor}, Interference: {self.r_interference}", RNS.LOG_EXTREME)
elif (command == KISS.CMD_STAT_PHYPRM):
if (byte == KISS.FESC):
escape = True
@@ -1259,22 +1335,49 @@ class RNodeInterface(Interface):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 10):
if (len(command_buffer) == 12):
lst = (command_buffer[0] << 8 | command_buffer[1])/1000.0
lsr = command_buffer[2] << 8 | command_buffer[3]
prs = command_buffer[4] << 8 | command_buffer[5]
prt = command_buffer[6] << 8 | command_buffer[7]
cst = command_buffer[8] << 8 | command_buffer[9]
dft = command_buffer[10] << 8 | command_buffer[11]
if lst != self.r_symbol_time_ms or lsr != self.r_symbol_rate or prs != self.r_preamble_symbols or prt != self.r_premable_time_ms or cst != self.r_csma_slot_time_ms:
if lst != self.r_symbol_time_ms or lsr != self.r_symbol_rate or prs != self.r_preamble_symbols or prt != self.r_premable_time_ms or cst != self.r_csma_slot_time_ms or dft != self.r_csma_difs_ms:
self.r_symbol_time_ms = lst
self.r_symbol_rate = lsr
self.r_preamble_symbols = prs
self.r_premable_time_ms = prt
self.r_csma_slot_time_ms = cst
RNS.log(str(self)+" Radio reporting symbol time is "+str(round(self.r_symbol_time_ms,2))+"ms (at "+str(self.r_symbol_rate)+" baud)", RNS.LOG_DEBUG)
RNS.log(str(self)+" Radio reporting preamble is "+str(self.r_preamble_symbols)+" symbols ("+str(self.r_premable_time_ms)+"ms)", RNS.LOG_DEBUG)
RNS.log(str(self)+" Radio reporting CSMA slot time is "+str(self.r_csma_slot_time_ms)+"ms", RNS.LOG_DEBUG)
self.r_csma_difs_ms = dft
RNS.log(f"{self} Radio reporting symbol time is "+str(round(self.r_symbol_time_ms,2))+"ms ("+str(self.r_symbol_rate)+" baud)", RNS.LOG_DEBUG)
RNS.log(f"{self} Radio reporting preamble is "+str(self.r_preamble_symbols)+" symbols ("+str(self.r_premable_time_ms)+"ms)", RNS.LOG_DEBUG)
RNS.log(f"{self} Radio reporting CSMA slot time is "+str(self.r_csma_slot_time_ms)+"ms", RNS.LOG_DEBUG)
RNS.log(f"{self} Radio reporting DIFS time is "+str(self.r_csma_difs_ms)+"ms", RNS.LOG_DEBUG)
elif (command == KISS.CMD_STAT_CSMA):
if (byte == KISS.FESC):
escape = True
else:
if (escape):
if (byte == KISS.TFEND):
byte = KISS.FEND
if (byte == KISS.TFESC):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 3):
cbw = command_buffer[0]
cbl = command_buffer[1]
cbh = command_buffer[2]
if cbw != self.r_csma_cw_band or cbl != self.r_csma_cw_min or cbh != self.r_csma_cw_max:
self.r_csma_cw_band = cbw
self.r_csma_cw_min = cbl
self.r_csma_cw_max = cbh
# TODO: Remove debug
# RNS.log(f"{self} Radio reporting contention window band is {self.r_csma_cw_band}", RNS.LOG_EXTREME)
# RNS.log(f"{self} Radio reporting minimum contention window is {self.r_csma_cw_min}", RNS.LOG_EXTREME)
# RNS.log(f"{self} Radio reporting maximum contention window is {self.r_csma_cw_max}", RNS.LOG_EXTREME)
elif (command == KISS.CMD_STAT_BAT):
if (byte == KISS.FESC):
escape = True
@@ -1294,6 +1397,22 @@ class RNodeInterface(Interface):
bat_percent = 0
self.r_battery_state = command_buffer[0]
self.r_battery_percent = bat_percent
elif (command == KISS.CMD_STAT_TEMP):
if (byte == KISS.FESC):
escape = True
else:
if (escape):
if (byte == KISS.TFEND):
byte = KISS.FEND
if (byte == KISS.TFESC):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 1):
temp = command_buffer[0]-120
if temp >= -30 and temp <= 90: self.r_temperature = temp
else: self.r_temperature = None
self.cpu_temp = self.r_temperature
elif (command == KISS.CMD_RANDOM):
self.r_random = byte
elif (command == KISS.CMD_PLATFORM):
@@ -1364,7 +1483,7 @@ class RNodeInterface(Interface):
if got == 0:
time_since_last = int(time.time()*1000) - last_read_ms
if len(data_buffer) > 0 and time_since_last > self.timeout:
RNS.log(str(self)+" serial read timeout in command "+str(command), RNS.LOG_WARNING)
RNS.log(f"{self} device read timeout in command {command} after {RNS.prettytime(self.timeout/1000.0)}", RNS.LOG_WARNING)
data_buffer = b""
in_frame = False
command = KISS.CMD_UNKNOWN
@@ -1375,19 +1494,19 @@ class RNodeInterface(Interface):
if time.time() > self.first_tx + self.id_interval:
RNS.log("Interface "+str(self)+" is transmitting beacon data: "+str(self.id_callsign.decode("utf-8")), RNS.LOG_DEBUG)
self.process_outgoing(self.id_callsign)
if (time.time() - self.last_port_io > self.port_io_timeout):
self.detect()
if (time.time() - self.last_port_io > self.port_io_timeout*3):
raise IOError("Connected port for "+str(self)+" became unresponsive")
if self.bt_manager != None:
sleep(0.08)
if self.use_tcp:
if self.tcp and self.tcp.connected:
if time.time() > self.tcp.last_write + self.tcp.ACTIVITY_KEEPALIVE:
self.detect()
if (time.time() - self.last_port_io > self.port_io_timeout): self.detect()
if (time.time() - self.last_port_io > self.port_io_timeout*3): raise IOError("Connected port for "+str(self)+" became unresponsive")
if self.bt_manager != None or self.ble != None: sleep(0.08)
except Exception as e:
self.online = False
RNS.log("A serial port occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
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:
@@ -1445,12 +1564,18 @@ class RNodeInterface(Interface):
def detach(self):
self.detached = True
self.disable_external_framebuffer()
self.setRadioState(KISS.RADIO_STATE_OFF)
self.leave()
try:
self.disable_external_framebuffer()
self.setRadioState(KISS.RADIO_STATE_OFF)
self.leave()
if self.use_ble:
self.ble.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:
time.sleep(0.5)
self.tcp.close()
def should_ingress_limit(self):
return False
@@ -1471,6 +1596,17 @@ class RNodeInterface(Interface):
def get_battery_percent(self):
return self.r_battery_percent
def tcp_receive(self, data):
with self.tcp_rx_lock: self.tcp_rx_queue += data
def tcp_waiting(self): return len(self.tcp_tx_queue) > 0
def get_tcp_waiting(self, n):
with self.tcp_tx_lock:
data = self.tcp_tx_queue[:n]
self.tcp_tx_queue = self.tcp_tx_queue[n:]
return data
def ble_receive(self, data):
with self.ble_rx_lock:
self.ble_rx_queue += data
@@ -1485,7 +1621,7 @@ class RNodeInterface(Interface):
return data
def __str__(self):
return "RNodeInterface["+str(self.name)+"]"
return f"RNodeInterface[{self.name}]"
class BLEConnection(BluetoothDispatcher):
UART_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
@@ -1497,7 +1633,7 @@ class BLEConnection(BluetoothDispatcher):
MTU_TIMEOUT = 4.0
CONNECT_TIMEOUT = 7.0
RECONNECT_WAIT = 1.0
RECONNECT_WAIT = 2.5
@property
def is_open(self):
@@ -1598,13 +1734,17 @@ class BLEConnection(BluetoothDispatcher):
self.write_thread = None
def connection_job(self):
ble_devices = []
while self.should_run:
if self.bt_manager.bt_enabled():
if self.ble_device == None:
self.ble_device = self.find_target_device()
if not self.connected:
if len(ble_devices) == 0:
ble_devices = self.find_target_devices()
if len(ble_devices) > 0: self.ble_device = ble_devices.pop()
else: self.ble_device == None
if self.ble_device != None:
if not self.connected:
if self.ble_device != None:
if self.was_connected:
RNS.log(f"Throttling BLE reconnect for {BLEConnection.RECONNECT_WAIT} seconds", RNS.LOG_DEBUG)
time.sleep(BLEConnection.RECONNECT_WAIT)
@@ -1616,7 +1756,7 @@ class BLEConnection(BluetoothDispatcher):
RNS.log("Bluetooth was disabled, closing active BLE device connection", RNS.LOG_ERROR)
self.close()
time.sleep(2)
time.sleep(1)
def connect_device(self):
if self.ble_device != None and self.bt_manager.bt_enabled():
@@ -1634,12 +1774,15 @@ class BLEConnection(BluetoothDispatcher):
else:
RNS.log(f"BLE device connection timed out for {self.owner}", RNS.LOG_DEBUG)
if self.mtu_requested_time:
RNS.log("MTU update timeout, tearing down connection")
RNS.log("MTU update timeout, tearing down connection and resetting BLE dispatcher")
self.owner.hw_errors.append({"error": KISS.ERROR_INVALID_BLE_MTU, "description": "The Bluetooth Low Energy transfer MTU could not be configured for the connected device, and communication has failed. Restart Reticulum and any connected applications to retry connecting."})
self.close()
self.close_gatt()
self.should_run = False
self.close_gatt()
self.owner.awaiting_ble_reset = True
else:
self.close_gatt()
self.connect_job_running = False
@@ -1649,15 +1792,17 @@ class BLEConnection(BluetoothDispatcher):
self.ble_device = None
self.close_gatt()
def find_target_device(self):
def find_target_devices(self):
found_device = None
potential_devices = self.bt_manager.get_paired_devices()
suitable_devices = []
if self.target_bt_addr != None:
for device in potential_devices:
if (device.getType() == AndroidBluetoothManager.DEVICE_TYPE_LE) or (device.getType() == AndroidBluetoothManager.DEVICE_TYPE_DUAL):
if str(device.getAddress()).replace(":", "").lower() == str(self.target_bt_addr).replace(":", "").lower():
found_device = device
suitable_devices.append(device)
break
if not found_device and self.target_name != None:
@@ -1665,6 +1810,7 @@ class BLEConnection(BluetoothDispatcher):
if (device.getType() == AndroidBluetoothManager.DEVICE_TYPE_LE) or (device.getType() == AndroidBluetoothManager.DEVICE_TYPE_DUAL):
if device.getName().lower() == self.target_name.lower():
found_device = device
suitable_devices.append(device)
break
if not found_device:
@@ -1672,9 +1818,9 @@ class BLEConnection(BluetoothDispatcher):
if (device.getType() == AndroidBluetoothManager.DEVICE_TYPE_LE) or (device.getType() == AndroidBluetoothManager.DEVICE_TYPE_DUAL):
if device.getName().startswith("RNode "):
found_device = device
break
suitable_devices.append(device)
return found_device
return suitable_devices
def on_connection_state_change(self, status, state):
if status == GATT_SUCCESS and state:
@@ -1718,4 +1864,139 @@ class BLEConnection(BluetoothDispatcher):
def on_characteristic_changed(self, characteristic):
if characteristic.getUuid().toString() == BLEConnection.UART_TX_CHAR_UUID:
recvd = bytes(characteristic.getValue())
self.owner.ble_receive(recvd)
self.owner.ble_receive(recvd)
class TCPConnection():
TARGET_PORT = 7633
CONNECT_TIMEOUT = 5.0
INITIAL_CONNECT_TIMEOUT = 5.0
RECONNECT_WAIT = 4.0
ACTIVITY_TIMEOUT = 6.0
ACTIVITY_KEEPALIVE = ACTIVITY_TIMEOUT-2.5
TCP_USER_TIMEOUT = 24
TCP_PROBE_AFTER = 5
TCP_PROBE_INTERVAL = 2
TCP_PROBES = 12
@property
def is_open(self):
return self.connected
@property
def in_waiting(self):
buflen = len(self.owner.tcp_rx_queue)
return buflen > 0
def write(self, data_bytes):
if self.connected and self.socket:
with self.owner.tcp_tx_lock:
if len(self.owner.tcp_tx_queue) > 0:
self.socket.sendall(self.owner.tcp_tx_queue)
self.owner.tcp_tx_queue = b""
self.socket.sendall(data_bytes)
self.last_write = time.time()
else:
with self.owner.tcp_tx_lock: self.owner.tcp_tx_queue += data_bytes
return len(data_bytes)
def read(self, n=4096):
with self.owner.tcp_rx_lock:
data = self.owner.tcp_rx_queue[:n]
self.owner.tcp_rx_queue = self.owner.tcp_rx_queue[n:]
return data
def close(self):
if self.connected:
RNS.log(f"Disconnecting TCP socket for {self.owner}", RNS.LOG_DEBUG)
self.must_disconnect = True
if self.socket: self.socket.close()
def __init__(self, owner=None, target_host=None):
self.owner = owner
self.target_host = target_host
self.connected = False
self.reconnecting = False
self.running = False
self.should_run = False
self.must_disconnect = False
self.connect_job_running = False
self.last_write = time.time()
self.should_run = True
self.connection_thread = threading.Thread(target=self.initial_connect, daemon=True).start()
def set_timeouts_linux(self):
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, int(self.TCP_USER_TIMEOUT * 1000))
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, int(self.TCP_PROBE_AFTER))
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, int(self.TCP_PROBE_INTERVAL))
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, int(self.TCP_PROBES))
def set_timeouts_osx(self):
if hasattr(socket, "TCP_KEEPALIVE"): TCP_KEEPIDLE = socket.TCP_KEEPALIVE
else: TCP_KEEPIDLE = 0x10
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self.socket.setsockopt(socket.IPPROTO_TCP, TCP_KEEPIDLE, int(self.TCP_PROBE_AFTER))
def cleanup(self):
try:
if self.socket: self.socket.close()
except Exception as e:
RNS.log(f"Error while disconnecting TCP socket on cleanup for {self.owner}", RNS.LOG_ERROR)
self.should_run = False
def initial_connect(self):
if self.connect(initial=True): threading.Thread(target=self.read_loop, daemon=True).start()
def connect(self, initial=False):
try:
if initial:
RNS.log(f"Establishing TCP connection to device for {self.owner}...", RNS.LOG_DEBUG)
address_info = socket.getaddrinfo(self.target_host, self.TARGET_PORT, proto=socket.IPPROTO_TCP)[0]
address_family = address_info[0]
target_address = address_info[4]
self.socket = socket.socket(address_family, socket.SOCK_STREAM)
self.socket.settimeout(self.INITIAL_CONNECT_TIMEOUT)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect(target_address)
self.socket.settimeout(None)
self.connected = True
self.last_write = time.time()
RNS.log(f"TCP connection to device for {self.owner} established", RNS.LOG_DEBUG)
if RNS.vendor.platformutils.is_linux(): self.set_timeouts_linux()
elif RNS.vendor.platformutils.is_darwin(): self.set_timeouts_osx()
return True
except Exception as e:
if initial:
RNS.log(f"TCP connection to device for {self.owner} could not be established: {e}", RNS.LOG_ERROR)
return False
else: raise e
def read_loop(self):
try:
data_in = b""
while not self.must_disconnect:
if self.socket: data_in = self.socket.recv(4096)
else: data_in = b""
if len(data_in) > 0: self.owner.tcp_receive(data_in)
else:
self.connected = False
RNS.log(f"The TCP socket for {self} was closed", RNS.LOG_WARNING)
break
except Exception as e:
self.connected = False
RNS.log(f"A TCP read error occurred for {self}, the contained exception was: {e}", RNS.LOG_WARNING)
+13 -5
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -53,7 +61,7 @@ class SerialInterface(Interface):
serial = None
def __init__(self, owner, configuration):
import importlib
import importlib.util
if RNS.vendor.platformutils.is_android():
self.on_android = True
if importlib.util.find_spec('usbserial4a') != None:
+12 -4
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
+263 -104
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2024 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -33,6 +41,9 @@ import RNS
class AutoInterface(Interface):
HW_MTU = 1196
FIXED_MTU = True
DEFAULT_DISCOVERY_PORT = 29716
DEFAULT_DATA_PORT = 42671
DEFAULT_GROUP_ID = "reticulum".encode("utf-8")
@@ -47,7 +58,10 @@ class AutoInterface(Interface):
MULTICAST_PERMANENT_ADDRESS_TYPE = "0"
MULTICAST_TEMPORARY_ADDRESS_TYPE = "1"
PEERING_TIMEOUT = 7.5
PEERING_TIMEOUT = 22.0
ANNOUNCE_INTERVAL = 1.6
PEER_JOB_INTERVAL = 4.0
MCAST_ECHO_TIMEOUT = 6.5
ALL_IGNORE_IFS = ["lo0"]
DARWIN_IGNORE_IFS = ["awdl0", "llw0", "lo0", "en5"]
@@ -79,7 +93,6 @@ class AutoInterface(Interface):
return ifas
def interface_name_to_index(self, ifname):
# socket.if_nametoindex doesn't work with uuid interface names on windows, it wants the ethernet_0 style
# we will just get the index from netinfo instead as it seems to work
if RNS.vendor.platformutils.is_windows():
@@ -99,38 +112,43 @@ class AutoInterface(Interface):
ignored_interfaces = c.as_list("ignored_devices") if "ignored_devices" in c else None
configured_bitrate = c["configured_bitrate"] if "configured_bitrate" in c else None
from RNS.vendor.ifaddr import niwrapper
from RNS.Interfaces import netinfo
super().__init__()
self.netinfo = niwrapper
self.HW_MTU = 1064
self.netinfo = netinfo
self.HW_MTU = AutoInterface.HW_MTU
self.IN = True
self.OUT = False
self.name = name
self.owner = owner
self.online = False
self.final_init_done = False
self.peers = {}
self.link_local_addresses = []
self.adopted_interfaces = {}
self.interface_servers = {}
self.multicast_echoes = {}
self.initial_echoes = {}
self.timed_out_interfaces = {}
self.spawned_interfaces = {}
self.write_lock = threading.Lock()
self.mif_deque = deque(maxlen=AutoInterface.MULTI_IF_DEQUE_LEN)
self.mif_deque_times = deque(maxlen=AutoInterface.MULTI_IF_DEQUE_LEN)
self.carrier_changed = False
self.outbound_udp_socket = None
self.announce_rate_target = None
self.announce_interval = AutoInterface.PEERING_TIMEOUT/6.0
self.peer_job_interval = AutoInterface.PEERING_TIMEOUT*1.1
self.peering_timeout = AutoInterface.PEERING_TIMEOUT
self.multicast_echo_timeout = AutoInterface.PEERING_TIMEOUT/2
self.announce_rate_target = None
self.announce_interval = AutoInterface.ANNOUNCE_INTERVAL
self.peer_job_interval = AutoInterface.PEER_JOB_INTERVAL
self.peering_timeout = AutoInterface.PEERING_TIMEOUT
self.multicast_echo_timeout = AutoInterface.MCAST_ECHO_TIMEOUT
self.reverse_peering_interval = self.announce_interval*3.25
# Increase peering timeout on Android, due to potential
# low-power modes implemented on many chipsets.
if RNS.vendor.platformutils.is_android():
self.peering_timeout *= 3
self.peering_timeout *= 1.25
if allowed_interfaces == None:
self.allowed_interfaces = []
@@ -152,6 +170,8 @@ class AutoInterface(Interface):
else:
self.discovery_port = discovery_port
self.unicast_discovery_port = self.discovery_port+1
if multicast_address_type == None:
self.multicast_address_type = AutoInterface.MULTICAST_TEMPORARY_ADDRESS_TYPE
elif str(multicast_address_type).lower() == "temporary":
@@ -227,33 +247,48 @@ class AutoInterface(Interface):
if link_local_addr == None:
RNS.log(str(self)+" No link-local IPv6 address configured for "+str(ifname)+", skipping interface", RNS.LOG_EXTREME)
else:
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)
RNS.log(str(self)+" Creating unicast discovery listener on "+str(ifname)+" with address "+str(link_local_addr), RNS.LOG_EXTREME)
# Struct with interface index
if_struct = struct.pack("I", self.interface_name_to_index(ifname))
# Set up multicast socket
# Set up unicast discovery socket
unicast_discovery_socket = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
unicast_discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"): unicast_discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
# Bind unicast discovery socket
if RNS.vendor.platformutils.is_windows():
# Windows throws "[WinError 10049] The requested address is not valid in its context"
# when trying to use the multicast address as host, or when providing interface index
# passing an empty host appears to work, but probably not exactly how we want it to...
unicast_discovery_socket.bind(('', self.unicast_discovery_port))
else:
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])
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)
# Set up multicast discovery socket
discovery_socket = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
if hasattr(socket, "SO_REUSEPORT"): discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
discovery_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, if_struct)
# Join multicast group
mcast_group = socket.inet_pton(socket.AF_INET6, mcast_addr) + if_struct
discovery_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mcast_group)
# Bind socket
# Bind multicast socket
if RNS.vendor.platformutils.is_windows():
# window throws "[WinError 10049] The requested address is not valid in its context"
# Windows throws "[WinError 10049] The requested address is not valid in its context"
# when trying to use the multicast address as host, or when providing interface index
# passing an empty host appears to work, but probably not exactly how we want it to...
discovery_socket.bind(('', self.discovery_port))
else:
if self.discovery_scope == AutoInterface.SCOPE_LINK:
addr_info = socket.getaddrinfo(mcast_addr+"%"+ifname, self.discovery_port, socket.AF_INET6, socket.SOCK_DGRAM)
else:
@@ -261,13 +296,13 @@ class AutoInterface(Interface):
discovery_socket.bind(addr_info[0][4])
# Set up thread for discovery packets
def discovery_loop():
self.discovery_handler(discovery_socket, ifname)
thread = threading.Thread(target=discovery_loop)
thread.daemon = True
thread.start()
# 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()
# Set up thread for unicast discovery packets
def unicast_discovery_loop(): self.discovery_handler(unicast_discovery_socket, ifname, announce=False)
thread = threading.Thread(target=unicast_discovery_loop, daemon=True).start()
suitable_interfaces += 1
@@ -288,48 +323,50 @@ class AutoInterface(Interface):
else:
self.bitrate = AutoInterface.BITRATE_GUESS
peering_wait = self.announce_interval*1.2
RNS.log(str(self)+" discovering peers for "+str(round(peering_wait, 2))+" seconds...", RNS.LOG_VERBOSE)
def final_init(self):
peering_wait = self.announce_interval*1.2
RNS.log(str(self)+" discovering peers for "+str(round(peering_wait, 2))+" seconds...", RNS.LOG_VERBOSE)
self.owner = owner
socketserver.UDPServer.address_family = socket.AF_INET6
socketserver.UDPServer.address_family = socket.AF_INET6
for ifname in self.adopted_interfaces:
local_addr = self.adopted_interfaces[ifname]+"%"+str(self.interface_name_to_index(ifname))
addr_info = socket.getaddrinfo(local_addr, self.data_port, socket.AF_INET6, socket.SOCK_DGRAM)
address = addr_info[0][4]
for ifname in self.adopted_interfaces:
local_addr = self.adopted_interfaces[ifname]+"%"+str(self.interface_name_to_index(ifname))
addr_info = socket.getaddrinfo(local_addr, self.data_port, socket.AF_INET6, socket.SOCK_DGRAM)
address = addr_info[0][4]
udp_server = socketserver.UDPServer(address, self.handler_factory(self.process_incoming))
self.interface_servers[ifname] = udp_server
thread = threading.Thread(target=udp_server.serve_forever)
thread.daemon = True
thread.start()
job_thread = threading.Thread(target=self.peer_jobs)
job_thread.daemon = True
job_thread.start()
time.sleep(peering_wait)
self.online = True
def discovery_handler(self, socket, ifname):
def announce_loop():
self.announce_handler(ifname)
udp_server = socketserver.UDPServer(address, self.handler_factory(self.process_incoming))
self.interface_servers[ifname] = udp_server
thread = threading.Thread(target=announce_loop)
thread.daemon = True
thread.start()
thread = threading.Thread(target=udp_server.serve_forever)
thread.daemon = True
thread.start()
job_thread = threading.Thread(target=self.peer_jobs)
job_thread.daemon = True
job_thread.start()
time.sleep(peering_wait)
self.online = True
self.final_init_done = True
def discovery_handler(self, socket, ifname, announce=True):
def announce_loop(): self.announce_handler(ifname)
if announce:
thread = threading.Thread(target=announce_loop)
thread.daemon = True
thread.start()
while True:
data, ipv6_src = socket.recvfrom(1024)
expected_hash = RNS.Identity.full_hash(self.group_id+ipv6_src[0].encode("utf-8"))
if data == expected_hash:
self.add_peer(ipv6_src[0], ifname)
else:
RNS.log(str(self)+" received peering packet on "+str(ifname)+" from "+str(ipv6_src[0])+", but authentication hash was incorrect.", RNS.LOG_DEBUG)
if self.final_init_done:
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:
self.add_peer(ipv6_src[0], ifname)
else:
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:
@@ -347,8 +384,24 @@ class AutoInterface(Interface):
# Remove any timed out peers
for peer_addr in timed_out_peers:
removed_peer = self.peers.pop(peer_addr)
if peer_addr in self.spawned_interfaces:
spawned_interface = self.spawned_interfaces[peer_addr]
spawned_interface.detach()
spawned_interface.teardown()
RNS.log(str(self)+" removed peer "+str(peer_addr)+" on "+str(removed_peer[0]), RNS.LOG_DEBUG)
# Send reverse peering packets
for peer_addr in self.peers:
try:
peer = self.peers[peer_addr]
ifname = peer[0]
last_outbound = peer[2]
if now > last_outbound+self.reverse_peering_interval:
self.reverse_announce(ifname, peer_addr)
peer[2] = time.time()
except Exception as e:
RNS.log(f"Error while sending reverse peering packet to {peer_addr}: {e}", RNS.LOG_ERROR)
for ifname in self.adopted_interfaces:
# Check that the link-local address has not changed
try:
@@ -394,9 +447,10 @@ class AutoInterface(Interface):
RNS.log("Could not get device information while updating link-local addresses for "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
# Check multicast echo timeouts
last_multicast_echo = 0
if ifname in self.multicast_echoes:
last_multicast_echo = self.multicast_echoes[ifname]
last_multicast_echo = 0
multicast_echo_received = False
if ifname in self.multicast_echoes: last_multicast_echo = self.multicast_echoes[ifname]
if ifname in self.initial_echoes: multicast_echo_received = True
if now - last_multicast_echo > self.multicast_echo_timeout:
if ifname in self.timed_out_interfaces and self.timed_out_interfaces[ifname] == False:
@@ -408,6 +462,11 @@ class AutoInterface(Interface):
self.carrier_changed = True
RNS.log(str(self)+" Carrier recovered on "+str(ifname), RNS.LOG_WARNING)
self.timed_out_interfaces[ifname] = False
if not multicast_echo_received:
RNS.log(f"{self} No multicast echoes received on {ifname}. The networking hardware or a firewall may be blocking multicast traffic.", RNS.LOG_ERROR)
# else:
# RNS.log(f"{self} Initial multicast echo on {ifname} received {RNS.prettytime(time.time()-self.initial_echoes[ifname])} ago.", RNS.LOG_DEBUG)
def announce_handler(self, ifname):
@@ -415,6 +474,20 @@ class AutoInterface(Interface):
self.peer_announce(ifname)
time.sleep(self.announce_interval)
def reverse_announce(self, ifname, peer_addr):
try:
link_local_address = self.adopted_interfaces[ifname]
discovery_token = RNS.Identity.full_hash(self.group_id+link_local_address.encode("utf-8"))
announce_socket = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
addr_info = socket.getaddrinfo(f"{peer_addr}%{ifname}", self.unicast_discovery_port, socket.AF_INET6, socket.SOCK_DGRAM)
ifis = struct.pack("I", self.interface_name_to_index(ifname))
announce_socket.sendto(discovery_token, addr_info[0][4])
announce_socket.close()
except Exception as e:
RNS.log(f"Could not send reverse peering packet to {peer_addr} on {ifname}: {e}", RNS.LOG_ERROR)
def peer_announce(self, ifname):
try:
link_local_address = self.adopted_interfaces[ifname]
@@ -433,6 +506,10 @@ class AutoInterface(Interface):
else:
pass
@property
def peer_count(self):
return len(self.spawned_interfaces)
def add_peer(self, addr, ifname):
if addr in self.link_local_addresses:
ifname = None
@@ -442,58 +519,139 @@ class AutoInterface(Interface):
if ifname != None:
self.multicast_echoes[ifname] = time.time()
if not ifname in self.initial_echoes: self.initial_echoes[ifname] = time.time()
else:
RNS.log(str(self)+" received multicast echo on unexpected interface "+str(ifname), RNS.LOG_WARNING)
else:
if not addr in self.peers:
self.peers[addr] = [ifname, time.time()]
self.peers[addr] = [ifname, time.time(), time.time()]
spawned_interface = AutoInterfacePeer(self, addr, ifname)
spawned_interface.OUT = self.OUT
spawned_interface.IN = self.IN
spawned_interface.parent_interface = self
spawned_interface.bitrate = self.bitrate
spawned_interface.ifac_size = self.ifac_size
spawned_interface.ifac_netname = self.ifac_netname
spawned_interface.ifac_netkey = self.ifac_netkey
if spawned_interface.ifac_netname != None or spawned_interface.ifac_netkey != None:
ifac_origin = b""
if spawned_interface.ifac_netname != None:
ifac_origin += RNS.Identity.full_hash(spawned_interface.ifac_netname.encode("utf-8"))
if spawned_interface.ifac_netkey != None:
ifac_origin += RNS.Identity.full_hash(spawned_interface.ifac_netkey.encode("utf-8"))
ifac_origin_hash = RNS.Identity.full_hash(ifac_origin)
spawned_interface.ifac_key = RNS.Cryptography.hkdf(
length=64,
derive_from=ifac_origin_hash,
salt=RNS.Reticulum.IFAC_SALT,
context=None
)
spawned_interface.ifac_identity = RNS.Identity.from_bytes(spawned_interface.ifac_key)
spawned_interface.ifac_signature = spawned_interface.ifac_identity.sign(RNS.Identity.full_hash(spawned_interface.ifac_key))
spawned_interface.announce_rate_target = self.announce_rate_target
spawned_interface.announce_rate_grace = self.announce_rate_grace
spawned_interface.announce_rate_penalty = self.announce_rate_penalty
spawned_interface.mode = self.mode
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.online = True
RNS.Transport.interfaces.append(spawned_interface)
if addr in self.spawned_interfaces:
self.spawned_interfaces[addr].detach()
self.spawned_interfaces[addr].teardown()
if addr in self.spawned_interfaces: self.spawned_interfaces.pop(addr)
self.spawned_interfaces[addr] = spawned_interface
RNS.log(str(self)+" added peer "+str(addr)+" on "+str(ifname), RNS.LOG_DEBUG)
else:
self.refresh_peer(addr)
def refresh_peer(self, addr):
self.peers[addr][1] = time.time()
try: self.peers[addr][1] = time.time()
except Exception as e: RNS.log(f"An error occurred while refreshing peer {addr} on {self}: {e}", RNS.LOG_ERROR)
def process_incoming(self, data):
data_hash = RNS.Identity.full_hash(data)
deque_hit = False
if data_hash in self.mif_deque:
for te in self.mif_deque_times:
if te[0] == data_hash and time.time() < te[1]+AutoInterface.MULTI_IF_DEQUE_TTL:
deque_hit = True
break
def process_incoming(self, data, addr=None):
if self.online and addr in self.spawned_interfaces:
self.spawned_interfaces[addr].process_incoming(data, addr)
if not deque_hit:
self.mif_deque.append(data_hash)
self.mif_deque_times.append([data_hash, time.time()])
self.rxb += len(data)
self.owner.inbound(data, self)
def process_outgoing(self, data): pass
def process_outgoing(self,data):
for peer in self.peers:
def detach(self): self.online = False
def __str__(self): return f"AutoInterface[{self.name}]"
class AutoInterfacePeer(Interface):
def __init__(self, owner, addr, ifname):
super().__init__()
self.owner = owner
self.parent_interface = owner
self.addr = addr
self.ifname = ifname
self.peer_addr = None
self.addr_info = None
self.HW_MTU = self.owner.HW_MTU
self.FIXED_MTU = self.owner.FIXED_MTU
def __str__(self):
return f"AutoInterfacePeer[{self.ifname}/{self.addr}]"
def process_incoming(self, data, addr=None):
if self.online and self.owner.online:
data_hash = RNS.Identity.full_hash(data)
deque_hit = False
if data_hash in self.owner.mif_deque:
for te in self.owner.mif_deque_times:
if te[0] == data_hash and time.time() < te[1]+AutoInterface.MULTI_IF_DEQUE_TTL:
deque_hit = True
break
if not deque_hit:
self.owner.refresh_peer(self.addr)
self.owner.mif_deque.append(data_hash)
self.owner.mif_deque_times.append([data_hash, time.time()])
self.rxb += len(data)
self.owner.rxb += len(data)
self.owner.owner.inbound(data, self)
def process_outgoing(self, data):
if self.online:
with self.owner.write_lock:
try:
if self.outbound_udp_socket == None:
self.outbound_udp_socket = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
peer_addr = str(peer)+"%"+str(self.interface_name_to_index(self.peers[peer][0]))
addr_info = socket.getaddrinfo(peer_addr, self.data_port, socket.AF_INET6, socket.SOCK_DGRAM)
self.outbound_udp_socket.sendto(data, addr_info[0][4])
if self.owner.outbound_udp_socket == None: self.owner.outbound_udp_socket = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
if self.peer_addr == None: self.peer_addr = str(self.addr)+"%"+str(self.owner.interface_name_to_index(self.ifname))
if self.addr_info == None: self.addr_info = socket.getaddrinfo(self.peer_addr, self.owner.data_port, socket.AF_INET6, socket.SOCK_DGRAM)
self.owner.outbound_udp_socket.sendto(data, self.addr_info[0][4])
self.txb += len(data)
self.owner.txb += len(data)
except Exception as e:
RNS.log("Could not transmit on "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
self.txb += len(data)
def detach(self):
self.online = False
self.detached = True
def teardown(self):
if not self.detached:
RNS.log(f"The interface {self} experienced an unrecoverable error and is being torn down.", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error: RNS.panic()
# Until per-device sub-interfacing is implemented,
# ingress limiting should be disabled on AutoInterface
def should_ingress_limit(self):
return False
else: RNS.log(f"The interface {self} is being torn down.", RNS.LOG_VERBOSE)
def __str__(self):
return "AutoInterface["+self.name+"]"
self.online = False
self.OUT = False
self.IN = False
if self.addr in self.owner.spawned_interfaces:
try: self.owner.spawned_interfaces.pop(self.addr)
except Exception as e:
RNS.log(f"Could not remove {self} from parent interface on detach. The contained exception was: {e}", RNS.LOG_ERROR)
if self in RNS.Transport.interfaces: RNS.Transport.interfaces.remove(self)
class AutoInterfaceHandler(socketserver.BaseRequestHandler):
def __init__(self, callback, *args, **keys):
@@ -502,4 +660,5 @@ class AutoInterfaceHandler(socketserver.BaseRequestHandler):
def handle(self):
data = self.request[0]
self.callback(data)
addr = self.client_address[0]
self.callback(data, addr)
+698
View File
@@ -0,0 +1,698 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from RNS.Interfaces.Interface import Interface
import threading
import socket
import select
import time
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
epoll = None
listener_filenos = {}
spawned_interface_filenos = {}
epoll = None
_job_active = False
_job_lock = threading.Lock()
@staticmethod
def get_address_for_if(name, bind_port, prefer_ipv6=False):
from RNS.Interfaces import netinfo
ifaddr = netinfo.ifaddresses(name)
if len(ifaddr) < 1:
raise SystemError(f"No addresses available on specified kernel interface \"{name}\" for BackboneInterface to bind to")
if (prefer_ipv6 or not netinfo.AF_INET in ifaddr) and netinfo.AF_INET6 in ifaddr:
bind_ip = ifaddr[netinfo.AF_INET6][0]["addr"]
if bind_ip.lower().startswith("fe80::"):
# We'll need to add the interface as scope for link-local addresses
return BackboneInterface.get_address_for_host(f"{bind_ip}%{name}", bind_port, prefer_ipv6)
else:
return BackboneInterface.get_address_for_host(bind_ip, bind_port, prefer_ipv6)
elif netinfo.AF_INET in ifaddr:
bind_ip = ifaddr[netinfo.AF_INET][0]["addr"]
return (bind_ip, bind_port)
else:
raise SystemError(f"No addresses available on specified kernel interface \"{name}\" for BackboneInterface to bind to")
@staticmethod
def get_address_for_host(name, bind_port, prefer_ipv6=False):
address_infos = socket.getaddrinfo(name, bind_port, proto=socket.IPPROTO_TCP)
address_info = address_infos[0]
for entry in address_infos:
if prefer_ipv6 and entry[0] == socket.AF_INET6:
address_info = entry; break
elif not prefer_ipv6 and entry[0] == socket.AF_INET:
address_info = entry; break
if address_info[0] == socket.AF_INET6:
return (name, bind_port, address_info[4][2], address_info[4][3])
elif address_info[0] == socket.AF_INET:
return (name, bind_port)
else:
raise SystemError(f"No suitable kernel interface available for address \"{name}\" for BackboneInterface to bind to")
@property
def clients(self):
return len(self.spawned_interfaces)
def __init__(self, owner, configuration):
if not RNS.vendor.platformutils.is_linux() and not RNS.vendor.platformutils.is_android():
raise OSError("BackboneInterface is only supported on Linux-based operating systems")
super().__init__()
c = Interface.get_config_obj(configuration)
name = c["name"]
device = c["device"] if "device" in c else None
port = int(c["port"]) if "port" in c else None
bindip = c["listen_ip"] if "listen_ip" in c else None
bindport = int(c["listen_port"]) if "listen_port" in c else None
prefer_ipv6 = c.as_bool("prefer_ipv6") if "prefer_ipv6" in c else False
if port != None: bindport = port
self.HW_MTU = BackboneInterface.HW_MTU
self.online = False
self.IN = True
self.OUT = False
self.name = name
self.detached = False
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.spawned_interfaces = []
self.supports_discovery = True
if bindport == None:
raise SystemError(f"No TCP port configured for interface \"{name}\"")
else:
self.bind_port = bindport
bind_address = None
if device != None:
bind_address = self.get_address_for_if(device, self.bind_port, prefer_ipv6)
else:
if bindip == None:
raise SystemError(f"No TCP bind IP configured for interface \"{name}\"")
bind_address = self.get_address_for_host(bindip, self.bind_port, prefer_ipv6)
if bind_address != None:
self.receives = True
self.bind_ip = bind_address[0]
self.owner = owner
if len(bind_address) == 2 : BackboneInterface.add_listener(self, bind_address, socket_type=socket.AF_INET)
elif len(bind_address) == 4: BackboneInterface.add_listener(self, bind_address, socket_type=socket.AF_INET6)
self.bitrate = self.BITRATE_GUESS
self.online = True
else:
raise SystemError("Insufficient parameters to create listener")
@staticmethod
def start():
if not BackboneInterface._job_active: threading.Thread(target=BackboneInterface.__job, daemon=True).start()
@staticmethod
def ensure_epoll():
if not BackboneInterface.epoll: BackboneInterface.epoll = select.epoll()
@staticmethod
def add_listener(interface, bind_address, socket_type=socket.AF_INET):
BackboneInterface.ensure_epoll()
if socket_type == socket.AF_INET:
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(bind_address)
elif socket_type == socket.AF_INET6:
server_socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(bind_address)
elif socket_type == socket.AF_UNIX:
server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server_socket.bind(bind_address)
else: raise TypeError(f"Invalid socket type {socket_type} for {interface}")
server_socket.listen(1)
server_socket.setblocking(0)
BackboneInterface.listener_filenos[server_socket.fileno()] = (interface, server_socket)
BackboneInterface.epoll.register(server_socket.fileno(), select.EPOLLIN)
BackboneInterface.start()
@staticmethod
def add_client_socket(client_socket, interface):
BackboneInterface.ensure_epoll()
BackboneInterface.spawned_interface_filenos[client_socket.fileno()] = interface
BackboneInterface.register_in(client_socket.fileno())
BackboneInterface.start()
@staticmethod
def register_in(fileno):
if fileno < 0:
RNS.log(f"Attempt to register invalid file descriptor {fileno}", RNS.LOG_ERROR)
return
try: BackboneInterface.epoll.register(fileno, select.EPOLLIN)
except Exception as e:
RNS.log(f"An error occurred while registering EPOLL_IN for file descriptor {fileno}: {e}", RNS.LOG_ERROR)
@staticmethod
def deregister_fileno(fileno):
if fileno < 0:
RNS.log(f"Attempt to deregister invalid file descriptor {fileno}", RNS.LOG_ERROR)
return
try: BackboneInterface.epoll.unregister(fileno)
except Exception as e:
RNS.log(f"An error occurred while deregistering file descriptor {fileno}: {e}", RNS.LOG_DEBUG)
@staticmethod
def deregister_listeners():
for fileno in BackboneInterface.listener_filenos:
owner_interface, server_socket = BackboneInterface.listener_filenos[fileno]
fileno = server_socket.fileno()
BackboneInterface.deregister_fileno(fileno)
server_socket.close()
BackboneInterface.listener_filenos.clear()
@staticmethod
def tx_ready(interface):
if interface.socket:
fileno = interface.socket.fileno()
if fileno in BackboneInterface.spawned_interface_filenos:
try:
BackboneInterface.epoll.modify(interface.socket.fileno(), select.EPOLLOUT)
except Exception as e:
RNS.trace_exception(e)
@staticmethod
def __job():
with BackboneInterface._job_lock:
if BackboneInterface._job_active: return
else:
BackboneInterface._job_active = True
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):
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_DEBUG)
received_bytes = b""
if len(received_bytes): spawned_interface.receive(received_bytes)
else:
BackboneInterface.deregister_fileno(fileno); client_socket.close()
try:
if fileno in BackboneInterface.spawned_interface_filenos: BackboneInterface.spawned_interface_filenos.pop(fileno)
except Exception as e: RNS.log(f"Error while removing spawned interface file descriptor from BackboneInterface I/O handler: {e}", RNS.LOG_ERROR)
try:
if spawned_interface.parent_interface:
pif = spawned_interface.parent_interface
if pif.spawned_interfaces != None:
while spawned_interface in pif.spawned_interfaces: pif.spawned_interfaces.remove(spawned_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)
except Exception as e:
written = 0
if not spawned_interface.detached: RNS.log(f"Error while writing to {spawned_interface}: {e}", RNS.LOG_DEBUG)
BackboneInterface.deregister_fileno(fileno)
try:
if fileno in BackboneInterface.spawned_interface_filenos: BackboneInterface.spawned_interface_filenos.pop(fileno)
except Exception as e: RNS.log(f"Error while removing spawned interface file descriptor from BackboneInterface I/O handler: {e}", RNS.LOG_ERROR)
try:
if spawned_interface.parent_interface:
pif = spawned_interface.parent_interface
if pif.spawned_interfaces != None:
while spawned_interface in pif.spawned_interfaces: pif.spawned_interfaces.remove(spawned_interface)
except Exception as e: RNS.log(f"Error while removing spawned interface from {pif}: {e}", RNS.LOG_ERROR)
try: client_socket.close()
except Exception as e: RNS.log(f"Error while closing socket for {spawned_interface}: {e}", RNS.LOG_ERROR)
spawned_interface.receive(b"")
spawned_interface.transmit_buffer = spawned_interface.transmit_buffer[written:]
if len(spawned_interface.transmit_buffer) == 0: BackboneInterface.epoll.modify(fileno, select.EPOLLIN)
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):
BackboneInterface.deregister_fileno(fileno)
try:
if fileno in BackboneInterface.spawned_interface_filenos: BackboneInterface.spawned_interface_filenos.pop(fileno)
except Exception as e: RNS.log(f"Error while removing spawned interface file descriptor from BackboneInterface I/O handler: {e}", RNS.LOG_ERROR)
try:
if spawned_interface.parent_interface:
pif = spawned_interface.parent_interface
if pif.spawned_interfaces != None:
while spawned_interface in pif.spawned_interfaces: pif.spawned_interfaces.remove(spawned_interface)
except Exception as e: RNS.log(f"Error while removing spawned interface from {pif}: {e}", RNS.LOG_ERROR)
try: client_socket.close()
except Exception as e: RNS.log(f"Error while closing socket for {spawned_interface}: {e}", RNS.LOG_ERROR)
spawned_interface.receive(b"")
elif fileno in BackboneInterface.listener_filenos:
owner_interface, server_socket = BackboneInterface.listener_filenos[fileno]
if fileno == server_socket.fileno() and (event & select.EPOLLIN):
client_socket, address = server_socket.accept()
client_socket.setblocking(0)
if not owner_interface.incoming_connection(client_socket):
try: client_socket.close()
except Exception as e: RNS.log(f"Error while closing socket for failed incoming connection: {e}", RNS.LOG_ERROR)
elif fileno == server_socket.fileno() and (event & select.EPOLLHUP):
try: BackboneInterface.deregister_fileno(fileno)
except Exception as e: RNS.log(f"Error while deregistering listener file descriptor {fileno}: {e}", RNS.LOG_ERROR)
try: server_socket.close()
except Exception as e: RNS.log(f"Error while closing listener socket for {server_socket}: {e}", RNS.LOG_ERROR)
except Exception as e:
RNS.log(f"BackboneInterface error: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
finally:
BackboneInterface.deregister_listeners()
def incoming_connection(self, socket):
RNS.log("Accepting incoming connection", RNS.LOG_VERBOSE)
try:
spawned_configuration = {"name": "Client on "+self.name, "target_host": None, "target_port": None}
spawned_interface = BackboneClientInterface(self.owner, spawned_configuration, connected_socket=socket)
spawned_interface.OUT = self.OUT
spawned_interface.IN = self.IN
spawned_interface.socket = socket
spawned_interface.target_ip = socket.getpeername()[0]
spawned_interface.target_port = str(socket.getpeername()[1])
spawned_interface.parent_interface = self
spawned_interface.bitrate = self.bitrate
spawned_interface.optimise_mtu()
spawned_interface.ifac_size = self.ifac_size
spawned_interface.ifac_netname = self.ifac_netname
spawned_interface.ifac_netkey = self.ifac_netkey
if spawned_interface.ifac_netname != None or spawned_interface.ifac_netkey != None:
ifac_origin = b""
if spawned_interface.ifac_netname != None:
ifac_origin += RNS.Identity.full_hash(spawned_interface.ifac_netname.encode("utf-8"))
if spawned_interface.ifac_netkey != None:
ifac_origin += RNS.Identity.full_hash(spawned_interface.ifac_netkey.encode("utf-8"))
ifac_origin_hash = RNS.Identity.full_hash(ifac_origin)
spawned_interface.ifac_key = RNS.Cryptography.hkdf(
length=64,
derive_from=ifac_origin_hash,
salt=RNS.Reticulum.IFAC_SALT,
context=None
)
spawned_interface.ifac_identity = RNS.Identity.from_bytes(spawned_interface.ifac_key)
spawned_interface.ifac_signature = spawned_interface.ifac_identity.sign(RNS.Identity.full_hash(spawned_interface.ifac_key))
spawned_interface.announce_rate_target = self.announce_rate_target
spawned_interface.announce_rate_grace = self.announce_rate_grace
spawned_interface.announce_rate_penalty = self.announce_rate_penalty
spawned_interface.mode = self.mode
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.online = True
RNS.log("Spawned new BackboneClient Interface: "+str(spawned_interface), RNS.LOG_VERBOSE)
RNS.Transport.interfaces.append(spawned_interface)
while spawned_interface in self.spawned_interfaces: self.spawned_interfaces.remove(spawned_interface)
self.spawned_interfaces.append(spawned_interface)
BackboneInterface.add_client_socket(socket, spawned_interface)
except Exception as e:
RNS.log(f"An error occurred while accepting incoming connection on {self}: {e}", RNS.LOG_ERROR)
return False
return True
def received_announce(self, from_spawned=False):
if from_spawned: self.ia_freq_deque.append(time.time())
def sent_announce(self, from_spawned=False):
if from_spawned: self.oa_freq_deque.append(time.time())
def process_outgoing(self, data):
pass
def detach(self):
self.detached = True
self.online = False
detached = []
for fileno in BackboneInterface.listener_filenos:
owner_interface, listener_socket = BackboneInterface.listener_filenos[fileno]
if owner_interface == self:
if hasattr(listener_socket, "shutdown"):
if callable(listener_socket.shutdown):
try: listener_socket.shutdown(socket.SHUT_RDWR)
except Exception as e:
if str(e).endswith("Transport endpoint is not connected"): pass
else: RNS.log("Error while shutting down socket for "+str(self)+": "+str(e), RNS.LOG_ERROR)
def __str__(self):
if ":" in self.bind_ip:
ip_str = f"[{self.bind_ip}]"
else:
ip_str = f"{self.bind_ip}"
return "BackboneInterface["+self.name+"/"+ip_str+":"+str(self.bind_port)+"]"
class BackboneClientInterface(Interface):
BITRATE_GUESS = 100_000_000
DEFAULT_IFAC_SIZE = 16
AUTOCONFIGURE_MTU = True
RECONNECT_WAIT = 5
RECONNECT_MAX_TRIES = None
# TCP socket options
TCP_USER_TIMEOUT = 24
TCP_PROBE_AFTER = 5
TCP_PROBE_INTERVAL = 2
TCP_PROBES = 12
INITIAL_CONNECT_TIMEOUT = 5
SYNCHRONOUS_START = True
def __init__(self, owner, configuration, connected_socket=None):
super().__init__()
c = Interface.get_config_obj(configuration)
name = c["name"]
target_ip = c["target_host"] if "target_host" in c and c["target_host"] != None else None
target_port = int(c["target_port"]) if "target_port" in c and c["target_host"] != None else None
i2p_tunneled = c.as_bool("i2p_tunneled") if "i2p_tunneled" in c else False
connect_timeout = c.as_int("connect_timeout") if "connect_timeout" in c else None
max_reconnect_tries = c.as_int("max_reconnect_tries") if "max_reconnect_tries" in c else None
prefer_ipv6 = c.as_bool("prefer_ipv6") if "prefer_ipv6" in c else False
self.HW_MTU = BackboneInterface.HW_MTU
self.IN = True
self.OUT = False
self.socket = None
self.parent_interface = None
self.name = name
self.initiator = False
self.reconnecting = False
self.never_connected = True
self.owner = owner
self.online = False
self.detached = False
self.prefer_ipv6 = prefer_ipv6
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""
if max_reconnect_tries == None:
self.max_reconnect_tries = BackboneClientInterface.RECONNECT_MAX_TRIES
else:
self.max_reconnect_tries = max_reconnect_tries
if connected_socket != None:
self.receives = True
self.target_ip = None
self.target_port = None
self.socket = connected_socket
self.set_timeouts_linux()
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
elif target_ip != None and target_port != None:
self.receives = True
self.target_ip = target_ip
self.target_port = target_port
self.initiator = True
if connect_timeout != None:
self.connect_timeout = connect_timeout
else:
self.connect_timeout = BackboneClientInterface.INITIAL_CONNECT_TIMEOUT
if BackboneClientInterface.SYNCHRONOUS_START:
self.initial_connect()
else:
thread = threading.Thread(target=self.initial_connect)
thread.daemon = True
thread.start()
def initial_connect(self):
if not self.connect(initial=True):
thread = threading.Thread(target=self.reconnect)
thread.daemon = True
thread.start()
else:
self.wants_tunnel = True
def set_timeouts_linux(self):
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, int(BackboneClientInterface.TCP_USER_TIMEOUT * 1000))
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, int(BackboneClientInterface.TCP_PROBE_AFTER))
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, int(BackboneClientInterface.TCP_PROBE_INTERVAL))
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, int(BackboneClientInterface.TCP_PROBES))
def detach(self):
self.online = False
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:
if str(e).endswith("Transport endpoint is not connected"): pass
else: RNS.log("Error while shutting down socket for "+str(self)+": "+str(e), RNS.LOG_ERROR)
try:
if self.socket != None: self.socket.close()
except Exception as e: RNS.log("Error while closing socket for "+str(self)+": "+str(e), RNS.LOG_ERROR)
self.socket = None
def connect(self, initial=False):
try:
if initial:
RNS.log("Establishing TCP connection for "+str(self)+"...", RNS.LOG_DEBUG)
address_infos = socket.getaddrinfo(self.target_ip, self.target_port, proto=socket.IPPROTO_TCP)
address_info = address_infos[0]
for entry in address_infos:
if self.prefer_ipv6 and entry[0] == socket.AF_INET6:
address_info = entry; break
elif not self.prefer_ipv6 and entry[0] == socket.AF_INET:
address_info = entry; break
address_family = address_info[0]
target_address = address_info[4]
self.socket = socket.socket(address_family, socket.SOCK_STREAM)
self.socket.settimeout(BackboneClientInterface.INITIAL_CONNECT_TIMEOUT)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect(target_address)
self.socket.settimeout(None)
BackboneInterface.add_client_socket(self.socket, self)
self.online = True
if initial:
RNS.log("TCP connection for "+str(self)+" established", RNS.LOG_DEBUG)
except Exception as e:
if initial:
RNS.log("Initial connection for "+str(self)+" could not be established: "+str(e), RNS.LOG_ERROR)
RNS.log("Leaving unconnected and retrying connection in "+str(BackboneClientInterface.RECONNECT_WAIT)+" seconds.", RNS.LOG_ERROR)
return False
else:
raise e
self.set_timeouts_linux()
self.online = True
self.never_connected = False
return True
def reconnect(self):
if self.initiator:
if not self.reconnecting:
self.reconnecting = True
attempts = 0
while not self.online and not self.detached:
time.sleep(BackboneClientInterface.RECONNECT_WAIT)
attempts += 1
if self.max_reconnect_tries != None and attempts > self.max_reconnect_tries:
RNS.log("Max reconnection attempts reached for "+str(self), RNS.LOG_ERROR)
self.teardown()
break
try: self.connect()
except Exception as e:
RNS.log("Connection attempt for "+str(self)+" failed: "+str(e), RNS.LOG_DEBUG)
if not self.online: return
if not self.never_connected:
RNS.log("Reconnected socket for "+str(self)+".", RNS.LOG_INFO)
self.reconnecting = False
RNS.Transport.synthesize_tunnel(self)
else:
RNS.log("Attempt to reconnect on a non-initiator TCP interface. This should not happen.", RNS.LOG_ERROR)
raise IOError("Attempt to reconnect on a non-initiator TCP interface")
def process_incoming(self, data):
if self.online and not self.detached:
self.rxb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.rxb += len(data)
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)
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 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]))
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
else:
self.online = False
if self.initiator and not self.detached:
RNS.log("The socket for "+str(self)+" was closed, attempting to reconnect...", RNS.LOG_WARNING)
def job(): self.reconnect()
threading.Thread(target=job, daemon=True).start()
else:
RNS.log("The socket for remote client "+str(self)+" was closed.", RNS.LOG_VERBOSE)
self.teardown()
except Exception as e:
self.online = False
RNS.log("An interface error occurred for "+str(self)+", the contained exception was: "+str(e), RNS.LOG_WARNING)
if self.initiator:
RNS.log("Attempting to reconnect...", RNS.LOG_WARNING)
def job(): self.reconnect()
threading.Thread(target=job, daemon=True).start()
else:
self.teardown()
def teardown(self):
if self.initiator and not self.detached:
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is being torn down. Restart Reticulum to attempt to open this interface again.", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
else:
RNS.log("The interface "+str(self)+" is being torn down.", RNS.LOG_VERBOSE)
self.online = False
self.OUT = False
self.IN = False
if hasattr(self, "parent_interface") and self.parent_interface != None:
while self in self.parent_interface.spawned_interfaces:
self.parent_interface.spawned_interfaces.remove(self)
if self in RNS.Transport.interfaces:
if not self.initiator:
RNS.Transport.interfaces.remove(self)
def __str__(self):
if ":" in self.target_ip: ip_str = f"[{self.target_ip}]"
else: ip_str = f"{self.target_ip}"
return "BackboneInterface["+str(self.name)+"/"+ip_str+":"+str(self.target_port)+"]"
+13 -4
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2024 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -872,6 +880,7 @@ class I2PInterface(Interface):
self.ifac_size = ifac_size
self.ifac_netname = ifac_netname
self.ifac_netkey = ifac_netkey
self.supports_discovery = True
self.online = False
+62 -12
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -64,13 +72,25 @@ class Interface:
IC_BURST_PENALTY = 5*60
IC_HELD_RELEASE_INTERVAL = 30
def __init__(self):
self.rxb = 0
self.txb = 0
self.created = time.time()
self.online = False
self.bitrate = 1e6
AUTOCONFIGURE_MTU = False
FIXED_MTU = False
def __init__(self):
self.rxb = 0
self.txb = 0
self.created = time.time()
self.detached = False
self.online = False
self.bitrate = 62500
self.HW_MTU = None
self.supports_discovery = False
self.discoverable = False
self.last_discovery_announce = 0
self.bootstrap_only = False
self.parent_interface = None
self.spawned_interfaces = None
self.tunnel_id = None
self.ingress_control = True
self.ic_max_held_announces = Interface.MAX_HELD_ANNOUNCES
self.ic_burst_hold = Interface.IC_BURST_HOLD
@@ -117,6 +137,33 @@ class Interface:
else:
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
else:
self.HW_MTU = None
RNS.log(f"{self} hardware MTU set to {self.HW_MTU}", RNS.LOG_DEBUG) # TODO: Remove debug
def age(self):
return time.time()-self.created
@@ -152,12 +199,12 @@ class Interface:
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):
def received_announce(self, from_spawned=False):
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)
def sent_announce(self):
def sent_announce(self, from_spawned=False):
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)
@@ -238,6 +285,9 @@ class Interface:
RNS.log("Error while processing announce queue on "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("The announce queue for this interface has been cleared.", RNS.LOG_ERROR)
def final_init(self):
pass
def detach(self):
pass
+13 -5
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -63,7 +71,7 @@ class KISSInterface(Interface):
serial = None
def __init__(self, owner, configuration):
import importlib
import importlib.util
if importlib.util.find_spec('serial') != None:
import serial
else:
+212 -110
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -21,6 +29,7 @@
# SOFTWARE.
from RNS.Interfaces.Interface import Interface
from RNS.Interfaces.BackboneInterface import BackboneInterface
import socketserver
import threading
import socket
@@ -52,16 +61,17 @@ class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
class LocalClientInterface(Interface):
RECONNECT_WAIT = 8
AUTOCONFIGURE_MTU = True
def __init__(self, owner, name, target_port = None, connected_socket=None):
def __init__(self, owner, name, target_port = None, connected_socket=None, socket_path=None):
super().__init__()
# TODO: Remove at some point
# self.rxptime = 0
self.epoll_backend = False
self.HW_MTU = 262144
self.online = False
self.HW_MTU = 1064
self.online = False
if socket_path != None and RNS.Reticulum.get_instance().use_af_unix: self.socket_path = f"\0rns/{socket_path}"
else: self.socket_path = None
self.IN = True
self.OUT = False
@@ -72,16 +82,29 @@ 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""
if RNS.vendor.platformutils.use_epoll():
self.epoll_backend = True
if connected_socket != None:
self.receives = True
self.target_ip = None
self.target_port = None
self.socket = connected_socket
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
if self.socket.family == socket.AF_INET:
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.is_connected_to_shared_instance = False
elif self.socket_path != None:
self.receives = True
self.target_ip = None
self.target_port = None
self.connect()
elif target_port != None:
self.receives = True
self.target_ip = "127.0.0.1"
@@ -89,7 +112,7 @@ class LocalClientInterface(Interface):
self.connect()
self.owner = owner
self.bitrate = 1000*1000*1000
self.bitrate = 1_000_000_000
self.online = True
self.writing = False
@@ -100,22 +123,30 @@ class LocalClientInterface(Interface):
self.announce_rate_penalty = None
if connected_socket == None:
thread = threading.Thread(target=self.read_loop)
thread.daemon = True
thread.start()
if not self.epoll_backend:
thread = threading.Thread(target=self.read_loop)
thread.daemon = True
thread.start()
def should_ingress_limit(self):
return False
def connect(self):
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect((self.target_ip, self.target_port))
if self.socket_path != None:
self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.socket.connect(self.socket_path)
else:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect((self.target_ip, self.target_port))
self.online = True
self.is_connected_to_shared_instance = True
self.never_connected = False
if self.epoll_backend: BackboneInterface.add_client_socket(self.socket, self)
return True
@@ -139,9 +170,11 @@ class LocalClientInterface(Interface):
RNS.log("Reconnected socket for "+str(self)+".", RNS.LOG_INFO)
self.reconnecting = False
thread = threading.Thread(target=self.read_loop)
thread.daemon = True
thread.start()
if not self.epoll_backend:
thread = threading.Thread(target=self.read_loop)
thread.daemon = True
thread.start()
def job():
time.sleep(LocalClientInterface.RECONNECT_WAIT+2)
RNS.Transport.shared_connection_reappeared()
@@ -154,87 +187,107 @@ class LocalClientInterface(Interface):
def process_incoming(self, data):
self.rxb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.rxb += len(data)
if self.parent_interface != None: self.parent_interface.rxb += len(data)
# TODO: Remove at some point
# processing_start = time.time()
self.owner.inbound(data, self)
# TODO: Remove at some point
# duration = time.time() - processing_start
# self.rxptime += duration
try:
self.owner.inbound(data, self)
except Exception as e:
RNS.log(f"An error in the processing of an incoming frame for {self}: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
def process_outgoing(self, data):
if self.online:
try:
self.writing = True
if self.epoll_backend:
self.transmit_buffer += bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
BackboneInterface.tx_ready(self)
if self._force_bitrate:
if not hasattr(self, "send_lock"):
self.send_lock = Lock()
else:
self.writing = True
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)
if self._force_bitrate:
if not hasattr(self, "send_lock"):
self.send_lock = Lock()
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
self.socket.sendall(data)
self.writing = False
self.txb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.txb += len(data)
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])
self.socket.sendall(data)
self.writing = False
self.txb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.txb += len(data)
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)
RNS.trace_exception(e)
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
def receive(self, data_in):
try:
if len(data_in) > 0: self.handle_hdlc(data_in)
else:
self.online = False
if self.is_connected_to_shared_instance and not self.detached:
RNS.log("Socket for "+str(self)+" was closed, attempting to reconnect...", RNS.LOG_WARNING)
RNS.Transport.shared_connection_disappeared()
# TODO: Potentially run this in a thread, but since if we get here,
# there's no other connectivity left to block anyway, it might be
# unnecessary.
self.reconnect()
else:
self.teardown(nowarning=True)
except Exception as e:
self.online = False
RNS.log("An interface error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Tearing down "+str(self), RNS.LOG_ERROR)
self.teardown()
def read_loop(self):
try:
in_frame = False
escape = False
data_buffer = b""
self.frame_buffer = b""
data_in = b""
while True:
data_in = self.socket.recv(4096)
if len(data_in) > 0:
pointer = 0
while pointer < len(data_in):
byte = data_in[pointer]
pointer += 1
if (in_frame and byte == HDLC.FLAG):
in_frame = False
self.process_incoming(data_buffer)
elif (byte == HDLC.FLAG):
in_frame = True
data_buffer = b""
elif (in_frame and len(data_buffer) < self.HW_MTU):
if (byte == HDLC.ESC):
escape = True
else:
if (escape):
if (byte == HDLC.FLAG ^ HDLC.ESC_MASK):
byte = HDLC.FLAG
if (byte == HDLC.ESC ^ HDLC.ESC_MASK):
byte = HDLC.ESC
escape = False
data_buffer = data_buffer+bytes([byte])
if len(data_in) > 0: self.handle_hdlc(data_in)
else:
self.online = False
if self.is_connected_to_shared_instance and not self.detached:
RNS.log("Socket for "+str(self)+" was closed, attempting to reconnect...", RNS.LOG_WARNING)
RNS.Transport.shared_connection_disappeared()
# TODO: Potentially run this in a thread, but since if we get here,
# there's no other connectivity left to block anyway, it might be
# unnecessary.
self.reconnect()
else:
self.teardown(nowarning=True)
break
except Exception as e:
self.online = False
RNS.log("An interface error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
@@ -249,12 +302,14 @@ class LocalClientInterface(Interface):
self.detached = True
try:
self.socket.shutdown(socket.SHUT_RDWR)
if self.socket != None:
self.socket.shutdown(socket.SHUT_RDWR)
except Exception as e:
RNS.log("Error while shutting down socket for "+str(self)+": "+str(e))
try:
self.socket.close()
if self.socket != None:
self.socket.close()
except Exception as e:
RNS.log("Error while closing socket for "+str(self)+": "+str(e))
@@ -288,67 +343,113 @@ class LocalClientInterface(Interface):
def __str__(self):
return "LocalInterface["+str(self.target_port)+"]"
if self.socket_path: return "LocalInterface["+str(self.socket_path.replace("\0", ""))+"]"
else: return "LocalInterface["+str(self.target_port)+"]"
class LocalServerInterface(Interface):
AUTOCONFIGURE_MTU = True
def __init__(self, owner, bindport=None):
def __init__(self, owner, bindport=None, socket_path=None):
super().__init__()
self.epoll_backend = False
self.online = False
self.clients = 0
if socket_path != None and RNS.Reticulum.get_instance().use_af_unix: self.socket_path = f"\0rns/{socket_path}"
else: self.socket_path = None
self.IN = True
self.OUT = False
self.name = "Reticulum"
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
if (bindport != None):
if RNS.vendor.platformutils.use_epoll():
self.epoll_backend = True
if socket_path != None and self.epoll_backend:
self.receives = True
self.bind_ip = None
self.bind_port = None
self.owner = owner
self.is_local_shared_instance = True
BackboneInterface.add_listener(self, self.socket_path, socket_type=socket.AF_UNIX)
elif bindport != None:
self.receives = True
self.bind_ip = "127.0.0.1"
self.bind_port = bindport
def handlerFactory(callback):
def createHandler(*args, **keys):
return LocalInterfaceHandler(callback, *args, **keys)
return createHandler
self.owner = owner
self.is_local_shared_instance = True
address = (self.bind_ip, self.bind_port)
if self.epoll_backend: BackboneInterface.add_listener(self, address)
else:
def handlerFactory(callback):
def createHandler(*args, **keys):
return LocalInterfaceHandler(callback, *args, **keys)
return createHandler
self.server = ThreadingTCPServer(address, handlerFactory(self.incoming_connection))
thread = threading.Thread(target=self.server.serve_forever)
thread.daemon = True
thread.start()
self.announce_rate_target = None
self.announce_rate_grace = None
self.announce_rate_penalty = None
self.bitrate = 1000*1000*1000
self.online = True
self.server = ThreadingTCPServer(address, handlerFactory(self.incoming_connection))
self.server.daemon_threads = True
thread = threading.Thread(target=self.server.serve_forever)
thread.daemon = True
thread.start()
self.announce_rate_target = None
self.announce_rate_grace = None
self.announce_rate_penalty = None
self.bitrate = 1000*1000*1000
self.online = True
def incoming_connection(self, handler):
interface_name = str(str(handler.client_address[1]))
spawned_interface = LocalClientInterface(self.owner, name=interface_name, connected_socket=handler.request)
spawned_interface.OUT = self.OUT
spawned_interface.IN = self.IN
spawned_interface.target_ip = handler.client_address[0]
spawned_interface.target_port = str(handler.client_address[1])
spawned_interface.parent_interface = self
spawned_interface.bitrate = self.bitrate
if hasattr(self, "_force_bitrate"):
spawned_interface._force_bitrate = self._force_bitrate
# RNS.log("Accepting new connection to shared instance: "+str(spawned_interface), RNS.LOG_EXTREME)
RNS.Transport.interfaces.append(spawned_interface)
RNS.Transport.local_client_interfaces.append(spawned_interface)
self.clients += 1
spawned_interface.read_loop()
if self.epoll_backend:
client_socket = handler
if client_socket.family == socket.AF_INET:
interface_name = str(str(client_socket.getpeername()[1]))
elif client_socket.family == socket.AF_UNIX:
interface_name = f"{self.clients}@{self.socket_path}"
spawned_interface = LocalClientInterface(self.owner, name=interface_name, connected_socket=client_socket)
spawned_interface.OUT = self.OUT
spawned_interface.IN = self.IN
spawned_interface.socket = client_socket
spawned_interface.parent_interface = self
spawned_interface.bitrate = self.bitrate
if client_socket.family == socket.AF_INET:
spawned_interface.target_ip = client_socket.getpeername()[0]
spawned_interface.target_port = str(client_socket.getpeername()[1])
elif client_socket.family == socket.AF_UNIX:
spawned_interface.target_ip = None
spawned_interface.target_port = interface_name
spawned_interface.socket_path = self.socket_path
if hasattr(self, "_force_bitrate"): spawned_interface._force_bitrate = self._force_bitrate
RNS.Transport.interfaces.append(spawned_interface)
RNS.Transport.local_client_interfaces.append(spawned_interface)
BackboneInterface.add_client_socket(client_socket, spawned_interface)
self.clients += 1
return True
else:
interface_name = str(str(handler.client_address[1]))
spawned_interface = LocalClientInterface(self.owner, name=interface_name, connected_socket=handler.request)
spawned_interface.OUT = self.OUT
spawned_interface.IN = self.IN
spawned_interface.target_ip = handler.client_address[0]
spawned_interface.target_port = str(handler.client_address[1])
spawned_interface.parent_interface = self
spawned_interface.bitrate = self.bitrate
if hasattr(self, "_force_bitrate"): spawned_interface._force_bitrate = self._force_bitrate
RNS.Transport.interfaces.append(spawned_interface)
RNS.Transport.local_client_interfaces.append(spawned_interface)
self.clients += 1
spawned_interface.read_loop()
def process_outgoing(self, data):
pass
@@ -360,7 +461,8 @@ class LocalServerInterface(Interface):
if from_spawned: self.oa_freq_deque.append(time.time())
def __str__(self):
return "Shared Instance["+str(self.bind_port)+"]"
if self.socket_path: return "Shared Instance["+str(self.socket_path.replace("\0", ""))+"]"
else: return "Shared Instance["+str(self.bind_port)+"]"
class LocalInterfaceHandler(socketserver.BaseRequestHandler):
def __init__(self, callback, *args, **keys):
+12 -4
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
+373 -80
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -24,6 +32,7 @@ from RNS.Interfaces.Interface import Interface
from time import sleep
import sys
import threading
import socket
import time
import math
import RNS
@@ -55,6 +64,8 @@ class KISS():
CMD_STAT_CHTM = 0x25
CMD_STAT_PHYPRM = 0x26
CMD_STAT_BAT = 0x27
CMD_STAT_CSMA = 0x28
CMD_STAT_TEMP = 0x29
CMD_BLINK = 0x30
CMD_RANDOM = 0x40
CMD_FB_EXT = 0x41
@@ -125,7 +136,7 @@ class RNodeInterface(Interface):
if RNS.vendor.platformutils.is_android():
raise SystemError("Invalid interface type. The Android-specific RNode interface must be used on Android")
import importlib
import importlib.util
if importlib.util.find_spec('serial') != None:
import serial
else:
@@ -149,8 +160,11 @@ class RNodeInterface(Interface):
lt_alock = float(c["airtime_limit_long"]) if "airtime_limit_long" in c else None
force_ble = False
ble_name = None
ble_addr = None
ble_name = None
ble_addr = None
force_tcp = False
tcp_host = None
port = c["port"] if "port" in c else None
@@ -158,6 +172,7 @@ class RNodeInterface(Interface):
raise ValueError("No port specified for RNode interface")
if port != None:
tcp_uri_scheme = "tcp://"
ble_uri_scheme = "ble://"
if port.lower().startswith(ble_uri_scheme):
force_ble = True
@@ -170,6 +185,13 @@ class RNodeInterface(Interface):
else:
ble_name = ble_string
elif port.lower().startswith(tcp_uri_scheme):
force_tcp = True
tcp_string = port[len(tcp_uri_scheme):]
port = None
if len(tcp_string) == 0: pass
else: tcp_host = tcp_string
self.HW_MTU = 508
self.pyserial = serial
@@ -184,6 +206,15 @@ class RNodeInterface(Interface):
self.online = False
self.detached = False
self.reconnecting= False
self.hw_errors = []
self.use_tcp = False
self.tcp = None
self.tcp_host = tcp_host
self.tcp_rx_queue= b""
self.tcp_tx_queue= b""
self.tcp_rx_lock = threading.Lock()
self.tcp_tx_lock = threading.Lock()
self.use_ble = False
self.ble_name = ble_name
@@ -203,6 +234,7 @@ class RNodeInterface(Interface):
self.bitrate = 0
self.st_alock = st_alock
self.lt_alock = lt_alock
self.cpu_temp = None
self.platform = None
self.display = None
self.mcu = None
@@ -233,12 +265,23 @@ class RNodeInterface(Interface):
self.r_airtime_long = 0.0
self.r_channel_load_short = 0.0
self.r_channel_load_long = 0.0
self.r_symbol_time_ms = None
self.r_symbol_rate = None
self.r_preamble_symbols = None
self.r_premable_time_ms = None
self.r_symbol_time_ms = None
self.r_symbol_rate = None
self.r_preamble_symbols = None
self.r_premable_time_ms = None
self.r_csma_slot_time_ms = None
self.r_csma_difs_ms = None
self.r_csma_cw_band = None
self.r_csma_cw_min = None
self.r_csma_cw_max = None
self.r_current_rssi = None
self.r_noise_floor = None
self.r_interference = None
self.r_interference_l = None
self.r_battery_state = RNodeInterface.BATTERY_STATE_UNKNOWN
self.r_battery_percent = 0
self.r_temperature = None
self.r_framebuffer = b""
self.r_framebuffer_readtime = 0
self.r_framebuffer_latency = 0
@@ -246,23 +289,24 @@ class RNodeInterface(Interface):
self.r_disp_readtime = 0
self.r_disp_latency = 0
self.should_read_display = False
self.should_read_display = False
self.read_display_interval = RNodeInterface.DISPLAY_READ_INTERVAL
self.packet_queue = []
self.flow_control = flow_control
self.interface_ready = False
self.announce_rate_target = None
self.supports_discovery = True
if force_ble or self.ble_addr != None or self.ble_name != None:
self.use_ble = True
if force_ble or self.ble_addr != None or self.ble_name != None: self.use_ble = True
if force_tcp or self.tcp_host != None: self.use_tcp = True
self.validcfg = True
if (self.frequency < RNodeInterface.FREQ_MIN or self.frequency > RNodeInterface.FREQ_MAX):
RNS.log("Invalid frequency configured for "+str(self), RNS.LOG_ERROR)
self.validcfg = False
if (self.txpower < 0 or self.txpower > 22):
if (self.txpower < 0 or self.txpower > 37):
RNS.log("Invalid TX power configured for "+str(self), RNS.LOG_ERROR)
self.validcfg = False
@@ -304,10 +348,8 @@ class RNodeInterface(Interface):
try:
self.open_port()
if self.serial.is_open:
self.configure_device()
else:
raise IOError("Could not open serial 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)
@@ -320,8 +362,8 @@ class RNodeInterface(Interface):
def open_port(self):
if not self.use_ble:
RNS.log("Opening serial port "+self.port+"...")
if not self.use_ble and not self.use_tcp:
RNS.log(f"Opening serial port {self.port}...")
self.serial = self.pyserial.Serial(
port = self.port,
baudrate = self.speed,
@@ -337,19 +379,37 @@ class RNodeInterface(Interface):
)
else:
RNS.log(f"Opening BLE connection for {self}...")
if self.ble != None and self.ble.running == False:
self.ble.close()
self.ble.cleanup()
self.ble = None
if self.use_ble:
RNS.log(f"Opening BLE connection for {self}...")
self.timeout = 1250
if self.ble != None and self.ble.running == False:
self.ble.close()
self.ble.cleanup()
self.ble = None
if self.ble == None:
self.ble = BLEConnection(owner=self, target_name=self.ble_name, target_bt_addr=self.ble_addr)
self.serial = self.ble
if self.ble == None:
self.ble = BLEConnection(owner=self, target_name=self.ble_name, target_bt_addr=self.ble_addr)
self.serial = self.ble
open_time = time.time()
while not self.ble.connected and time.time() < open_time + self.ble.CONNECT_TIMEOUT:
time.sleep(1)
open_time = time.time()
while not self.ble.connected and time.time() < open_time + self.ble.CONNECT_TIMEOUT:
time.sleep(1)
if self.use_tcp:
self.timeout = 1500
RNS.log(f"Opening TCP connection for {self}...")
if self.tcp != None and self.tcp.running == False:
self.tcp.close()
self.tcp.cleanup()
self.tcp = None
if self.tcp == None:
self.tcp = TCPConnection(owner=self, target_host=self.tcp_host)
self.serial = self.tcp
open_time = time.time()
while not self.tcp.connected and time.time() < open_time + self.tcp.CONNECT_TIMEOUT:
time.sleep(1)
def reset_radio_state(self):
self.r_frequency = None
@@ -370,38 +430,41 @@ class RNodeInterface(Interface):
thread.start()
self.detect()
if not self.use_ble:
sleep(0.2)
else:
ble_detect_timeout = 5
if self.use_tcp:
tcp_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 self.detected:
detect_time = RNS.prettytime(time.time()-detect_time)
else:
RNS.log(f"RNode detect timed out over {self.port}", RNS.LOG_ERROR)
while not self.detected and time.time() < detect_time + tcp_detect_timeout: time.sleep(0.1)
if not self.detected: RNS.log(f"RNode detect timed out over TCP", RNS.LOG_ERROR)
elif self.use_ble:
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)
else:
sleep(0.2)
if not self.detected:
RNS.log("Could not detect device for "+str(self), RNS.LOG_ERROR)
RNS.log(f"Could not detect device for {self}", RNS.LOG_ERROR)
self.serial.close()
else:
if self.platform == KISS.PLATFORM_ESP32 or self.platform == KISS.PLATFORM_NRF52:
self.display = True
if self.platform == KISS.PLATFORM_ESP32 or self.platform == KISS.PLATFORM_NRF52: self.display = True
RNS.log("Serial port "+self.port+" is now open")
RNS.log("Configuring RNode interface...", RNS.LOG_VERBOSE)
self.initRadio()
if (self.validateRadioState()):
self.interface_ready = True
RNS.log(str(self)+" is configured and powered up")
sleep(0.3)
self.online = True
else:
RNS.log("After configuring "+str(self)+", the reported radio parameters did not match your configuration.", RNS.LOG_ERROR)
RNS.log("Make sure that your hardware actually supports the parameters specified in the configuration", RNS.LOG_ERROR)
RNS.log("Aborting RNode startup", RNS.LOG_ERROR)
self.serial.close()
if self.use_tcp: RNS.log(f"TCP connection to {self.tcp_host} is now open", RNS.LOG_VERBOSE)
elif self.use_ble: RNS.log(f"BLE connection to {self} is now open", RNS.LOG_VERBOSE)
else: RNS.log(f"Serial port {self.port} is now open", RNS.LOG_VERBOSE)
RNS.log("Configuring RNode interface...", RNS.LOG_VERBOSE)
self.initRadio()
if (self.validateRadioState()):
self.interface_ready = True
RNS.log(str(self)+" is configured and powered up")
sleep(0.3)
self.online = True
else:
RNS.log("After configuring "+str(self)+", the reported radio parameters did not match your configuration.", RNS.LOG_ERROR)
RNS.log("Make sure that your hardware actually supports the parameters specified in the configuration", RNS.LOG_ERROR)
RNS.log("Aborting RNode startup", RNS.LOG_ERROR)
self.serial.close()
def initRadio(self):
@@ -596,10 +659,9 @@ class RNodeInterface(Interface):
def validateRadioState(self):
RNS.log("Waiting for radio configuration validation for "+str(self)+"...", RNS.LOG_VERBOSE)
if self.use_ble:
sleep(1.00)
else:
sleep(0.25)
if self.use_ble: sleep(1.00)
elif self.use_tcp: sleep(1.5)
else: sleep(0.25)
if self.use_ble and self.ble != None and self.ble.device_disappeared:
RNS.log(f"Device disappeared during radio state validation for {self}", RNS.LOG_ERROR)
@@ -869,16 +931,50 @@ class RNodeInterface(Interface):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 8):
if (len(command_buffer) == 11):
ats = command_buffer[0] << 8 | command_buffer[1]
atl = command_buffer[2] << 8 | command_buffer[3]
cus = command_buffer[4] << 8 | command_buffer[5]
cul = command_buffer[6] << 8 | command_buffer[7]
crs = command_buffer[8]
nfl = command_buffer[9]
ntf = command_buffer[10]
self.r_airtime_short = ats/100.0
self.r_airtime_long = atl/100.0
self.r_channel_load_short = cus/100.0
self.r_channel_load_long = cul/100.0
self.r_current_rssi = crs-RNodeInterface.RSSI_OFFSET
self.r_noise_floor = nfl-RNodeInterface.RSSI_OFFSET
# TODO: Remove debug
# interference_log_threshold = 10
# if ntf == 0xFF:
# self.r_interference = None
# if self.r_noise_floor != None:
# # Filter potential false interference events due to LNA recalibration
# if self.r_interference_l != None:
# if self.r_interference_l[1] < self.r_noise_floor+interference_log_threshold:
# self.r_interference_l = None
# else:
# if self.r_noise_floor != None:
# interference = ntf-RNodeInterface.RSSI_OFFSET
# # Filter potential false interference events due to LNA recalibration
# if interference > self.r_noise_floor+interference_log_threshold:
# self.r_interference = ntf-RNodeInterface.RSSI_OFFSET
# self.r_interference_l = [time.time(), self.r_interference]
if ntf == 0xFF:
self.r_interference = None
else:
self.r_interference = ntf-RNodeInterface.RSSI_OFFSET
self.r_interference_l = [time.time(), self.r_interference]
if self.r_interference != None:
RNS.log(f"{self} Radio detected interference at {self.r_interference} dBm", RNS.LOG_DEBUG)
# TODO: Remove debug
# RNS.log(f"RSSI: {self.r_current_rssi}, Noise floor: {self.r_noise_floor}, Interference: {self.r_interference}", RNS.LOG_DEBUG)
elif (command == KISS.CMD_STAT_PHYPRM):
if (byte == KISS.FESC):
escape = True
@@ -890,22 +986,49 @@ class RNodeInterface(Interface):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 10):
if (len(command_buffer) == 12):
lst = (command_buffer[0] << 8 | command_buffer[1])/1000.0
lsr = command_buffer[2] << 8 | command_buffer[3]
prs = command_buffer[4] << 8 | command_buffer[5]
prt = command_buffer[6] << 8 | command_buffer[7]
cst = command_buffer[8] << 8 | command_buffer[9]
dft = command_buffer[10] << 8 | command_buffer[11]
if lst != self.r_symbol_time_ms or lsr != self.r_symbol_rate or prs != self.r_preamble_symbols or prt != self.r_premable_time_ms or cst != self.r_csma_slot_time_ms:
if lst != self.r_symbol_time_ms or lsr != self.r_symbol_rate or prs != self.r_preamble_symbols or prt != self.r_premable_time_ms or cst != self.r_csma_slot_time_ms or dft != self.r_csma_difs_ms:
self.r_symbol_time_ms = lst
self.r_symbol_rate = lsr
self.r_preamble_symbols = prs
self.r_premable_time_ms = prt
self.r_csma_slot_time_ms = cst
RNS.log(str(self)+" Radio reporting symbol time is "+str(round(self.r_symbol_time_ms,2))+"ms (at "+str(self.r_symbol_rate)+" baud)", RNS.LOG_DEBUG)
RNS.log(str(self)+" Radio reporting preamble is "+str(self.r_preamble_symbols)+" symbols ("+str(self.r_premable_time_ms)+"ms)", RNS.LOG_DEBUG)
RNS.log(str(self)+" Radio reporting CSMA slot time is "+str(self.r_csma_slot_time_ms)+"ms", RNS.LOG_DEBUG)
self.r_csma_difs_ms = dft
RNS.log(f"{self} Radio reporting symbol time is "+str(round(self.r_symbol_time_ms,2))+"ms ("+str(self.r_symbol_rate)+" baud)", RNS.LOG_DEBUG)
RNS.log(f"{self} Radio reporting preamble is "+str(self.r_preamble_symbols)+" symbols ("+str(self.r_premable_time_ms)+"ms)", RNS.LOG_DEBUG)
RNS.log(f"{self} Radio reporting CSMA slot time is "+str(self.r_csma_slot_time_ms)+"ms", RNS.LOG_DEBUG)
RNS.log(f"{self} Radio reporting DIFS time is "+str(self.r_csma_difs_ms)+"ms", RNS.LOG_DEBUG)
elif (command == KISS.CMD_STAT_CSMA):
if (byte == KISS.FESC):
escape = True
else:
if (escape):
if (byte == KISS.TFEND):
byte = KISS.FEND
if (byte == KISS.TFESC):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 3):
cbw = command_buffer[0]
cbl = command_buffer[1]
cbh = command_buffer[2]
if cbw != self.r_csma_cw_band or cbl != self.r_csma_cw_min or cbh != self.r_csma_cw_max:
self.r_csma_cw_band = cbw
self.r_csma_cw_min = cbl
self.r_csma_cw_max = cbh
# TODO: Remove debug
# RNS.log(f"{self} Radio reporting contention window band is {self.r_csma_cw_band}", RNS.LOG_EXTREME)
# RNS.log(f"{self} Radio reporting minimum contention window is {self.r_csma_cw_min}", RNS.LOG_EXTREME)
# RNS.log(f"{self} Radio reporting maximum contention window is {self.r_csma_cw_max}", RNS.LOG_EXTREME)
elif (command == KISS.CMD_STAT_BAT):
if (byte == KISS.FESC):
escape = True
@@ -925,6 +1048,22 @@ class RNodeInterface(Interface):
bat_percent = 0
self.r_battery_state = command_buffer[0]
self.r_battery_percent = bat_percent
elif (command == KISS.CMD_STAT_TEMP):
if (byte == KISS.FESC):
escape = True
else:
if (escape):
if (byte == KISS.TFEND):
byte = KISS.FEND
if (byte == KISS.TFESC):
byte = KISS.FESC
escape = False
command_buffer = command_buffer+bytes([byte])
if (len(command_buffer) == 1):
temp = command_buffer[0]-120
if temp >= -30 and temp <= 90: self.r_temperature = temp
else: self.r_temperature = None
self.cpu_temp = self.r_temperature
elif (command == KISS.CMD_RANDOM):
self.r_random = byte
elif (command == KISS.CMD_PLATFORM):
@@ -994,7 +1133,7 @@ class RNodeInterface(Interface):
else:
time_since_last = int(time.time()*1000) - last_read_ms
if len(data_buffer) > 0 and time_since_last > self.timeout:
RNS.log(str(self)+" serial read timeout in command "+str(command), RNS.LOG_WARNING)
RNS.log(f"{self} device read timeout in command {command} after {RNS.prettytime(self.timeout/1000.0)}", RNS.LOG_WARNING)
data_buffer = b""
in_frame = False
command = KISS.CMD_UNKNOWN
@@ -1006,6 +1145,11 @@ class RNodeInterface(Interface):
RNS.log("Interface "+str(self)+" is transmitting beacon data: "+str(self.id_callsign.decode("utf-8")), RNS.LOG_DEBUG)
self.process_outgoing(self.id_callsign)
if self.use_tcp:
if self.tcp and self.tcp.connected:
if time.time() > self.tcp.last_write + self.tcp.ACTIVITY_KEEPALIVE:
self.detect()
sleep(0.08)
except Exception as e:
@@ -1034,23 +1178,28 @@ class RNodeInterface(Interface):
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()
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)
self.reconnecting = False
if self.online:
RNS.log("Reconnected serial port for "+str(self))
if self.online: RNS.log(f"Reconnected port for {self}")
def detach(self):
self.detached = True
self.disable_external_framebuffer()
self.setRadioState(KISS.RADIO_STATE_OFF)
self.leave()
try:
self.disable_external_framebuffer()
self.setRadioState(KISS.RADIO_STATE_OFF)
self.leave()
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_ble: self.ble.close()
if self.use_tcp:
time.sleep(0.5)
self.tcp.close()
def should_ingress_limit(self):
return False
@@ -1071,6 +1220,17 @@ class RNodeInterface(Interface):
def get_battery_percent(self):
return self.r_battery_percent
def tcp_receive(self, data):
with self.tcp_rx_lock: self.tcp_rx_queue += data
def tcp_waiting(self): return len(self.tcp_tx_queue) > 0
def get_tcp_waiting(self, n):
with self.tcp_tx_lock:
data = self.tcp_tx_queue[:n]
self.tcp_tx_queue = self.tcp_tx_queue[n:]
return data
def ble_receive(self, data):
with self.ble_rx_lock:
self.ble_rx_queue += data
@@ -1085,7 +1245,7 @@ class RNodeInterface(Interface):
return data
def __str__(self):
return "RNodeInterface["+str(self.name)+"]"
return f"RNodeInterface[{self.name}]"
class BLEConnection():
UART_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
@@ -1138,7 +1298,7 @@ class BLEConnection():
self.connect_job_running = False
self.device_disappeared = False
import importlib
import importlib.util
if BLEConnection.bleak == None:
if importlib.util.find_spec("bleak") != None:
import bleak
@@ -1264,3 +1424,136 @@ class BLEConnection():
RNS.log(f"Error while determining device bond status for {device}, the contained exception was: {e}", RNS.LOG_ERROR)
return False
class TCPConnection():
TARGET_PORT = 7633
CONNECT_TIMEOUT = 5.0
INITIAL_CONNECT_TIMEOUT = 5.0
RECONNECT_WAIT = 4.0
ACTIVITY_TIMEOUT = 6.0
ACTIVITY_KEEPALIVE = ACTIVITY_TIMEOUT-2.5
TCP_USER_TIMEOUT = 24
TCP_PROBE_AFTER = 5
TCP_PROBE_INTERVAL = 2
TCP_PROBES = 12
@property
def is_open(self):
return self.connected
@property
def in_waiting(self):
buflen = len(self.owner.tcp_rx_queue)
return buflen > 0
def write(self, data_bytes):
if self.connected and self.socket:
with self.owner.tcp_tx_lock:
if len(self.owner.tcp_tx_queue) > 0:
self.socket.sendall(self.owner.tcp_tx_queue)
self.owner.tcp_tx_queue = b""
self.socket.sendall(data_bytes)
self.last_write = time.time()
else:
with self.owner.tcp_tx_lock: self.owner.tcp_tx_queue += data_bytes
return len(data_bytes)
def read(self, n):
with self.owner.tcp_rx_lock:
data = self.owner.tcp_rx_queue[:n]
self.owner.tcp_rx_queue = self.owner.tcp_rx_queue[n:]
return data
def close(self):
if self.connected:
RNS.log(f"Disconnecting TCP socket for {self.owner}", RNS.LOG_DEBUG)
self.must_disconnect = True
if self.socket: self.socket.close()
def __init__(self, owner=None, target_host=None):
self.owner = owner
self.target_host = target_host
self.connected = False
self.reconnecting = False
self.running = False
self.should_run = False
self.must_disconnect = False
self.connect_job_running = False
self.last_write = time.time()
self.should_run = True
self.connection_thread = threading.Thread(target=self.initial_connect, daemon=True).start()
def set_timeouts_linux(self):
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, int(self.TCP_USER_TIMEOUT * 1000))
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, int(self.TCP_PROBE_AFTER))
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, int(self.TCP_PROBE_INTERVAL))
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, int(self.TCP_PROBES))
def set_timeouts_osx(self):
if hasattr(socket, "TCP_KEEPALIVE"): TCP_KEEPIDLE = socket.TCP_KEEPALIVE
else: TCP_KEEPIDLE = 0x10
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self.socket.setsockopt(socket.IPPROTO_TCP, TCP_KEEPIDLE, int(self.TCP_PROBE_AFTER))
def cleanup(self):
try:
if self.socket: self.socket.close()
except Exception as e: RNS.log(f"Error while disconnecting TCP socket on cleanup for {self.owner}", RNS.LOG_ERROR)
self.should_run = False
def initial_connect(self):
if self.connect(initial=True): threading.Thread(target=self.read_loop, daemon=True).start()
def connect(self, initial=False):
try:
if initial:
RNS.log(f"Establishing TCP connection to device for {self.owner}...", RNS.LOG_DEBUG)
address_info = socket.getaddrinfo(self.target_host, self.TARGET_PORT, proto=socket.IPPROTO_TCP)[0]
address_family = address_info[0]
target_address = address_info[4]
self.socket = socket.socket(address_family, socket.SOCK_STREAM)
self.socket.settimeout(self.INITIAL_CONNECT_TIMEOUT)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect(target_address)
self.socket.settimeout(None)
self.connected = True
self.last_write = time.time()
RNS.log(f"TCP connection to device for {self.owner} established", RNS.LOG_DEBUG)
if RNS.vendor.platformutils.is_linux(): self.set_timeouts_linux()
elif RNS.vendor.platformutils.is_darwin(): self.set_timeouts_osx()
return True
except Exception as e:
if initial:
RNS.log(f"TCP connection to device for {self.owner} could not be established: {e}", RNS.LOG_ERROR)
return False
else: raise e
def read_loop(self):
try:
data_in = b""
while not self.must_disconnect:
if self.socket: data_in = self.socket.recv(4096)
else: data_in = b""
if len(data_in) > 0: self.owner.tcp_receive(data_in)
else:
self.connected = False
RNS.log(f"The TCP socket for {self} was closed", RNS.LOG_WARNING)
break
except Exception as e:
self.connected = False
RNS.log(f"A TCP read error occurred for {self}, the contained exception was: {e}", RNS.LOG_WARNING)
+37 -124
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2024 Jacob Eva. Adapted from the RNodeInterface by Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -33,6 +41,8 @@ class KISS():
FESC = 0xDB
TFEND = 0xDC
TFESC = 0xDD
CMD_DATA = 0x00
CMD_UNKNOWN = 0xFE
CMD_FREQUENCY = 0x01
@@ -64,7 +74,7 @@ class KISS():
CMD_FW_VERSION = 0x50
CMD_ROM_READ = 0x51
CMD_RESET = 0x55
CMD_INTERFACES = 0x64
CMD_INTERFACES = 0x71
CMD_INT0_DATA = 0x00
CMD_INT1_DATA = 0x10
@@ -79,19 +89,6 @@ class KISS():
CMD_INT10_DATA = 0xE0
CMD_INT11_DATA = 0xF0
CMD_SEL_INT0 = 0x1E
CMD_SEL_INT1 = 0x1F
CMD_SEL_INT2 = 0x2F
CMD_SEL_INT3 = 0x74
CMD_SEL_INT4 = 0x7F
CMD_SEL_INT5 = 0x9F
CMD_SEL_INT6 = 0xAF
CMD_SEL_INT7 = 0xBF
CMD_SEL_INT8 = 0xCF
CMD_SEL_INT9 = 0xDF
CMD_SEL_INT10 = 0xEF
CMD_SEL_INT11 = 0xFF
DETECT_REQ = 0x73
DETECT_RESP = 0x46
@@ -116,33 +113,7 @@ class KISS():
SX128X = 0x20
SX1280 = 0x21
def int_data_cmd_to_index(int_data_cmd):
if int_data_cmd == KISS.CMD_INT0_DATA:
return 0
elif int_data_cmd == KISS.CMD_INT1_DATA:
return 1
elif int_data_cmd == KISS.CMD_INT2_DATA:
return 2
elif int_data_cmd == KISS.CMD_INT3_DATA:
return 3
elif int_data_cmd == KISS.CMD_INT4_DATA:
return 4
elif int_data_cmd == KISS.CMD_INT5_DATA:
return 5
elif int_data_cmd == KISS.CMD_INT6_DATA:
return 6
elif int_data_cmd == KISS.CMD_INT7_DATA:
return 7
elif int_data_cmd == KISS.CMD_INT8_DATA:
return 8
elif int_data_cmd == KISS.CMD_INT9_DATA:
return 9
elif int_data_cmd == KISS.CMD_INT10_DATA:
return 10
elif int_data_cmd == KISS.CMD_INT11_DATA:
return 11
else:
return 0
CMD_SEL_INT = 0x1F
def interface_type_to_str(interface_type):
if interface_type == KISS.SX126X or interface_type == KISS.SX1262:
@@ -178,7 +149,7 @@ class RNodeMultiInterface(Interface):
if RNS.vendor.platformutils.is_android():
raise SystemError("Invalid interface type. The Android-specific RNode interface must be used on Android")
import importlib
import importlib.util
if importlib.util.find_spec('serial') != None:
import serial
else:
@@ -208,16 +179,17 @@ class RNodeMultiInterface(Interface):
enabled_count += 1
# Create an array with a row for each subinterface
subint_config = [[0 for x in range(11)] for y in range(enabled_count)]
subint_config = [[None for x in range(11)] for y in range(enabled_count)]
subint_index = 0
for subinterface in c:
if isinstance(c[subinterface], dict):
subinterface_config = c[subinterface]
if (("interface_enabled" in subinterface_config) and subinterface_config.as_bool("interface_enabled") == True) or (("enabled" in c) and c.as_bool("enabled") == True):
subint_vport = subinterface_config["vport"] if "vport" in subinterface_config else None
subint_config[subint_index][0] = subinterface
subint_vport = subinterface_config["vport"] if "vport" in subinterface_config else None
subint_config[subint_index][1] = subint_vport
frequency = int(subinterface_config["frequency"]) if "frequency" in subinterface_config else None
@@ -241,6 +213,7 @@ class RNodeMultiInterface(Interface):
subint_config[subint_index][10] = False
else:
subint_config[subint_index][10] = True
subint_index += 1
# if no subinterfaces are defined
@@ -474,11 +447,10 @@ class RNodeMultiInterface(Interface):
c4 = frequency & 0xFF
data = KISS.escape(bytes([c1])+bytes([c2])+bytes([c3])+bytes([c4]))
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_FREQUENCY])+data+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_FREQUENCY])+data+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring frequency for "+str(self))
self.selected_index = interface.index
def setBandwidth(self, bandwidth, interface):
c1 = bandwidth >> 24
@@ -487,35 +459,31 @@ class RNodeMultiInterface(Interface):
c4 = bandwidth & 0xFF
data = KISS.escape(bytes([c1])+bytes([c2])+bytes([c3])+bytes([c4]))
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_BANDWIDTH])+data+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_BANDWIDTH])+data+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring bandwidth for "+str(self))
self.selected_index = interface.index
def setTXPower(self, txpower, interface):
txp = txpower.to_bytes(1, byteorder="big", signed=True)
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_TXPOWER])+txp+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_TXPOWER])+txp+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring TX power for "+str(self))
self.selected_index = interface.index
def setSpreadingFactor(self, sf, interface):
sf = bytes([sf])
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_SF])+sf+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_SF])+sf+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring spreading factor for "+str(self))
self.selected_index = interface.index
def setCodingRate(self, cr, interface):
cr = bytes([cr])
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_CR])+cr+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_CR])+cr+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring coding rate for "+str(self))
self.selected_index = interface.index
def setSTALock(self, st_alock, interface):
if st_alock != None:
@@ -524,11 +492,10 @@ class RNodeMultiInterface(Interface):
c2 = at & 0xFF
data = KISS.escape(bytes([c1])+bytes([c2]))
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_ST_ALOCK])+data+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_ST_ALOCK])+data+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring short-term airtime limit for "+str(self))
self.selected_index = interface.index
def setLTALock(self, lt_alock, interface):
if lt_alock != None:
@@ -537,19 +504,17 @@ class RNodeMultiInterface(Interface):
c2 = at & 0xFF
data = KISS.escape(bytes([c1])+bytes([c2]))
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_LT_ALOCK])+data+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_LT_ALOCK])+data+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring long-term airtime limit for "+str(self))
self.selected_index = interface.index
def setRadioState(self, state, interface):
#self.state = state
kiss_command = bytes([KISS.FEND])+bytes([interface.sel_cmd])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_RADIO_STATE])+bytes([state])+bytes([KISS.FEND])
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_RADIO_STATE])+bytes([state])+bytes([KISS.FEND])
written = self.serial.write(kiss_command)
if written != len(kiss_command):
raise IOError("An IO error occurred while configuring radio state for "+str(self))
self.selected_index = interface.index
def validate_firmware(self):
if (self.maj_version >= RNodeMultiInterface.REQUIRED_FW_VER_MAJ):
@@ -570,7 +535,7 @@ class RNodeMultiInterface(Interface):
pass
else:
data = KISS.escape(data)
frame = bytes([0xc0])+bytes([interface.data_cmd])+data+bytes([0xc0])
frame = bytes([KISS.FEND])+bytes([KISS.CMD_SEL_INT])+bytes([interface.index])+bytes([KISS.FEND])+bytes([KISS.FEND])+bytes([KISS.CMD_DATA])+data+bytes([KISS.FEND])
written = self.serial.write(frame)
self.txb += len(data)
@@ -599,21 +564,9 @@ class RNodeMultiInterface(Interface):
last_read_ms = int(time.time()*1000)
if (in_frame and byte == KISS.FEND and
(command == KISS.CMD_INT0_DATA or
command == KISS.CMD_INT1_DATA or
command == KISS.CMD_INT2_DATA or
command == KISS.CMD_INT3_DATA or
command == KISS.CMD_INT4_DATA or
command == KISS.CMD_INT5_DATA or
command == KISS.CMD_INT6_DATA or
command == KISS.CMD_INT7_DATA or
command == KISS.CMD_INT8_DATA or
command == KISS.CMD_INT9_DATA or
command == KISS.CMD_INT10_DATA or
command == KISS.CMD_INT11_DATA)):
(command == KISS.CMD_DATA)):
in_frame = False
self.subinterfaces[KISS.int_data_cmd_to_index(command)].process_incoming(data_buffer)
self.selected_index = KISS.int_data_cmd_to_index(command)
self.subinterfaces[self.selected_index].process_incoming(data_buffer)
data_buffer = b""
command_buffer = b""
elif (byte == KISS.FEND):
@@ -678,6 +631,9 @@ class RNodeMultiInterface(Interface):
RNS.log(str(self.subinterfaces[self.selected_index])+" Radio reporting bandwidth is "+str(self.subinterfaces[self.selected_index].r_bandwidth/1000.0)+" KHz", RNS.LOG_DEBUG)
self.subinterfaces[self.selected_index].updateBitrate()
elif (command == KISS.CMD_SEL_INT):
self.selected_index = byte
elif (command == KISS.CMD_TXPOWER):
txp = byte - 256 if byte > 127 else byte
self.subinterfaces[self.selected_index].r_txpower = txp
@@ -979,7 +935,7 @@ class RNodeSubInterface(Interface):
if RNS.vendor.platformutils.is_android():
raise SystemError("Invalid interface type. The Android-specific RNode interface must be used on Android")
import importlib
import importlib.util
if importlib.util.find_spec('serial') != None:
import serial
else:
@@ -989,51 +945,9 @@ class RNodeSubInterface(Interface):
super().__init__()
if index == 0:
sel_cmd = KISS.CMD_SEL_INT0
data_cmd= KISS.CMD_INT0_DATA
elif index == 1:
sel_cmd = KISS.CMD_SEL_INT1
data_cmd= KISS.CMD_INT1_DATA
elif index == 2:
sel_cmd = KISS.CMD_SEL_INT2
data_cmd= KISS.CMD_INT2_DATA
elif index == 3:
sel_cmd = KISS.CMD_SEL_INT3
data_cmd= KISS.CMD_INT3_DATA
elif index == 4:
sel_cmd = KISS.CMD_SEL_INT4
data_cmd= KISS.CMD_INT4_DATA
elif index == 5:
sel_cmd = KISS.CMD_SEL_INT5
data_cmd= KISS.CMD_INT5_DATA
elif index == 6:
sel_cmd = KISS.CMD_SEL_INT6
data_cmd= KISS.CMD_INT6_DATA
elif index == 7:
sel_cmd = KISS.CMD_SEL_INT7
data_cmd= KISS.CMD_INT7_DATA
elif index == 8:
sel_cmd = KISS.CMD_SEL_INT8
data_cmd= KISS.CMD_INT8_DATA
elif index == 9:
sel_cmd = KISS.CMD_SEL_INT9
data_cmd= KISS.CMD_INT9_DATA
elif index == 10:
sel_cmd = KISS.CMD_SEL_INT10
data_cmd= KISS.CMD_INT10_DATA
elif index == 11:
sel_cmd = KISS.CMD_SEL_INT11
data_cmd= KISS.CMD_INT11_DATA
else:
sel_cmd = KISS.CMD_SEL_INT0
data_cmd= KISS.CMD_INT0_DATA
self.owner = owner
self.name = name
self.index = index
self.sel_cmd = sel_cmd
self.data_cmd = data_cmd
self.interface_type= interface_type
self.flow_control= flow_control
self.online = False
@@ -1079,7 +993,6 @@ class RNodeSubInterface(Interface):
self.announce_rate_target = None
self.mode = None
self.announce_cap = None
self.bitrate = None
self.ifac_size = None
@@ -1099,7 +1012,7 @@ class RNodeSubInterface(Interface):
RNS.log("Invalid interface type configured for "+str(self), RNS.LOG_ERROR)
self.validcfg = False
if (self.txpower < -9 or self.txpower > 27):
if (self.txpower < -9 or self.txpower > 37):
RNS.log("Invalid TX power configured for "+str(self), RNS.LOG_ERROR)
self.validcfg = False
+13 -5
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -53,7 +61,7 @@ class SerialInterface(Interface):
serial = None
def __init__(self, owner, configuration):
import importlib
import importlib.util
if importlib.util.find_spec('serial') != None:
import serial
else:
+88 -56
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2024 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -30,6 +38,9 @@ import sys
import os
import RNS
class TCPInterface():
HW_MTU = 262144
class HDLC():
FLAG = 0x7E
ESC = 0x7D
@@ -64,6 +75,7 @@ class ThreadingTCP6Server(socketserver.ThreadingMixIn, socketserver.TCPServer):
class TCPClientInterface(Interface):
BITRATE_GUESS = 10*1000*1000
DEFAULT_IFAC_SIZE = 16
AUTOCONFIGURE_MTU = True
RECONNECT_WAIT = 5
RECONNECT_MAX_TRIES = None
@@ -95,9 +107,13 @@ class TCPClientInterface(Interface):
i2p_tunneled = c.as_bool("i2p_tunneled") if "i2p_tunneled" in c else False
connect_timeout = c.as_int("connect_timeout") if "connect_timeout" in c else None
max_reconnect_tries = c.as_int("max_reconnect_tries") if "max_reconnect_tries" in c else None
fixed_mtu = c.as_int("fixed_mtu") if "fixed_mtu" in c else None
if fixed_mtu:
if fixed_mtu < RNS.Reticulum.MTU: raise ValueError(f"Configured MTU of {fixed_mtu} bytes is too small")
self.AUTOCONFIGURE_MTU = False
self.FIXED_MTU = True
self.HW_MTU = 1064
self.HW_MTU = TCPInterface.HW_MTU if not fixed_mtu else fixed_mtu
self.IN = True
self.OUT = False
self.socket = None
@@ -115,10 +131,9 @@ class TCPClientInterface(Interface):
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.bitrate = TCPClientInterface.BITRATE_GUESS
if max_reconnect_tries == None:
self.max_reconnect_tries = TCPClientInterface.RECONNECT_MAX_TRIES
else:
self.max_reconnect_tries = max_reconnect_tries
self.supports_discovery = True
if max_reconnect_tries == None: self.max_reconnect_tries = TCPClientInterface.RECONNECT_MAX_TRIES
else: self.max_reconnect_tries = max_reconnect_tries
if connected_socket != None:
self.receives = True
@@ -192,19 +207,21 @@ class TCPClientInterface(Interface):
self.socket.setsockopt(socket.IPPROTO_TCP, TCP_KEEPIDLE, int(TCPClientInterface.I2P_PROBE_AFTER))
def detach(self):
self.online = False
if self.socket != None:
if hasattr(self.socket, "close"):
if callable(self.socket.close):
RNS.log("Detaching "+str(self), RNS.LOG_DEBUG)
self.detached = True
try:
self.socket.shutdown(socket.SHUT_RDWR)
if self.socket != None:
self.socket.shutdown(socket.SHUT_RDWR)
except Exception as e:
RNS.log("Error while shutting down socket for "+str(self)+": "+str(e))
try:
self.socket.close()
if self.socket != None:
self.socket.close()
except Exception as e:
RNS.log("Error while closing socket for "+str(self)+": "+str(e))
@@ -285,14 +302,15 @@ class TCPClientInterface(Interface):
raise IOError("Attempt to reconnect on a non-initiator TCP interface")
def process_incoming(self, data):
self.rxb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.rxb += len(data)
self.owner.inbound(data, self)
if self.online and not self.detached:
self.rxb += len(data)
if hasattr(self, "parent_interface") and self.parent_interface != None:
self.parent_interface.rxb += len(data)
self.owner.inbound(data, self)
def process_outgoing(self, data):
if self.online:
if self.online and not self.detached:
# while self.writing:
# time.sleep(0.01)
@@ -320,19 +338,20 @@ class TCPClientInterface(Interface):
try:
in_frame = False
escape = False
frame_buffer = b""
data_in = b""
data_buffer = b""
command = KISS.CMD_UNKNOWN
while True:
data_in = self.socket.recv(4096)
if self.socket: data_in = self.socket.recv(4096)
else: data_in = b""
if len(data_in) > 0:
pointer = 0
while pointer < len(data_in):
byte = data_in[pointer]
pointer += 1
if self.kiss_framing:
# Read loop for KISS framing
if self.kiss_framing:
# Read loop for KISS framing
pointer = 0
while pointer < len(data_in):
byte = data_in[pointer]
pointer += 1
if (in_frame and byte == KISS.FEND and command == KISS.CMD_DATA):
in_frame = False
self.process_incoming(data_buffer)
@@ -358,25 +377,26 @@ class TCPClientInterface(Interface):
escape = False
data_buffer = data_buffer+bytes([byte])
else:
# Read loop for HDLC framing
if (in_frame and byte == HDLC.FLAG):
in_frame = False
self.process_incoming(data_buffer)
elif (byte == HDLC.FLAG):
in_frame = True
data_buffer = b""
elif (in_frame and len(data_buffer) < self.HW_MTU):
if (byte == HDLC.ESC):
escape = True
else:
# Read loop for standard HDLC framing
frame_buffer += data_in
flags_remaining = True
while flags_remaining:
frame_start = frame_buffer.find(HDLC.FLAG)
if frame_start != -1:
frame_end = frame_buffer.find(HDLC.FLAG, frame_start+1)
if frame_end != -1:
frame = 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)
frame_buffer = frame_buffer[frame_end:]
else:
if (escape):
if (byte == HDLC.FLAG ^ HDLC.ESC_MASK):
byte = HDLC.FLAG
if (byte == HDLC.ESC ^ HDLC.ESC_MASK):
byte = HDLC.ESC
escape = False
data_buffer = data_buffer+bytes([byte])
flags_remaining = False
else:
flags_remaining = False
else:
self.online = False
if self.initiator and not self.detached:
@@ -431,12 +451,13 @@ class TCPClientInterface(Interface):
class TCPServerInterface(Interface):
BITRATE_GUESS = 10*1000*1000
BITRATE_GUESS = 10_000_000
DEFAULT_IFAC_SIZE = 16
AUTOCONFIGURE_MTU = True
@staticmethod
def get_address_for_if(name, bind_port, prefer_ipv6=False):
import RNS.vendor.ifaddr.niwrapper as netinfo
from RNS.Interfaces import netinfo
ifaddr = netinfo.ifaddresses(name)
if len(ifaddr) < 1:
raise SystemError(f"No addresses available on specified kernel interface \"{name}\" for TCPServerInterface to bind to")
@@ -445,9 +466,9 @@ class TCPServerInterface(Interface):
bind_ip = ifaddr[netinfo.AF_INET6][0]["addr"]
if bind_ip.lower().startswith("fe80::"):
# We'll need to add the interface as scope for link-local addresses
return TCPServerInterface.get_address_for_host(f"{bind_ip}%{name}", bind_port)
return TCPServerInterface.get_address_for_host(f"{bind_ip}%{name}", bind_port, prefer_ipv6)
else:
return TCPServerInterface.get_address_for_host(bind_ip, bind_port)
return TCPServerInterface.get_address_for_host(bind_ip, bind_port, prefer_ipv6)
elif netinfo.AF_INET in ifaddr:
bind_ip = ifaddr[netinfo.AF_INET][0]["addr"]
return (bind_ip, bind_port)
@@ -455,8 +476,15 @@ class TCPServerInterface(Interface):
raise SystemError(f"No addresses available on specified kernel interface \"{name}\" for TCPServerInterface to bind to")
@staticmethod
def get_address_for_host(name, bind_port):
address_info = socket.getaddrinfo(name, bind_port, proto=socket.IPPROTO_TCP)[0]
def get_address_for_host(name, bind_port, prefer_ipv6=False):
address_infos = socket.getaddrinfo(name, bind_port, proto=socket.IPPROTO_TCP)
address_info = address_infos[0]
for entry in address_infos:
if prefer_ipv6 and entry[0] == socket.AF_INET6:
address_info = entry; break
elif not prefer_ipv6 and entry[0] == socket.AF_INET:
address_info = entry; break
if address_info[0] == socket.AF_INET6:
return (name, bind_port, address_info[4][2], address_info[4][3])
elif address_info[0] == socket.AF_INET:
@@ -484,7 +512,8 @@ class TCPServerInterface(Interface):
if port != None:
bindport = port
self.HW_MTU = 1064
self.supports_discovery = True
self.HW_MTU = TCPInterface.HW_MTU
self.online = False
self.spawned_interfaces = []
@@ -508,7 +537,7 @@ class TCPServerInterface(Interface):
else:
if bindip == None:
raise SystemError(f"No TCP bind IP configured for interface \"{name}\"")
bind_address = TCPServerInterface.get_address_for_host(bindip, self.bind_port)
bind_address = TCPServerInterface.get_address_for_host(bindip, self.bind_port, prefer_ipv6)
if bind_address != None:
self.receives = True
@@ -531,6 +560,7 @@ class TCPServerInterface(Interface):
else:
ThreadingTCPServer.allow_reuse_address = True
self.server = ThreadingTCPServer(bind_address, handlerFactory(self.incoming_connection))
self.server.daemon_threads = True
self.bitrate = TCPServerInterface.BITRATE_GUESS
@@ -553,6 +583,7 @@ class TCPServerInterface(Interface):
spawned_interface.target_port = str(handler.client_address[1])
spawned_interface.parent_interface = self
spawned_interface.bitrate = self.bitrate
spawned_interface.optimise_mtu()
spawned_interface.ifac_size = self.ifac_size
spawned_interface.ifac_netname = self.ifac_netname
@@ -596,15 +627,16 @@ class TCPServerInterface(Interface):
def process_outgoing(self, data):
pass
def detach(self):
self.detached = True
self.online = False
if self.server != None:
if hasattr(self.server, "shutdown"):
if callable(self.server.shutdown):
try:
RNS.log("Detaching "+str(self), RNS.LOG_DEBUG)
self.server.shutdown()
self.detached = True
self.server.server_close()
self.server = None
except Exception as e:
+14 -6
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -35,13 +43,13 @@ class UDPInterface(Interface):
@staticmethod
def get_address_for_if(name):
import RNS.vendor.ifaddr.niwrapper as netinfo
from RNS.Interfaces import netinfo
ifaddr = netinfo.ifaddresses(name)
return ifaddr[netinfo.AF_INET][0]["addr"]
@staticmethod
def get_broadcast_for_if(name):
import RNS.vendor.ifaddr.niwrapper as netinfo
from RNS.Interfaces import netinfo
ifaddr = netinfo.ifaddresses(name)
return ifaddr[netinfo.AF_INET][0]["broadcast"]
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -23,6 +31,8 @@
import os
import glob
import RNS.Interfaces.Android
import RNS.Interfaces.util
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")
+7
View File
@@ -0,0 +1,7 @@
import os
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"))]))
+325
View File
@@ -0,0 +1,325 @@
# MIT License
#
# Copyright (c) 2014 Stefan C. Mueller
# Copyright (c) 2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The 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 socket
import ipaddress
import platform
import ctypes.util
import collections
from typing import List, Iterable, Optional, Tuple, Union
AF_INET6 = socket.AF_INET6.value
AF_INET = socket.AF_INET.value
def interfaces() -> List[str]:
adapters = get_adapters(include_unconfigured=True)
return [a.name for a in adapters]
def interface_names_to_indexes() -> dict:
adapters = get_adapters(include_unconfigured=True)
results = {}
for adapter in adapters:
results[adapter.name] = adapter.index
return results
def interface_name_to_nice_name(ifname) -> str:
try:
adapters = get_adapters(include_unconfigured=True)
for adapter in adapters:
if adapter.name == ifname:
if hasattr(adapter, "nice_name"):
return adapter.nice_name
except: return None
return None
def ifaddresses(ifname) -> dict:
adapters = get_adapters(include_unconfigured=True)
ifa = {}
for a in adapters:
if a.name == ifname:
ipv4s = []
ipv6s = []
for ip in a.ips:
t = {}
if ip.is_IPv4:
net = ipaddress.ip_network(str(ip.ip)+"/"+str(ip.network_prefix), strict=False)
t["addr"] = ip.ip
t["prefix"] = ip.network_prefix
t["broadcast"] = str(net.broadcast_address)
ipv4s.append(t)
if ip.is_IPv6:
t["addr"] = ip.ip[0]
ipv6s.append(t)
if len(ipv4s) > 0: ifa[AF_INET] = ipv4s
if len(ipv6s) > 0: ifa[AF_INET6] = ipv6s
return ifa
def get_adapters(include_unconfigured=False):
if os.name == "posix": return _get_adapters_posix(include_unconfigured=include_unconfigured)
elif os.name == "nt": return _get_adapters_win(include_unconfigured=include_unconfigured)
else: raise RuntimeError(f"Unsupported Operating System: {os.name}")
class Adapter(object):
def __init__(self, name: str, nice_name: str, ips: List["IP"], index: Optional[int] = None) -> None:
self.name = name
self.nice_name = nice_name
self.ips = ips
self.index = index
def __repr__(self) -> str:
return "Adapter(name={name}, nice_name={nice_name}, ips={ips}, index={index})".format(
name=repr(self.name), nice_name=repr(self.nice_name), ips=repr(self.ips), index=repr(self.index))
_IPv4Address = str
_IPv6Address = Tuple[str, int, int]
class IP(object):
def __init__(self, ip: Union[_IPv4Address, _IPv6Address], network_prefix: int, nice_name: str) -> None:
self.ip = ip
self.network_prefix = network_prefix
self.nice_name = nice_name
@property
def is_IPv4(self) -> bool: return not isinstance(self.ip, tuple)
@property
def is_IPv6(self) -> bool: return isinstance(self.ip, tuple)
def __repr__(self) -> str:
return "IP(ip={ip}, network_prefix={network_prefix}, nice_name={nice_name})".format(ip=repr(self.ip), network_prefix=repr(self.network_prefix), nice_name=repr(self.nice_name))
if platform.system() == "Darwin" or "BSD" in platform.system():
class sockaddr(ctypes.Structure):
_fields_ = [
("sa_len", ctypes.c_uint8),
("sa_familiy", ctypes.c_uint8),
("sa_data", ctypes.c_uint8 * 14)]
class sockaddr_in(ctypes.Structure):
_fields_ = [
("sa_len", ctypes.c_uint8),
("sa_familiy", ctypes.c_uint8),
("sin_port", ctypes.c_uint16),
("sin_addr", ctypes.c_uint8 * 4),
("sin_zero", ctypes.c_uint8 * 8)]
class sockaddr_in6(ctypes.Structure):
_fields_ = [
("sa_len", ctypes.c_uint8),
("sa_familiy", ctypes.c_uint8),
("sin6_port", ctypes.c_uint16),
("sin6_flowinfo", ctypes.c_uint32),
("sin6_addr", ctypes.c_uint8 * 16),
("sin6_scope_id", ctypes.c_uint32)]
else:
class sockaddr(ctypes.Structure): # type: ignore
_fields_ = [("sa_familiy", ctypes.c_uint16), ("sa_data", ctypes.c_uint8 * 14)]
class sockaddr_in(ctypes.Structure): # type: ignore
_fields_ = [
("sin_familiy", ctypes.c_uint16),
("sin_port", ctypes.c_uint16),
("sin_addr", ctypes.c_uint8 * 4),
("sin_zero", ctypes.c_uint8 * 8)]
class sockaddr_in6(ctypes.Structure): # type: ignore
_fields_ = [
("sin6_familiy", ctypes.c_uint16),
("sin6_port", ctypes.c_uint16),
("sin6_flowinfo", ctypes.c_uint32),
("sin6_addr", ctypes.c_uint8 * 16),
("sin6_scope_id", ctypes.c_uint32)]
def sockaddr_to_ip(sockaddr_ptr: "ctypes.pointer[sockaddr]") -> Optional[Union[_IPv4Address, _IPv6Address]]:
if sockaddr_ptr:
if sockaddr_ptr[0].sa_familiy == socket.AF_INET:
ipv4 = ctypes.cast(sockaddr_ptr, ctypes.POINTER(sockaddr_in))
ippacked = bytes(bytearray(ipv4[0].sin_addr))
ip = str(ipaddress.ip_address(ippacked))
return ip
elif sockaddr_ptr[0].sa_familiy == socket.AF_INET6:
ipv6 = ctypes.cast(sockaddr_ptr, ctypes.POINTER(sockaddr_in6))
flowinfo = ipv6[0].sin6_flowinfo
ippacked = bytes(bytearray(ipv6[0].sin6_addr))
ip = str(ipaddress.ip_address(ippacked))
scope_id = ipv6[0].sin6_scope_id
return (ip, flowinfo, scope_id)
return None
def ipv6_prefixlength(address: ipaddress.IPv6Address) -> int:
prefix_length = 0
for i in range(address.max_prefixlen):
if int(address) >> i & 1: prefix_length = prefix_length + 1
return prefix_length
if os.name == "posix":
class ifaddrs(ctypes.Structure): pass
ifaddrs._fields_ = [
("ifa_next", ctypes.POINTER(ifaddrs)),
("ifa_name", ctypes.c_char_p),
("ifa_flags", ctypes.c_uint),
("ifa_addr", ctypes.POINTER(sockaddr)),
("ifa_netmask", ctypes.POINTER(sockaddr)),]
libc = ctypes.CDLL(ctypes.util.find_library("socket" if os.uname()[0] == "SunOS" else "c"), use_errno=True) # type: ignore
def _get_adapters_posix(include_unconfigured: bool = False) -> Iterable[Adapter]:
addr0 = addr = ctypes.POINTER(ifaddrs)()
retval = libc.getifaddrs(ctypes.byref(addr))
if retval != 0:
eno = ctypes.get_errno()
raise OSError(eno, os.strerror(eno))
ips = collections.OrderedDict()
def add_ip(adapter_name: str, ip: Optional[IP]) -> None:
if adapter_name not in ips:
index = None # type: Optional[int]
try:
index = socket.if_nametoindex(adapter_name) # type: ignore
except (OSError, AttributeError): pass
ips[adapter_name] = Adapter(adapter_name, adapter_name, [], index=index)
if ip is not None:
ips[adapter_name].ips.append(ip)
while addr:
name = addr[0].ifa_name.decode(encoding="UTF-8")
ip_addr = sockaddr_to_ip(addr[0].ifa_addr)
if ip_addr:
if addr[0].ifa_netmask and not addr[0].ifa_netmask[0].sa_familiy:
addr[0].ifa_netmask[0].sa_familiy = addr[0].ifa_addr[0].sa_familiy
netmask = sockaddr_to_ip(addr[0].ifa_netmask)
if isinstance(netmask, tuple):
netmaskStr = str(netmask[0])
prefixlen = ipv6_prefixlength(ipaddress.IPv6Address(netmaskStr))
else:
assert netmask is not None, f"sockaddr_to_ip({addr[0].ifa_netmask}) returned None"
netmaskStr = str("0.0.0.0/" + netmask)
prefixlen = ipaddress.IPv4Network(netmaskStr).prefixlen
ip = IP(ip_addr, prefixlen, name)
add_ip(name, ip)
else:
if include_unconfigured:
add_ip(name, None)
addr = addr[0].ifa_next
libc.freeifaddrs(addr0)
return ips.values()
elif os.name == "nt":
from ctypes import wintypes
NO_ERROR = 0
ERROR_BUFFER_OVERFLOW = 111
MAX_ADAPTER_NAME_LENGTH = 256
MAX_ADAPTER_DESCRIPTION_LENGTH = 128
MAX_ADAPTER_ADDRESS_LENGTH = 8
AF_UNSPEC = 0
class SOCKET_ADDRESS(ctypes.Structure): _fields_ = [("lpSockaddr", ctypes.POINTER(sockaddr)), ("iSockaddrLength", wintypes.INT)]
class IP_ADAPTER_UNICAST_ADDRESS(ctypes.Structure): pass
IP_ADAPTER_UNICAST_ADDRESS._fields_ = [
("Length", wintypes.ULONG),
("Flags", wintypes.DWORD),
("Next", ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
("Address", SOCKET_ADDRESS),
("PrefixOrigin", ctypes.c_uint),
("SuffixOrigin", ctypes.c_uint),
("DadState", ctypes.c_uint),
("ValidLifetime", wintypes.ULONG),
("PreferredLifetime", wintypes.ULONG),
("LeaseLifetime", wintypes.ULONG),
("OnLinkPrefixLength", ctypes.c_uint8)]
class IP_ADAPTER_ADDRESSES(ctypes.Structure): pass
IP_ADAPTER_ADDRESSES._fields_ = [
("Length", wintypes.ULONG),
("IfIndex", wintypes.DWORD),
("Next", ctypes.POINTER(IP_ADAPTER_ADDRESSES)),
("AdapterName", ctypes.c_char_p),
("FirstUnicastAddress", ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
("FirstAnycastAddress", ctypes.c_void_p),
("FirstMulticastAddress", ctypes.c_void_p),
("FirstDnsServerAddress", ctypes.c_void_p),
("DnsSuffix", ctypes.c_wchar_p),
("Description", ctypes.c_wchar_p),
("FriendlyName", ctypes.c_wchar_p)]
iphlpapi = ctypes.windll.LoadLibrary("Iphlpapi") # type: ignore
def _enumerate_interfaces_of_adapter_win(nice_name: str, address: IP_ADAPTER_UNICAST_ADDRESS) -> Iterable[IP]:
# Iterate through linked list and fill list
addresses = [] # type: List[IP_ADAPTER_UNICAST_ADDRESS]
while True:
addresses.append(address)
if not address.Next: break
address = address.Next[0]
for address in addresses:
ip = sockaddr_to_ip(address.Address.lpSockaddr)
assert ip is not None, f"sockaddr_to_ip({address.Address.lpSockaddr}) returned None"
network_prefix = address.OnLinkPrefixLength
yield IP(ip, network_prefix, nice_name)
def _get_adapters_win(include_unconfigured: bool = False) -> Iterable[Adapter]:
addressbuffersize = wintypes.ULONG(15 * 1024)
retval = ERROR_BUFFER_OVERFLOW
while retval == ERROR_BUFFER_OVERFLOW:
addressbuffer = ctypes.create_string_buffer(addressbuffersize.value)
retval = iphlpapi.GetAdaptersAddresses(
wintypes.ULONG(AF_UNSPEC),
wintypes.ULONG(0),
None,
ctypes.byref(addressbuffer),
ctypes.byref(addressbuffersize))
if retval != NO_ERROR:
raise ctypes.WinError() # type: ignore
# Iterate through adapters and fill array
address_infos = [] # type: List[IP_ADAPTER_ADDRESSES]
address_info = IP_ADAPTER_ADDRESSES.from_buffer(addressbuffer)
while True:
address_infos.append(address_info)
if not address_info.Next: break
address_info = address_info.Next[0]
# Iterate through unicast addresses
result = [] # type: List[Adapter]
for adapter_info in address_infos:
name = adapter_info.AdapterName.decode()
nice_name = adapter_info.Description
index = adapter_info.IfIndex
if adapter_info.FirstUnicastAddress:
ips = _enumerate_interfaces_of_adapter_win(adapter_info.FriendlyName, adapter_info.FirstUnicastAddress[0])
ips = list(ips)
result.append(Adapter(name, nice_name, ips, index=index))
elif include_unconfigured: result.append(Adapter(name, nice_name, [], index=index))
return result
+291 -71
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors.
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -28,10 +36,11 @@ from time import sleep
from .vendor import umsgpack as umsgpack
import threading
import inspect
import struct
import math
import time
import RNS
import io
class LinkCallbacks:
def __init__(self):
@@ -68,21 +77,26 @@ class Link:
Timeout for link establishment in seconds per hop to destination.
"""
TRAFFIC_TIMEOUT_MIN_MS = 5
TRAFFIC_TIMEOUT_FACTOR = 6
LINK_MTU_SIZE = 3
TRAFFIC_TIMEOUT_MIN_MS = 5
TRAFFIC_TIMEOUT_FACTOR = 6
KEEPALIVE_MAX_RTT = 1.75
KEEPALIVE_TIMEOUT_FACTOR = 4
"""
RTT timeout factor used in link timeout calculation.
"""
STALE_GRACE = 2
STALE_GRACE = 5
"""
Grace period in seconds used in link timeout calculation.
"""
KEEPALIVE = 360
KEEPALIVE_MAX = 360
KEEPALIVE_MIN = 5
KEEPALIVE = KEEPALIVE_MAX
"""
Interval for sending keep-alive packets on established links in seconds.
Default interval for sending keep-alive packets on established links in seconds.
"""
STALE_TIME = 2*KEEPALIVE
STALE_FACTOR = 2
STALE_TIME = STALE_FACTOR*KEEPALIVE
"""
If no traffic or keep-alive packets are received within this period, the
link will be marked as stale, and a final keep-alive packet will be sent.
@@ -91,31 +105,109 @@ class Link:
and will be torn down.
"""
PENDING = 0x00
HANDSHAKE = 0x01
ACTIVE = 0x02
STALE = 0x03
CLOSED = 0x04
WATCHDOG_MAX_SLEEP = 5
TIMEOUT = 0x01
INITIATOR_CLOSED = 0x02
DESTINATION_CLOSED = 0x03
PENDING = 0x00
HANDSHAKE = 0x01
ACTIVE = 0x02
STALE = 0x03
CLOSED = 0x04
ACCEPT_NONE = 0x00
ACCEPT_APP = 0x01
ACCEPT_ALL = 0x02
TIMEOUT = 0x01
INITIATOR_CLOSED = 0x02
DESTINATION_CLOSED = 0x03
ACCEPT_NONE = 0x00
ACCEPT_APP = 0x01
ACCEPT_ALL = 0x02
resource_strategies = [ACCEPT_NONE, ACCEPT_APP, ACCEPT_ALL]
MODE_AES128_CBC = 0x00
MODE_AES256_CBC = 0x01
MODE_AES256_GCM = 0x02
MODE_OTP_RESERVED = 0x03
MODE_PQ_RESERVED_1 = 0x04
MODE_PQ_RESERVED_2 = 0x05
MODE_PQ_RESERVED_3 = 0x06
MODE_PQ_RESERVED_4 = 0x07
ENABLED_MODES = [MODE_AES256_CBC]
MODE_DEFAULT = MODE_AES256_CBC
MODE_DESCRIPTIONS = {MODE_AES128_CBC: "AES_128_CBC",
MODE_AES256_CBC: "AES_256_CBC",
MODE_AES256_GCM: "MODE_AES256_GCM",
MODE_OTP_RESERVED: "MODE_OTP_RESERVED",
MODE_PQ_RESERVED_1: "MODE_PQ_RESERVED_1",
MODE_PQ_RESERVED_2: "MODE_PQ_RESERVED_2",
MODE_PQ_RESERVED_3: "MODE_PQ_RESERVED_3",
MODE_PQ_RESERVED_4: "MODE_PQ_RESERVED_4"}
MTU_BYTEMASK = 0x1FFFFF
MODE_BYTEMASK = 0xE0
@staticmethod
def signalling_bytes(mtu, mode):
if not mode in Link.ENABLED_MODES: raise TypeError(f"Requested link mode {Link.MODE_DESCRIPTIONS[mode]} not enabled")
signalling_value = (mtu & Link.MTU_BYTEMASK)+(((mode<<5) & Link.MODE_BYTEMASK)<<16)
return struct.pack(">I", signalling_value)[1:]
@staticmethod
def mtu_from_lr_packet(packet):
if len(packet.data) == Link.ECPUBSIZE+Link.LINK_MTU_SIZE:
return (packet.data[Link.ECPUBSIZE] << 16) + (packet.data[Link.ECPUBSIZE+1] << 8) + (packet.data[Link.ECPUBSIZE+2]) & Link.MTU_BYTEMASK
else: return None
@staticmethod
def mtu_from_lp_packet(packet):
if len(packet.data) == RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2+Link.LINK_MTU_SIZE:
mtu_bytes = packet.data[RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2:RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2+Link.LINK_MTU_SIZE]
return (mtu_bytes[0] << 16) + (mtu_bytes[1] << 8) + (mtu_bytes[2]) & Link.MTU_BYTEMASK
else: return None
@staticmethod
def mode_byte(mode):
if mode in Link.ENABLED_MODES: return (mode << 5) & Link.MODE_BYTEMASK
else: raise TypeError(f"Requested link mode {mode} not enabled")
@staticmethod
def mode_from_lr_packet(packet):
if len(packet.data) > Link.ECPUBSIZE:
mode = (packet.data[Link.ECPUBSIZE] & Link.MODE_BYTEMASK) >> 5
return mode
else: return Link.MODE_DEFAULT
@staticmethod
def mode_from_lp_packet(packet):
if len(packet.data) > RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2:
mode = packet.data[RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2] >> 5
return mode
else: return Link.MODE_DEFAULT
@staticmethod
def validate_request(owner, data, packet):
if len(data) == (Link.ECPUBSIZE):
if len(data) == Link.ECPUBSIZE or len(data) == Link.ECPUBSIZE+Link.LINK_MTU_SIZE:
try:
link = Link(owner = owner, peer_pub_bytes=data[:Link.ECPUBSIZE//2], peer_sig_pub_bytes=data[Link.ECPUBSIZE//2:Link.ECPUBSIZE])
link.set_link_id(packet)
if len(data) == Link.ECPUBSIZE+Link.LINK_MTU_SIZE:
RNS.log("Link request includes MTU signalling", RNS.LOG_DEBUG) # TODO: Remove debug
try:
link.mtu = Link.mtu_from_lr_packet(packet) or Reticulum.MTU
except Exception as e:
RNS.trace_exception(e)
link.mtu = RNS.Reticulum.MTU
link.mode = Link.mode_from_lr_packet(packet)
# TODO: Remove debug
RNS.log(f"Incoming link request with mode {Link.MODE_DESCRIPTIONS[link.mode]}", RNS.LOG_DEBUG)
link.update_mdu()
link.destination = packet.destination
link.establishment_timeout = Link.ESTABLISHMENT_TIMEOUT_PER_HOP * max(1, packet.hops) + Link.KEEPALIVE
link.establishment_cost += len(packet.raw)
RNS.log("Validating link request "+RNS.prettyhexrep(link.link_id), RNS.LOG_VERBOSE)
RNS.log(f"Validating link request {RNS.prettyhexrep(link.link_id)}", RNS.LOG_DEBUG)
RNS.log(f"Link MTU configured to {RNS.prettysize(link.mtu)}", RNS.LOG_EXTREME)
RNS.log(f"Establishment timeout is {RNS.prettytime(link.establishment_timeout)} for incoming link request "+RNS.prettyhexrep(link.link_id), RNS.LOG_EXTREME)
link.handshake()
link.attached_interface = packet.receiving_interface
@@ -134,23 +226,28 @@ class Link:
return None
else:
RNS.log("Invalid link request payload size, dropping request", RNS.LOG_DEBUG)
RNS.log(f"Invalid link request payload size of {len(data)} bytes, dropping request", RNS.LOG_DEBUG)
return None
def __init__(self, destination=None, established_callback = None, closed_callback = None, owner=None, peer_pub_bytes = None, peer_sig_pub_bytes = None):
if destination != None and destination.type != RNS.Destination.SINGLE:
raise TypeError("Links can only be established to the \"single\" destination type")
def __init__(self, destination=None, established_callback=None, closed_callback=None, owner=None, peer_pub_bytes=None, peer_sig_pub_bytes=None, mode=MODE_DEFAULT):
if destination != None and destination.type != RNS.Destination.SINGLE: raise TypeError("Links can only be established to the \"single\" destination type")
self.mode = mode
self.rtt = None
self.mtu = RNS.Reticulum.MTU
self.establishment_cost = 0
self.establishment_rate = None
self.expected_rate = None
self.callbacks = LinkCallbacks()
self.resource_strategy = Link.ACCEPT_NONE
self.last_resource_window = None
self.last_resource_eifr = None
self.outgoing_resources = []
self.incoming_resources = []
self.pending_requests = []
self.last_inbound = 0
self.last_outbound = 0
self.last_keepalive = 0
self.last_proof = 0
self.last_data = 0
self.tx = 0
@@ -208,8 +305,15 @@ class Link:
if closed_callback != None:
self.set_link_closed_callback(closed_callback)
if (self.initiator):
self.request_data = self.pub_bytes+self.sig_pub_bytes
if self.initiator:
signalling_bytes = b""
nh_hw_mtu = RNS.Transport.next_hop_interface_hw_mtu(destination.hash)
if RNS.Reticulum.link_mtu_discovery() and nh_hw_mtu:
signalling_bytes = Link.signalling_bytes(nh_hw_mtu, self.mode)
RNS.log(f"Signalling link MTU of {RNS.prettysize(nh_hw_mtu)} for link", RNS.LOG_DEBUG) # TODO: Remove debug
else: signalling_bytes = Link.signalling_bytes(RNS.Reticulum.MTU, self.mode)
RNS.log(f"Establishing link with mode {Link.MODE_DESCRIPTIONS[self.mode]}", RNS.LOG_DEBUG) # TODO: Remove debug
self.request_data = self.pub_bytes+self.sig_pub_bytes+signalling_bytes
self.packet = RNS.Packet(destination, self.request_data, packet_type=RNS.Packet.LINKREQUEST)
self.packet.pack()
self.establishment_cost += len(self.packet.raw)
@@ -233,8 +337,17 @@ class Link:
if not hasattr(self.peer_pub, "curve"):
self.peer_pub.curve = Link.CURVE
@staticmethod
def link_id_from_lr_packet(packet):
hashable_part = packet.get_hashable_part()
if len(packet.data) > Link.ECPUBSIZE:
diff = len(packet.data) - Link.ECPUBSIZE
hashable_part = hashable_part[:-diff]
return RNS.Identity.truncated_hash(hashable_part)
def set_link_id(self, packet):
self.link_id = packet.getTruncatedHash()
self.link_id = Link.link_id_from_lr_packet(packet)
self.hash = self.link_id
def handshake(self):
@@ -242,21 +355,25 @@ class Link:
self.status = Link.HANDSHAKE
self.shared_key = self.prv.exchange(self.peer_pub)
if self.mode == Link.MODE_AES128_CBC: derived_key_length = 32
elif self.mode == Link.MODE_AES256_CBC: derived_key_length = 64
else: raise TypeError(f"Invalid link mode {self.mode} on {self}")
self.derived_key = RNS.Cryptography.hkdf(
length=32,
length=derived_key_length,
derive_from=self.shared_key,
salt=self.get_salt(),
context=self.get_context(),
)
else:
RNS.log("Handshake attempt on "+str(self)+" with invalid state "+str(self.status), RNS.LOG_ERROR)
context=self.get_context())
else: RNS.log("Handshake attempt on "+str(self)+" with invalid state "+str(self.status), RNS.LOG_ERROR)
def prove(self):
signed_data = self.link_id+self.pub_bytes+self.sig_pub_bytes
signalling_bytes = Link.signalling_bytes(self.mtu, self.mode)
signed_data = self.link_id+self.pub_bytes+self.sig_pub_bytes+signalling_bytes
signature = self.owner.identity.sign(signed_data)
proof_data = signature+self.pub_bytes
proof_data = signature+self.pub_bytes+signalling_bytes
proof = RNS.Packet(self, proof_data, packet_type=RNS.Packet.PROOF, context=RNS.Packet.LRPROOF)
proof.send()
self.establishment_cost += len(proof.raw)
@@ -279,6 +396,17 @@ class Link:
def validate_proof(self, packet):
try:
if self.status == Link.PENDING:
signalling_bytes = b""
confirmed_mtu = None
mode = Link.mode_from_lp_packet(packet)
RNS.log(f"Validating link request proof with mode {Link.MODE_DESCRIPTIONS[mode]}", RNS.LOG_DEBUG) # TODO: Remove debug
if mode != self.mode: raise TypeError(f"Invalid link mode {mode} in link request proof")
if len(packet.data) == RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2+Link.LINK_MTU_SIZE:
confirmed_mtu = Link.mtu_from_lp_packet(packet)
signalling_bytes = Link.signalling_bytes(confirmed_mtu, mode)
packet.data = packet.data[:RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2]
RNS.log(f"Destination confirmed link MTU of {RNS.prettysize(confirmed_mtu)}", RNS.LOG_DEBUG) # TODO: Remove debug
if self.initiator and len(packet.data) == RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2:
peer_pub_bytes = packet.data[RNS.Identity.SIGLENGTH//8:RNS.Identity.SIGLENGTH//8+Link.ECPUBSIZE//2]
peer_sig_pub_bytes = self.destination.identity.get_public_key()[Link.ECPUBSIZE//2:Link.ECPUBSIZE]
@@ -286,7 +414,7 @@ class Link:
self.handshake()
self.establishment_cost += len(packet.raw)
signed_data = self.link_id+self.peer_pub_bytes+self.peer_sig_pub_bytes
signed_data = self.link_id+self.peer_pub_bytes+self.peer_sig_pub_bytes+signalling_bytes
signature = packet.data[:RNS.Identity.SIGLENGTH//8]
if self.destination.identity.validate(signature, signed_data):
@@ -296,15 +424,19 @@ class Link:
self.rtt = time.time() - self.request_time
self.attached_interface = packet.receiving_interface
self.__remote_identity = self.destination.identity
self.mtu = confirmed_mtu or RNS.Reticulum.MTU
self.update_mdu()
self.status = Link.ACTIVE
self.activated_at = time.time()
self.last_proof = self.activated_at
RNS.Transport.activate_link(self)
RNS.log("Link "+str(self)+" established with "+str(self.destination)+", RTT is "+str(round(self.rtt, 3))+"s", RNS.LOG_VERBOSE)
RNS.log("Link "+str(self)+" established with "+str(self.destination)+", RTT is "+RNS.prettyshorttime(self.rtt), RNS.LOG_DEBUG)
if self.rtt != None and self.establishment_cost != None and self.rtt > 0 and self.establishment_cost > 0:
self.establishment_rate = self.establishment_cost/self.rtt
self.__update_keepalive()
rtt_data = umsgpack.packb(self.rtt)
rtt_packet = RNS.Packet(self, rtt_data, context=RNS.Packet.LRRTT)
rtt_packet.send()
@@ -361,7 +493,7 @@ class Link:
if timeout == None:
timeout = self.rtt * self.traffic_timeout_factor + RNS.Resource.RESPONSE_MAX_GRACE_TIME*1.125
if len(packed_request) <= Link.MDU:
if len(packed_request) <= self.mdu:
request_packet = RNS.Packet(self, packed_request, RNS.Packet.DATA, context = RNS.Packet.REQUEST)
packet_receipt = request_packet.send()
@@ -395,6 +527,10 @@ class Link:
)
def update_mdu(self):
self.mdu = self.mtu - RNS.Reticulum.HEADER_MAXSIZE - RNS.Reticulum.IFAC_MIN_SIZE
self.mdu = math.floor((self.mtu-RNS.Reticulum.IFAC_MIN_SIZE-RNS.Reticulum.HEADER_MINSIZE-RNS.Identity.TOKEN_OVERHEAD)/RNS.Identity.AES128_BLOCKSIZE)*RNS.Identity.AES128_BLOCKSIZE - 1
def rtt_packet(self, packet):
try:
measured_rtt = time.time() - self.request_time
@@ -408,6 +544,8 @@ class Link:
if self.rtt != None and self.establishment_cost != None and self.rtt > 0 and self.establishment_cost > 0:
self.establishment_rate = self.establishment_cost/self.rtt
self.__update_keepalive()
try:
if self.owner.callbacks.link_established != None:
self.owner.callbacks.link_established(self)
@@ -468,6 +606,39 @@ class Link:
else:
return None
def get_mtu(self):
"""
:returns: The MTU of an established link.
"""
if self.status == Link.ACTIVE:
return self.mtu
else:
return None
def get_mdu(self):
"""
:returns: The packet MDU of an established link.
"""
if self.status == Link.ACTIVE:
return self.mdu
else:
return None
def get_expected_rate(self):
"""
:returns: The packet expected in-flight data rate of an established link.
"""
if self.status == Link.ACTIVE:
return self.expected_rate
else:
return None
def get_mode(self):
"""
:returns: The mode of an established link.
"""
return self.mode
def get_salt(self):
return self.link_id
@@ -517,23 +688,23 @@ class Link:
def had_outbound(self, is_keepalive=False):
self.last_outbound = time.time()
if not is_keepalive:
self.last_data = self.last_outbound
if not is_keepalive: self.last_data = self.last_outbound
else: self.last_keepalive = self.last_outbound
def __teardown_packet(self):
teardown_packet = RNS.Packet(self, self.link_id, context=RNS.Packet.LINKCLOSE)
teardown_packet.send()
self.had_outbound()
def teardown(self):
"""
Closes the link and purges encryption keys. New keys will
be used if a new link to the same destination is established.
"""
if self.status != Link.PENDING and self.status != Link.CLOSED:
teardown_packet = RNS.Packet(self, self.link_id, context=RNS.Packet.LINKCLOSE)
teardown_packet.send()
self.had_outbound()
if self.status != Link.PENDING and self.status != Link.CLOSED: self.__teardown_packet()
self.status = Link.CLOSED
if self.initiator:
self.teardown_reason = Link.INITIATOR_CLOSED
else:
self.teardown_reason = Link.DESTINATION_CLOSED
if self.initiator: self.teardown_reason = Link.INITIATOR_CLOSED
else: self.teardown_reason = Link.DESTINATION_CLOSED
self.link_closed()
def teardown_packet(self, packet):
@@ -620,9 +791,10 @@ class Link:
elif self.status == Link.ACTIVE:
activated_at = self.activated_at if self.activated_at != None else 0
last_inbound = max(max(self.last_inbound, self.last_proof), activated_at)
now = time.time()
if time.time() >= last_inbound + self.keepalive:
if self.initiator:
if now >= last_inbound + self.keepalive:
if self.initiator and now >= self.last_keepalive + self.keepalive:
self.send_keepalive()
if time.time() >= last_inbound + self.stale_time:
@@ -636,6 +808,7 @@ class Link:
elif self.status == Link.STALE:
sleep_time = 0.001
self.__teardown_packet()
self.status = Link.CLOSED
self.teardown_reason = Link.TIMEOUT
self.link_closed()
@@ -648,6 +821,7 @@ class Link:
self.teardown()
sleep_time = 0.1
sleep_time = min(sleep_time, Link.WATCHDOG_MAX_SLEEP)
sleep(sleep_time)
if not self.__track_phy_stats:
@@ -670,6 +844,10 @@ class Link:
self.snr = packet.snr
if packet.q != None:
self.q = packet.q
def __update_keepalive(self):
self.keepalive = max(min(self.rtt*(Link.KEEPALIVE_MAX/Link.KEEPALIVE_MAX_RTT), Link.KEEPALIVE_MAX), Link.KEEPALIVE_MIN)
self.stale_time = self.keepalive * Link.STALE_FACTOR
def send_keepalive(self):
keepalive_packet = RNS.Packet(self, bytes([0xFF]), context=RNS.Packet.KEEPALIVE)
@@ -688,6 +866,7 @@ class Link:
response_generator = request_handler[1]
allow = request_handler[2]
allowed_list = request_handler[3]
auto_compress = request_handler[4]
allowed = False
if not allow == RNS.Destination.ALLOW_NONE:
@@ -706,18 +885,29 @@ class Link:
else:
raise TypeError("Invalid signature for response generator callback")
if response != None:
packed_response = umsgpack.packb([request_id, response])
file_response = False
file_handle = None
if type(response) == list or type(response) == tuple:
metadata = None
if len(response) > 0 and type(response[0]) == io.BufferedReader:
if len(response) > 1: metadata = response[1]
file_handle = response[0]
file_response = True
if len(packed_response) <= Link.MDU:
RNS.Packet(self, packed_response, RNS.Packet.DATA, context = RNS.Packet.RESPONSE).send()
if response != None:
if file_response:
response_resource = RNS.Resource(file_handle, self, metadata=metadata, request_id = request_id, is_response = True, auto_compress=auto_compress)
else:
response_resource = RNS.Resource(packed_response, self, request_id = request_id, is_response = True)
packed_response = umsgpack.packb([request_id, response])
if len(packed_response) <= self.mdu:
RNS.Packet(self, packed_response, RNS.Packet.DATA, context = RNS.Packet.RESPONSE).send()
else:
response_resource = RNS.Resource(packed_response, self, request_id = request_id, is_response = True, auto_compress=auto_compress)
else:
identity_string = str(self.get_remote_identity()) if self.get_remote_identity() != None else "<Unknown>"
RNS.log("Request "+RNS.prettyhexrep(request_id)+" from "+identity_string+" not allowed for: "+str(path), RNS.LOG_DEBUG)
def handle_response(self, request_id, response_data, response_size, response_transfer_size):
def handle_response(self, request_id, response_data, response_size, response_transfer_size, metadata=None):
if self.status == Link.ACTIVE:
remove = None
for pending_request in self.pending_requests:
@@ -728,7 +918,7 @@ class Link:
if pending_request.response_transfer_size == None:
pending_request.response_transfer_size = 0
pending_request.response_transfer_size += response_transfer_size
pending_request.response_received(response_data)
pending_request.response_received(response_data, metadata)
except Exception as e:
RNS.log("Error occurred while handling response. The contained exception was: "+str(e), RNS.LOG_ERROR)
@@ -751,12 +941,21 @@ class Link:
def response_resource_concluded(self, resource):
if resource.status == RNS.Resource.COMPLETE:
packed_response = resource.data.read()
unpacked_response = umsgpack.unpackb(packed_response)
request_id = unpacked_response[0]
response_data = unpacked_response[1]
# If the response resource has metadata, this
# is a file response, and we'll pass the open
# file handle directly.
if resource.has_metadata:
self.handle_response(resource.request_id, resource.data, resource.total_size, resource.size, metadata=resource.metadata)
# If not, we'll unpack the response data and
# pass the unpacked structure to the handler
else:
packed_response = resource.data.read()
unpacked_response = umsgpack.unpackb(packed_response)
request_id = unpacked_response[0]
response_data = unpacked_response[1]
self.handle_response(request_id, response_data, resource.total_size, resource.size)
self.handle_response(request_id, response_data, resource.total_size, resource.size)
else:
RNS.log("Incoming response resource failed with status: "+RNS.hexrep([resource.status]), RNS.LOG_DEBUG)
for pending_request in self.pending_requests:
@@ -895,6 +1094,8 @@ class Link:
resource_advertisement.link = self
if self.callbacks.resource(resource_advertisement):
RNS.Resource.accept(packet, self.callbacks.resource_concluded)
else:
RNS.Resource.reject(packet)
except Exception as e:
RNS.log("Error while executing resource accept callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
elif self.resource_strategy == Link.ACCEPT_ALL:
@@ -940,6 +1141,15 @@ class Link:
if resource_hash == resource.hash:
resource.cancel()
elif packet.context == RNS.Packet.RESOURCE_RCL:
plaintext = self.decrypt(packet.data)
if plaintext != None:
self.__update_phy_stats(packet)
resource_hash = plaintext[:RNS.Identity.HASHLENGTH//8]
for resource in self.outgoing_resources:
if resource_hash == resource.hash:
resource._rejected()
elif packet.context == RNS.Packet.KEEPALIVE:
if not self.initiator and packet.data == bytes([0xFF]):
keepalive_packet = RNS.Packet(self, bytes([0xFE]), context=RNS.Packet.KEEPALIVE)
@@ -980,8 +1190,7 @@ class Link:
def encrypt(self, plaintext):
try:
if not self.token:
try:
self.token = Token(self.derived_key)
try: self.token = Token(self.derived_key)
except Exception as e:
RNS.log("Could not instantiate token while performing encryption on link "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
raise e
@@ -995,9 +1204,7 @@ class Link:
def decrypt(self, ciphertext):
try:
if not self.token:
self.token = Token(self.derived_key)
if not self.token: self.token = Token(self.derived_key)
return self.token.decrypt(ciphertext)
except Exception as e:
@@ -1075,10 +1282,15 @@ class Link:
self.callbacks.remote_identified = callback
def resource_concluded(self, resource):
concluded_at = time.time()
if resource in self.incoming_resources:
self.last_resource_window = resource.window
self.last_resource_eifr = resource.eifr
self.incoming_resources.remove(resource)
self.expected_rate = (resource.size*8)/(max(concluded_at-resource.started_transferring, 0.0001))
if resource in self.outgoing_resources:
self.outgoing_resources.remove(resource)
self.expected_rate = (resource.size*8)/(max(concluded_at-resource.started_transferring, 0.0001))
def set_resource_strategy(self, resource_strategy):
"""
@@ -1105,6 +1317,12 @@ class Link:
return False
def get_last_resource_window(self):
return self.last_resource_window
def get_last_resource_eifr(self):
return self.last_resource_eifr
def cancel_outgoing_resource(self, resource):
if resource in self.outgoing_resources:
self.outgoing_resources.remove(resource)
@@ -1161,6 +1379,7 @@ class RequestReceipt():
self.response = None
self.response_transfer_size = None
self.response_size = None
self.metadata = None
self.status = RequestReceipt.SENT
self.sent_at = time.time()
self.progress = 0
@@ -1247,10 +1466,11 @@ class RequestReceipt():
resource.cancel()
def response_received(self, response):
def response_received(self, response, metadata=None):
if not self.status == RequestReceipt.FAILED:
self.progress = 1.0
self.response = response
self.metadata = metadata
self.status = RequestReceipt.READY
self.response_concluded_at = time.time()
+35 -12
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors.
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -35,10 +43,10 @@ class Packet:
For ``RNS.Destination.GROUP`` destinations, Reticulum will use the
pre-shared key configured for the destination. All packets to group
destinations are encrypted with the same AES-128 key.
destinations are encrypted with the same AES-256 key.
For ``RNS.Destination.SINGLE`` destinations, Reticulum will use a newly
derived ephemeral AES-128 key for every packet.
derived ephemeral AES-256 key for every packet.
For :ref:`RNS.Link<api-link>` destinations, Reticulum will use per-link
ephemeral keys, and offers **Forward Secrecy**.
@@ -106,6 +114,11 @@ class Packet:
TIMEOUT_PER_HOP = RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT
__slots__ = "hops", "header", "header_type", "packet_type", "transport_type", "context", "context_flag", "destination"
__slots__ += "transport_id", "data", "flags", "raw", "packed", "sent", "create_receipt", "receipt", "fromPacked", "MTU"
__slots__ += "sent_at", "packet_hash", "ratchet_id", "attached_interface", "receiving_interface", "rssi", "snr", "q"
__slots__ += "ciphertext", "plaintext", "destination_hash", "destination_type", "link", "map_hash"
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):
@@ -137,7 +150,11 @@ class Packet:
self.fromPacked = True
self.create_receipt = False
self.MTU = RNS.Reticulum.MTU
if destination and destination.type == RNS.Destination.LINK:
self.MTU = destination.mtu
else:
self.MTU = RNS.Reticulum.MTU
self.sent_at = None
self.packet_hash = None
self.ratchet_id = None
@@ -262,17 +279,19 @@ class Packet:
if not self.sent:
if self.destination.type == RNS.Destination.LINK:
if self.destination.status == RNS.Link.CLOSED:
raise IOError("Attempt to transmit over a closed link")
RNS.log("Attempt to transmit over a closed link, dropping packet", RNS.LOG_DEBUG)
self.sent = False
self.receipt = None
return False
else:
self.destination.last_outbound = time.time()
self.destination.tx += 1
self.destination.txbytes += len(self.data)
if not self.packed:
self.pack()
if not self.packed: self.pack()
if RNS.Transport.outbound(self):
return self.receipt
if RNS.Transport.outbound(self): return self.receipt
else:
RNS.log("No interfaces could process the outbound packet", RNS.LOG_ERROR)
self.sent = False
@@ -501,6 +520,10 @@ class PacketReceipt:
return False
elif len(proof) == PacketReceipt.IMPL_LENGTH:
# This is an implicit proof
if not hasattr(self.destination, "identity"):
return False
if self.destination.identity == None:
return False
+12 -4
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors.
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
+262 -117
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors.
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -25,6 +33,7 @@ import os
import bz2
import math
import time
import struct
import tempfile
import threading
from threading import Lock
@@ -99,22 +108,20 @@ class Resource:
# it is to be handled within reasonable
# time constraint, even on small systems.
#
# A small system in this regard is
# defined as a Raspberry Pi, which should
# be able to compress, encrypt and hash-map
# the resource in about 10 seconds.
#
# This constant will be used when determining
# how to sequence the sending of large resources.
#
# Capped at 16777215 (0xFFFFFF) per segment to
# fit in 3 bytes in resource advertisements.
MAX_EFFICIENT_SIZE = 16 * 1024 * 1024 - 1
MAX_EFFICIENT_SIZE = 1 * 1024 * 1024 - 1
RESPONSE_MAX_GRACE_TIME = 10
# Max metadata size is 16777215 (0xFFFFFF) bytes
METADATA_MAX_SIZE = 16 * 1024 * 1024 - 1
# The maximum size to auto-compress with
# bz2 before sending.
AUTO_COMPRESS_MAX_SIZE = MAX_EFFICIENT_SIZE
AUTO_COMPRESS_MAX_SIZE = 64 * 1024 * 1024
PART_TIMEOUT_FACTOR = 4
PART_TIMEOUT_FACTOR_AFTER_RTT = 2
@@ -141,6 +148,19 @@ class Resource:
COMPLETE = 0x06
FAILED = 0x07
CORRUPT = 0x08
REJECTED = 0x00
@staticmethod
def reject(advertisement_packet):
try:
adv = ResourceAdvertisement.unpack(advertisement_packet.plaintext)
resource_hash = adv.h
reject_packet = RNS.Packet(advertisement_packet.link, resource_hash, context=RNS.Packet.RESOURCE_RCL)
reject_packet.send()
except Exception as e:
RNS.log(f"An error ocurred while rejecting advertised resource: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
@staticmethod
def accept(advertisement_packet, callback=None, progress_callback = None, request_id = None):
@@ -150,42 +170,53 @@ class Resource:
resource = Resource(None, advertisement_packet.link, request_id = request_id)
resource.status = Resource.TRANSFERRING
resource.flags = adv.f
resource.size = adv.t
resource.total_size = adv.d
resource.uncompressed_size = adv.d
resource.hash = adv.h
resource.original_hash = adv.o
resource.random_hash = adv.r
resource.hashmap_raw = adv.m
resource.encrypted = True if resource.flags & 0x01 else False
resource.compressed = True if resource.flags >> 1 & 0x01 else False
resource.initiator = False
resource.callback = callback
resource.__progress_callback = progress_callback
resource.total_parts = int(math.ceil(resource.size/float(Resource.SDU)))
resource.received_count = 0
resource.outstanding_parts = 0
resource.parts = [None] * resource.total_parts
resource.window = Resource.WINDOW
resource.window_max = Resource.WINDOW_MAX_SLOW
resource.window_min = Resource.WINDOW_MIN
resource.window_flexibility = Resource.WINDOW_FLEXIBILITY
resource.last_activity = time.time()
resource.flags = adv.f
resource.size = adv.t
resource.total_size = adv.d
resource.uncompressed_size = adv.d
resource.hash = adv.h
resource.original_hash = adv.o
resource.random_hash = adv.r
resource.hashmap_raw = adv.m
resource.encrypted = True if resource.flags & 0x01 else False
resource.compressed = True if resource.flags >> 1 & 0x01 else False
resource.initiator = False
resource.callback = callback
resource.__progress_callback = progress_callback
resource.total_parts = int(math.ceil(resource.size/float(resource.sdu)))
resource.received_count = 0
resource.outstanding_parts = 0
resource.parts = [None] * resource.total_parts
resource.window = Resource.WINDOW
resource.window_max = Resource.WINDOW_MAX_SLOW
resource.window_min = Resource.WINDOW_MIN
resource.window_flexibility = Resource.WINDOW_FLEXIBILITY
resource.last_activity = time.time()
resource.started_transferring = resource.last_activity
resource.storagepath = RNS.Reticulum.resourcepath+"/"+resource.original_hash.hex()
resource.segment_index = adv.i
resource.total_segments = adv.l
if adv.l > 1:
resource.split = True
else:
resource.split = False
resource.storagepath = RNS.Reticulum.resourcepath+"/"+resource.original_hash.hex()
resource.meta_storagepath = resource.storagepath+".meta"
resource.segment_index = adv.i
resource.total_segments = adv.l
if adv.l > 1: resource.split = True
else: resource.split = False
if adv.x: resource.has_metadata = True
else: resource.has_metadata = False
resource.hashmap = [None] * resource.total_parts
resource.hashmap_height = 0
resource.waiting_for_hmu = False
resource.receiving_part = False
resource.consecutive_completed_height = -1
previous_window = resource.link.get_last_resource_window()
previous_eifr = resource.link.get_last_resource_eifr()
if previous_window:
resource.window = previous_window
if previous_eifr:
resource.previous_eifr = previous_eifr
if not resource.link.has_incoming_resource(resource):
resource.link.register_incoming_resource(resource)
@@ -198,9 +229,7 @@ class Resource:
RNS.log("Error while executing resource started callback from "+str(resource)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
resource.hashmap_update(0, resource.hashmap_raw)
resource.watchdog_job()
return resource
else:
@@ -214,15 +243,33 @@ class Resource:
# Create a resource for transmission to a remote destination
# The data passed can be either a bytes-array or a file opened
# in binary read mode.
def __init__(self, data, link, advertise=True, auto_compress=True, callback=None, progress_callback=None, timeout = None, segment_index = 1, original_hash = None, request_id = None, is_response = False):
def __init__(self, data, link, metadata=None, advertise=True, auto_compress=True, callback=None, progress_callback=None,
timeout = None, segment_index = 1, original_hash = None, request_id = None, is_response = False, sent_metadata_size=0):
data_size = None
resource_data = None
self.assembly_lock = False
self.preparing_next_segment = False
self.next_segment = None
self.metadata = None
self.has_metadata = False
self.metadata_size = sent_metadata_size
if metadata != None:
packed_metadata = umsgpack.packb(metadata)
metadata_size = len(packed_metadata)
if metadata_size > Resource.METADATA_MAX_SIZE:
raise SystemError("Resource metadata size exceeded")
else:
self.metadata = struct.pack(">I", metadata_size)[1:] + packed_metadata
self.metadata_size = len(self.metadata)
self.has_metadata = True
else:
self.metadata = b""
if sent_metadata_size > 0: self.has_metadata = True
if data != None:
if not hasattr(data, "read") and len(data) > Resource.MAX_EFFICIENT_SIZE:
if not hasattr(data, "read") and self.metadata_size + len(data) > Resource.MAX_EFFICIENT_SIZE:
original_data = data
data_size = len(original_data)
data = tempfile.TemporaryFile()
@@ -230,31 +277,43 @@ class Resource:
del original_data
if hasattr(data, "read"):
if data_size == None:
data_size = os.stat(data.name).st_size
if data_size == None: data_size = os.stat(data.name).st_size
self.total_size = data_size + self.metadata_size
self.total_size = data_size
if data_size <= Resource.MAX_EFFICIENT_SIZE:
if self.total_size <= Resource.MAX_EFFICIENT_SIZE:
self.total_segments = 1
self.segment_index = 1
self.split = False
resource_data = data.read()
data.close()
else:
self.total_segments = ((data_size-1)//Resource.MAX_EFFICIENT_SIZE)+1
# 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
seek_index = segment_index-1
seek_position = seek_index*Resource.MAX_EFFICIENT_SIZE
first_read_size = Resource.MAX_EFFICIENT_SIZE - self.metadata_size
if segment_index == 1:
seek_position = 0
segment_read_size = first_read_size
else:
seek_position = first_read_size + ((seek_index-1)*Resource.MAX_EFFICIENT_SIZE)
segment_read_size = Resource.MAX_EFFICIENT_SIZE
data.seek(seek_position)
resource_data = data.read(Resource.MAX_EFFICIENT_SIZE)
resource_data = data.read(segment_read_size)
self.input_file = data
elif isinstance(data, bytes):
data_size = len(data)
self.total_size = data_size
self.total_size = data_size + self.metadata_size
resource_data = data
self.total_segments = 1
@@ -267,10 +326,16 @@ class Resource:
else:
raise TypeError("Invalid data instance type passed to resource initialisation")
data = resource_data
if resource_data:
if self.has_metadata: data = self.metadata + resource_data
else: data = resource_data
self.status = Resource.NONE
self.link = link
if self.link.mtu:
self.sdu = self.link.mtu - RNS.Reticulum.HEADER_MAXSIZE - RNS.Reticulum.IFAC_MIN_SIZE
else:
self.sdu = link.mdu or Resource.SDU
self.max_retries = Resource.MAX_RETRIES
self.max_adv_retries = Resource.MAX_ADV_RETRIES
self.retries_left = self.max_retries
@@ -286,10 +351,24 @@ class Resource:
self.req_sent = 0
self.req_resp_rtt_rate = 0
self.rtt_rxd_bytes_at_part_req = 0
self.req_data_rtt_rate = 0
self.eifr = None
self.previous_eifr = None
self.fast_rate_rounds = 0
self.very_slow_rate_rounds = 0
self.request_id = request_id
self.started_transferring = None
self.is_response = is_response
self.auto_compress_limit = Resource.AUTO_COMPRESS_MAX_SIZE
self.auto_compress_option = auto_compress
if type(auto_compress) == bool:
self.auto_compress = auto_compress
elif type(auto_compress) == int:
self.auto_compress = True
self.auto_compress_limit = auto_compress
else:
raise TypeError(f"Invalid type {type(auto_compress)} for auto_compress option")
self.req_hashlist = []
self.receiver_min_consecutive_height = 0
@@ -305,10 +384,10 @@ class Resource:
self.uncompressed_data = data
compression_began = time.time()
if (auto_compress and len(self.uncompressed_data) <= Resource.AUTO_COMPRESS_MAX_SIZE):
RNS.log("Compressing resource data...", RNS.LOG_DEBUG)
if self.auto_compress and data_size <= self.auto_compress_limit:
RNS.log("Compressing resource data...", RNS.LOG_EXTREME)
self.compressed_data = bz2.compress(self.uncompressed_data)
RNS.log("Compression completed in "+str(round(time.time()-compression_began, 3))+" seconds", RNS.LOG_DEBUG)
RNS.log("Compression completed in "+str(round(time.time()-compression_began, 3))+" seconds", RNS.LOG_EXTREME)
else:
self.compressed_data = self.uncompressed_data
@@ -317,25 +396,26 @@ class Resource:
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_DEBUG)
RNS.log("Compression saved "+str(saved_bytes)+" bytes, sending compressed", RNS.LOG_EXTREME)
self.data = b""
self.data += RNS.Identity.get_random_hash()[:Resource.RANDOM_HASH_SIZE]
self.data += self.compressed_data
self.compressed = True
self.uncompressed_data = None
else:
self.data = b""
self.data += RNS.Identity.get_random_hash()[:Resource.RANDOM_HASH_SIZE]
self.data += self.uncompressed_data
self.uncompressed_data = self.data
self.compressed = False
self.compressed_data = None
if auto_compress:
RNS.log("Compression did not decrease size, sending uncompressed", RNS.LOG_DEBUG)
if self.auto_compress and data_size <= self.auto_compress_limit:
RNS.log("Compression did not decrease size, sending uncompressed", RNS.LOG_EXTREME)
self.compressed_data = None
self.uncompressed_data = None
# Resources handle encryption directly to
# make optimal use of packet MTU on an entire
@@ -346,13 +426,13 @@ class Resource:
self.size = len(self.data)
self.sent_parts = 0
hashmap_entries = int(math.ceil(self.size/float(Resource.SDU)))
hashmap_entries = int(math.ceil(self.size/float(self.sdu)))
self.total_parts = hashmap_entries
hashmap_ok = False
while not hashmap_ok:
hashmap_computation_began = time.time()
RNS.log("Starting resource hashmap computation with "+str(hashmap_entries)+" entries...", RNS.LOG_DEBUG)
RNS.log("Starting resource hashmap computation with "+str(hashmap_entries)+" entries...", RNS.LOG_EXTREME)
self.random_hash = RNS.Identity.get_random_hash()[:Resource.RANDOM_HASH_SIZE]
self.hash = RNS.Identity.full_hash(data+self.random_hash)
@@ -368,11 +448,11 @@ class Resource:
self.hashmap = b""
collision_guard_list = []
for i in range(0,hashmap_entries):
data = self.data[i*Resource.SDU:(i+1)*Resource.SDU]
data = self.data[i*self.sdu:(i+1)*self.sdu]
map_hash = self.get_map_hash(data)
if map_hash in collision_guard_list:
RNS.log("Found hash collision in resource map, remapping...", RNS.LOG_VERBOSE)
RNS.log("Found hash collision in resource map, remapping...", RNS.LOG_DEBUG)
hashmap_ok = False
break
else:
@@ -388,8 +468,9 @@ class Resource:
self.hashmap += part.map_hash
self.parts.append(part)
RNS.log("Hashmap computation concluded in "+str(round(time.time()-hashmap_computation_began, 3))+" seconds", RNS.LOG_DEBUG)
RNS.log("Hashmap computation concluded in "+str(round(time.time()-hashmap_computation_began, 3))+" seconds", RNS.LOG_EXTREME)
self.data = None
if advertise:
self.advertise()
else:
@@ -442,12 +523,13 @@ class Resource:
try:
self.advertisement_packet.send()
self.last_activity = time.time()
self.started_transferring = self.last_activity
self.adv_sent = self.last_activity
self.rtt = None
self.status = Resource.ADVERTISED
self.retries_left = self.max_adv_retries
self.link.register_outgoing_resource(self)
RNS.log("Sent resource advertisement for "+RNS.prettyhexrep(self.hash), RNS.LOG_DEBUG)
RNS.log("Sent resource advertisement for "+RNS.prettyhexrep(self.hash), RNS.LOG_EXTREME)
except Exception as e:
RNS.log("Could not advertise resource, the contained exception was: "+str(e), RNS.LOG_ERROR)
self.cancel()
@@ -455,9 +537,25 @@ class Resource:
self.watchdog_job()
def update_eifr(self):
if self.rtt == None:
rtt = self.link.rtt
else:
rtt = self.rtt
if self.req_data_rtt_rate != 0:
expected_inflight_rate = self.req_data_rtt_rate*8
else:
if self.previous_eifr != None:
expected_inflight_rate = self.previous_eifr
else:
expected_inflight_rate = self.link.establishment_cost*8 / rtt
self.eifr = expected_inflight_rate
if self.link: self.link.expected_rate = self.eifr
def watchdog_job(self):
thread = threading.Thread(target=self.__watchdog_job)
thread.daemon = True
thread = threading.Thread(target=self.__watchdog_job, daemon=True)
thread.start()
def __watchdog_job(self):
@@ -469,7 +567,6 @@ class Resource:
sleep(0.025)
sleep_time = None
if self.status == Resource.ADVERTISED:
sleep_time = (self.adv_sent+self.timeout+Resource.PROCESSING_GRACE)-time.time()
if sleep_time < 0:
@@ -493,22 +590,25 @@ class Resource:
elif self.status == Resource.TRANSFERRING:
if not self.initiator:
if self.rtt == None:
rtt = self.link.rtt
else:
rtt = self.rtt
window_remaining = self.outstanding_parts
retries_used = self.max_retries - self.retries_left
extra_wait = retries_used * Resource.PER_RETRY_DELAY
sleep_time = self.last_activity + (rtt*(self.part_timeout_factor+window_remaining)) + Resource.RETRY_GRACE_TIME + extra_wait - time.time()
self.update_eifr()
expected_tof_remaining = (self.outstanding_parts*self.sdu*8)/self.eifr
if self.req_resp_rtt_rate != 0:
sleep_time = self.last_activity + self.part_timeout_factor*expected_tof_remaining + Resource.RETRY_GRACE_TIME + extra_wait - time.time()
else:
sleep_time = self.last_activity + self.part_timeout_factor*((3*self.sdu)/self.eifr) + Resource.RETRY_GRACE_TIME + extra_wait - time.time()
# TODO: Remove debug at some point
# RNS.log(f"EIFR {RNS.prettyspeed(self.eifr)}, ETOF {RNS.prettyshorttime(expected_tof_remaining)} ", RNS.LOG_DEBUG, pt=True)
# RNS.log(f"Resource ST {RNS.prettyshorttime(sleep_time)}, RTT {RNS.prettyshorttime(self.rtt or self.link.rtt)}, {self.outstanding_parts} left", RNS.LOG_DEBUG, pt=True)
if sleep_time < 0:
if self.retries_left > 0:
ms = "" if self.outstanding_parts == 1 else "s"
RNS.log("Timed out waiting for "+str(self.outstanding_parts)+" part"+ms+", requesting retry", RNS.LOG_DEBUG)
RNS.log(f"Timed out waiting for {self.outstanding_parts} part{ms}, requesting retry on {self}", RNS.LOG_DEBUG)
if self.window > self.window_min:
self.window -= 1
if self.window_max > self.window_min:
@@ -553,8 +653,11 @@ class Resource:
self.last_part_sent = time.time()
sleep_time = 0.001
elif self.status == Resource.REJECTED:
sleep_time = 0.001
if sleep_time == 0:
RNS.log("Warning! Link watchdog sleep time of 0!", RNS.LOG_WARNING)
RNS.log("Warning! Link watchdog sleep time of 0!", RNS.LOG_DEBUG)
if sleep_time == None or sleep_time < 0:
RNS.log("Timing error, cancelling resource transfer.", RNS.LOG_ERROR)
self.cancel()
@@ -568,29 +671,37 @@ class Resource:
self.status = Resource.ASSEMBLING
stream = b"".join(self.parts)
if self.encrypted:
data = self.link.decrypt(stream)
else:
data = stream
if self.encrypted: data = self.link.decrypt(stream)
else: data = stream
# Strip off random hash
data = data[Resource.RANDOM_HASH_SIZE:]
if self.compressed:
self.data = bz2.decompress(data)
else:
self.data = data
if self.compressed: self.data = bz2.decompress(data)
else: self.data = data
calculated_hash = RNS.Identity.full_hash(self.data+self.random_hash)
if calculated_hash == self.hash:
if self.has_metadata and self.segment_index == 1:
# TODO: Add early metadata_ready callback
metadata_size = self.data[0] << 16 | self.data[1] << 8 | self.data[2]
packed_metadata = self.data[3:3+metadata_size]
metadata_file = open(self.meta_storagepath, "wb")
metadata_file.write(packed_metadata)
metadata_file.close()
del packed_metadata
data = self.data[3+metadata_size:]
else:
data = self.data
self.file = open(self.storagepath, "ab")
self.file.write(self.data)
self.file.write(data)
self.file.close()
self.status = Resource.COMPLETE
del data
self.prove()
else:
self.status = Resource.CORRUPT
else: self.status = Resource.CORRUPT
except Exception as e:
@@ -602,21 +713,27 @@ class Resource:
if self.segment_index == self.total_segments:
if self.callback != None:
if not os.path.isfile(self.meta_storagepath):
self.metadata = None
else:
metadata_file = open(self.meta_storagepath, "rb")
self.metadata = umsgpack.unpackb(metadata_file.read())
metadata_file.close()
try: os.unlink(self.meta_storagepath)
except Exception as e:
RNS.log(f"Error while cleaning up resource metadata file, the contained exception was: {e}", RNS.LOG_ERROR)
self.data = open(self.storagepath, "rb")
try:
self.callback(self)
try: self.callback(self)
except Exception as e:
RNS.log("Error while executing resource assembled callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
try:
if hasattr(self.data, "close") and callable(self.data.close):
self.data.close()
os.unlink(self.storagepath)
if hasattr(self.data, "close") and callable(self.data.close): self.data.close()
if os.path.isfile(self.storagepath): os.unlink(self.storagepath)
except Exception as e:
RNS.log("Error while cleaning up resource files, the contained exception was:", RNS.LOG_ERROR)
RNS.log(str(e))
RNS.log(f"Error while cleaning up resource files, the contained exception was: {e}", RNS.LOG_ERROR)
else:
RNS.log("Resource segment "+str(self.segment_index)+" of "+str(self.total_segments)+" received, waiting for next segment to be announced", RNS.LOG_DEBUG)
@@ -647,6 +764,8 @@ class Resource:
request_id = self.request_id,
is_response = self.is_response,
advertise = False,
auto_compress = self.auto_compress_option,
sent_metadata_size = self.metadata_size,
)
def validate_proof(self, proof_data):
@@ -659,18 +778,18 @@ class Resource:
# If all segments were processed, we'll
# signal that the resource sending concluded
if self.callback != None:
try:
self.callback(self)
except Exception as e:
RNS.log("Error while executing resource concluded callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
try: self.callback(self)
except Exception as e: RNS.log("Error while executing resource concluded callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
finally:
try:
if hasattr(self, "input_file"):
if hasattr(self.input_file, "close") and callable(self.input_file.close):
self.input_file.close()
except Exception as e:
RNS.log("Error while closing resource input file: "+str(e), RNS.LOG_ERROR)
if hasattr(self.input_file, "close") and callable(self.input_file.close): self.input_file.close()
except Exception as e: RNS.log("Error while closing resource input file: "+str(e), RNS.LOG_ERROR)
else:
try:
if hasattr(self, "input_file"):
if hasattr(self.input_file, "close") and callable(self.input_file.close): self.input_file.close()
except Exception as e: RNS.log("Error while closing resource input file: "+str(e), RNS.LOG_ERROR)
else:
# Otherwise we'll recursively create the
# next segment of the resource
@@ -678,8 +797,16 @@ class Resource:
RNS.log(f"Next segment preparation for resource {self} was not started yet, manually preparing now. This will cause transfer slowdown.", RNS.LOG_WARNING)
self.__prepare_next_segment()
while self.next_segment == None:
time.sleep(0.05)
while self.next_segment == None: time.sleep(0.05)
self.data = None
self.metadata = None
self.parts = None
self.input_file = None
self.link = None
self.req_hashlist = None
self.hashmap = None
self.next_segment.advertise()
else:
pass
@@ -770,6 +897,7 @@ class Resource:
if rtt != 0:
self.req_data_rtt_rate = req_transferred/rtt
self.update_eifr()
self.rtt_rxd_bytes_at_part_req = self.rtt_rxd_bytes
if self.req_data_rtt_rate > Resource.RATE_FAST and self.fast_rate_rounds < Resource.FAST_RATE_THRESHOLD:
@@ -957,6 +1085,18 @@ class Resource:
except Exception as e:
RNS.log("Error while executing callbacks on resource cancel from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
def _rejected(self):
if self.status < Resource.COMPLETE:
if self.initiator:
self.status = Resource.REJECTED
self.link.cancel_outgoing_resource(self)
if self.callback != None:
try:
self.link.resource_concluded(self)
self.callback(self)
except Exception as e:
RNS.log("Error while executing callbacks on resource reject from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
def set_callback(self, callback):
self.callback = callback
@@ -981,7 +1121,7 @@ class Resource:
processed_segments = self.segment_index-1
current_segment_parts = self.total_parts
max_parts_per_segment = math.ceil(Resource.MAX_EFFICIENT_SIZE/Resource.SDU)
max_parts_per_segment = math.ceil(Resource.MAX_EFFICIENT_SIZE/self.sdu)
previously_processed_parts = processed_segments*max_parts_per_segment
@@ -1004,7 +1144,7 @@ class Resource:
processed_segments = self.segment_index-1
current_segment_parts = self.total_parts
max_parts_per_segment = math.ceil(Resource.MAX_EFFICIENT_SIZE/Resource.SDU)
max_parts_per_segment = math.ceil(Resource.MAX_EFFICIENT_SIZE/self.sdu)
previously_processed_parts = processed_segments*max_parts_per_segment
@@ -1128,6 +1268,7 @@ class ResourceAdvertisement:
self.c = resource.compressed # Compression flag
self.e = resource.encrypted # Encryption flag
self.s = resource.split # Split flag
self.x = resource.has_metadata # Metadata flag
self.i = resource.segment_index # Segment index
self.l = resource.total_segments # Total segments
self.q = resource.request_id # ID of associated request
@@ -1143,7 +1284,7 @@ class ResourceAdvertisement:
self.p = True
# Flags
self.f = 0x00 | self.p << 4 | self.u << 3 | self.s << 2 | self.c << 1 | self.e
self.f = 0x00 | self.x << 5 | self.p << 4 | self.u << 3 | self.s << 2 | self.c << 1 | self.e
def get_transfer_size(self):
return self.t
@@ -1163,6 +1304,9 @@ class ResourceAdvertisement:
def is_compressed(self):
return self.c
def has_metadata(self):
return self.x
def get_link(self):
return self.link
@@ -1212,5 +1356,6 @@ class ResourceAdvertisement:
adv.s = True if ((adv.f >> 2) & 0x01) == 0x01 else False
adv.u = True if ((adv.f >> 3) & 0x01) == 0x01 else False
adv.p = True if ((adv.f >> 4) & 0x01) == 0x01 else False
adv.x = True if ((adv.f >> 5) & 0x01) == 0x01 else False
return adv
+785 -395
View File
File diff suppressed because it is too large Load Diff
+1154 -666
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
+182 -156
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -25,6 +33,7 @@
import RNS
import argparse
import threading
import shutil
import time
import sys
import os
@@ -34,22 +43,45 @@ from RNS._version import __version__
APP_NAME = "rncp"
allow_all = False
allow_fetch = False
allow_overwrite_on_receive = False
fetch_auto_compress = True
fetch_jail = None
save_path = None
show_phy_rates = False
allowed_identity_hashes = []
identity = None
def prepare_identity(identity_path):
global identity
if identity_path == None:
identity_path = RNS.Reticulum.identitypath+"/"+APP_NAME
if os.path.isfile(identity_path):
identity = RNS.Identity.from_file(identity_path)
if identity == None:
RNS.log(f"Could not load identity for rncp. The identity file at \"{identity_path}\" may be corrupt or unreadable.", RNS.LOG_ERROR)
RNS.exit(2)
if identity == None:
RNS.log("No valid saved identity found, creating new...", RNS.LOG_INFO)
identity = RNS.Identity()
identity.to_file(identity_path)
REQ_FETCH_NOT_ALLOWED = 0xF0
es = " "
erase_str = "\33[2K\r"
def listen(configdir, verbosity = 0, quietness = 0, allowed = [], display_identity = False,
limit = None, disable_auth = None, fetch_allowed = False, jail = None, save = None, announce = False):
global allow_all, allow_fetch, allowed_identity_hashes, fetch_jail, save_path
from tempfile import TemporaryFile
def listen(configdir, identitypath = None, verbosity = 0, quietness = 0, allowed = [], display_identity = False,
limit = None, disable_auth = None, fetch_allowed = False, no_compress=False,
jail = None, save = None, announce = False, allow_overwrite=False):
global allow_all, allow_fetch, allowed_identity_hashes, fetch_jail, save_path, identity
global fetch_auto_compress, allow_overwrite_on_receive
allow_fetch = fetch_allowed
fetch_auto_compress = not no_compress
allow_overwrite_on_receive = allow_overwrite
identity = None
if announce < 0:
announce = False
@@ -68,28 +100,21 @@ def listen(configdir, verbosity = 0, quietness = 0, allowed = [], display_identi
save_path = sp
else:
RNS.log("Output directory not writable", RNS.LOG_ERROR)
exit(4)
RNS.exit(4)
else:
RNS.log("Output directory not found", RNS.LOG_ERROR)
exit(3)
RNS.exit(3)
RNS.log("Saving received files in \""+save_path+"\"", RNS.LOG_VERBOSE)
identity_path = RNS.Reticulum.identitypath+"/"+APP_NAME
if os.path.isfile(identity_path):
identity = RNS.Identity.from_file(identity_path)
if identity == None:
RNS.log("No valid saved identity found, creating new...", RNS.LOG_INFO)
identity = RNS.Identity()
identity.to_file(identity_path)
prepare_identity(identitypath)
destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "receive")
if display_identity:
print("Identity : "+str(identity))
print("Listening on : "+RNS.prettyhexrep(destination.hash))
exit(0)
RNS.exit(0)
if disable_auth:
allow_all = True
@@ -139,13 +164,13 @@ def listen(configdir, verbosity = 0, quietness = 0, allowed = [], display_identi
raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
exit(1)
RNS.exit(1)
if len(allowed_identity_hashes) < 1 and not disable_auth:
print("Warning: No allowed identities configured, rncp will not accept any files!")
def fetch_request(path, data, request_id, link_id, remote_identity, requested_at):
global allow_fetch, fetch_jail
global allow_fetch, fetch_jail, fetch_auto_compress
if not allow_fetch:
return REQ_FETCH_NOT_ALLOWED
@@ -171,22 +196,15 @@ def listen(configdir, verbosity = 0, quietness = 0, allowed = [], display_identi
if target_link != None:
RNS.log("Sending file "+str(file_path)+" to client", RNS.LOG_VERBOSE)
temp_file = TemporaryFile()
real_file = open(file_path, "rb")
filename_bytes = os.path.basename(file_path).encode("utf-8")
filename_len = len(filename_bytes)
try:
metadata = {"name": os.path.basename(file_path).encode("utf-8") }
fetch_resource = RNS.Resource(open(file_path, "rb"), target_link, metadata=metadata, auto_compress=fetch_auto_compress)
return True
if filename_len > 0xFFFF:
print("Filename exceeds max size, cannot send")
exit(1)
except Exception as e:
RNS.log(f"Could not send file to client. The contained exception was: {e}", RNS.LOG_ERROR)
return False
temp_file.write(filename_len.to_bytes(2, "big"))
temp_file.write(filename_bytes)
temp_file.write(real_file.read())
temp_file.seek(0)
fetch_resource = RNS.Resource(temp_file, target_link)
return True
else:
return None
@@ -211,8 +229,7 @@ def listen(configdir, verbosity = 0, quietness = 0, allowed = [], display_identi
threading.Thread(target=job, daemon=True).start()
while True:
time.sleep(1)
while True: time.sleep(1)
def client_link_established(link):
RNS.log("Incoming link established", RNS.LOG_VERBOSE)
@@ -257,34 +274,42 @@ def receive_resource_started(resource):
print("Starting resource transfer "+RNS.prettyhexrep(resource.hash)+id_str)
def receive_resource_concluded(resource):
global save_path
global save_path, allow_overwrite_on_receive
if resource.status == RNS.Resource.COMPLETE:
print(str(resource)+" completed")
if resource.total_size > 4:
filename_len = int.from_bytes(resource.data.read(2), "big")
filename = resource.data.read(filename_len).decode("utf-8")
counter = 0
if save_path:
saved_filename = os.path.abspath(os.path.expanduser(save_path+"/"+filename))
if not saved_filename.startswith(save_path+"/"):
RNS.log(f"Invalid save path {saved_filename}, ignoring", RNS.LOG_ERROR)
return
else:
saved_filename = filename
full_save_path = saved_filename
while os.path.isfile(full_save_path):
counter += 1
full_save_path = saved_filename+"."+str(counter)
file = open(full_save_path, "wb")
file.write(resource.data.read())
file.close()
if resource.metadata == None:
print("Invalid data received, ignoring resource")
return
else:
print("Invalid data received, ignoring resource")
try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
counter = 0
if save_path:
saved_filename = os.path.abspath(os.path.expanduser(save_path+"/"+filename))
if not saved_filename.startswith(save_path+"/"):
RNS.log(f"Invalid save path {saved_filename}, ignoring", RNS.LOG_ERROR)
return
else:
saved_filename = filename
full_save_path = saved_filename
if allow_overwrite_on_receive:
if os.path.isfile(full_save_path):
try: os.unlink(full_save_path)
except Exception as e:
RNS.log(f"Could not overwrite existing file {full_save_path}, renaming instead", RNS.LOG_ERROR)
while os.path.isfile(full_save_path):
counter += 1
full_save_path = saved_filename+"."+str(counter)
shutil.move(resource.data.name, full_save_path)
except Exception as e:
RNS.log(f"An error occurred while saving received resource: {e}", RNS.LOG_ERROR)
return
else:
print("Resource failed")
@@ -294,9 +319,10 @@ current_resource = None
stats = []
speed = 0.0
phy_speed = 0.0
phy_got_total = 0
def sender_progress(resource):
stats_max = 32
global current_resource, stats, speed, phy_speed, resource_done
global current_resource, stats, speed, phy_speed, phy_got_total, resource_done
current_resource = resource
now = time.time()
@@ -321,6 +347,7 @@ def sender_progress(resource):
phy_diff = phy_got - stats[0][2]
if phy_diff > 0:
phy_speed = phy_diff/span
# phy_got_total += phy_diff
if resource.status < RNS.Resource.COMPLETE:
resource_done = False
@@ -328,10 +355,11 @@ def sender_progress(resource):
resource_done = True
link = None
def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, silent=False, phy_rates=False, save=None):
global current_resource, resource_done, link, speed, show_phy_rates, save_path
def fetch(configdir, identitypath = None, verbosity = 0, quietness = 0, destination = None, file = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, silent=False, phy_rates=False, save=None, allow_overwrite=False):
global current_resource, resource_done, link, speed, show_phy_rates, save_path, allow_overwrite_on_receive, identity
targetloglevel = 3+verbosity-quietness
show_phy_rates = phy_rates
allow_overwrite_on_receive = allow_overwrite
if save:
sp = os.path.abspath(os.path.expanduser(save))
@@ -340,10 +368,10 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
save_path = sp
else:
RNS.log("Output directory not writable", RNS.LOG_ERROR)
exit(4)
RNS.exit(4)
else:
RNS.log("Output directory not found", RNS.LOG_ERROR)
exit(3)
RNS.exit(3)
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
@@ -355,23 +383,12 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
exit(1)
RNS.exit(1)
reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel)
identity_path = RNS.Reticulum.identitypath+"/"+APP_NAME
if os.path.isfile(identity_path):
identity = RNS.Identity.from_file(identity_path)
if identity == None:
RNS.log("Could not load identity for rncp. The identity file at \""+str(identity_path)+"\" may be corrupt or unreadable.", RNS.LOG_ERROR)
exit(2)
else:
identity = None
if identity == None:
RNS.log("No valid saved identity found, creating new...", RNS.LOG_INFO)
identity = RNS.Identity()
identity.to_file(identity_path)
prepare_identity(identitypath)
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
@@ -396,7 +413,7 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
print("Path not found")
else:
print(f"{erase_str}Path not found")
exit(1)
RNS.exit(1)
else:
if silent:
print("Establishing link with "+RNS.prettyhexrep(destination_hash))
@@ -425,7 +442,7 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
print("Could not establish link with "+RNS.prettyhexrep(destination_hash))
else:
print(f"{erase_str}Could not establish link with "+RNS.prettyhexrep(destination_hash))
exit(1)
RNS.exit(1)
else:
if silent:
print("Requesting file from remote...")
@@ -439,6 +456,7 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
resource_resolved = False
resource_status = "unrequested"
current_resource = None
current_transfer_started = None
def request_response(request_receipt):
nonlocal request_resolved, request_status
if request_receipt.response == False:
@@ -458,39 +476,48 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
request_resolved = True
def fetch_resource_started(resource):
nonlocal resource_status
nonlocal resource_status, current_transfer_started
current_resource = resource
current_resource.progress_callback(sender_progress)
resource_status = "started"
if not current_transfer_started: current_transfer_started = time.time()
def fetch_resource_concluded(resource):
nonlocal resource_resolved, resource_status
global save_path
global save_path, allow_overwrite_on_receive
if resource.status == RNS.Resource.COMPLETE:
if resource.total_size > 4:
filename_len = int.from_bytes(resource.data.read(2), "big")
filename = resource.data.read(filename_len).decode("utf-8")
counter = 0
if save_path:
saved_filename = os.path.abspath(os.path.expanduser(save_path+"/"+filename))
else:
saved_filename = filename
full_save_path = saved_filename
while os.path.isfile(full_save_path):
counter += 1
full_save_path = saved_filename+"."+str(counter)
file = open(full_save_path, "wb")
file.write(resource.data.read())
file.close()
resource_status = "completed"
if resource.metadata == None:
print("Invalid data received, ignoring resource")
return
else:
print("Invalid data received, ignoring resource")
resource_status = "invalid_data"
try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
counter = 0
if save_path:
saved_filename = os.path.abspath(os.path.expanduser(save_path+"/"+filename))
if not saved_filename.startswith(save_path+"/"):
print(f"Invalid save path {saved_filename}, ignoring")
return
else:
saved_filename = filename
full_save_path = saved_filename
if allow_overwrite_on_receive:
if os.path.isfile(full_save_path):
try: os.unlink(full_save_path)
except Exception as e:
print(f"Could not overwrite existing file {full_save_path}, renaming instead")
while os.path.isfile(full_save_path):
counter += 1
full_save_path = saved_filename+"."+str(counter)
shutil.move(resource.data.name, full_save_path)
except Exception as e:
print(f"An error occurred while saving received resource: {e}")
return
else:
print("Resource failed")
@@ -516,25 +543,25 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
print("Fetch request failed, fetching the file "+str(file)+" was not allowed by the remote")
link.teardown()
time.sleep(0.15)
exit(0)
RNS.exit(0)
elif request_status == "not_found":
if not silent: print(f"{erase_str}", end="")
print("Fetch request failed, the file "+str(file)+" was not found on the remote")
link.teardown()
time.sleep(0.15)
exit(0)
RNS.exit(0)
elif request_status == "remote_error":
if not silent: print(f"{erase_str}", end="")
print("Fetch request failed due to an error on the remote system")
link.teardown()
time.sleep(0.15)
exit(0)
RNS.exit(0)
elif request_status == "unknown":
if not silent: print(f"{erase_str}", end="")
print("Fetch request failed due to an unknown error (probably not authorised)")
link.teardown()
time.sleep(0.15)
exit(0)
RNS.exit(0)
elif request_status == "found":
if not silent: print(f"{erase_str}", end="")
@@ -556,34 +583,38 @@ def fetch(configdir, verbosity = 0, quietness = 0, destination = None, file = No
if prg != 1.0:
print(f"{erase_str}Transferring file {syms[i]} {stat_str}", end=es)
else:
end_time = time.time(); delta_time = end_time - current_transfer_started
speed = current_resource.total_size/delta_time; dt_str = RNS.prettytime(delta_time)
ss = size_str(speed, "b")
stat_str = f"{percent}% - {ps} of {ts} in {dt_str} - {ss}ps{phy_str}"
print(f"{erase_str}Transfer complete {stat_str}", end=es)
else:
print(f"{erase_str}Waiting for transfer to start {syms[i]} ", end=es)
sys.stdout.flush()
i = (i+1)%len(syms)
if current_resource.status != RNS.Resource.COMPLETE:
if not current_resource or current_resource.status != RNS.Resource.COMPLETE:
if silent:
print("The transfer failed")
else:
print(f"{erase_str}The transfer failed")
exit(1)
RNS.exit(1)
else:
if silent:
print(str(file)+" fetched from "+RNS.prettyhexrep(destination_hash))
else:
print("\n"+str(file)+" fetched from "+RNS.prettyhexrep(destination_hash))
link.teardown()
time.sleep(0.15)
exit(0)
time.sleep(0.1)
RNS.exit(0)
link.teardown()
exit(0)
time.sleep(0.1)
RNS.exit(0)
def send(configdir, verbosity = 0, quietness = 0, destination = None, file = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, silent=False, phy_rates=False):
global current_resource, resource_done, link, speed, show_phy_rates
from tempfile import TemporaryFile
def send(configdir, identitypath = None, verbosity = 0, quietness = 0, destination = None, file = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, silent=False, phy_rates=False, no_compress=False):
global current_resource, resource_done, link, speed, show_phy_rates, phy_got_total, phy_speed, identity
targetloglevel = 3+verbosity-quietness
show_phy_rates = phy_rates
@@ -597,47 +628,22 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
exit(1)
RNS.exit(1)
file_path = os.path.expanduser(file)
if not os.path.isfile(file_path):
print("File not found")
exit(1)
sys.exit(1)
temp_file = TemporaryFile()
real_file = open(file_path, "rb")
filename_bytes = os.path.basename(file_path).encode("utf-8")
filename_len = len(filename_bytes)
if filename_len > 0xFFFF:
print("Filename exceeds max size, cannot send")
exit(1)
else:
print("Preparing file...", end=es)
temp_file.write(filename_len.to_bytes(2, "big"))
temp_file.write(filename_bytes)
temp_file.write(real_file.read())
temp_file.seek(0)
metadata = {"name": os.path.basename(file_path).encode("utf-8") }
print(f"{erase_str}", end="")
reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel)
identity_path = RNS.Reticulum.identitypath+"/"+APP_NAME
if os.path.isfile(identity_path):
identity = RNS.Identity.from_file(identity_path)
if identity == None:
RNS.log("Could not load identity for rncp. The identity file at \""+str(identity_path)+"\" may be corrupt or unreadable.", RNS.LOG_ERROR)
exit(2)
else:
identity = None
if identity == None:
RNS.log("No valid saved identity found, creating new...", RNS.LOG_INFO)
identity = RNS.Identity()
identity.to_file(identity_path)
prepare_identity(identitypath)
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
@@ -662,7 +668,7 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
print("Path not found")
else:
print(f"{erase_str}Path not found")
exit(1)
RNS.exit(1)
else:
if silent:
print("Establishing link with "+RNS.prettyhexrep(destination_hash))
@@ -691,13 +697,13 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
print("Link establishment with "+RNS.prettyhexrep(destination_hash)+" timed out")
else:
print(f"{erase_str}Link establishment with "+RNS.prettyhexrep(destination_hash)+" timed out")
exit(1)
RNS.exit(1)
elif not RNS.Transport.has_path(destination_hash):
if silent:
print("No path found to "+RNS.prettyhexrep(destination_hash))
else:
print(f"{erase_str}No path found to "+RNS.prettyhexrep(destination_hash))
exit(1)
RNS.exit(1)
else:
if silent:
print("Advertising file resource...")
@@ -705,7 +711,13 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
print(f"{erase_str}Advertising file resource ", end=es)
link.identify(identity)
resource = RNS.Resource(temp_file, link, callback = sender_progress, progress_callback = sender_progress)
auto_compress = True
if no_compress: auto_compress = False
try: resource = RNS.Resource(open(file_path, "rb"), link, metadata=metadata, callback = sender_progress, progress_callback = sender_progress, auto_compress = auto_compress)
except Exception as e:
print(f"Could not start transfer: {e}")
RNS.exit(1)
current_resource = resource
while resource.status < RNS.Resource.TRANSFERRING:
@@ -715,13 +727,14 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
sys.stdout.flush()
i = (i+1)%len(syms)
resource_started_at = time.time()
if resource.status > RNS.Resource.COMPLETE:
if silent:
print("File was not accepted by "+RNS.prettyhexrep(destination_hash))
else:
print(f"{erase_str}File was not accepted by "+RNS.prettyhexrep(destination_hash))
exit(1)
RNS.exit(1)
else:
if silent:
print("Transferring file...")
@@ -732,7 +745,7 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
time.sleep(0.1)
prg = current_resource.get_progress()
percent = round(prg * 100.0, 1)
if show_phy_rates:
if show_phy_rates and not resource_done:
pss = size_str(phy_speed, "b")
phy_str = f" ({pss}ps at physical layer)"
else:
@@ -754,6 +767,11 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
if not silent:
i = progress_update(i)
resource_concluded_at = time.time()
transfer_time = resource_concluded_at - resource_started_at
speed = current_resource.total_size/transfer_time
# phy_speed = phy_got_total/transfer_time
if not silent:
i = progress_update(i, done=True)
@@ -762,7 +780,7 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
print("The transfer failed")
else:
print(f"{erase_str}The transfer failed")
exit(1)
RNS.exit(1)
else:
if silent:
print(str(file_path)+" copied to "+RNS.prettyhexrep(destination_hash))
@@ -770,9 +788,7 @@ def send(configdir, verbosity = 0, quietness = 0, destination = None, file = Non
print("\n"+str(file_path)+" copied to "+RNS.prettyhexrep(destination_hash))
link.teardown()
time.sleep(0.25)
real_file.close()
temp_file.close()
exit(0)
RNS.exit(0)
def main():
try:
@@ -784,14 +800,17 @@ def main():
parser.add_argument('-q', '--quiet', action='count', default=0, help="decrease verbosity")
parser.add_argument("-S", '--silent', action='store_true', default=False, help="disable transfer progress output")
parser.add_argument("-l", '--listen', action='store_true', default=False, help="listen for incoming transfer requests")
parser.add_argument("-C", '--no-compress', action='store_true', default=False, help="disable automatic compression")
parser.add_argument("-F", '--allow-fetch', action='store_true', default=False, help="allow authenticated clients to fetch files")
parser.add_argument("-f", '--fetch', action='store_true', default=False, help="fetch file from remote listener instead of sending")
parser.add_argument("-j", "--jail", metavar="path", action="store", default=None, help="restrict fetch requests to specified path", type=str)
parser.add_argument("-s", "--save", metavar="path", action="store", default=None, help="save received files in specified path", type=str)
parser.add_argument('-O', '--overwrite', action='store_true', default=False, help="Allow overwriting received files, instead of adding postfix")
parser.add_argument("-b", action='store', metavar="seconds", default=-1, help="announce interval, 0 to only announce at startup", type=int)
parser.add_argument('-a', metavar="allowed_hash", dest="allowed", action='append', help="allow this identity (or add in ~/.rncp/allowed_identities)", type=str)
parser.add_argument('-n', '--no-auth', action='store_true', default=False, help="accept requests from anyone")
parser.add_argument('-p', '--print-identity', action='store_true', default=False, help="print identity and destination info and exit")
parser.add_argument('-i', metavar="identity", action='store', dest="identity", default=None, help="path to identity to use", type=str)
parser.add_argument("-w", action="store", metavar="seconds", type=float, help="sender timeout before giving up", default=RNS.Transport.PATH_REQUEST_TIMEOUT)
parser.add_argument('-P', '--phy-rates', action='store_true', default=False, help="display physical layer transfer rates")
# parser.add_argument("--limit", action="store", metavar="files", type=float, help="maximum number of files to accept", default=None)
@@ -802,22 +821,26 @@ def main():
if args.listen or args.print_identity:
listen(
configdir = args.config,
identitypath = args.identity,
verbosity=args.verbose,
quietness=args.quiet,
allowed = args.allowed,
fetch_allowed = args.allow_fetch,
no_compress = args.no_compress,
jail = args.jail,
save = args.save,
display_identity=args.print_identity,
# limit=args.limit,
disable_auth=args.no_auth,
announce=args.b,
allow_overwrite=args.overwrite,
)
elif args.fetch:
if args.destination != None and args.file != None:
fetch(
configdir = args.config,
identitypath = args.identity,
verbosity = args.verbose,
quietness = args.quiet,
destination = args.destination,
@@ -826,6 +849,7 @@ def main():
silent = args.silent,
phy_rates = args.phy_rates,
save = args.save,
allow_overwrite=args.overwrite,
)
else:
print("")
@@ -835,6 +859,7 @@ def main():
elif args.destination != None and args.file != None:
send(
configdir = args.config,
identitypath = args.identity,
verbosity = args.verbose,
quietness = args.quiet,
destination = args.destination,
@@ -842,6 +867,7 @@ def main():
timeout = args.w,
silent = args.silent,
phy_rates = args.phy_rates,
no_compress = args.no_compress,
)
else:
@@ -855,7 +881,7 @@ def main():
resource.cancel()
if link != None:
link.teardown()
exit()
RNS.exit()
def size_str(num, suffix='B'):
units = ['','K','M','G','T','P','E','Z']
+27 -15
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2023 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -63,7 +71,7 @@ def main():
# parser.add_argument("file", nargs="?", default=None, help="input file path", type=str)
parser.add_argument("--config", metavar="path", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-i", "--identity", metavar="identity", action="store", default=None, help="hexadecimal Reticulum Destination hash or path to Identity file", type=str)
parser.add_argument("-i", "--identity", metavar="identity", action="store", default=None, help="hexadecimal Reticulum identity or destination hash, or path to Identity file", type=str)
parser.add_argument("-g", "--generate", metavar="file", action="store", default=None, help="generate a new Identity")
parser.add_argument("-m", "--import", dest="import_str", metavar="identity_data", action="store", default=None, help="import Reticulum identity in hex, base32 or base64 format", type=str)
parser.add_argument("-x", "--export", action="store_true", default=None, help="export identity to hex, base32 or base64 format")
@@ -194,7 +202,7 @@ def main():
else:
try:
identity.to_file(args.generate)
RNS.log("New identity written to "+str(args.generate))
RNS.log(f"New identity {identity} written to {args.generate}")
exit(0)
except Exception as e:
RNS.log("An error ocurred while saving the generated Identity.", RNS.LOG_ERROR)
@@ -205,29 +213,32 @@ def main():
if len(identity_str) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2 and not os.path.isfile(identity_str):
# Try recalling Identity from hex-encoded hash
try:
destination_hash = bytes.fromhex(identity_str)
identity = RNS.Identity.recall(destination_hash)
ident_hash = bytes.fromhex(identity_str)
identity = RNS.Identity.recall(ident_hash) or RNS.Identity.recall(ident_hash, from_identity_hash=True)
if identity == None:
if not args.request:
RNS.log("Could not recall Identity for "+RNS.prettyhexrep(destination_hash)+".", RNS.LOG_ERROR)
RNS.log("Could not recall Identity for "+RNS.prettyhexrep(ident_hash)+".", RNS.LOG_ERROR)
RNS.log("You can query the network for unknown Identities with the -R option.", RNS.LOG_ERROR)
exit(5)
else:
RNS.Transport.request_path(destination_hash)
RNS.Transport.request_path(ident_hash)
def spincheck():
return RNS.Identity.recall(destination_hash) != None
spin(spincheck, "Requesting unknown Identity for "+RNS.prettyhexrep(destination_hash), args.t)
return RNS.Identity.recall(ident_hash) != None
spin(spincheck, "Requesting unknown Identity for "+RNS.prettyhexrep(ident_hash), args.t)
if not spincheck():
RNS.log("Identity request timed out", RNS.LOG_ERROR)
exit(6)
else:
identity = RNS.Identity.recall(destination_hash)
RNS.log("Received Identity "+str(identity)+" for destination "+RNS.prettyhexrep(destination_hash)+" from the network")
identity = RNS.Identity.recall(ident_hash)
RNS.log("Received Identity "+str(identity)+" for destination "+RNS.prettyhexrep(ident_hash)+" from the network")
else:
RNS.log("Recalled Identity "+str(identity)+" for destination "+RNS.prettyhexrep(destination_hash))
ident_str = str(identity)
hash_str = RNS.prettyhexrep(ident_hash)
if ident_str == hash_str: RNS.log(f"Recalled Identity {ident_str}")
else: RNS.log(f"Recalled Identity {ident_str} for destination {hash_str}")
except Exception as e:
@@ -286,6 +297,7 @@ def main():
destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, app_name, *aspects)
RNS.log("Created destination "+str(destination))
RNS.log("Announcing destination "+RNS.prettyhexrep(destination.hash))
time.sleep(1.1)
destination.announce()
time.sleep(0.25)
exit(0)
+12 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2023 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
+726 -142
View File
File diff suppressed because it is too large Load Diff
+236 -259
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -31,7 +39,8 @@ import argparse
from RNS._version import __version__
remote_link = None
def connect_remote(destination_hash, auth_identity, timeout, no_output = False):
output_rst_str = "\r \r"
def connect_remote(destination_hash, auth_identity, timeout, no_output = False, purpose="management"):
global remote_link, reticulum
if not RNS.Transport.has_path(destination_hash):
if not no_output:
@@ -43,7 +52,7 @@ def connect_remote(destination_hash, auth_identity, timeout, no_output = False):
time.sleep(0.1)
if time.time() - pr_time > timeout:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Path request timed out")
exit(12)
@@ -52,98 +61,210 @@ def connect_remote(destination_hash, auth_identity, timeout, no_output = False):
def remote_link_closed(link):
if link.teardown_reason == RNS.Link.TIMEOUT:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("The link timed out, exiting now")
elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("The link was closed by the server, exiting now")
else:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Link closed unexpectedly, exiting now")
exit(10)
def remote_link_established(link):
global remote_link
link.identify(auth_identity)
if purpose == "management": link.identify(auth_identity)
remote_link = link
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Establishing link with remote transport instance...", end=" ")
sys.stdout.flush()
remote_destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "remote", "management")
if purpose == "management": remote_destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "remote", "management")
elif purpose == "blackhole": remote_destination = RNS.Destination(remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "rnstransport", "info", "blackhole")
link = RNS.Link(remote_destination)
link.set_link_established_callback(remote_link_established)
link.set_link_closed_callback(remote_link_closed)
def parse_hash(input_str):
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(input_str) != dest_len: raise ValueError("Hash length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
hash_bytes = bytes.fromhex(input_str)
return hash_bytes
except Exception as e: raise ValueError("Invalid hash entered. Check your input.")
def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity, timeout, drop_queues,
drop_via, max_hops, remote=None, management_identity=None, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT,
no_output=False, json=False):
blackholed=False, blackhole=False, unblackhole=False, blackhole_duration=None, blackhole_reason=None,
remote_blackhole_list=False, remote_blackhole_list_filter=None, no_output=False, json=False):
global remote_link, reticulum
reticulum = RNS.Reticulum(configdir = configdir, loglevel = 3+verbosity)
if remote:
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(remote) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
if len(remote) != dest_len: raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
identity_hash = bytes.fromhex(remote)
remote_hash = RNS.Destination.hash_from_name_and_identity("rnstransport.remote.management", identity_hash)
except Exception as e:
raise ValueError("Invalid destination entered. Check your input.")
except Exception as e: raise ValueError("Invalid destination entered. Check your input.")
identity = RNS.Identity.from_file(os.path.expanduser(management_identity))
if identity == None:
raise ValueError("Could not load management identity from "+str(management_identity))
if identity == None: raise ValueError("Could not load management identity from "+str(management_identity))
try:
connect_remote(remote_hash, identity, remote_timeout, no_output)
except Exception as e:
raise e
try: connect_remote(remote_hash, identity, remote_timeout, no_output)
except Exception as e: raise e
except Exception as e:
print(str(e))
exit(20)
while remote_link == None:
time.sleep(0.1)
while remote_link == None: time.sleep(0.1)
if blackholed or remote_blackhole_list:
blackholed_list = None
if blackholed:
if remote_link:
if not no_output:
print(output_rst_str, end="")
print("Listing blackholed identities on remote instances not yet implemented")
exit(255)
if table:
try: blackholed_list = reticulum.get_blackholed_identities()
except Exception as e:
print(f"Could not get blackholed identities from RNS instance: {e}")
exit(20)
elif remote_blackhole_list:
try: identity_hash = parse_hash(destination_hexhash)
except Exception as e:
print(f"{e}")
exit(20)
remote_hash = RNS.Destination.hash_from_name_and_identity("rnstransport.info.blackhole", identity_hash)
connect_remote(remote_hash, None, remote_timeout, no_output, purpose="blackhole")
while remote_link == None: time.sleep(0.1)
if not no_output:
print(output_rst_str, end="")
print("Sending request...", end=" ")
sys.stdout.flush()
receipt = remote_link.request("/list")
while not receipt.concluded(): time.sleep(0.1)
response = receipt.get_response()
if type(response) == dict:
blackholed_list = response
print(output_rst_str, end="")
else:
if not no_output:
print(output_rst_str, end="")
print("The remote request failed.")
exit(10)
else:
print(f"Nowhere to fetch blackhole list from")
exit(255)
if not blackholed_list:
print("No blackholed identity data available")
exit(20)
else:
rmlen = 64
def trunc(input_str):
if len(input_str) <= rmlen: return input_str
else: return f"{input_str[:rmlen-1]}"
try:
now = time.time()
for identity_hash in blackholed_list:
until = blackholed_list[identity_hash]["until"]
reason = blackholed_list[identity_hash]["reason"]
source = blackholed_list[identity_hash]["source"]
until_str = f"for {RNS.prettytime(max(0, until-now))}" if until else "indefinitely"
reason_str = f" ({trunc(reason)})" if reason else ""
by_str = f" by {RNS.prettyhexrep(source)}" if source != RNS.Transport.identity.hash else ""
filter_str = f"{RNS.prettyhexrep(identity_hash)} {until_str} {reason_str} {by_str}"
if not remote_blackhole_list:
if destination_hexhash and not destination_hexhash in filter_str: continue
else:
if remote_blackhole_list_filter and not remote_blackhole_list_filter in filter_str: continue
print(f"{RNS.prettyhexrep(identity_hash)} blackholed {until_str}{reason_str}{by_str}")
except Exception as e:
print(f"Error while displaying collected blackhole data: {e}")
exit(20)
elif blackhole:
if remote_link:
if not no_output:
print(output_rst_str, end="")
print("Blackholing identity on remote instances not yet implemented")
exit(255)
try:
identity_hash = parse_hash(destination_hexhash)
until = time.time()+blackhole_duration*60*60 if blackhole_duration else None
result = reticulum.blackhole_identity(identity_hash, until=until, reason=blackhole_reason)
if result == True: print(f"Blackholed identity {destination_hexhash}")
elif result == None: print(f"Identity {destination_hexhash} already blackholed")
else: print(f"Could not blackhole identity {destination_hexhash}")
except Exception as e:
print(f"Could not blackhole identity: {e}")
exit(20)
elif unblackhole:
if remote_link:
if not no_output:
print(output_rst_str, end="")
print("Blackholing identity on remote instances not yet implemented")
exit(255)
try:
identity_hash = parse_hash(destination_hexhash)
result = reticulum.unblackhole_identity(identity_hash)
if result == True: print(f"Lifted blackhole for identity {destination_hexhash}")
elif result == None: print(f"Identity {destination_hexhash} not blackholed")
else: print(f"Could not unblackhole identity {destination_hexhash}")
except Exception as e:
print(f"Could not unblackhole identity: {e}")
exit(20)
elif table:
destination_hash = None
if destination_hexhash != None:
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(destination_hexhash) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e:
raise ValueError("Invalid destination entered. Check your input.")
if len(destination_hexhash) != dest_len: raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
sys.exit(1)
if not remote_link:
table = sorted(reticulum.get_path_table(max_hops=max_hops), key=lambda e: (e["interface"], e["hops"]) )
if not remote_link: table = sorted(reticulum.get_path_table(max_hops=max_hops), key=lambda e: (e["interface"], e["hops"]) )
else:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Sending request...", end=" ")
sys.stdout.flush()
receipt = remote_link.request("/path", data = ["table", destination_hash, max_hops])
while not receipt.concluded():
time.sleep(0.1)
while not receipt.concluded(): time.sleep(0.1)
response = receipt.get_response()
if response:
table = response
print("\r \r", end="")
print(output_rst_str, end="")
else:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("The remote request failed. Likely authentication failure.")
exit(10)
@@ -152,20 +273,18 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
import json
for p in table:
for k in p:
if isinstance(p[k], bytes):
p[k] = RNS.hexrep(p[k], delimit=False)
if isinstance(p[k], bytes): p[k] = RNS.hexrep(p[k], delimit=False)
print(json.dumps(table))
exit()
else:
for path in table:
if destination_hash == None or destination_hash == path["hash"]:
displayed += 1
exp_str = RNS.timestamp_str(path["expires"])
if path["hops"] == 1:
m_str = " "
else:
m_str = "s"
if path["hops"] == 1: m_str = " "
else: m_str = "s"
print(RNS.prettyhexrep(path["hash"])+" is "+str(path["hops"])+" hop"+m_str+" away via "+RNS.prettyhexrep(path["via"])+" on "+path["interface"]+" expires "+RNS.timestamp_str(path["expires"]))
if destination_hash != None and displayed == 0:
@@ -177,21 +296,17 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
if destination_hexhash != None:
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(destination_hexhash) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e:
raise ValueError("Invalid destination entered. Check your input.")
if len(destination_hexhash) != dest_len: raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
sys.exit(1)
if not remote_link:
table = reticulum.get_rate_table()
if not remote_link: table = reticulum.get_rate_table()
else:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Sending request...", end=" ")
sys.stdout.flush()
receipt = remote_link.request("/path", data = ["rates", destination_hash])
@@ -200,10 +315,10 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
response = receipt.get_response()
if response:
table = response
print("\r \r", end="")
print(output_rst_str, end="")
else:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("The remote request failed. Likely authentication failure.")
exit(10)
@@ -212,15 +327,12 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
import json
for p in table:
for k in p:
if isinstance(p[k], bytes):
p[k] = RNS.hexrep(p[k], delimit=False)
if isinstance(p[k], bytes): p[k] = RNS.hexrep(p[k], delimit=False)
print(json.dumps(table))
exit()
else:
if len(table) == 0:
print("No information available")
if len(table) == 0: print("No information available")
else:
displayed = 0
for entry in table:
@@ -266,7 +378,7 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
elif drop_queues:
if remote_link:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Dropping announce queues on remote instances not yet implemented")
exit(255)
@@ -276,24 +388,20 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
elif drop:
if remote_link:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Dropping path on remote instances not yet implemented")
exit(255)
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(destination_hexhash) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e:
raise ValueError("Invalid destination entered. Check your input.")
if len(destination_hexhash) != dest_len: raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
sys.exit(1)
if reticulum.drop_path(destination_hash):
print("Dropped path to "+RNS.prettyhexrep(destination_hash))
if reticulum.drop_path(destination_hash): print("Dropped path to "+RNS.prettyhexrep(destination_hash))
else:
print("Unable to drop path to "+RNS.prettyhexrep(destination_hash)+". Does it exist?")
sys.exit(1)
@@ -301,24 +409,20 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
elif drop_via:
if remote_link:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Dropping all paths via specific transport instance on remote instances yet not implemented")
exit(255)
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(destination_hexhash) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e:
raise ValueError("Invalid destination entered. Check your input.")
if len(destination_hexhash) != dest_len: raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
sys.exit(1)
if reticulum.drop_all_via(destination_hash):
print("Dropped all paths via "+RNS.prettyhexrep(destination_hash))
if reticulum.drop_all_via(destination_hash): print("Dropped all paths via "+RNS.prettyhexrep(destination_hash))
else:
print("Unable to drop paths via "+RNS.prettyhexrep(destination_hash)+". Does the transport instance exist?")
sys.exit(1)
@@ -326,18 +430,15 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
else:
if remote_link:
if not no_output:
print("\r \r", end="")
print(output_rst_str, end="")
print("Requesting paths on remote instances not implemented")
exit(255)
try:
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(destination_hexhash) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try:
destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e:
raise ValueError("Invalid destination entered. Check your input.")
if len(destination_hexhash) != dest_len: raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
try: destination_hash = bytes.fromhex(destination_hexhash)
except Exception as e: raise ValueError("Invalid destination entered. Check your input.")
except Exception as e:
print(str(e))
sys.exit(1)
@@ -366,166 +467,57 @@ def program_setup(configdir, table, rates, drop, destination_hexhash, verbosity,
next_hop = RNS.prettyhexrep(next_hop_bytes)
next_hop_interface = reticulum.get_next_hop_if_name(destination_hash)
if hops != 1:
ms = "s"
else:
ms = ""
if hops != 1: ms = "s"
else: ms = ""
print("\rPath found, destination "+RNS.prettyhexrep(destination_hash)+" is "+str(hops)+" hop"+ms+" away via "+next_hop+" on "+next_hop_interface)
else:
print("\r \rPath not found")
sys.exit(1)
def main():
try:
parser = argparse.ArgumentParser(description="Reticulum Path Discovery Utility")
parser.add_argument("--config",
action="store",
default=None,
help="path to alternative Reticulum config directory",
type=str
)
parser.add_argument(
"--version",
action="version",
version="rnpath {version}".format(version=__version__)
)
parser.add_argument(
"-t",
"--table",
action="store_true",
help="show all known paths",
default=False
)
parser.add_argument(
"-m",
"--max",
action="store",
metavar="hops",
type=int,
help="maximum hops to filter path table by",
default=None
)
parser.add_argument(
"-r",
"--rates",
action="store_true",
help="show announce rate info",
default=False
)
parser.add_argument(
"-d",
"--drop",
action="store_true",
help="remove the path to a destination",
default=False
)
parser.add_argument(
"-D",
"--drop-announces",
action="store_true",
help="drop all queued announces",
default=False
)
parser.add_argument(
"-x", "--drop-via",
action="store_true",
help="drop all paths via specified transport instance",
default=False
)
parser.add_argument(
"-w",
action="store",
metavar="seconds",
type=float,
help="timeout before giving up",
default=RNS.Transport.PATH_REQUEST_TIMEOUT
)
parser.add_argument(
"-R",
action="store",
metavar="hash",
help="transport identity hash of remote instance to manage",
default=None,
type=str
)
parser.add_argument(
"-i",
action="store",
metavar="path",
help="path to identity used for remote management",
default=None,
type=str
)
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(
"-j",
"--json",
action="store_true",
help="output in JSON format",
default=False
)
parser.add_argument(
"destination",
nargs="?",
default=None,
help="hexadecimal hash of the destination",
type=str
)
parser = argparse.ArgumentParser(description="Reticulum Path Management Utility")
parser.add_argument("--config", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("--version", action="version", version="rnpath {version}".format(version=__version__))
parser.add_argument("-t", "--table", action="store_true", help="show all known paths", default=False)
parser.add_argument("-m", "--max", action="store", metavar="hops", type=int, help="maximum hops to filter path table by", default=None)
parser.add_argument("-r", "--rates", action="store_true", help="show announce rate info", default=False)
parser.add_argument("-d", "--drop", action="store_true", help="remove the path to a destination", default=False)
parser.add_argument("-D", "--drop-announces", action="store_true", help="drop all queued announces", default=False)
parser.add_argument("-x", "--drop-via", action="store_true", help="drop all paths via specified transport instance", default=False)
parser.add_argument("-w", action="store", metavar="seconds", type=float, help="timeout before giving up", default=RNS.Transport.PATH_REQUEST_TIMEOUT)
parser.add_argument("-R", action="store", metavar="hash", help="transport identity hash of remote instance to manage", default=None, type=str)
parser.add_argument("-i", action="store", metavar="path", help="path to identity used for remote management", default=None, type=str)
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("-b", "--blackholed", action="store_true", help="list blackholed identities", default=False)
parser.add_argument("-B", "--blackhole", action="store_true", help="blackhole identity", default=False)
parser.add_argument("-U", "--unblackhole", action="store_true", help="unblackhole identity", default=False)
parser.add_argument( "--duration", action="store", type=float, help="duration of blackhole enforcement in hours", default=None)
parser.add_argument( "--reason", action="store", type=str, help="reason for blackholing identity", default=None)
parser.add_argument("-p", "--blackholed-list", action="store_true", help="view published blackhole list for remote transport instance", default=False)
parser.add_argument("-j", "--json", action="store_true", help="output in JSON format", default=False)
parser.add_argument("destination", nargs="?", default=None, help="hexadecimal hash of the destination", type=str)
parser.add_argument("list_filter", nargs="?", default=None, help="filter for remote blackhole list view", type=str)
parser.add_argument('-v', '--verbose', action='count', default=0)
args = parser.parse_args()
if args.config:
configarg = args.config
else:
configarg = None
if args.config: configarg = args.config
else: configarg = None
if not args.drop_announces and not args.table and not args.rates and not args.destination and not args.drop_via:
if not args.drop_announces and not args.table and not args.rates and not args.destination and not args.drop_via and not args.blackholed:
print("")
parser.print_help()
print("")
else:
program_setup(
configdir = configarg,
table = args.table,
rates = args.rates,
drop = args.drop,
destination_hexhash = args.destination,
verbosity = args.verbose,
timeout = args.w,
drop_queues = args.drop_announces,
drop_via = args.drop_via,
max_hops = args.max,
remote=args.R,
management_identity=args.i,
remote_timeout=args.W,
json=args.json,
)
program_setup(configdir = configarg, table = args.table, rates = args.rates, drop = args.drop, destination_hexhash = args.destination,
verbosity = args.verbose, timeout = args.w, drop_queues = args.drop_announces, drop_via = args.drop_via, max_hops = args.max,
remote=args.R, management_identity=args.i, remote_timeout=args.W, blackholed=args.blackholed, blackhole=args.blackhole,
unblackhole=args.unblackhole, blackhole_duration=args.duration, blackhole_reason=args.reason, remote_blackhole_list=args.blackholed_list,
remote_blackhole_list_filter=args.list_filter, json=args.json)
sys.exit(0)
except KeyboardInterrupt:
@@ -535,38 +527,23 @@ def main():
def pretty_date(time=False):
from datetime import datetime
now = datetime.now()
if type(time) is int:
diff = now - datetime.fromtimestamp(time)
elif isinstance(time,datetime):
diff = now - time
elif not time:
diff = now - now
if type(time) is int: diff = now - datetime.fromtimestamp(time)
elif isinstance(time,datetime): diff = now - time
elif not time: diff = now - now
second_diff = diff.seconds
day_diff = diff.days
if day_diff < 0:
return ''
if day_diff < 0: return ''
if day_diff == 0:
if second_diff < 10:
return str(second_diff) + " seconds"
if second_diff < 60:
return str(second_diff) + " seconds"
if second_diff < 120:
return "1 minute"
if second_diff < 3600:
return str(int(second_diff / 60)) + " minutes"
if second_diff < 7200:
return "an hour"
if second_diff < 86400:
return str(int(second_diff / 3600)) + " hours"
if day_diff == 1:
return "1 day"
if day_diff < 7:
return str(day_diff) + " days"
if day_diff < 31:
return str(int(day_diff / 7)) + " weeks"
if day_diff < 365:
return str(int(day_diff / 30)) + " months"
if second_diff < 10: return str(second_diff) + " seconds"
if second_diff < 60: return str(second_diff) + " seconds"
if second_diff < 120: return "1 minute"
if second_diff < 3600: return str(int(second_diff / 60)) + " minutes"
if second_diff < 7200: return "an hour"
if second_diff < 86400: return str(int(second_diff / 3600)) + " hours"
if day_diff == 1: return "1 day"
if day_diff < 7: return str(day_diff) + " days"
if day_diff < 31: return str(int(day_diff / 7)) + " weeks"
if day_diff < 365: return str(int(day_diff / 30)) + " months"
return str(int(day_diff / 365)) + " years"
if __name__ == "__main__":
main()
if __name__ == "__main__": main()
+12 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
+109 -15
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -29,7 +37,7 @@ import time
from RNS._version import __version__
def program_setup(configdir, verbosity = 0, quietness = 0, service = False):
def program_setup(configdir, verbosity = 0, quietness = 0, service = False, interactive=False):
targetverbosity = verbosity-quietness
if service:
@@ -42,10 +50,14 @@ def program_setup(configdir, verbosity = 0, quietness = 0, service = False):
if reticulum.is_connected_to_shared_instance:
RNS.log("Started rnsd version {version} connected to another shared local instance, this is probably NOT what you want!".format(version=__version__), RNS.LOG_WARNING)
else:
# TODO: Rethink why this was added
# if RNS.Reticulum.get_instance().shared_instance_interface:
# RNS.Reticulum.get_instance().shared_instance_interface.server.daemon_threads = True
RNS.log("Started rnsd version {version}".format(version=__version__), RNS.LOG_NOTICE)
while True:
time.sleep(1)
if interactive: import code; code.interact(local=globals())
else:
while True: time.sleep(1)
def main():
try:
@@ -54,6 +66,7 @@ def main():
parser.add_argument('-v', '--verbose', action='count', default=0)
parser.add_argument('-q', '--quiet', action='count', default=0)
parser.add_argument('-s', '--service', action='store_true', default=False, help="rnsd is running as a service and should log to file")
parser.add_argument('-i', '--interactive', action='store_true', default=False, help="drop into interactive shell after initialisation")
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="rnsd {version}".format(version=__version__))
@@ -68,7 +81,7 @@ def main():
else:
configarg = None
program_setup(configdir = configarg, verbosity=args.verbose, quietness=args.quiet, service=args.service)
program_setup(configdir = configarg, verbosity=args.verbose, quietness=args.quiet, service=args.service, interactive=args.interactive)
except KeyboardInterrupt:
print("")
@@ -104,12 +117,24 @@ share_instance = Yes
# If you want to run multiple *different* shared instances
# on the same system, you will need to specify different
# shared instance ports for each. The defaults are given
# below, and again, these options can be left out if you
# don't need them.
# instance names for each. On platforms supporting domain
# sockets, this can be done with the instance_name option:
shared_instance_port = 37428
instance_control_port = 37429
instance_name = default
# Some platforms don't support domain sockets, and if that
# is the case, you can isolate different instances by
# specifying a unique set of ports for each:
# shared_instance_port = 37428
# instance_control_port = 37429
# If you want to explicitly use TCP for shared instance
# communication, instead of domain sockets, this is also
# possible, by using the following option:
# shared_instance_type = tcp
# On systems where running instances may not have access
@@ -135,13 +160,62 @@ instance_control_port = 37429
# remote_management_allowed = 9fb6d773498fb3feda407ed8ef2c3229, 2d882c5586e548d79b5af27bca1776dc
# For easier management, discovery and configuration of
# networks with many individual transport instances,
# you can specify a network identity to be used across
# a set of instances. If sending interface discovery
# announces, these will all be signed by the specified
# network identity, and other nodes discovering your
# interfaces will be able to identify that they belong
# to the same network, even though they exist on different
# transport nodes.
# network_identity = ~/.reticulum/storage/identity/network
# You can configure whether Reticulum should discover
# available interfaces from other Transport Instances over
# the network. If this option is enabled, Reticulum will
# collect interface information discovered from the network.
# discover_interfaces = No
# If you only want to discover interfaces from specific
# networks, you can provide a list of network identities
# from which to discover interfaces. If this option is not
# provided, interfaces will be discovered from all transport
# instances on all connected networks.
# interface_discovery_sources = 78616ff7c4b8d3886d67d494b440f333, cb127015e13aa6ea1e0a606cdc9123d0
# It is possible to automatically bring up and connect new
# interfaces discovered over the network. This option is
# disabled by default, but allows you to specify a maximum
# number of discovered interfaces to automatically connect.
# Additionally, if this option is enabled, Reticulum will
# also try to autoconnect available auto-discovered inter-
# faces on startup, up to the maximum number specified.
# autoconnect_discovered_interfaces = 0
# To prevent interface discovery spamming, a valid crypto-
# graphic stamp is required per announced interface. You
# can configure the minimum required value to accept as
# valid for discovered interfaces.
# required_discovery_value = 14
# 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
# an optional directive, and can be left out for brevity.
# This behaviour is disabled by default.
panic_on_interface_error = No
# panic_on_interface_error = No
# When Transport is enabled, it is possible to allow the
@@ -152,7 +226,27 @@ panic_on_interface_error = No
# Transport Instance, and printed to the log at startup.
# Optional, and disabled by default.
respond_to_probes = No
# respond_to_probes = No
# You can publish your local list of blackholed identities
# for other transport instances to use for automatic,
# network-wide blackhole management.
# publish_blackhole = No
# List of remote transport identities from which to auto-
# matically source lists of blackholed identities.
#
# If you're connecting to a large external network, you
# can use one or more external blackhole list to block
# spammy and excessive announces onto your network. This
# funtionality is especially useful if you're hosting public
# entrypoints or gateways. The list source below provides a
# functional example, but better, more timely maintained
# lists probably exist in the community.
# blackhole_sources = 521c87a83afb8f29e4455e77930b973b
[logging]
+316 -160
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -27,6 +35,7 @@ import os
import sys
import time
import argparse
import io
from RNS._version import __version__
@@ -133,14 +142,12 @@ 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,
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):
if remote:
require_shared = False
else:
require_shared = True
def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=False, astats=False, lstats=False, sorting=None, sort_reverse=False,
remote=None, management_identity=None, remote_timeout=RNS.Transport.PATH_REQUEST_TIMEOUT, must_exit=True, rns_instance=None,
traffic_totals=False, discovered_interfaces=False, config_entries=False):
if remote: require_shared = False
else: require_shared = True
try:
if rns_instance:
@@ -151,15 +158,150 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
except Exception as e:
print("No shared RNS instance available to get status from")
if must_exit:
exit(1)
else:
return
if must_exit: exit(1)
else: return
link_count = None
stats = None
details = False
if config_entries:
discovered_interfaces = True
details = True
if discovered_interfaces:
if_discovery = RNS.Discovery.InterfaceDiscovery(discover_interfaces=False)
ifs = if_discovery.list_discovered_interfaces()
print("")
if json:
import json
for i in ifs:
for e in i:
if isinstance(i[e], bytes): i[e] = RNS.hexrep(i[e], delimit=False)
print(json.dumps(ifs))
else:
filtered_ifs = []
for i in ifs:
name = i["name"]
if not name_filter or name_filter.lower() in name.lower(): filtered_ifs.append(i)
if details:
for idx, i in enumerate(filtered_ifs):
try:
name = i["name"]
if_type = i["type"]
status = i["status"]
if status == "available": status_display = "Available"
elif status == "unknown": status_display = "Unknown"
elif status == "stale": status_display = "Stale"
else: status_display = status
now = time.time()
dago = now-i["discovered"]
hago = now-i["last_heard"]
discovered_display = f"{RNS.prettytime(dago, compact=True)} ago"
last_heard_display = f"{RNS.prettytime(hago, compact=True)} ago"
transport_str = "Enabled" if i["transport"] else "Disabled"
if i["latitude"] is not None and i["longitude"] is not None:
lat = round(i["latitude"], 4)
lon = round(i["longitude"], 4)
if i["height"] != None: height = ", "+str(i["height"])+"m h"
else: height = ""
location = f"{lat}, {lon}{height}"
else: location = "Unknown"
transport_id = None
network = None
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 network: print(f"Network ID : {network}")
if transport_id: print(f"Transport ID : {transport_id}")
print(f"Name : {name}")
print(f"Type : {if_type}")
print(f"Status : {status_display}")
print(f"Transport : {transport_str}")
print(f"Distance : {i['hops']} hop{'' if i['hops'] == 1 else 's'}")
print(f"Discovered : {discovered_display}")
print(f"Last Heard : {last_heard_display}")
print(f"Location : {location}")
if "frequency" in i: print(f"Frequency : {i['frequency']:,} Hz")
if "bandwidth" in i: print(f"Bandwidth : {i['bandwidth']:,} Hz")
if "sf" in i: print(f"Sprd. Factor : {i['sf']}")
if "cr" in i: print(f"Coding Rate : {i['cr']}")
if "modulation" in i: print(f"Modulation : {i['modulation']}")
if "reachable_on" in i: print(f"Address : {i['reachable_on']}:{i['port']}")
print(f"Stamp Value : {i['value']}")
print(f"\nConfiguration Entry:")
config_lines = i["config_entry"].split('\n')
for line in config_lines: print(f" {line}")
except Exception as e:
pass
else:
print(f"{'Name':<25} {'Type':<12} {'Status':<12} {'Last Heard':<12} {'Value':<8} {'Location':<15}")
print("-" * 89)
for i in filtered_ifs:
try:
name = i["name"][:24] + "" if len(i["name"]) > 24 else i["name"]
if_type = i["type"].replace("Interface", "")
status = i["status"]
if status == "available": status_display = "✓ Available"
elif status == "unknown": status_display = "? Unknown"
elif status == "stale": status_display = "× Stale"
else: status_display = status
now = time.time()
last_heard = i["last_heard"]
diff = now - last_heard
if diff < 60: last_heard_display = "Just now"
elif diff < 3600:
mins = int(diff / 60)
last_heard_display = f"{mins}m ago"
elif diff < 86400:
hours = int(diff / 3600)
last_heard_display = f"{hours}h ago"
else:
days = int(diff / 86400)
last_heard_display = f"{days}d ago"
value = str(i["value"])
if i["latitude"] is not None and i["longitude"] is not None:
lat = round(i["latitude"], 4)
lon = round(i["longitude"], 4)
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}")
except Exception as e:
pass
if must_exit: exit(0)
else: return
if remote:
try:
if management_identity is None:
raise ValueError("Remote management requires an identity file. Use -i to specify the path to a management identity.")
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
if len(remote) != dest_len:
raise ValueError("Destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2))
@@ -179,25 +321,19 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
stats, link_count = remote_status
except Exception as e:
raise e
except Exception as e:
print(str(e))
if must_exit:
exit(20)
else:
return
if must_exit: exit(20)
else: return
else:
if lstats:
try:
link_count = reticulum.get_link_count()
except Exception as e:
pass
try: link_count = reticulum.get_link_count()
except Exception as e: pass
try:
stats = reticulum.get_interface_stats()
except Exception as e:
pass
try: stats = reticulum.get_interface_stats()
except Exception as e: pass
if stats != None:
if json:
@@ -214,10 +350,8 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
i[k] = RNS.hexrep(i[k], delimit=False)
print(json.dumps(stats))
if must_exit:
exit()
else:
return
if must_exit: exit()
else: return
interfaces = stats["interfaces"]
if sorting != None and isinstance(sorting, str):
@@ -228,6 +362,10 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
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":
@@ -239,13 +377,16 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if sorting == "held":
interfaces.sort(key=lambda i: i["held_announces"], reverse=not sort_reverse)
for ifstat in interfaces:
name = ifstat["name"]
if dispall or not (
name.startswith("LocalInterface[") or
name.startswith("TCPInterface[Client") or
name.startswith("BackboneInterface[Client on") or
name.startswith("AutoInterfacePeer[") or
name.startswith("WeaveInterfacePeer[") or
name.startswith("I2PInterfacePeer[Connected peer") or
(name.startswith("I2PInterface[") and ("i2p_connectable" in ifstat and ifstat["i2p_connectable"] == False))
):
@@ -254,23 +395,15 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if name_filter == None or name_filter.lower() in name.lower():
print("")
if ifstat["status"]:
ss = "Up"
else:
ss = "Down"
if ifstat["status"]: ss = "Up"
else: ss = "Down"
if ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT:
modestr = "Access Point"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_POINT_TO_POINT:
modestr = "Point-to-Point"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_ROAMING:
modestr = "Roaming"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_BOUNDARY:
modestr = "Boundary"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_GATEWAY:
modestr = "Gateway"
else:
modestr = "Full"
if ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT: modestr = "Access Point"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_POINT_TO_POINT: modestr = "Point-to-Point"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_ROAMING: modestr = "Roaming"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_BOUNDARY: modestr = "Boundary"
elif ifstat["mode"] == RNS.Interfaces.Interface.Interface.MODE_GATEWAY: modestr = "Gateway"
else: modestr = "Full"
if ifstat["clients"] != None:
@@ -302,6 +435,9 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
print(" {n}".format(n=ifstat["name"]))
if "autoconnect_source" in ifstat and ifstat["autoconnect_source"] != None:
print(" Source : Auto-connect via <{ns}>".format(ns=ifstat["autoconnect_source"]))
if "ifac_netname" in ifstat and ifstat["ifac_netname"] != None:
print(" Network : {nn}".format(nn=ifstat["ifac_netname"]))
@@ -316,6 +452,34 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if "bitrate" in ifstat and ifstat["bitrate"] != None:
print(" Rate : {ss}".format(ss=speed_str(ifstat["bitrate"])))
if "noise_floor" in ifstat:
if not "interference" in ifstat: nstr = ""
else:
nf = ifstat["interference"]
lstr = ", no interference"
if "interference_last_ts" in ifstat and "interference_last_dbm" in ifstat:
lago = time.time()-ifstat["interference_last_ts"]
ldbm = ifstat["interference_last_dbm"]
lstr = f"\n Intrfrnc. : {ldbm} dBm {RNS.prettytime(lago, compact=True)} ago"
nstr = f"\n Intrfrnc. : {nf} dBm" if nf else lstr
if ifstat["noise_floor"] != None: print(" Noise Fl. : {nfl} dBm{ntr}".format(nfl=str(ifstat["noise_floor"]), ntr=nstr))
else: print(" Noise Fl. : Unknown")
if "cpu_load" in ifstat:
if ifstat["cpu_load"] != None: print(" CPU load : {v} %".format(v=str(ifstat["cpu_load"])))
else: print(" CPU load : Unknown")
if "cpu_temp" in ifstat:
if ifstat["cpu_temp"] != None: print(" CPU temp : {v}°C".format(v=str(ifstat["cpu_temp"])))
else: print(" CPU load : Unknown")
if "mem_load" in ifstat:
if ifstat["cpu_load"] != None: print(" Mem usage : {v} %".format(v=str(ifstat["mem_load"])))
else: print(" Mem usage : Unknown")
if "battery_percent" in ifstat and ifstat["battery_percent"] != None:
try:
bpi = int(ifstat["battery_percent"])
@@ -326,10 +490,22 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if "airtime_short" in ifstat and "airtime_long" in ifstat:
print(" Airtime : {ats}% (15s), {atl}% (1h)".format(ats=str(ifstat["airtime_short"]),atl=str(ifstat["airtime_long"])))
if "channel_load_short" in ifstat and "channel_load_long" in ifstat:
print(" Ch.Load : {ats}% (15s), {atl}% (1h)".format(ats=str(ifstat["channel_load_short"]),atl=str(ifstat["channel_load_long"])))
print(" Ch. Load : {ats}% (15s), {atl}% (1h)".format(ats=str(ifstat["channel_load_short"]),atl=str(ifstat["channel_load_long"])))
if "switch_id" in ifstat:
if ifstat["switch_id"] != None: print(" Switch ID : {v}".format(v=str(ifstat["switch_id"])))
else: print(" Switch ID : Unknown")
if "endpoint_id" in ifstat:
if ifstat["endpoint_id"] != None: print(" Endpoint : {v}".format(v=str(ifstat["endpoint_id"])))
else: print(" Endpoint : Unknown")
if "via_switch_id" in ifstat:
if ifstat["via_switch_id"] != None: print(" Via : {v}".format(v=str(ifstat["via_switch_id"])))
else: print(" Via : Unknown")
if "peers" in ifstat and ifstat["peers"] != None:
print(" Peers : {np} reachable".format(np=ifstat["peers"]))
@@ -339,7 +515,7 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
if "ifac_signature" in ifstat and ifstat["ifac_signature"] != None:
sigstr = "<…"+RNS.hexrep(ifstat["ifac_signature"][-5:], delimit=False)+">"
print(" Access : {nb}-bit IFAC by {sig}".format(nb=ifstat["ifac_size"]*8, sig=sigstr))
if "i2p_b32" in ifstat and ifstat["i2p_b32"] != None:
print(" I2P B32 : {ep}".format(ep=str(ifstat["i2p_b32"])))
@@ -349,19 +525,33 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
print(" Queued : {np} announce".format(np=aqn))
else:
print(" Queued : {np} announces".format(np=aqn))
if astats and "held_announces" in ifstat and ifstat["held_announces"] != None and ifstat["held_announces"] > 0:
aqn = ifstat["held_announces"]
if aqn == 1:
print(" Held : {np} announce".format(np=aqn))
else:
print(" Held : {np} announces".format(np=aqn))
if astats and "incoming_announce_frequency" in ifstat and ifstat["incoming_announce_frequency"] != None:
print(" Announces : {iaf}".format(iaf=RNS.prettyfrequency(ifstat["outgoing_announce_frequency"])))
print(" {iaf}".format(iaf=RNS.prettyfrequency(ifstat["incoming_announce_frequency"])))
print(" Traffic : {txb}\n {rxb}".format(rxb=size_str(ifstat["rxb"]), txb=size_str(ifstat["txb"])))
rxb_str = ""+RNS.prettysize(ifstat["rxb"])
txb_str = ""+RNS.prettysize(ifstat["txb"])
strdiff = len(rxb_str)-len(txb_str)
if strdiff > 0:
txb_str += " "*strdiff
elif strdiff < 0:
rxb_str += " "*-strdiff
rxstat = rxb_str
txstat = txb_str
if "rxs" in ifstat and "txs" in ifstat:
rxstat += " "+RNS.prettyspeed(ifstat["rxs"])
txstat += " "+RNS.prettyspeed(ifstat["txs"])
print(f" Traffic : {txstat}\n {rxstat}")
lstr = ""
if link_count != None and lstats:
@@ -371,8 +561,23 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
else:
lstr = f" {link_count} entr{ms} in link table"
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
rxstat = rxb_str+" "+RNS.prettyspeed(stats["rxs"])
txstat = txb_str+" "+RNS.prettyspeed(stats["txs"])
print(f"\n Totals : {txstat}\n {rxstat}")
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:
print(" Network Identity "+RNS.prettyhexrep(stats["network_id"]))
if "probe_responder" in stats and stats["probe_responder"] != None:
print(" Probe responder at "+RNS.prettyhexrep(stats["probe_responder"])+ " active")
if "transport_uptime" in stats and stats["transport_uptime"] != None:
@@ -382,7 +587,7 @@ def program_setup(configdir, dispall=False, verbosity=0, name_filter=None, json=
print(f"\n{lstr}")
print("")
else:
if not remote:
print("Could not get RNS status")
@@ -399,116 +604,67 @@ 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(
"-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(
"-l",
"--link-stats",
action="store_true",
help="show link stats",
default=False,
)
parser.add_argument(
"-s",
"--sort",
action="store",
help="sort interfaces by [rate, traffic, rx, tx, announces, arx, atx, held]",
default=None,
type=str
)
parser.add_argument(
"-r",
"--reverse",
action="store_true",
help="reverse sorting",
default=False,
)
parser.add_argument(
"-j",
"--json",
action="store_true",
help="output in JSON format",
default=False
)
parser.add_argument(
"-R",
action="store",
metavar="hash",
help="transport identity hash of remote instance to get status from",
default=None,
type=str
)
parser.add_argument(
"-i",
action="store",
metavar="path",
help="path to identity used for remote management",
default=None,
type=str
)
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("-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("-l", "--link-stats", action="store_true", help="show link stats", default=False)
parser.add_argument("-t", "--totals", action="store_true", help="display traffic totals", default=False)
parser.add_argument("-s", "--sort", action="store", help="sort interfaces by [rate, traffic, rx, tx, rxs, txs, announces, arx, atx, held]", default=None, type=str)
parser.add_argument("-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)
parser.add_argument("-i", action="store", metavar="path", help="path to identity used for remote management", default=None, type=str)
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("-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)
parser.add_argument("filter", nargs="?", default=None, help="only display interfaces with names including filter", type=str)
args = parser.parse_args()
if args.config:
configarg = args.config
else:
configarg = None
if args.config: configarg = args.config
else: configarg = None
program_setup(
configdir = configarg,
dispall = args.all,
verbosity=args.verbose,
name_filter=args.filter,
json=args.json,
astats=args.announce_stats,
lstats=args.link_stats,
sorting=args.sort,
sort_reverse=args.reverse,
remote=args.R,
management_identity=args.i,
remote_timeout=args.w,
must_exit=must_exit,
rns_instance=rns_instance,
)
if args.monitor:
if args.R: require_shared = False
else: require_shared = True
try: reticulum = RNS.Reticulum(configdir=configarg, loglevel=3+args.verbose, require_shared_instance=require_shared)
except Exception as e:
print("No shared RNS instance available to get status from")
exit(1)
while True:
buffer = io.StringIO()
old_stdout = sys.stdout
sys.stdout = buffer
try:
program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
astats=args.announce_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse, remote=args.R,
management_identity=args.i, remote_timeout=args.w, must_exit=False, rns_instance=reticulum, traffic_totals=args.totals,
discovered_interfaces=args.discovered, config_entries=args.D)
finally:
sys.stdout = old_stdout
output = buffer.getvalue()
print("\033[H\033[2J", end="")
print(output, end="", flush=True)
time.sleep(args.monitor_interval)
else:
program_setup(configdir = configarg, dispall = args.all, verbosity=args.verbose, name_filter=args.filter, json=args.json,
astats=args.announce_stats, lstats=args.link_stats, sorting=args.sort, sort_reverse=args.reverse, remote=args.R,
management_identity=args.i, remote_timeout=args.w, must_exit=must_exit, rns_instance=rns_instance, traffic_totals=args.totals,
discovered_interfaces=args.discovered, config_entries=args.D)
except KeyboardInterrupt:
print("")
if must_exit:
exit()
else:
return
if must_exit: exit()
else: return
def speed_str(num, suffix='bps'):
units = ['','k','M','G','T','P','E','Z']
+31 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -11,8 +11,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -83,7 +91,25 @@ def listen(configdir, identitypath = None, verbosity = 0, quietness = 0, allowed
except Exception as e:
print(str(e))
exit(1)
try:
allowed_file_name = "allowed_identities"
allowed_file = None
if os.path.isfile(os.path.expanduser("/etc/rnx/"+allowed_file_name)):
allowed_file = os.path.expanduser("/etc/rnx/"+allowed_file_name)
elif os.path.isfile(os.path.expanduser("~/.config/rnx/"+allowed_file_name)):
allowed_file = os.path.expanduser("~/.config/rnx/"+allowed_file_name)
elif os.path.isfile(os.path.expanduser("~/.rnx/"+allowed_file_name)):
allowed_file = os.path.expanduser("~/.rnx/"+allowed_file_name)
if allowed_file != None:
with open(allowed_file, "r") as af_handle:
allowed_by_file = af_handle.read().replace("\r", "").split("\n")
for allowed_ID in allowed_by_file:
if len(allowed_ID) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2:
allowed_identity_hashes.append(bytes.fromhex(allowed_ID))
except Exception as e:
print(str(e))
exit(1)
if len(allowed_identity_hashes) < 1 and not disable_auth:
print("Warning: No allowed identities configured, rncx will not accept any commands!")
+237 -130
View File
@@ -1,6 +1,6 @@
# MIT License
# Reticulum License
#
# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -9,8 +9,16 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# - 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,
@@ -24,6 +32,7 @@ import os
import sys
import glob
import time
import datetime
import random
import threading
@@ -35,6 +44,7 @@ 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
@@ -48,6 +58,11 @@ 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"))]))
import importlib.util
if importlib.util.find_spec("cython"): import cython; compiled = cython.compiled
else: compiled = False
LOG_NONE = -1
LOG_CRITICAL = 0
LOG_ERROR = 1
LOG_WARNING = 2
@@ -68,6 +83,7 @@ logfile = None
logdest = LOG_STDOUT
logcall = None
logtimefmt = "%Y-%m-%d %H:%M:%S"
logtimefmt_p = "%H:%M:%S.%f"
compact_log_fmt = False
instance_random = random.Random()
@@ -79,21 +95,21 @@ logging_lock = threading.Lock()
def loglevelname(level):
if (level == LOG_CRITICAL):
return "Critical"
return "[Critical]"
if (level == LOG_ERROR):
return "Error"
return "[Error] "
if (level == LOG_WARNING):
return "Warning"
return "[Warning] "
if (level == LOG_NOTICE):
return "Notice"
return "[Notice] "
if (level == LOG_INFO):
return "Info"
return "[Info] "
if (level == LOG_VERBOSE):
return "Verbose"
return "[Verbose] "
if (level == LOG_DEBUG):
return "Debug"
return "[Debug] "
if (level == LOG_EXTREME):
return "Extra"
return "[Extra] "
return "Unknown"
@@ -108,51 +124,55 @@ def timestamp_str(time_s):
timestamp = time.localtime(time_s)
return time.strftime(logtimefmt, timestamp)
def log(msg, level=3, _override_destination = False):
def precise_timestamp_str(time_s):
return datetime.datetime.now().strftime(logtimefmt_p)[:-3]
def log(msg, level=3, _override_destination = False, pt=False):
if loglevel == LOG_NONE: return
global _always_override_destination, compact_log_fmt
msg = str(msg)
if loglevel >= level:
if not compact_log_fmt:
logstring = "["+timestamp_str(time.time())+"] ["+loglevelname(level)+"] "+msg
if pt:
logstring = "["+precise_timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg
else:
logstring = "["+timestamp_str(time.time())+"] "+msg
if not compact_log_fmt:
logstring = "["+timestamp_str(time.time())+"] "+loglevelname(level)+" "+msg
else:
logstring = "["+timestamp_str(time.time())+"] "+msg
logging_lock.acquire()
with logging_lock:
if (logdest == LOG_STDOUT or _always_override_destination or _override_destination):
if not threading.main_thread().is_alive(): return
else:
try: print(logstring)
except: pass
if (logdest == LOG_STDOUT or _always_override_destination or _override_destination):
print(logstring)
logging_lock.release()
elif (logdest == LOG_FILE and logfile != None):
try:
file = open(logfile, "a")
file.write(logstring+"\n")
file.close()
if os.path.getsize(logfile) > LOG_MAXSIZE:
prevfile = logfile+".1"
if os.path.isfile(prevfile):
os.unlink(prevfile)
os.rename(logfile, prevfile)
elif (logdest == LOG_FILE and logfile != None):
try:
file = open(logfile, "a")
file.write(logstring+"\n")
file.close()
if os.path.getsize(logfile) > LOG_MAXSIZE:
prevfile = logfile+".1"
if os.path.isfile(prevfile):
os.unlink(prevfile)
os.rename(logfile, prevfile)
except Exception as e:
_always_override_destination = True
log("Exception occurred while writing log message to log file: "+str(e), LOG_CRITICAL)
log("Dumping future log events to console!", LOG_CRITICAL)
log(msg, level)
logging_lock.release()
except Exception as e:
logging_lock.release()
_always_override_destination = True
log("Exception occurred while writing log message to log file: "+str(e), LOG_CRITICAL)
log("Dumping future log events to console!", LOG_CRITICAL)
log(msg, level)
elif logdest == LOG_CALLBACK:
try:
logcall(logstring)
logging_lock.release()
except Exception as e:
logging_lock.release()
_always_override_destination = True
log("Exception occurred while calling external log handler: "+str(e), LOG_CRITICAL)
log("Dumping future log events to console!", LOG_CRITICAL)
log(msg, level)
elif logdest == LOG_CALLBACK:
try:
logcall(logstring)
except Exception as e:
_always_override_destination = True
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():
@@ -233,6 +253,11 @@ def prettydistance(m, suffix="m"):
return "%.2f %s%s" % (num, last_unit, suffix)
def prettytime(time, verbose=False, compact=False):
neg = False
if time < 0:
time = abs(time)
neg = True
days = int(time // (24 * 3600))
time = time % (24 * 3600)
hours = int(time // 3600)
@@ -283,10 +308,17 @@ def prettytime(time, verbose=False, compact=False):
if tstr == "":
return "0s"
else:
return tstr
if not neg:
return tstr
else:
return f"-{tstr}"
def prettyshorttime(time, verbose=False, compact=False):
neg = False
time = time*1e6
if time < 0:
time = abs(time)
neg = True
seconds = int(time // 1e6); time %= 1e6
milliseconds = int(time // 1e3); time %= 1e3
@@ -330,7 +362,10 @@ def prettyshorttime(time, verbose=False, compact=False):
if tstr == "":
return "0us"
else:
return tstr
if not neg:
return tstr
else:
return f"-{tstr}"
def phyparams():
print("Required Physical Layer MTU : "+str(Reticulum.MTU)+" bytes")
@@ -344,97 +379,169 @@ def phyparams():
def panic():
os._exit(255)
def exit():
print("")
sys.exit(0)
exit_called = False
def exit(code=0):
global exit_called
if not exit_called:
exit_called = True
Reticulum.exit_handler()
os._exit(code)
class Profiler:
_ran = False
profilers = {}
tags = {}
profiler_ran = False
profiler_tags = {}
def profiler(tag=None, capture=False, super_tag=None):
global profiler_ran, profiler_tags
try:
thread_ident = threading.get_ident()
if capture:
end = time.perf_counter()
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)
if not profiler_ran:
profiler_ran = True
@staticmethod
def get_profiler(tag=None, super_tag=None):
if tag in Profiler.profilers:
return Profiler.profilers[tag]
else:
if not tag in profiler_tags:
profiler_tags[tag] = {"threads": {}, "super": super_tag}
if not thread_ident in profiler_tags[tag]["threads"]:
profiler_tags[tag]["threads"][thread_ident] = {"current_start": None, "captures": []}
profiler = Profiler(tag, super_tag)
Profiler.profilers[tag] = profiler
return profiler
profiler_tags[tag]["threads"][thread_ident]["current_start"] = time.perf_counter()
def __init__(self, tag=None, super_tag=None):
self.paused = False
self.pause_time = 0
self.pause_started = None
self.tag = tag
self.super_tag = super_tag
if self.super_tag in Profiler.profilers:
self.super_profiler = Profiler.profilers[self.super_tag]
self.pause_super = self.super_profiler.pause
self.resume_super = self.super_profiler.resume
else:
def noop(self=None):
pass
self.super_profiler = None
self.pause_super = noop
self.resume_super = noop
except Exception as e:
trace_exception(e)
def __enter__(self):
self.pause_super()
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 thread_ident in Profiler.tags[tag]["threads"]:
Profiler.tags[tag]["threads"][thread_ident] = {"current_start": None, "captures": []}
def profiler_results():
from statistics import mean, median, stdev
results = {}
for tag in profiler_tags:
tag_captures = []
tag_entry = profiler_tags[tag]
Profiler.tags[tag]["threads"][thread_ident]["current_start"] = time.perf_counter()
self.resume_super()
def __exit__(self, exc_type, exc_value, traceback):
self.pause_super()
tag = self.tag
super_tag = self.super_tag
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)
if not Profiler._ran:
Profiler._ran = True
self.resume_super()
def pause(self, pause_started=None):
if not self.paused:
self.paused = True
self.pause_started = pause_started or time.perf_counter()
self.pause_super(self.pause_started)
def resume(self):
if self.paused:
self.pause_time += time.perf_counter() - self.pause_started
self.paused = False
self.resume_super()
@staticmethod
def ran():
return Profiler._ran
@staticmethod
def results():
from statistics import mean, median, stdev
results = {}
for thread_ident in tag_entry["threads"]:
thread_entry = tag_entry["threads"][thread_ident]
thread_captures = thread_entry["captures"]
sample_count = len(thread_captures)
for tag in Profiler.tags:
tag_captures = []
tag_entry = Profiler.tags[tag]
if sample_count > 2:
thread_results = {
"count": sample_count,
"mean": mean(thread_captures),
"median": median(thread_captures),
"stdev": stdev(thread_captures)
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.extend(thread_captures)
sample_count = len(tag_captures)
if sample_count > 1:
tag_results = {
"name": tag,
"super": tag_entry["super"],
"count": len(tag_captures),
"mean": mean(tag_captures),
"median": median(tag_captures),
"stdev": stdev(tag_captures)
}
elif sample_count == 1:
tag_results = {
"name": tag,
"super": tag_entry["super"],
"count": len(tag_captures),
"mean": mean(tag_captures),
"median": median(tag_captures),
"stdev": None
}
tag_captures.extend(thread_captures)
sample_count = len(tag_captures)
if sample_count > 2:
tag_results = {
"name": tag,
"super": tag_entry["super"],
"count": len(tag_captures),
"mean": mean(tag_captures),
"median": median(tag_captures),
"stdev": stdev(tag_captures)
}
results[tag] = tag_results
def print_results_recursive(tag, results, level=0):
print_tag_results(tag, level+1)
def print_results_recursive(tag, results, level=0):
print_tag_results(tag, level+1)
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)
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("")
print("\nProfiler results:\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)
tag = results[tag_name]
if tag["super"] == None:
print_results_recursive(tag, results)
def print_tag_results(tag, level):
ind = " "*level
name = tag["name"]; count = tag["count"]
mean = tag["mean"]; tag["median"]; stdev = tag["stdev"]
print(f"{ind}{name}")
print(f"{ind} Samples : {count}")
print(f"{ind} Mean : {prettyshorttime(mean)}")
print(f"{ind} Median : {prettyshorttime(median)}")
print(f"{ind} St.dev. : {prettyshorttime(stdev)}")
print("")
print("\nProfiler results:\n")
for tag_name in results:
tag = results[tag_name]
if tag["super"] == None:
print_results_recursive(tag, results)
profile = Profiler.get_profiler
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.8.7"
__version__ = "1.1.0"
+25 -34
View File
@@ -19,8 +19,7 @@ import sys
from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF16_BE, BOM_UTF16_LE
import RNS.vendor.six as six
__version__ = '5.0.6'
__version__ = '5.0.9'
# imported lazily to avoid startup performance hit if it isn't used
compiler = None
@@ -121,10 +120,6 @@ OPTION_DEFAULTS = {
'write_empty_values': False,
}
# this could be replaced if six is used for compatibility, or there are no
# more assertions about items being a string
def getObj(s):
global compiler
if compiler is None:
@@ -553,11 +548,11 @@ class Section(dict):
"""Fetch the item and do string interpolation."""
val = dict.__getitem__(self, key)
if self.main.interpolation:
if isinstance(val, six.string_types):
if isinstance(val, str):
return self._interpolate(key, val)
if isinstance(val, list):
def _check(entry):
if isinstance(entry, six.string_types):
if isinstance(entry, str):
return self._interpolate(key, entry)
return entry
new = [_check(entry) for entry in val]
@@ -580,7 +575,7 @@ class Section(dict):
``unrepr`` must be set when setting a value to a dictionary, without
creating a new sub-section.
"""
if not isinstance(key, six.string_types):
if not isinstance(key, str):
raise ValueError('The key "%s" is not a string.' % key)
# add the comment
@@ -614,11 +609,11 @@ class Section(dict):
if key not in self:
self.scalars.append(key)
if not self.main.stringify:
if isinstance(value, six.string_types):
if isinstance(value, str):
pass
elif isinstance(value, (list, tuple)):
for entry in value:
if not isinstance(entry, six.string_types):
if not isinstance(entry, str):
raise TypeError('Value is not a string "%s".' % entry)
else:
raise TypeError('Value is not a string "%s".' % value)
@@ -959,7 +954,7 @@ class Section(dict):
return False
else:
try:
if not isinstance(val, six.string_types):
if not isinstance(val, str):
# TODO: Why do we raise a KeyError here?
raise KeyError()
else:
@@ -1230,7 +1225,7 @@ class ConfigObj(Section):
def _load(self, infile, configspec):
if isinstance(infile, six.string_types):
if isinstance(infile, str):
self.filename = infile
if os.path.isfile(infile):
with open(infile, 'rb') as h:
@@ -1298,7 +1293,7 @@ class ConfigObj(Section):
break
break
assert all(isinstance(line, six.string_types) for line in content), repr(content)
assert all(isinstance(line, str) for line in content), repr(content)
content = [line.rstrip('\r\n') for line in content]
self._parse(content)
@@ -1403,7 +1398,7 @@ class ConfigObj(Section):
else:
line = infile
if isinstance(line, six.text_type):
if isinstance(line, str):
# it's already decoded and there's no need to do anything
# else, just use the _decode utility method to handle
# listifying appropriately
@@ -1448,7 +1443,7 @@ class ConfigObj(Section):
# No encoding specified - so we need to check for UTF8/UTF16
for BOM, (encoding, final_encoding) in list(BOMS.items()):
if not isinstance(line, six.binary_type) or not line.startswith(BOM):
if not isinstance(line, bytes) or not line.startswith(BOM):
# didn't specify a BOM, or it's not a bytestring
continue
else:
@@ -1464,9 +1459,9 @@ class ConfigObj(Section):
else:
infile = newline
# UTF-8
if isinstance(infile, six.text_type):
if isinstance(infile, str):
return infile.splitlines(True)
elif isinstance(infile, six.binary_type):
elif isinstance(infile, bytes):
return infile.decode('utf-8').splitlines(True)
else:
return self._decode(infile, 'utf-8')
@@ -1474,12 +1469,8 @@ class ConfigObj(Section):
return self._decode(infile, encoding)
if six.PY2 and isinstance(line, str):
# don't actually do any decoding, since we're on python 2 and
# returning a bytestring is fine
return self._decode(infile, None)
# No BOM discovered and no encoding specified, default to UTF-8
if isinstance(infile, six.binary_type):
if isinstance(infile, bytes):
return infile.decode('utf-8').splitlines(True)
else:
return self._decode(infile, 'utf-8')
@@ -1487,7 +1478,7 @@ class ConfigObj(Section):
def _a_to_u(self, aString):
"""Decode ASCII strings to unicode if a self.encoding is specified."""
if isinstance(aString, six.binary_type) and self.encoding:
if isinstance(aString, bytes) and self.encoding:
return aString.decode(self.encoding)
else:
return aString
@@ -1499,9 +1490,9 @@ class ConfigObj(Section):
if is a string, it also needs converting to a list.
"""
if isinstance(infile, six.string_types):
if isinstance(infile, str):
return infile.splitlines(True)
if isinstance(infile, six.binary_type):
if isinstance(infile, bytes):
# NOTE: Could raise a ``UnicodeDecodeError``
if encoding:
return infile.decode(encoding).splitlines(True)
@@ -1510,7 +1501,7 @@ class ConfigObj(Section):
if encoding:
for i, line in enumerate(infile):
if isinstance(line, six.binary_type):
if isinstance(line, bytes):
# NOTE: The isinstance test here handles mixed lists of unicode/string
# NOTE: But the decode will break on any non-string values
# NOTE: Or could raise a ``UnicodeDecodeError``
@@ -1520,7 +1511,7 @@ class ConfigObj(Section):
def _decode_element(self, line):
"""Decode element to unicode if necessary."""
if isinstance(line, six.binary_type) and self.default_encoding:
if isinstance(line, bytes) and self.default_encoding:
return line.decode(self.default_encoding)
else:
return line
@@ -1532,7 +1523,7 @@ class ConfigObj(Section):
Used by ``stringify`` within validate, to turn non-string values
into strings.
"""
if not isinstance(value, six.string_types):
if not isinstance(value, str):
# intentially 'str' because it's just whatever the "normal"
# string type is for the python version we're dealing with
return str(value)
@@ -1786,7 +1777,7 @@ class ConfigObj(Section):
return self._quote(value[0], multiline=False) + ','
return ', '.join([self._quote(val, multiline=False)
for val in value])
if not isinstance(value, six.string_types):
if not isinstance(value, str):
if self.stringify:
# intentially 'str' because it's just whatever the "normal"
# string type is for the python version we're dealing with
@@ -2111,7 +2102,7 @@ class ConfigObj(Section):
if not output.endswith(newline):
output += newline
if isinstance(output, six.binary_type):
if isinstance(output, bytes):
output_bytes = output
else:
output_bytes = output.encode(self.encoding or
@@ -2170,7 +2161,7 @@ class ConfigObj(Section):
if preserve_errors:
# We do this once to remove a top level dependency on the validate module
# Which makes importing configobj faster
from validate import VdtMissingValue
from configobj.validate import VdtMissingValue
self._vdtMissingValue = VdtMissingValue
section = self
@@ -2353,7 +2344,7 @@ class ConfigObj(Section):
This method raises a ``ReloadError`` if the ConfigObj doesn't have
a filename attribute pointing to a file.
"""
if not isinstance(self.filename, six.string_types):
if not isinstance(self.filename, str):
raise ReloadError()
filename = self.filename
@@ -2480,4 +2471,4 @@ def get_extra_values(conf, _prepend=()):
return out
"""*A programming language is a medium of expression.* - Paul Graham"""
"""*A programming language is a medium of expression.* - Paul Graham"""
-33
View File
@@ -1,33 +0,0 @@
# Copyright (c) 2014 Stefan C. Mueller
# 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 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
from RNS.vendor.ifaddr._shared import Adapter, IP
if os.name == "nt":
from RNS.vendor.ifaddr._win32 import get_adapters
elif os.name == "posix":
from RNS.vendor.ifaddr._posix import get_adapters
else:
raise RuntimeError("Unsupported Operating System: %s" % os.name)
__all__ = ['Adapter', 'IP', 'get_adapters']
-93
View File
@@ -1,93 +0,0 @@
# Copyright (c) 2014 Stefan C. Mueller
# 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 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 ctypes.util
import ipaddress
import collections
import socket
from typing import Iterable, Optional
import RNS.vendor.ifaddr._shared as shared
class ifaddrs(ctypes.Structure):
pass
ifaddrs._fields_ = [
('ifa_next', ctypes.POINTER(ifaddrs)),
('ifa_name', ctypes.c_char_p),
('ifa_flags', ctypes.c_uint),
('ifa_addr', ctypes.POINTER(shared.sockaddr)),
('ifa_netmask', ctypes.POINTER(shared.sockaddr)),
]
libc = ctypes.CDLL(ctypes.util.find_library("socket" if os.uname()[0] == "SunOS" else "c"), use_errno=True) # type: ignore
def get_adapters(include_unconfigured: bool = False) -> Iterable[shared.Adapter]:
addr0 = addr = ctypes.POINTER(ifaddrs)()
retval = libc.getifaddrs(ctypes.byref(addr))
if retval != 0:
eno = ctypes.get_errno()
raise OSError(eno, os.strerror(eno))
ips = collections.OrderedDict()
def add_ip(adapter_name: str, ip: Optional[shared.IP]) -> None:
if adapter_name not in ips:
index = None # type: Optional[int]
try:
# Mypy errors on this when the Windows CI runs:
# error: Module has no attribute "if_nametoindex"
index = socket.if_nametoindex(adapter_name) # type: ignore
except (OSError, AttributeError):
pass
ips[adapter_name] = shared.Adapter(adapter_name, adapter_name, [], index=index)
if ip is not None:
ips[adapter_name].ips.append(ip)
while addr:
name = addr[0].ifa_name.decode(encoding='UTF-8')
ip_addr = shared.sockaddr_to_ip(addr[0].ifa_addr)
if ip_addr:
if addr[0].ifa_netmask and not addr[0].ifa_netmask[0].sa_familiy:
addr[0].ifa_netmask[0].sa_familiy = addr[0].ifa_addr[0].sa_familiy
netmask = shared.sockaddr_to_ip(addr[0].ifa_netmask)
if isinstance(netmask, tuple):
netmaskStr = str(netmask[0])
prefixlen = shared.ipv6_prefixlength(ipaddress.IPv6Address(netmaskStr))
else:
assert netmask is not None, f'sockaddr_to_ip({addr[0].ifa_netmask}) returned None'
netmaskStr = str('0.0.0.0/' + netmask)
prefixlen = ipaddress.IPv4Network(netmaskStr).prefixlen
ip = shared.IP(ip_addr, prefixlen, name)
add_ip(name, ip)
else:
if include_unconfigured:
add_ip(name, None)
addr = addr[0].ifa_next
libc.freeifaddrs(addr0)
return ips.values()
-198
View File
@@ -1,198 +0,0 @@
# Copyright (c) 2014 Stefan C. Mueller
# 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 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 ctypes
import socket
import ipaddress
import platform
from typing import List, Optional, Tuple, Union
class Adapter(object):
"""
Represents a network interface device controller (NIC), such as a
network card. An adapter can have multiple IPs.
On Linux aliasing (multiple IPs per physical NIC) is implemented
by creating 'virtual' adapters, each represented by an instance
of this class. Each of those 'virtual' adapters can have both
a IPv4 and an IPv6 IP address.
"""
def __init__(self, name: str, nice_name: str, ips: List['IP'], index: Optional[int] = None) -> None:
#: Unique name that identifies the adapter in the system.
#: On Linux this is of the form of `eth0` or `eth0:1`, on
#: Windows it is a UUID in string representation, such as
#: `{846EE342-7039-11DE-9D20-806E6F6E6963}`.
self.name = name
#: Human readable name of the adpater. On Linux this
#: is currently the same as :attr:`name`. On Windows
#: this is the name of the device.
self.nice_name = nice_name
#: List of :class:`ifaddr.IP` instances in the order they were
#: reported by the system.
self.ips = ips
#: Adapter index as used by some API (e.g. IPv6 multicast group join).
self.index = index
def __repr__(self) -> str:
return "Adapter(name={name}, nice_name={nice_name}, ips={ips}, index={index})".format(
name=repr(self.name), nice_name=repr(self.nice_name), ips=repr(self.ips), index=repr(self.index)
)
# Type of an IPv4 address (a string in "xxx.xxx.xxx.xxx" format)
_IPv4Address = str
# Type of an IPv6 address (a three-tuple `(ip, flowinfo, scope_id)`)
_IPv6Address = Tuple[str, int, int]
class IP(object):
"""
Represents an IP address of an adapter.
"""
def __init__(self, ip: Union[_IPv4Address, _IPv6Address], network_prefix: int, nice_name: str) -> None:
#: IP address. For IPv4 addresses this is a string in
#: "xxx.xxx.xxx.xxx" format. For IPv6 addresses this
#: is a three-tuple `(ip, flowinfo, scope_id)`, where
#: `ip` is a string in the usual collon separated
#: hex format.
self.ip = ip
#: Number of bits of the IP that represent the
#: network. For a `255.255.255.0` netmask, this
#: number would be `24`.
self.network_prefix = network_prefix
#: Human readable name for this IP.
#: On Linux is this currently the same as the adapter name.
#: On Windows this is the name of the network connection
#: as configured in the system control panel.
self.nice_name = nice_name
@property
def is_IPv4(self) -> bool:
"""
Returns `True` if this IP is an IPv4 address and `False`
if it is an IPv6 address.
"""
return not isinstance(self.ip, tuple)
@property
def is_IPv6(self) -> bool:
"""
Returns `True` if this IP is an IPv6 address and `False`
if it is an IPv4 address.
"""
return isinstance(self.ip, tuple)
def __repr__(self) -> str:
return "IP(ip={ip}, network_prefix={network_prefix}, nice_name={nice_name})".format(
ip=repr(self.ip), network_prefix=repr(self.network_prefix), nice_name=repr(self.nice_name)
)
if platform.system() == "Darwin" or "BSD" in platform.system():
# BSD derived systems use marginally different structures
# than either Linux or Windows.
# I still keep it in `shared` since we can use
# both structures equally.
class sockaddr(ctypes.Structure):
_fields_ = [
('sa_len', ctypes.c_uint8),
('sa_familiy', ctypes.c_uint8),
('sa_data', ctypes.c_uint8 * 14),
]
class sockaddr_in(ctypes.Structure):
_fields_ = [
('sa_len', ctypes.c_uint8),
('sa_familiy', ctypes.c_uint8),
('sin_port', ctypes.c_uint16),
('sin_addr', ctypes.c_uint8 * 4),
('sin_zero', ctypes.c_uint8 * 8),
]
class sockaddr_in6(ctypes.Structure):
_fields_ = [
('sa_len', ctypes.c_uint8),
('sa_familiy', ctypes.c_uint8),
('sin6_port', ctypes.c_uint16),
('sin6_flowinfo', ctypes.c_uint32),
('sin6_addr', ctypes.c_uint8 * 16),
('sin6_scope_id', ctypes.c_uint32),
]
else:
class sockaddr(ctypes.Structure): # type: ignore
_fields_ = [('sa_familiy', ctypes.c_uint16), ('sa_data', ctypes.c_uint8 * 14)]
class sockaddr_in(ctypes.Structure): # type: ignore
_fields_ = [
('sin_familiy', ctypes.c_uint16),
('sin_port', ctypes.c_uint16),
('sin_addr', ctypes.c_uint8 * 4),
('sin_zero', ctypes.c_uint8 * 8),
]
class sockaddr_in6(ctypes.Structure): # type: ignore
_fields_ = [
('sin6_familiy', ctypes.c_uint16),
('sin6_port', ctypes.c_uint16),
('sin6_flowinfo', ctypes.c_uint32),
('sin6_addr', ctypes.c_uint8 * 16),
('sin6_scope_id', ctypes.c_uint32),
]
def sockaddr_to_ip(sockaddr_ptr: 'ctypes.pointer[sockaddr]') -> Optional[Union[_IPv4Address, _IPv6Address]]:
if sockaddr_ptr:
if sockaddr_ptr[0].sa_familiy == socket.AF_INET:
ipv4 = ctypes.cast(sockaddr_ptr, ctypes.POINTER(sockaddr_in))
ippacked = bytes(bytearray(ipv4[0].sin_addr))
ip = str(ipaddress.ip_address(ippacked))
return ip
elif sockaddr_ptr[0].sa_familiy == socket.AF_INET6:
ipv6 = ctypes.cast(sockaddr_ptr, ctypes.POINTER(sockaddr_in6))
flowinfo = ipv6[0].sin6_flowinfo
ippacked = bytes(bytearray(ipv6[0].sin6_addr))
ip = str(ipaddress.ip_address(ippacked))
scope_id = ipv6[0].sin6_scope_id
return (ip, flowinfo, scope_id)
return None
def ipv6_prefixlength(address: ipaddress.IPv6Address) -> int:
prefix_length = 0
for i in range(address.max_prefixlen):
if int(address) >> i & 1:
prefix_length = prefix_length + 1
return prefix_length
-145
View File
@@ -1,145 +0,0 @@
# Copyright (c) 2014 Stefan C. Mueller
# 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 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 ctypes
from ctypes import wintypes
from typing import Iterable, List
import RNS.vendor.ifaddr._shared as shared
NO_ERROR = 0
ERROR_BUFFER_OVERFLOW = 111
MAX_ADAPTER_NAME_LENGTH = 256
MAX_ADAPTER_DESCRIPTION_LENGTH = 128
MAX_ADAPTER_ADDRESS_LENGTH = 8
AF_UNSPEC = 0
class SOCKET_ADDRESS(ctypes.Structure):
_fields_ = [('lpSockaddr', ctypes.POINTER(shared.sockaddr)), ('iSockaddrLength', wintypes.INT)]
class IP_ADAPTER_UNICAST_ADDRESS(ctypes.Structure):
pass
IP_ADAPTER_UNICAST_ADDRESS._fields_ = [
('Length', wintypes.ULONG),
('Flags', wintypes.DWORD),
('Next', ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
('Address', SOCKET_ADDRESS),
('PrefixOrigin', ctypes.c_uint),
('SuffixOrigin', ctypes.c_uint),
('DadState', ctypes.c_uint),
('ValidLifetime', wintypes.ULONG),
('PreferredLifetime', wintypes.ULONG),
('LeaseLifetime', wintypes.ULONG),
('OnLinkPrefixLength', ctypes.c_uint8),
]
class IP_ADAPTER_ADDRESSES(ctypes.Structure):
pass
IP_ADAPTER_ADDRESSES._fields_ = [
('Length', wintypes.ULONG),
('IfIndex', wintypes.DWORD),
('Next', ctypes.POINTER(IP_ADAPTER_ADDRESSES)),
('AdapterName', ctypes.c_char_p),
('FirstUnicastAddress', ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
('FirstAnycastAddress', ctypes.c_void_p),
('FirstMulticastAddress', ctypes.c_void_p),
('FirstDnsServerAddress', ctypes.c_void_p),
('DnsSuffix', ctypes.c_wchar_p),
('Description', ctypes.c_wchar_p),
('FriendlyName', ctypes.c_wchar_p),
]
iphlpapi = ctypes.windll.LoadLibrary("Iphlpapi") # type: ignore
def enumerate_interfaces_of_adapter(
nice_name: str, address: IP_ADAPTER_UNICAST_ADDRESS
) -> Iterable[shared.IP]:
# Iterate through linked list and fill list
addresses = [] # type: List[IP_ADAPTER_UNICAST_ADDRESS]
while True:
addresses.append(address)
if not address.Next:
break
address = address.Next[0]
for address in addresses:
ip = shared.sockaddr_to_ip(address.Address.lpSockaddr)
assert ip is not None, f'sockaddr_to_ip({address.Address.lpSockaddr}) returned None'
network_prefix = address.OnLinkPrefixLength
yield shared.IP(ip, network_prefix, nice_name)
def get_adapters(include_unconfigured: bool = False) -> Iterable[shared.Adapter]:
# Call GetAdaptersAddresses() with error and buffer size handling
addressbuffersize = wintypes.ULONG(15 * 1024)
retval = ERROR_BUFFER_OVERFLOW
while retval == ERROR_BUFFER_OVERFLOW:
addressbuffer = ctypes.create_string_buffer(addressbuffersize.value)
retval = iphlpapi.GetAdaptersAddresses(
wintypes.ULONG(AF_UNSPEC),
wintypes.ULONG(0),
None,
ctypes.byref(addressbuffer),
ctypes.byref(addressbuffersize),
)
if retval != NO_ERROR:
raise ctypes.WinError() # type: ignore
# Iterate through adapters fill array
address_infos = [] # type: List[IP_ADAPTER_ADDRESSES]
address_info = IP_ADAPTER_ADDRESSES.from_buffer(addressbuffer)
while True:
address_infos.append(address_info)
if not address_info.Next:
break
address_info = address_info.Next[0]
# Iterate through unicast addresses
result = [] # type: List[shared.Adapter]
for adapter_info in address_infos:
# We don't expect non-ascii characters here, so encoding shouldn't matter
name = adapter_info.AdapterName.decode()
nice_name = adapter_info.Description
index = adapter_info.IfIndex
if adapter_info.FirstUnicastAddress:
ips = enumerate_interfaces_of_adapter(
adapter_info.FriendlyName, adapter_info.FirstUnicastAddress[0]
)
ips = list(ips)
result.append(shared.Adapter(name, nice_name, ips, index=index))
elif include_unconfigured:
result.append(shared.Adapter(name, nice_name, [], index=index))
return result
-57
View File
@@ -1,57 +0,0 @@
import ipaddress
import RNS.vendor.ifaddr
import socket
from typing import List
AF_INET6 = socket.AF_INET6.value
AF_INET = socket.AF_INET.value
def interfaces() -> List[str]:
adapters = RNS.vendor.ifaddr.get_adapters(include_unconfigured=True)
return [a.name for a in adapters]
def interface_names_to_indexes() -> dict:
adapters = RNS.vendor.ifaddr.get_adapters(include_unconfigured=True)
results = {}
for adapter in adapters:
results[adapter.name] = adapter.index
return results
def interface_name_to_nice_name(ifname) -> str:
try:
adapters = RNS.vendor.ifaddr.get_adapters(include_unconfigured=True)
for adapter in adapters:
if adapter.name == ifname:
if hasattr(adapter, "nice_name"):
return adapter.nice_name
except:
return None
return None
def ifaddresses(ifname) -> dict:
adapters = RNS.vendor.ifaddr.get_adapters(include_unconfigured=True)
ifa = {}
for a in adapters:
if a.name == ifname:
ipv4s = []
ipv6s = []
for ip in a.ips:
t = {}
if ip.is_IPv4:
net = ipaddress.ip_network(str(ip.ip)+"/"+str(ip.network_prefix), strict=False)
t["addr"] = ip.ip
t["prefix"] = ip.network_prefix
t["broadcast"] = str(net.broadcast_address)
ipv4s.append(t)
if ip.is_IPv6:
t["addr"] = ip.ip[0]
ipv6s.append(t)
if len(ipv4s) > 0:
ifa[AF_INET] = ipv4s
if len(ipv6s) > 0:
ifa[AF_INET6] = ipv6s
return ifa
View File
+51 -26
View File
@@ -1,42 +1,69 @@
# Reticulum License
#
# Copyright (c) 2016-2025 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
def get_platform():
from os import environ
if "ANDROID_ARGUMENT" in environ:
return "android"
elif "ANDROID_ROOT" in environ:
return "android"
if "ANDROID_ARGUMENT" in environ: return "android"
elif "ANDROID_ROOT" in environ: return "android"
else:
import sys
return sys.platform
def is_linux():
if get_platform() == "linux":
return True
else:
return False
if get_platform() == "linux": return True
else: return False
def is_darwin():
if get_platform() == "darwin":
return True
else:
return False
if get_platform() == "darwin": return True
else: return False
def is_android():
if get_platform() == "android":
return True
else:
return False
if get_platform() == "android": return True
else: return False
def is_windows():
if str(get_platform()).startswith("win"):
return True
else:
return False
if str(get_platform()).startswith("win"): return True
else: return False
def use_epoll():
if is_linux() or is_android(): return True
else: return False
def use_af_unix():
if is_linux() or is_android(): return True
else: return False
def platform_checks():
if is_windows():
import sys
if sys.version_info.major >= 3 and sys.version_info.minor >= 8:
pass
if sys.version_info.major >= 3 and sys.version_info.minor >= 8: pass
else:
import RNS
RNS.log("On Windows, Reticulum requires Python 3.8 or higher.", RNS.LOG_ERROR)
@@ -45,7 +72,5 @@ def platform_checks():
def cryptography_old_api():
import cryptography
if cryptography.__version__ == "2.8":
return True
else:
return False
if cryptography.__version__ == "2.8": return True
else: return False
-998
View File
@@ -1,998 +0,0 @@
# Copyright (c) 2010-2020 Benjamin Peterson
#
# 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 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.
"""Utilities for writing code that runs on Python 2 and 3"""
from __future__ import absolute_import
import functools
import itertools
import operator
import sys
import types
__author__ = "Benjamin Peterson <benjamin@python.org>"
__version__ = "1.16.0"
# Useful for very coarse version differentiation.
PY2 = sys.version_info[0] == 2
PY3 = sys.version_info[0] == 3
PY34 = sys.version_info[0:2] >= (3, 4)
if PY3:
string_types = str,
integer_types = int,
class_types = type,
text_type = str
binary_type = bytes
MAXSIZE = sys.maxsize
else:
string_types = basestring,
integer_types = (int, long)
class_types = (type, types.ClassType)
text_type = unicode
binary_type = str
if sys.platform.startswith("java"):
# Jython always uses 32 bits.
MAXSIZE = int((1 << 31) - 1)
else:
# It's possible to have sizeof(long) != sizeof(Py_ssize_t).
class X(object):
def __len__(self):
return 1 << 31
try:
len(X())
except OverflowError:
# 32-bit
MAXSIZE = int((1 << 31) - 1)
else:
# 64-bit
MAXSIZE = int((1 << 63) - 1)
del X
if PY34:
from importlib.util import spec_from_loader
else:
spec_from_loader = None
def _add_doc(func, doc):
"""Add documentation to a function."""
func.__doc__ = doc
def _import_module(name):
"""Import module, returning the module after the last dot."""
__import__(name)
return sys.modules[name]
class _LazyDescr(object):
def __init__(self, name):
self.name = name
def __get__(self, obj, tp):
result = self._resolve()
setattr(obj, self.name, result) # Invokes __set__.
try:
# This is a bit ugly, but it avoids running this again by
# removing this descriptor.
delattr(obj.__class__, self.name)
except AttributeError:
pass
return result
class MovedModule(_LazyDescr):
def __init__(self, name, old, new=None):
super(MovedModule, self).__init__(name)
if PY3:
if new is None:
new = name
self.mod = new
else:
self.mod = old
def _resolve(self):
return _import_module(self.mod)
def __getattr__(self, attr):
_module = self._resolve()
value = getattr(_module, attr)
setattr(self, attr, value)
return value
class _LazyModule(types.ModuleType):
def __init__(self, name):
super(_LazyModule, self).__init__(name)
self.__doc__ = self.__class__.__doc__
def __dir__(self):
attrs = ["__doc__", "__name__"]
attrs += [attr.name for attr in self._moved_attributes]
return attrs
# Subclasses should override this
_moved_attributes = []
class MovedAttribute(_LazyDescr):
def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None):
super(MovedAttribute, self).__init__(name)
if PY3:
if new_mod is None:
new_mod = name
self.mod = new_mod
if new_attr is None:
if old_attr is None:
new_attr = name
else:
new_attr = old_attr
self.attr = new_attr
else:
self.mod = old_mod
if old_attr is None:
old_attr = name
self.attr = old_attr
def _resolve(self):
module = _import_module(self.mod)
return getattr(module, self.attr)
class _SixMetaPathImporter(object):
"""
A meta path importer to import six.moves and its submodules.
This class implements a PEP302 finder and loader. It should be compatible
with Python 2.5 and all existing versions of Python3
"""
def __init__(self, six_module_name):
self.name = six_module_name
self.known_modules = {}
def _add_module(self, mod, *fullnames):
for fullname in fullnames:
self.known_modules[self.name + "." + fullname] = mod
def _get_module(self, fullname):
return self.known_modules[self.name + "." + fullname]
def find_module(self, fullname, path=None):
if fullname in self.known_modules:
return self
return None
def find_spec(self, fullname, path, target=None):
if fullname in self.known_modules:
return spec_from_loader(fullname, self)
return None
def __get_module(self, fullname):
try:
return self.known_modules[fullname]
except KeyError:
raise ImportError("This loader does not know module " + fullname)
def load_module(self, fullname):
try:
# in case of a reload
return sys.modules[fullname]
except KeyError:
pass
mod = self.__get_module(fullname)
if isinstance(mod, MovedModule):
mod = mod._resolve()
else:
mod.__loader__ = self
sys.modules[fullname] = mod
return mod
def is_package(self, fullname):
"""
Return true, if the named module is a package.
We need this method to get correct spec objects with
Python 3.4 (see PEP451)
"""
return hasattr(self.__get_module(fullname), "__path__")
def get_code(self, fullname):
"""Return None
Required, if is_package is implemented"""
self.__get_module(fullname) # eventually raises ImportError
return None
get_source = get_code # same as get_code
def create_module(self, spec):
return self.load_module(spec.name)
def exec_module(self, module):
pass
_importer = _SixMetaPathImporter(__name__)
class _MovedItems(_LazyModule):
"""Lazy loading of moved objects"""
__path__ = [] # mark as package
_moved_attributes = [
MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"),
MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"),
MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"),
MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"),
MovedAttribute("intern", "__builtin__", "sys"),
MovedAttribute("map", "itertools", "builtins", "imap", "map"),
MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"),
MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"),
MovedAttribute("getoutput", "commands", "subprocess"),
MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"),
MovedAttribute("reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload"),
MovedAttribute("reduce", "__builtin__", "functools"),
MovedAttribute("shlex_quote", "pipes", "shlex", "quote"),
MovedAttribute("StringIO", "StringIO", "io"),
MovedAttribute("UserDict", "UserDict", "collections"),
MovedAttribute("UserList", "UserList", "collections"),
MovedAttribute("UserString", "UserString", "collections"),
MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"),
MovedAttribute("zip", "itertools", "builtins", "izip", "zip"),
MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"),
MovedModule("builtins", "__builtin__"),
MovedModule("configparser", "ConfigParser"),
MovedModule("collections_abc", "collections", "collections.abc" if sys.version_info >= (3, 3) else "collections"),
MovedModule("copyreg", "copy_reg"),
MovedModule("dbm_gnu", "gdbm", "dbm.gnu"),
MovedModule("dbm_ndbm", "dbm", "dbm.ndbm"),
MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread" if sys.version_info < (3, 9) else "_thread"),
MovedModule("http_cookiejar", "cookielib", "http.cookiejar"),
MovedModule("http_cookies", "Cookie", "http.cookies"),
MovedModule("html_entities", "htmlentitydefs", "html.entities"),
MovedModule("html_parser", "HTMLParser", "html.parser"),
MovedModule("http_client", "httplib", "http.client"),
MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"),
MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"),
MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"),
MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"),
MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"),
MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"),
MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"),
MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"),
MovedModule("cPickle", "cPickle", "pickle"),
MovedModule("queue", "Queue"),
MovedModule("reprlib", "repr"),
MovedModule("socketserver", "SocketServer"),
MovedModule("_thread", "thread", "_thread"),
MovedModule("tkinter", "Tkinter"),
MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"),
MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"),
MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"),
MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"),
MovedModule("tkinter_tix", "Tix", "tkinter.tix"),
MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"),
MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"),
MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"),
MovedModule("tkinter_colorchooser", "tkColorChooser",
"tkinter.colorchooser"),
MovedModule("tkinter_commondialog", "tkCommonDialog",
"tkinter.commondialog"),
MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"),
MovedModule("tkinter_font", "tkFont", "tkinter.font"),
MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"),
MovedModule("tkinter_tksimpledialog", "tkSimpleDialog",
"tkinter.simpledialog"),
MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"),
MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"),
MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"),
MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"),
MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"),
MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"),
]
# Add windows specific modules.
if sys.platform == "win32":
_moved_attributes += [
MovedModule("winreg", "_winreg"),
]
for attr in _moved_attributes:
setattr(_MovedItems, attr.name, attr)
if isinstance(attr, MovedModule):
_importer._add_module(attr, "moves." + attr.name)
del attr
_MovedItems._moved_attributes = _moved_attributes
moves = _MovedItems(__name__ + ".moves")
_importer._add_module(moves, "moves")
class Module_six_moves_urllib_parse(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_parse"""
_urllib_parse_moved_attributes = [
MovedAttribute("ParseResult", "urlparse", "urllib.parse"),
MovedAttribute("SplitResult", "urlparse", "urllib.parse"),
MovedAttribute("parse_qs", "urlparse", "urllib.parse"),
MovedAttribute("parse_qsl", "urlparse", "urllib.parse"),
MovedAttribute("urldefrag", "urlparse", "urllib.parse"),
MovedAttribute("urljoin", "urlparse", "urllib.parse"),
MovedAttribute("urlparse", "urlparse", "urllib.parse"),
MovedAttribute("urlsplit", "urlparse", "urllib.parse"),
MovedAttribute("urlunparse", "urlparse", "urllib.parse"),
MovedAttribute("urlunsplit", "urlparse", "urllib.parse"),
MovedAttribute("quote", "urllib", "urllib.parse"),
MovedAttribute("quote_plus", "urllib", "urllib.parse"),
MovedAttribute("unquote", "urllib", "urllib.parse"),
MovedAttribute("unquote_plus", "urllib", "urllib.parse"),
MovedAttribute("unquote_to_bytes", "urllib", "urllib.parse", "unquote", "unquote_to_bytes"),
MovedAttribute("urlencode", "urllib", "urllib.parse"),
MovedAttribute("splitquery", "urllib", "urllib.parse"),
MovedAttribute("splittag", "urllib", "urllib.parse"),
MovedAttribute("splituser", "urllib", "urllib.parse"),
MovedAttribute("splitvalue", "urllib", "urllib.parse"),
MovedAttribute("uses_fragment", "urlparse", "urllib.parse"),
MovedAttribute("uses_netloc", "urlparse", "urllib.parse"),
MovedAttribute("uses_params", "urlparse", "urllib.parse"),
MovedAttribute("uses_query", "urlparse", "urllib.parse"),
MovedAttribute("uses_relative", "urlparse", "urllib.parse"),
]
for attr in _urllib_parse_moved_attributes:
setattr(Module_six_moves_urllib_parse, attr.name, attr)
del attr
Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes
_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"),
"moves.urllib_parse", "moves.urllib.parse")
class Module_six_moves_urllib_error(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_error"""
_urllib_error_moved_attributes = [
MovedAttribute("URLError", "urllib2", "urllib.error"),
MovedAttribute("HTTPError", "urllib2", "urllib.error"),
MovedAttribute("ContentTooShortError", "urllib", "urllib.error"),
]
for attr in _urllib_error_moved_attributes:
setattr(Module_six_moves_urllib_error, attr.name, attr)
del attr
Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes
_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"),
"moves.urllib_error", "moves.urllib.error")
class Module_six_moves_urllib_request(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_request"""
_urllib_request_moved_attributes = [
MovedAttribute("urlopen", "urllib2", "urllib.request"),
MovedAttribute("install_opener", "urllib2", "urllib.request"),
MovedAttribute("build_opener", "urllib2", "urllib.request"),
MovedAttribute("pathname2url", "urllib", "urllib.request"),
MovedAttribute("url2pathname", "urllib", "urllib.request"),
MovedAttribute("getproxies", "urllib", "urllib.request"),
MovedAttribute("Request", "urllib2", "urllib.request"),
MovedAttribute("OpenerDirector", "urllib2", "urllib.request"),
MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"),
MovedAttribute("ProxyHandler", "urllib2", "urllib.request"),
MovedAttribute("BaseHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"),
MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"),
MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"),
MovedAttribute("FileHandler", "urllib2", "urllib.request"),
MovedAttribute("FTPHandler", "urllib2", "urllib.request"),
MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"),
MovedAttribute("UnknownHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"),
MovedAttribute("urlretrieve", "urllib", "urllib.request"),
MovedAttribute("urlcleanup", "urllib", "urllib.request"),
MovedAttribute("URLopener", "urllib", "urllib.request"),
MovedAttribute("FancyURLopener", "urllib", "urllib.request"),
MovedAttribute("proxy_bypass", "urllib", "urllib.request"),
MovedAttribute("parse_http_list", "urllib2", "urllib.request"),
MovedAttribute("parse_keqv_list", "urllib2", "urllib.request"),
]
for attr in _urllib_request_moved_attributes:
setattr(Module_six_moves_urllib_request, attr.name, attr)
del attr
Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes
_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"),
"moves.urllib_request", "moves.urllib.request")
class Module_six_moves_urllib_response(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_response"""
_urllib_response_moved_attributes = [
MovedAttribute("addbase", "urllib", "urllib.response"),
MovedAttribute("addclosehook", "urllib", "urllib.response"),
MovedAttribute("addinfo", "urllib", "urllib.response"),
MovedAttribute("addinfourl", "urllib", "urllib.response"),
]
for attr in _urllib_response_moved_attributes:
setattr(Module_six_moves_urllib_response, attr.name, attr)
del attr
Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes
_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"),
"moves.urllib_response", "moves.urllib.response")
class Module_six_moves_urllib_robotparser(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_robotparser"""
_urllib_robotparser_moved_attributes = [
MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"),
]
for attr in _urllib_robotparser_moved_attributes:
setattr(Module_six_moves_urllib_robotparser, attr.name, attr)
del attr
Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes
_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"),
"moves.urllib_robotparser", "moves.urllib.robotparser")
class Module_six_moves_urllib(types.ModuleType):
"""Create a six.moves.urllib namespace that resembles the Python 3 namespace"""
__path__ = [] # mark as package
parse = _importer._get_module("moves.urllib_parse")
error = _importer._get_module("moves.urllib_error")
request = _importer._get_module("moves.urllib_request")
response = _importer._get_module("moves.urllib_response")
robotparser = _importer._get_module("moves.urllib_robotparser")
def __dir__(self):
return ['parse', 'error', 'request', 'response', 'robotparser']
_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"),
"moves.urllib")
def add_move(move):
"""Add an item to six.moves."""
setattr(_MovedItems, move.name, move)
def remove_move(name):
"""Remove item from six.moves."""
try:
delattr(_MovedItems, name)
except AttributeError:
try:
del moves.__dict__[name]
except KeyError:
raise AttributeError("no such move, %r" % (name,))
if PY3:
_meth_func = "__func__"
_meth_self = "__self__"
_func_closure = "__closure__"
_func_code = "__code__"
_func_defaults = "__defaults__"
_func_globals = "__globals__"
else:
_meth_func = "im_func"
_meth_self = "im_self"
_func_closure = "func_closure"
_func_code = "func_code"
_func_defaults = "func_defaults"
_func_globals = "func_globals"
try:
advance_iterator = next
except NameError:
def advance_iterator(it):
return it.next()
next = advance_iterator
try:
callable = callable
except NameError:
def callable(obj):
return any("__call__" in klass.__dict__ for klass in type(obj).__mro__)
if PY3:
def get_unbound_function(unbound):
return unbound
create_bound_method = types.MethodType
def create_unbound_method(func, cls):
return func
Iterator = object
else:
def get_unbound_function(unbound):
return unbound.im_func
def create_bound_method(func, obj):
return types.MethodType(func, obj, obj.__class__)
def create_unbound_method(func, cls):
return types.MethodType(func, None, cls)
class Iterator(object):
def next(self):
return type(self).__next__(self)
callable = callable
_add_doc(get_unbound_function,
"""Get the function out of a possibly unbound function""")
get_method_function = operator.attrgetter(_meth_func)
get_method_self = operator.attrgetter(_meth_self)
get_function_closure = operator.attrgetter(_func_closure)
get_function_code = operator.attrgetter(_func_code)
get_function_defaults = operator.attrgetter(_func_defaults)
get_function_globals = operator.attrgetter(_func_globals)
if PY3:
def iterkeys(d, **kw):
return iter(d.keys(**kw))
def itervalues(d, **kw):
return iter(d.values(**kw))
def iteritems(d, **kw):
return iter(d.items(**kw))
def iterlists(d, **kw):
return iter(d.lists(**kw))
viewkeys = operator.methodcaller("keys")
viewvalues = operator.methodcaller("values")
viewitems = operator.methodcaller("items")
else:
def iterkeys(d, **kw):
return d.iterkeys(**kw)
def itervalues(d, **kw):
return d.itervalues(**kw)
def iteritems(d, **kw):
return d.iteritems(**kw)
def iterlists(d, **kw):
return d.iterlists(**kw)
viewkeys = operator.methodcaller("viewkeys")
viewvalues = operator.methodcaller("viewvalues")
viewitems = operator.methodcaller("viewitems")
_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.")
_add_doc(itervalues, "Return an iterator over the values of a dictionary.")
_add_doc(iteritems,
"Return an iterator over the (key, value) pairs of a dictionary.")
_add_doc(iterlists,
"Return an iterator over the (key, [values]) pairs of a dictionary.")
if PY3:
def b(s):
return s.encode("latin-1")
def u(s):
return s
unichr = chr
import struct
int2byte = struct.Struct(">B").pack
del struct
byte2int = operator.itemgetter(0)
indexbytes = operator.getitem
iterbytes = iter
import io
StringIO = io.StringIO
BytesIO = io.BytesIO
del io
_assertCountEqual = "assertCountEqual"
if sys.version_info[1] <= 1:
_assertRaisesRegex = "assertRaisesRegexp"
_assertRegex = "assertRegexpMatches"
_assertNotRegex = "assertNotRegexpMatches"
else:
_assertRaisesRegex = "assertRaisesRegex"
_assertRegex = "assertRegex"
_assertNotRegex = "assertNotRegex"
else:
def b(s):
return s
# Workaround for standalone backslash
def u(s):
return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape")
unichr = unichr
int2byte = chr
def byte2int(bs):
return ord(bs[0])
def indexbytes(buf, i):
return ord(buf[i])
iterbytes = functools.partial(itertools.imap, ord)
import StringIO
StringIO = BytesIO = StringIO.StringIO
_assertCountEqual = "assertItemsEqual"
_assertRaisesRegex = "assertRaisesRegexp"
_assertRegex = "assertRegexpMatches"
_assertNotRegex = "assertNotRegexpMatches"
_add_doc(b, """Byte literal""")
_add_doc(u, """Text literal""")
def assertCountEqual(self, *args, **kwargs):
return getattr(self, _assertCountEqual)(*args, **kwargs)
def assertRaisesRegex(self, *args, **kwargs):
return getattr(self, _assertRaisesRegex)(*args, **kwargs)
def assertRegex(self, *args, **kwargs):
return getattr(self, _assertRegex)(*args, **kwargs)
def assertNotRegex(self, *args, **kwargs):
return getattr(self, _assertNotRegex)(*args, **kwargs)
if PY3:
exec_ = getattr(moves.builtins, "exec")
def reraise(tp, value, tb=None):
try:
if value is None:
value = tp()
if value.__traceback__ is not tb:
raise value.with_traceback(tb)
raise value
finally:
value = None
tb = None
else:
def exec_(_code_, _globs_=None, _locs_=None):
"""Execute code in a namespace."""
if _globs_ is None:
frame = sys._getframe(1)
_globs_ = frame.f_globals
if _locs_ is None:
_locs_ = frame.f_locals
del frame
elif _locs_ is None:
_locs_ = _globs_
exec("""exec _code_ in _globs_, _locs_""")
exec_("""def reraise(tp, value, tb=None):
try:
raise tp, value, tb
finally:
tb = None
""")
if sys.version_info[:2] > (3,):
exec_("""def raise_from(value, from_value):
try:
raise value from from_value
finally:
value = None
""")
else:
def raise_from(value, from_value):
raise value
print_ = getattr(moves.builtins, "print", None)
if print_ is None:
def print_(*args, **kwargs):
"""The new-style print function for Python 2.4 and 2.5."""
fp = kwargs.pop("file", sys.stdout)
if fp is None:
return
def write(data):
if not isinstance(data, basestring):
data = str(data)
# If the file has an encoding, encode unicode with it.
if (isinstance(fp, file) and
isinstance(data, unicode) and
fp.encoding is not None):
errors = getattr(fp, "errors", None)
if errors is None:
errors = "strict"
data = data.encode(fp.encoding, errors)
fp.write(data)
want_unicode = False
sep = kwargs.pop("sep", None)
if sep is not None:
if isinstance(sep, unicode):
want_unicode = True
elif not isinstance(sep, str):
raise TypeError("sep must be None or a string")
end = kwargs.pop("end", None)
if end is not None:
if isinstance(end, unicode):
want_unicode = True
elif not isinstance(end, str):
raise TypeError("end must be None or a string")
if kwargs:
raise TypeError("invalid keyword arguments to print()")
if not want_unicode:
for arg in args:
if isinstance(arg, unicode):
want_unicode = True
break
if want_unicode:
newline = unicode("\n")
space = unicode(" ")
else:
newline = "\n"
space = " "
if sep is None:
sep = space
if end is None:
end = newline
for i, arg in enumerate(args):
if i:
write(sep)
write(arg)
write(end)
if sys.version_info[:2] < (3, 3):
_print = print_
def print_(*args, **kwargs):
fp = kwargs.get("file", sys.stdout)
flush = kwargs.pop("flush", False)
_print(*args, **kwargs)
if flush and fp is not None:
fp.flush()
_add_doc(reraise, """Reraise an exception.""")
if sys.version_info[0:2] < (3, 4):
# This does exactly the same what the :func:`py3:functools.update_wrapper`
# function does on Python versions after 3.2. It sets the ``__wrapped__``
# attribute on ``wrapper`` object and it doesn't raise an error if any of
# the attributes mentioned in ``assigned`` and ``updated`` are missing on
# ``wrapped`` object.
def _update_wrapper(wrapper, wrapped,
assigned=functools.WRAPPER_ASSIGNMENTS,
updated=functools.WRAPPER_UPDATES):
for attr in assigned:
try:
value = getattr(wrapped, attr)
except AttributeError:
continue
else:
setattr(wrapper, attr, value)
for attr in updated:
getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
wrapper.__wrapped__ = wrapped
return wrapper
_update_wrapper.__doc__ = functools.update_wrapper.__doc__
def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS,
updated=functools.WRAPPER_UPDATES):
return functools.partial(_update_wrapper, wrapped=wrapped,
assigned=assigned, updated=updated)
wraps.__doc__ = functools.wraps.__doc__
else:
wraps = functools.wraps
def with_metaclass(meta, *bases):
"""Create a base class with a metaclass."""
# This requires a bit of explanation: the basic idea is to make a dummy
# metaclass for one level of class instantiation that replaces itself with
# the actual metaclass.
class metaclass(type):
def __new__(cls, name, this_bases, d):
if sys.version_info[:2] >= (3, 7):
# This version introduced PEP 560 that requires a bit
# of extra care (we mimic what is done by __build_class__).
resolved_bases = types.resolve_bases(bases)
if resolved_bases is not bases:
d['__orig_bases__'] = bases
else:
resolved_bases = bases
return meta(name, resolved_bases, d)
@classmethod
def __prepare__(cls, name, this_bases):
return meta.__prepare__(name, bases)
return type.__new__(metaclass, 'temporary_class', (), {})
def add_metaclass(metaclass):
"""Class decorator for creating a class with a metaclass."""
def wrapper(cls):
orig_vars = cls.__dict__.copy()
slots = orig_vars.get('__slots__')
if slots is not None:
if isinstance(slots, str):
slots = [slots]
for slots_var in slots:
orig_vars.pop(slots_var)
orig_vars.pop('__dict__', None)
orig_vars.pop('__weakref__', None)
if hasattr(cls, '__qualname__'):
orig_vars['__qualname__'] = cls.__qualname__
return metaclass(cls.__name__, cls.__bases__, orig_vars)
return wrapper
def ensure_binary(s, encoding='utf-8', errors='strict'):
"""Coerce **s** to six.binary_type.
For Python 2:
- `unicode` -> encoded to `str`
- `str` -> `str`
For Python 3:
- `str` -> encoded to `bytes`
- `bytes` -> `bytes`
"""
if isinstance(s, binary_type):
return s
if isinstance(s, text_type):
return s.encode(encoding, errors)
raise TypeError("not expecting type '%s'" % type(s))
def ensure_str(s, encoding='utf-8', errors='strict'):
"""Coerce *s* to `str`.
For Python 2:
- `unicode` -> encoded to `str`
- `str` -> `str`
For Python 3:
- `str` -> `str`
- `bytes` -> decoded to `str`
"""
# Optimization: Fast return for the common case.
if type(s) is str:
return s
if PY2 and isinstance(s, text_type):
return s.encode(encoding, errors)
elif PY3 and isinstance(s, binary_type):
return s.decode(encoding, errors)
elif not isinstance(s, (text_type, binary_type)):
raise TypeError("not expecting type '%s'" % type(s))
return s
def ensure_text(s, encoding='utf-8', errors='strict'):
"""Coerce *s* to six.text_type.
For Python 2:
- `unicode` -> `unicode`
- `str` -> `unicode`
For Python 3:
- `str` -> `str`
- `bytes` -> decoded to `str`
"""
if isinstance(s, binary_type):
return s.decode(encoding, errors)
elif isinstance(s, text_type):
return s
else:
raise TypeError("not expecting type '%s'" % type(s))
def python_2_unicode_compatible(klass):
"""
A class decorator that defines __unicode__ and __str__ methods under Python 2.
Under Python 3 it does nothing.
To support Python 2 and 3 with a single code base, define a __str__ method
returning text and apply this decorator to the class.
"""
if PY2:
if '__str__' not in klass.__dict__:
raise ValueError("@python_2_unicode_compatible cannot be applied "
"to %s because it doesn't define __str__()." %
klass.__name__)
klass.__unicode__ = klass.__str__
klass.__str__ = lambda self: self.__unicode__().encode('utf-8')
return klass
# Complete the moves implementation.
# This code is at the end of this module to speed up module loading.
# Turn this module into a package.
__path__ = [] # required for PEP 302 and PEP 451
__package__ = __name__ # see PEP 366 @ReservedAssignment
if globals().get("__spec__") is not None:
__spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable
# Remove other six meta path importers, since they cause problems. This can
# happen if six is removed from sys.modules and then reloaded. (Setuptools does
# this for some reason.)
if sys.meta_path:
for i, importer in enumerate(sys.meta_path):
# Here's some real nastiness: Another "instance" of the six module might
# be floating around. Therefore, we can't use isinstance() to check for
# the six meta path importer, since the other six instance will have
# inserted an importer with different class.
if (type(importer).__name__ == "_SixMetaPathImporter" and
importer.name == __name__):
del sys.meta_path[i]
break
del i, importer
# Finally, add the importer to the meta path import hook.
sys.meta_path.append(_importer)
+3 -18
View File
@@ -14,18 +14,6 @@ This document outlines the currently established development roadmap for Reticul
## 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.
- The current `0.8.x` release cycle aims at completing
- [ ] Hot-pluggable interface system
- [ ] External interface plugins
- [ ] Network-wide path balancing and multi-pathing
- [ ] Expanded hardware support
- [ ] Overhauling and updating the documentation
- [ ] Distributed Destination Naming System
- [ ] A standalone RNS Daemon app for Android
- [ ] Addding automatic retries to all use cases of the `Request` API
- [ ] Performance and memory optimisations of the Python reference implementation
- [ ] Fixing bugs discovered while operating Reticulum systems and applications
## 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.
@@ -50,17 +38,14 @@ These efforts are aimed at improving the ease of which Reticulum is understood,
### 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.
- OpenWRT support
- 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)
- Performance and memory optimisations of the Python implementation
- Bindings for other programming languages
### Functionality
These efforts aim to expand and improve the core functionality and reliability of Reticulum.
- Add support for user-supplied external interface drivers
- 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
@@ -70,11 +55,11 @@ These efforts aim to expand and improve the core functionality and reliability o
- [Metric-based path selection and multiple paths](https://github.com/markqvist/Reticulum/discussions/86)
### Usability & Utility
These effors seek to make Reticulum easier to use and operate, and to expand the utility of the stack on deployed systems.
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
- 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.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 15adafd4c6c385e9e28ce0d62286dd42
# This file records the configuration used when building these files. When it is not found, a full rebuild will be done.
config: e189c8207645fa72ffb805cde7491052
tags: 645f666f9bcd5a90fca523b33c5a78b7
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

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