mirror of
https://github.com/markqvist/Reticulum.git
synced 2026-06-23 12:24:30 -07:00
Compare commits
792 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07ff87974e | |||
| e8fa92950d | |||
| ab6532742e | |||
| 4e583770e5 | |||
| f9b6dc2ab8 | |||
| 1c2bc0c7b8 | |||
| 05760f914c | |||
| 3f6e8605af | |||
| b6bfd1655c | |||
| 8cbd0e22ff | |||
| 15ec64e974 | |||
| 3de16e085e | |||
| 4cbd4ed60c | |||
| b8fbd616e5 | |||
| f8a79d2f51 | |||
| 0218ff4e26 | |||
| 1f3ce7e78f | |||
| 9009e1d232 | |||
| cc73b2c2b9 | |||
| dbf19ed054 | |||
| a1cff4e8ab | |||
| c9822968c8 | |||
| 8acabd95b5 | |||
| 49f6a6924d | |||
| 8d73265cf4 | |||
| fceb7d18d7 | |||
| 337007cf70 | |||
| 4733d6d75a | |||
| c8235544e8 | |||
| 3d1111ff02 | |||
| 83c9f2b10a | |||
| 734eb53aa7 | |||
| 6d39cb8e7c | |||
| 3c3f38b239 | |||
| 86d52d3884 | |||
| 6782672cb8 | |||
| 7fada7e5ab | |||
| 4380026a4e | |||
| 5143ea3d02 | |||
| 4802bcd829 | |||
| 6038096b95 | |||
| 2acfc31350 | |||
| 2742e5253f | |||
| 46f2e994b9 | |||
| 2c97a20c12 | |||
| 9be10ebd47 | |||
| 93cbfe7f7e | |||
| 4589de2115 | |||
| 662054ae25 | |||
| 3cf186f3cb | |||
| 7a91c82e4b | |||
| 72aace40d3 | |||
| 0c9a65b5f1 | |||
| ea749499c3 | |||
| 828cbe7f20 | |||
| 1d8d547872 | |||
| 16c53221e3 | |||
| 74936010c4 | |||
| f3245e1d65 | |||
| 1f74570ed9 | |||
| 88d1b7d2d1 | |||
| fb5dcf0631 | |||
| a23086d3fc | |||
| a4cbcbca97 | |||
| 9dd008d42b | |||
| 76fa07cb90 | |||
| 35d72f27ed | |||
| 852891c779 | |||
| f4aa7dc389 | |||
| d7c3859f61 | |||
| 85d77c10a1 | |||
| 95222c7793 | |||
| 0a18b47e8c | |||
| 70f5126499 | |||
| b60eab0fcf | |||
| 17310fc294 | |||
| 9c892dc1a4 | |||
| c596dab806 | |||
| fcb590e661 | |||
| 328017cca0 | |||
| 63dba562ae | |||
| cf20f26098 | |||
| e1e6063d17 | |||
| ccbbe6f2f8 | |||
| 55c95bf59a | |||
| 043a5dc4e7 | |||
| 32a1cdf494 | |||
| f924086198 | |||
| 6abb31e469 | |||
| 3eee369704 | |||
| 695d4d8684 | |||
| 015692d51e | |||
| 86004a89e5 | |||
| 86031ef3f8 | |||
| 034239daf3 | |||
| a7b0f9924e | |||
| a1d35b34b9 | |||
| 8d7e337dff | |||
| de7e0996ce | |||
| 7377b69144 | |||
| c933cfdaa3 | |||
| 726185cee2 | |||
| de1000bfda | |||
| 555e8c0376 | |||
| d836de3fe7 | |||
| 6ade1269ea | |||
| a8b519e06e | |||
| 7d502306ea | |||
| e9fa57c660 | |||
| 7d4ab17f0d | |||
| d532902320 | |||
| e592244443 | |||
| c1def5da19 | |||
| 6a7f081f12 | |||
| 11555198eb | |||
| 6c77e27a50 | |||
| 17e8159fd8 | |||
| c71f5d8c5e | |||
| 31cc9fc7d1 | |||
| 1d2421b0af | |||
| a5df765951 | |||
| 622019ee06 | |||
| 45e12cc668 | |||
| a21024a57e | |||
| c175491bb0 | |||
| 09b0469faf | |||
| 3d63bbf4bf | |||
| 56d5d01497 | |||
| a70bd44426 | |||
| 8c082b2fcc | |||
| 1732cac806 | |||
| e1340e87eb | |||
| e9bfef2131 | |||
| b408699e65 | |||
| 3d1c508868 | |||
| 84e0746c9c | |||
| b5658c4865 | |||
| d413a4bc53 | |||
| ce5ab902b6 | |||
| 294408b0bb | |||
| 53372fbe4c | |||
| 7fdac2118b | |||
| 1dbf78ed71 | |||
| c9101a0c21 | |||
| 2e6264c04b | |||
| e0aa46ba22 | |||
| 8093c3cd2c | |||
| c6778e4e29 | |||
| c77548d299 | |||
| 26d435ea64 | |||
| c3f0d98e41 | |||
| 3c50f4aee9 | |||
| 4a930ba82a | |||
| 866e63f0fe | |||
| d461cfa8ce | |||
| 18708636fb | |||
| 1901cca2f3 | |||
| 344019f108 | |||
| e22a8021d3 | |||
| 111c9c0ed0 | |||
| 2445d18149 | |||
| 739523d559 | |||
| 23c0a493b1 | |||
| fa353fb0b3 | |||
| 9f817bd918 | |||
| 2e5480a6bd | |||
| 1b50b7f446 | |||
| ecc413ee01 | |||
| 0b1bf13b84 | |||
| 1fc6e68f3f | |||
| 1bee46ed81 | |||
| a7772ffcd9 | |||
| 1263444b2b | |||
| 286a78ef8c | |||
| 0accff3e18 | |||
| 5f62481e62 | |||
| 82b8e1f79a | |||
| 85e2ca96bc | |||
| fdbf287fee | |||
| fa4b69181f | |||
| a32641d9f4 | |||
| 44d8db043e | |||
| be89b12c96 | |||
| fd954589b5 | |||
| a2f44668b6 | |||
| ab2ab37844 | |||
| b280a734a2 | |||
| 5c1bd15639 | |||
| 24fc67f242 | |||
| 642e0fc87e | |||
| 1528c09049 | |||
| 0f4617e9c4 | |||
| a496e22ad1 | |||
| a420565ded | |||
| b3f0a479c2 | |||
| 9e18a6d1a8 | |||
| 34fd72dc97 | |||
| ed9df7b211 | |||
| 965dbca514 | |||
| f08272c853 | |||
| 843891cdd3 | |||
| a6d59b1fa7 | |||
| 51d1d9fbfd | |||
| de1358be8b | |||
| 4eb5dbc633 | |||
| a1e6ce2357 | |||
| 16e833ddb7 | |||
| 4af35bd7ea | |||
| 7d305527e9 | |||
| 1d84dc94a0 | |||
| f825ba38a0 | |||
| f076c2d143 | |||
| 58a20fffb5 | |||
| 15a123875f | |||
| 7cadb3af8b | |||
| 01984a33eb | |||
| 7329817d95 | |||
| ad4af7dd50 | |||
| f2a778ffa4 | |||
| 1a77b5752c | |||
| 2b3d6a0989 | |||
| 0b508a04b8 | |||
| 13aebeecf9 | |||
| 47d3c640d6 | |||
| 19f27598d9 | |||
| f2ef22e1a0 | |||
| 251e1b8a35 | |||
| 5de4e24a9f | |||
| 5e4d32c4c0 | |||
| e1327842b1 | |||
| c13412369a | |||
| 18e4e66db8 | |||
| 5392d635dd | |||
| e56e80aade | |||
| 994c4fd699 | |||
| ef64fefa96 | |||
| 344ff21c1e | |||
| d34e06cb8c | |||
| 8f65a0320b | |||
| b42e1c93da | |||
| e0ca14eb21 | |||
| 48fe97291b | |||
| f400fd7b60 | |||
| fd1d464f06 | |||
| 28afdb36fe | |||
| 6c7db096fc | |||
| 5a7fcb0ec3 | |||
| d647da7a4a | |||
| d7df390bb4 | |||
| 9d36ff48dd | |||
| 8743388263 | |||
| 58486654d5 | |||
| 326d719a49 | |||
| c9b6dc007a | |||
| 1bcac5e234 | |||
| dad58e14e2 | |||
| db85939322 | |||
| 4f4eb1fce5 | |||
| e55000ee1a | |||
| 9c2bf9fba8 | |||
| 563784573b | |||
| e2903f18da | |||
| 2f47456668 | |||
| 79b3101fe0 | |||
| 9788675934 | |||
| 10c63fcaa2 | |||
| 707c012318 | |||
| 3f30e17eb4 | |||
| 9eff138c3c | |||
| b0fb5d1898 | |||
| d542da38b2 | |||
| c8b446ecaf | |||
| 6ed6af5b98 | |||
| 12d39916b9 | |||
| 12d4de0619 | |||
| 7ab87f688a | |||
| 9024a277ac | |||
| fc00d9a5aa | |||
| 106a773f22 | |||
| 93d9cb3b69 | |||
| 99504b7f7d | |||
| 72c1995551 | |||
| 3d8c6c3839 | |||
| 0a06ffd074 | |||
| 12abb544bf | |||
| 78fe132cc2 | |||
| b516d7f092 | |||
| 0961df316f | |||
| 8ad2986877 | |||
| 6214487fb3 | |||
| 2219a5454c | |||
| 712a5d1b06 | |||
| cbc3b800fb | |||
| e7348d0812 | |||
| 59e638402c | |||
| bcd6de015d | |||
| b798c84160 | |||
| 708f666787 | |||
| 4f03302ae2 | |||
| d8f6ab206b | |||
| 472e69fe9a | |||
| aeed5279f8 | |||
| f3b8965fa6 | |||
| 1bbaab1db9 | |||
| bf2fcbba37 | |||
| a63dd67a07 | |||
| b27f9836ae | |||
| 9504c5b863 | |||
| 9767b3453e | |||
| 643fbbbc84 | |||
| 2a5bcd5f52 | |||
| 237c3160eb | |||
| fcdcf1a2a8 | |||
| 7c99aca1d0 | |||
| 309f1999e7 | |||
| fa6de7ff79 | |||
| 47dfcab170 | |||
| 8abd19800f | |||
| b2d6ed733d | |||
| 1179757893 | |||
| d328ef5ce0 | |||
| f577d3018f | |||
| e6db629915 | |||
| acaab30b91 | |||
| 76cedeed07 | |||
| 5beea74eb3 | |||
| 1f91a8f6f2 | |||
| 080216bd55 | |||
| aa37172293 | |||
| 5836d7f8ba | |||
| a699d7c110 | |||
| 8eedbb9d91 | |||
| 2afa85db60 | |||
| a5672e7afe | |||
| 77d40215c8 | |||
| 0896df05b6 | |||
| cf0b1c6237 | |||
| 08b129c8e0 | |||
| e2ea397715 | |||
| 25a73e6ef9 | |||
| f1c4bba3f2 | |||
| 704019ded8 | |||
| 79f5b92bae | |||
| 5aaa743ef8 | |||
| 9721c0bf85 | |||
| 56848cdb63 | |||
| 41ad089ff7 | |||
| 2df355d7b4 | |||
| 39a63b0643 | |||
| ddf14e5636 | |||
| 7138749307 | |||
| af7697f223 | |||
| 0bcb4b8573 | |||
| 6d47b59b1e | |||
| 3d8eaffe9a | |||
| d8039aca17 | |||
| 4e4d379486 | |||
| 87faffa785 | |||
| adef3f80f0 | |||
| 319c798f78 | |||
| 8579a7f2a5 | |||
| ffbbba7395 | |||
| e66745c9ef | |||
| 45fc9338a7 | |||
| f2969bd1b0 | |||
| e0f1f3f947 | |||
| e3827f2e25 | |||
| fad1d4972c | |||
| 2c33ce6c98 | |||
| c0d7f42f17 | |||
| d5a8e4b056 | |||
| 76dd50a060 | |||
| 6f9a9a7ad9 | |||
| eaec9a493b | |||
| d3c8555b39 | |||
| 446f5c0989 | |||
| f3b72a8a3c | |||
| d2c5a1f34b | |||
| 182b49cc04 | |||
| cc8bd34cd4 | |||
| 957ece7394 | |||
| 762343adf9 | |||
| 8d32b378d9 | |||
| 41e816d299 | |||
| 4226a62f23 | |||
| 5dda28559b | |||
| d055ca50d6 | |||
| 799bcfc7aa | |||
| 045cb662ef | |||
| 51e3983bf8 | |||
| 95fdc41845 | |||
| d795fbeaf3 | |||
| 13037d68ed | |||
| 6da5df9f21 | |||
| 8128f573ef | |||
| accf104553 | |||
| 5387264dcb | |||
| 308a6906db | |||
| 96ce7e3f47 | |||
| f186b6266b | |||
| 756029e5af | |||
| c1673f39b6 | |||
| 30a08c4192 | |||
| d680f4d411 | |||
| 29a52e19cf | |||
| 11511168dc | |||
| d4ea698236 | |||
| 11e06b477e | |||
| 4e4c68071f | |||
| 5f502746a4 | |||
| 17bbb9c0b4 | |||
| 8b13d6e08b | |||
| efa512be32 | |||
| 594f5fba1e | |||
| 2912fb2184 | |||
| 02496f39f7 | |||
| 4e31f113c6 | |||
| 9aded3e1da | |||
| 3337d18e9a | |||
| 2cb6d019f9 | |||
| 3dc260a300 | |||
| 4d7f5b8ca6 | |||
| 48be5f65d8 | |||
| b5d854a55c | |||
| 552663c625 | |||
| e6f0b92464 | |||
| 08a6820aa0 | |||
| cc1faa55be | |||
| 840966f3e6 | |||
| 763078a1ae | |||
| 5fb6abd019 | |||
| 7065856229 | |||
| 668ef9253a | |||
| 6f333b8234 | |||
| 32c839f497 | |||
| cbdef1d538 | |||
| c398b34dd8 | |||
| 9a1884cfec | |||
| 378dc1e931 | |||
| be821b6927 | |||
| af46e98865 | |||
| 65b1667ae7 | |||
| 5bc1fc2bde | |||
| 4ae0f28aa0 | |||
| 62ecc0549d | |||
| cbf4c71a73 | |||
| 1d27fae370 | |||
| 05b9a80a07 | |||
| 38241452d3 | |||
| 40e040807a | |||
| 437da99d63 | |||
| 3cbcbec942 | |||
| bc7a8cd09f | |||
| ab0ac46d5a | |||
| d7791c60e2 | |||
| 5dc8cdc6dc | |||
| cdc33a25c5 | |||
| 2b6766f68a | |||
| e871bbdc07 | |||
| 6a98158ba6 | |||
| ef8d44c257 | |||
| 6a48a4d1c0 | |||
| 4d2ba28934 | |||
| 98d4f1c69e | |||
| a0f0d73204 | |||
| 1dbb1a6a35 | |||
| cc50ca82b8 | |||
| 373790c890 | |||
| ef30d21b58 | |||
| c4cafed6aa | |||
| 828eec5e0d | |||
| a8c50fe7d4 | |||
| ab9fc7b370 | |||
| 0dc972f7c9 | |||
| 796cffe29d | |||
| a0f6c99fb5 | |||
| eff0c91ed0 | |||
| dba6cd8393 | |||
| e7daceec82 | |||
| a65473f6ab | |||
| 1851fda9e0 | |||
| 80eec131f8 | |||
| bfe5b876de | |||
| da8a0ee5e9 | |||
| 3269384439 | |||
| 9a766eac8c | |||
| 9d2456500a | |||
| df85beac3e | |||
| 3dd020cb86 | |||
| 67da6be040 | |||
| d2efd6c3e4 | |||
| ea4a525db6 | |||
| c83043b087 | |||
| c07e968218 | |||
| a6eeac14d2 | |||
| a65bc3bc7b | |||
| 8e4b0b3b16 | |||
| d34cefe31d | |||
| 3a68a3fc02 | |||
| a4b6a64611 | |||
| 4f189f5319 | |||
| cb69085280 | |||
| f4d13986af | |||
| 6125c835f7 | |||
| 3049049d5b | |||
| 628c4984a3 | |||
| b58cb3c0ed | |||
| b267687c7f | |||
| 581b16f87c | |||
| f9d42082a2 | |||
| f8925eaed1 | |||
| f4c1ece10a | |||
| d13b034cab | |||
| 008afd88d1 | |||
| 68ca903db4 | |||
| 8f4b4fa82d | |||
| 768f562437 | |||
| 9f0a4bfe69 | |||
| 13b4291840 | |||
| 6dc33126a5 | |||
| fa31dced22 | |||
| 194f6aef1d | |||
| a12b630a4e | |||
| c3ff73591a | |||
| 1967811d68 | |||
| 0e24a0d8bb | |||
| 5913f61e7d | |||
| 9a7e517c73 | |||
| 99af71de75 | |||
| 06848b6731 | |||
| 4ece3a6140 | |||
| ae92432878 | |||
| a4468da9b1 | |||
| 187931a0ea | |||
| d3533e17e8 | |||
| b0944429db | |||
| 7170573da7 | |||
| 4cd94c776a | |||
| 3483de1fc2 | |||
| df3c2cffb3 | |||
| f0e3bc0c14 | |||
| b4d1d54ccb | |||
| de3438248f | |||
| 456eea9c13 | |||
| 3cdebb6e8a | |||
| e0a9dad114 | |||
| b1aa355d5b | |||
| 129591392f | |||
| e51f0f14d9 | |||
| 2c520bb936 | |||
| d3bccb2b4e | |||
| e28f44cfe5 | |||
| 45e5c85868 | |||
| c5bc92e4ea | |||
| ebb8a35129 | |||
| f2046b2453 | |||
| f7351a3eb5 | |||
| 28d55279d8 | |||
| 8104db4fcc | |||
| b8658cd47c | |||
| ecaa8d53e0 | |||
| ca1ec1acef | |||
| 13283cb8e2 | |||
| 5a42adb05b | |||
| 98afe98870 | |||
| f5420d3be3 | |||
| 50b5ab80c4 | |||
| e6371d74b5 | |||
| 0ab38faeac | |||
| b0444104cc | |||
| 4757d6ee87 | |||
| 1780965ef8 | |||
| aaa88e9b7d | |||
| 17ce91a4a2 | |||
| 08751a762a | |||
| 77c0beecf2 | |||
| 28bcf6a8ac | |||
| 61004b4dfb | |||
| e5c22b8a3f | |||
| 001d0f30aa | |||
| fbe4bb03d1 | |||
| 3469b6beb8 | |||
| c696efe0bc | |||
| d0ca61f373 | |||
| 350687eda9 | |||
| d898641e6a | |||
| db576d73bb | |||
| 5fcdd17665 | |||
| e8f2bd9b0c | |||
| 0ff51fed44 | |||
| 6e25f96024 | |||
| ad228fb3b3 | |||
| a61b20a066 | |||
| a49b04af21 | |||
| 3002023a70 | |||
| f030cf6f22 | |||
| 9e7641d2d3 | |||
| c909871fb7 | |||
| 47f60b0320 | |||
| 6797909d90 | |||
| fd6d8ffff8 | |||
| 06de7f4a3d | |||
| 7221becd35 | |||
| a51f5f2eaf | |||
| 9e8d71ddaf | |||
| 9bc55a9047 | |||
| 3e7ab5136e | |||
| d2cf3c2a7e | |||
| 77519f1a0c | |||
| e869b3cac9 | |||
| a2878f1722 | |||
| 748a7290a9 | |||
| 6e80a553c8 | |||
| ec7aa44a17 | |||
| 4fa335639c | |||
| 67195c0b14 | |||
| ad1e6a41ee | |||
| a56d93fc1e | |||
| b8aa6a3e44 | |||
| 1709cd929a | |||
| 4f4961257c | |||
| 1b48f43a0d | |||
| e5d446a54e | |||
| 0af768e742 | |||
| 1a7d20a8d6 | |||
| ec4f4d5a83 | |||
| 8cefa4b2a9 | |||
| 2331f1ea3e | |||
| be7dafa30c | |||
| 3e20cb1b67 | |||
| 097e136662 | |||
| e3a716224d | |||
| 80dc567a53 | |||
| c6576d6504 | |||
| 89d5d9517d | |||
| dc315653c0 | |||
| 746b403890 | |||
| fc619460f0 | |||
| cd0f82d9ad | |||
| 330c2aacac | |||
| 63da084bbe | |||
| cbbd8221ee | |||
| 1d18d53052 | |||
| ceccf3153b | |||
| bde33e7d84 | |||
| 93330d96a0 | |||
| d93ce62878 | |||
| eafa4aefbb | |||
| 53df2fa5e0 | |||
| abc657806d | |||
| a0f219f7f4 | |||
| 47eba03a4b | |||
| 3289cd1299 | |||
| ab5fcd7a5b | |||
| 45494f21aa | |||
| 5d677d2fb7 | |||
| 808082e300 | |||
| 97cfdfd023 | |||
| 9b15cf2295 | |||
| eaa68c2d04 | |||
| ac5ca78c77 | |||
| 5b17dbdfd6 | |||
| d4ed20c7d5 | |||
| a5093ea8f0 | |||
| f5cf438abd | |||
| bf6e73e163 | |||
| 503f475ca5 | |||
| 8506118aee | |||
| dfa295a90a | |||
| 3ace1583da | |||
| c62b66195d | |||
| b724836d2b | |||
| 1e1b9dc79e | |||
| c668a51e39 | |||
| 09b34d34c6 | |||
| 54e18e41c5 | |||
| 5550bca040 | |||
| f7a02351d4 | |||
| 3125b99043 | |||
| 158765abb7 | |||
| 81aa9ac5b6 | |||
| 55f5842587 | |||
| 38dd63a99a | |||
| 558cd6c4a7 | |||
| 15e6a1bfde | |||
| c1087e62fd | |||
| 9d924dcd6d | |||
| 163d2ed157 | |||
| 68f07ddd38 | |||
| d956b93c13 | |||
| 3036305662 | |||
| ee603ce68e | |||
| 989513cb46 | |||
| 7e52c37580 | |||
| 0984f92fa2 | |||
| 2ab2d8e9df | |||
| b828e0e858 | |||
| d4dd706bba | |||
| ed30fa3e0a | |||
| 5e2b3df623 | |||
| ae7dffdfc0 | |||
| 32b5c7a3af | |||
| 8b08658b7f | |||
| ee79c3a732 | |||
| 0e5f4aa08a | |||
| ec0407e5c8 | |||
| db1380c413 | |||
| 7e3979dac0 | |||
| c1b6bde4a7 | |||
| 8df89cc2d0 | |||
| 19adadf4cf | |||
| c30feb3fc2 | |||
| 4c81589d5b | |||
| c014357e24 | |||
| ec41dc1a03 | |||
| 463dfa6fb4 | |||
| 0354b5969d | |||
| fc225bd55d | |||
| 67562126fc | |||
| 9319d613f5 | |||
| 014994a788 | |||
| 0f8efe3de1 | |||
| 274a8ca76a | |||
| ea3ad6b287 | |||
| f095b9cb8e | |||
| 6f8d3e882a | |||
| aabb763cea | |||
| 04d2626809 | |||
| 823bfd537c | |||
| 434ebd2954 | |||
| 44782c3429 | |||
| 890846fa8d | |||
| 36c761e8dd | |||
| 4a4b625075 | |||
| 4223203134 | |||
| e6966fe19a | |||
| e81c22cf53 | |||
| c02e59e3ab | |||
| 5d5abf352b | |||
| ec9bb33d16 | |||
| f3e836cec8 | |||
| 8a50528111 | |||
| 9523595282 | |||
| a762af035a | |||
| 760ab981d0 | |||
| 7b43ff0cef | |||
| 996161e2f4 | |||
| bf633bba5d | |||
| 8337a5945d | |||
| a736b3adfc | |||
| 25127cd3c9 | |||
| ebf084cff0 | |||
| cd8fe95d91 | |||
| e2efc61208 | |||
| 5de63d5bf2 | |||
| c9d744f88a | |||
| 18e0dbddfa | |||
| 52c816cb27 | |||
| 582d2b91f5 | |||
| 28a0dbb0e0 | |||
| 2895806541 | |||
| 5b8de73143 | |||
| 212af2f43b | |||
| 1282061701 | |||
| 49dba483a9 | |||
| ebec63487f | |||
| 9373819234 | |||
| 04925d8004 | |||
| 4284084fef | |||
| 63ad2afe3f | |||
| 61712d322a | |||
| 3599066356 | |||
| 18c2a38b97 | |||
| f55004a574 | |||
| 1768ddc459 | |||
| 95cea24527 | |||
| d002a75f34 | |||
| 0b6d239551 | |||
| 926b811a84 | |||
| 2bc8e11ad5 | |||
| f5412f5c0b | |||
| 5470f752b4 | |||
| 48c006a94c | |||
| 8445417661 | |||
| 30248854ed | |||
| f34bc75588 | |||
| 3b23e2f37d | |||
| 7417cf5947 | |||
| 60d8da843c | |||
| f9667fd684 | |||
| d9269c6047 | |||
| 6521f839cd |
@@ -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
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Build Reticulum
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- .gitignore
|
||||
- LICENSE
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- run: |
|
||||
python -m pip install -q cryptography
|
||||
make test
|
||||
|
||||
package:
|
||||
needs: test
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ contains(github.ref, '-') && 'development' || 'production' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- run: |
|
||||
python -m pip install -q build wheel setuptools
|
||||
make remove_symlinks
|
||||
make build_wheel
|
||||
make build_pure_wheel
|
||||
make create_symlinks
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: package
|
||||
path: dist/*.whl
|
||||
|
||||
# documentation:
|
||||
# needs: test
|
||||
# if: startsWith(github.ref, 'refs/tags/')
|
||||
# runs-on: ubuntu-latest
|
||||
# environment: ${{ contains(github.ref, '-') && 'development' || 'production' }}
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: actions/setup-python@v5
|
||||
# with:
|
||||
# python-version: 3.x
|
||||
# - run: |
|
||||
# sudo apt-get -qq update && sudo apt-get -qq install latexmk texlive-latex-recommended texlive-latex-extra texlive-fonts-recommended
|
||||
# python -m pip -q install sphinx sphinx-copybutton
|
||||
# cd docs && make latexpdf && make epub
|
||||
# - uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: documentation
|
||||
# path: |
|
||||
# docs/build/latex/*.pdf
|
||||
# docs/build/epub/*.epub
|
||||
|
||||
release:
|
||||
needs: [package]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ contains(github.ref, '-') && 'development' || 'production' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: .artifacts
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
# .artifacts/package/**.whl
|
||||
# .artifacts/documentation/latex/reticulumnetworkstack.pdf
|
||||
# .artifacts/documentation/epub/ReticulumNetworkStack.epub
|
||||
draft: true
|
||||
generate_release_notes: false
|
||||
prerelease: ${{ contains(github.ref, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
@@ -1,28 +0,0 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
||||
|
||||
name: Test suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Test
|
||||
run: |
|
||||
make test
|
||||
@@ -13,3 +13,4 @@ tests/rnsconfig/storage
|
||||
tests/rnsconfig/logfile*
|
||||
*.data
|
||||
*.result
|
||||
.buildinfo.bak
|
||||
|
||||
@@ -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()
|
||||
+630
@@ -1,3 +1,633 @@
|
||||
### 2026-05-05: RNS 1.2.2
|
||||
|
||||
This release adds release management workflows to the `rngit` utility. Downloading files and release artifacts from `rngit` will require the latest version of Nomad Network. Other nomadnet clients *may* have to update their file download link handling, if they don't already support passing query parameters for file download links.
|
||||
|
||||
**Changes**
|
||||
- Added release management to `rngit`.
|
||||
- Added release pages to the page node of `rngit`.
|
||||
- Added file downloads in the tree browser of `rngit`.
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
4bf0a376a9778de8a91b9ec8a5bc4b929be928eede8784b20022c7fe52bbce62 rns-1.2.2-py3-none-any.whl
|
||||
d85f8b765dcf718d284388b249ca0e48e785f250bb41773a83e159e46c5bcf70 rnspure-1.2.2-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.2.2-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-05-04: RNS 1.2.1
|
||||
|
||||
This release adds a nomadnet Git page node to the `rngit` utility.
|
||||
|
||||
**Changes**
|
||||
- Added nomadnet page node to `rngit`.
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
5ccbfc31b528133c4dd06c132034c2151e4eed74bc2dcf40af52385094492c9e rns-1.2.1-py3-none-any.whl
|
||||
cda45994a58f18bf25244a1f396c9197240bc012dd85c86bffc2e73dcf0607de rnspure-1.2.1-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.2.1-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-04-28: RNS 1.2.0
|
||||
|
||||
This release brings the ability to use Git natively over Reticulum networks, adds the `rnsh` program as part of the included utilities, and additionally includes several improvements and performance optimizations.
|
||||
|
||||
**Changes**
|
||||
- Added Reticulum Git Repositories Node utility as part of included utility programs.
|
||||
- Added git remote helper to interact with git repositories over Reticulum.
|
||||
- Added the `rnsh` program to the included utilities.
|
||||
- Added LocalInterface client TX hold on client app sleep on Android.
|
||||
- Added AutoInterface filters for `rmnet` interfaces on Android.
|
||||
- Added inbound packet wait during transport core initialization.
|
||||
- Added the ability to set logfile destination before RNS initialization.
|
||||
- Added automatic active link teardown on instance shutdown.
|
||||
- Improved link teardown on SIGINT/SIGTERM.
|
||||
- Improved ratchet cleaning.
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
b58e97332241755ed32e309d46e09615a123490430ae85fcbdec9318c9e26154 rns-1.2.0-py3-none-any.whl
|
||||
9813a6c2236edba18af7d3a072a6226bc65ae384d23b1f41467cb3617d65fdae rnspure-1.2.0-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.2.0-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-04-22: RNS 1.1.9
|
||||
|
||||
This maintenance release fixes a critical security issue, that would allow an attacker to craft a BZ2 decompression bomb via Resource transfers or Buffer StreamDataMessage, causing an out-of-memory condition and crashing the receiving process via OOM killer.
|
||||
|
||||
Big thanks to @defidude (github.com/ratspeak) for discovering and reporting this vulnerability!
|
||||
|
||||
**Changes**
|
||||
- Fixed bz2 decompression bomb vulnerability in Resource transfer assembly and Buffer StreamDataMessage unpacking.
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
39a131aeb5d76fd73bfc67f68135f49ab0cf8628af154e04096a05c208ce77b6 rns-1.1.9-py3-none-any.whl
|
||||
aab7bfc8c65514c9bdf4c22f00d288faf6c9e1777fc002dbe3eb29c286e67128 rnspure-1.1.9-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.1.9-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-04-21: RNS 1.1.8
|
||||
|
||||
This maintenance release fixes a critical bug in path state management, that could result in significant path convergence degradation under certain conditions.
|
||||
|
||||
**Changes**
|
||||
- Fixed path state potentially being applied before path table entry exists, causing worse paths to be selected.
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
9cf728e9e9a9fe113e4ac14e6b833f7ee65feedf8468e6ab94a261bf205f2632 rns-1.1.8-py3-none-any.whl
|
||||
407dc3975335e9eabaaddb7ed1dc75fc3a1b8d24a7207e740797440c2ad0b3e5 rnspure-1.1.8-py3-none-any.wh
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.1.8-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-04-21: RNS 1.1.7
|
||||
|
||||
**Changes**
|
||||
- Added periodic known destination data cleaning based on local relevance.
|
||||
- Improved resource transfer sequencing timing calculations and reliability.
|
||||
- Improved BackboneInterface error handling on EPOLL errors.
|
||||
- Ensured non-background data persist runs synchronously.
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
4d9702c5d9bb8a3c8b94766cb51cccad5afd78d615af9a6b146730347044e6f0 rns-1.1.7-py3-none-any.whl
|
||||
172dede7656b41b85e4319354ed04649b518e58c54586da7e443579c620a0a5b rnspure-1.1.7-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.1.7-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-04-18: RNS 1.1.6
|
||||
|
||||
**Changes**
|
||||
- Improved transport memory consumption.
|
||||
- Improved transport tunnel handling.
|
||||
- Improved gracious transport data persist handling.
|
||||
- Added ingress control bypass for pending path requests.
|
||||
- Added local destinations lookup map for better transport efficiency to local destinations.
|
||||
- Fixed disk I/O bound thread execution time starvation on cache management jobs.
|
||||
- Fixed invalid EPOLL modification error handler.
|
||||
- Fixed incorrect default IFAC size for autoconnected, discovered interfaces. Thanks @taprootmx!
|
||||
- Ensure loop-originating closures have variables captured at iteration-time. Thanks @taprootmx!
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
2ce4451668f8c464295cc269188c232e7805ddd618ec0135550a5e6809df5de0 rns-1.1.6-py3-none-any.whl
|
||||
ba3e541e69a2f4892177383c8ec4e7d172d298546317e08270928c0163865aa3 rnspure-1.1.6-py3-none-any.wh
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.1.6-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-04-13: RNS 1.1.5
|
||||
|
||||
**Changes**
|
||||
- Initial refactoring work for free-threaded transport I/O.
|
||||
- Improved interface discovery validation.
|
||||
- Fixed invalid ingress control burst activation and subsequent path resolution failure due to incorrect announce frequency calculation.
|
||||
- Fixed missing configuration entry generation for discovered I2P interfaces.
|
||||
- Fixed resource transfer cancellation failing on in-flight split resource transfers.
|
||||
- Fixed ingress control configuration not inheriting down to spawned interfaces on some interface types.
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
28f39ad97ef307a1e270b91ef19db07d8e1a7bbc8628c478303725894c64deff rns-1.1.5-py3-none-any.whl
|
||||
1a90db16d2cff4ad909b44baf9b4fd0177da2ed545cdb9cfb2c51423707b49e9 rnspure-1.1.5-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.1.5-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
#
|
||||
|
||||
### 2026-03-12: RNS 1.1.4
|
||||
|
||||
**Changes**
|
||||
- Fixed invalid application of IP/hostname validation for on non-relevant interfaces. Thanks @joakim!
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
b2a175abd64d1581dd058206832793dbf7053a304c819ff8bc143a79c49cb747 rns-1.1.4-py3-none-any.whl
|
||||
16c4ae6722bbd016e8db046e7bdd60eb24f9ec55966ec5723dc39301265d0186 rnspure-1.1.4-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.1.4-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 2026-01-17: RNS 1.1.3
|
||||
|
||||
**Changes**
|
||||
- Improved discovered interface auto-connect handling
|
||||
- Improved interface discovery handling
|
||||
- Added `discovered_interfaces` API method
|
||||
- Fixed a potential race condition in request timeout handling
|
||||
- Fixed a regression in resource file transfers
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
1de9b46c8f24931fa41974664ddbf4251d3fdd069be4de03c64b42a7cf4f8fb4 rns-1.1.3-py3-none-any.whl
|
||||
eac8d223fcb6ce94e1bd3f04730d8542675caf4b22286e11988e9402ea9b69c0 rnspure-1.1.3-py3-none-any.whl
|
||||
```
|
||||
|
||||
**Release Signatures**
|
||||
Release artifacts include `rsg` signature files that can be validated against the RNS release signing identity `<bc7291552be7a58f361522990465165c>` using `rnid`:
|
||||
|
||||
```sh
|
||||
rnid -i bc7291552be7a58f361522990465165c -V rns-1.1.3-py3-none-any.whl.rsg
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
Thanks to @gretel and @jacobeva, who contributed to this release!
|
||||
|
||||
**Changes**
|
||||
- Added support for packaging RNS to OpenWRT
|
||||
- Added ability to run `rnstatus` as application-local imported module
|
||||
- Added ability to reflect RNS log output to app-internal log handler callback
|
||||
- Added display read functionality to `RNodeInterface`
|
||||
- Fixed a regression in `RNodeMultiInterface` caused by earlier refactoring
|
||||
- Imrpoved documentation
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
e76ba8feeeae2c8df27e9906deebd7c721f0f0e887ad3fbd26df0212d6ce907a rns-0.8.7-py3-none-any.whl
|
||||
046608539bc235d52c970c7f3c54e7aa01a86016ae00263f8a55fc796b6939f5 rnspure-0.8.7-py3-none-any.whl
|
||||
```
|
||||
|
||||
### 2024-11-24: RNS β 0.8.6
|
||||
|
||||
This release adds full interface modularity and custom interface loading to RNS. Users can now easily create and use their own custom interfaces for communicating over practically anything. Support for IPv6 has also been added to the TCP-based interfaces.
|
||||
|
||||
In addition, several bugs have been fixed, and various internal improvements to code consistency and naming conventions have been carried out.
|
||||
|
||||
Thanks to @gretel and @deavmi, who contributed to this release!
|
||||
|
||||
**Changes**
|
||||
- Added ability to load and configure custom, user-supplied interfaces
|
||||
- Added IPv6 support to `TCPClientInterface` and `TCPServerInterface`
|
||||
- Added an init option to the API for requiring an existing shared instance
|
||||
- Changed `rnstatus` behaviour to only show status if Reticulum is already running
|
||||
- Fixed `KISSInterface` beacon length for compatibility with software modems
|
||||
- Fixed interface client count sometimes reporting incorrect values on TCP and I2P interfaces
|
||||
- Refactored and improved interface initialisation and configuration handling
|
||||
- Refactored interface code to be more consistent
|
||||
- Refactored various deprecated references and names
|
||||
- Updated documentation and manual
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
60be127f003cd7838149bf8f01020206f829a7bd192706a608e39d8d7193d07b rns-0.8.6-py3-none-any.whl
|
||||
d8701e19279d292b5b8af9da7c67b6ac88a992ca65109f8182c3e5c761a9ebeb rnspure-0.8.6-py3-none-any.whl
|
||||
```
|
||||
|
||||
### 2024-10-20: RNS β 0.8.5
|
||||
|
||||
This maintenance release fixes a number of bugs. Thanks to @faragher for contributing to this release!
|
||||
|
||||
**Changes**
|
||||
- Fixed missing close of file handles
|
||||
- Fixed invalid values returned from `get_snr()` and `get_q()` physical layer stats API functions
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
1757e809e083585bf4c23b6fe0f29954e5a1586ce14081099e38e606a75831df rns-0.8.5-py3-none-any.whl
|
||||
44254630634f4dbb1ce3242247fe8180379d27bff15d183263b1856fd662f88d rnspure-0.8.5-py3-none-any.whl
|
||||
```
|
||||
|
||||
### 2024-10-11: RNS β 0.8.4
|
||||
|
||||
This release fixes a number of bugs and improves reliability of automatic reconnection when BLE-connected RNodes unexpectedly disappear or lose connection.
|
||||
|
||||
**Changes**
|
||||
- Improved RNode BLE reconnection realiability
|
||||
- Added RNode battery state to `rnstatus` output
|
||||
- Fixed resource transfer hanging for a long time over slow links if proof packet is lost
|
||||
- Fixed missing import on Android
|
||||
|
||||
**Release Hashes**
|
||||
```
|
||||
d3f7a9fddc6c1e59b1e4895756fe602408ac6ef09de377ee65ec62d09fff97a3 rns-0.8.4-py3-none-any.whl
|
||||
eb3843bcab1428be0adb097988991229a4c03156ab40cc9c6e2d9c590d8b850b rnspure-0.8.4-py3-none-any.whl
|
||||
```
|
||||
|
||||
### 2024-10-10: RNS β 0.8.3
|
||||
|
||||
This release fixes a bug in resource transfer progress calculation, improves RNode error handling, and brings minor improvements to the `rncp` utility.
|
||||
|
||||
+26
-11
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
@@ -118,4 +118,4 @@ if __name__ == "__main__":
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("")
|
||||
exit()
|
||||
sys.exit(0)
|
||||
+3
-4
@@ -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
@@ -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
@@ -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)
|
||||
@@ -0,0 +1,297 @@
|
||||
# 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
|
||||
# and loaded. To use the interface place it in the folder
|
||||
# ~/.reticulum/interfaces, and add an interface entry to
|
||||
# your Reticulum configuration file similar to this:
|
||||
|
||||
# [[Example Custom Interface]]
|
||||
# type = ExampleInterface
|
||||
# enabled = no
|
||||
# mode = gateway
|
||||
# port = /dev/ttyUSB0
|
||||
# speed = 115200
|
||||
# databits = 8
|
||||
# parity = none
|
||||
# stopbits = 1
|
||||
|
||||
from time import sleep
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
# This HDLC helper class is used by the interface
|
||||
# to delimit and packetize data over the physical
|
||||
# medium - in this case a serial connection.
|
||||
class HDLC():
|
||||
# This example interface packetizes data using
|
||||
# simplified HDLC framing, similar to PPP
|
||||
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
|
||||
|
||||
# Let's define our custom interface class. It must
|
||||
# be a sub-class of the RNS "Interface" class.
|
||||
class ExampleInterface(Interface):
|
||||
# All interface classes must define a default
|
||||
# IFAC size, used in IFAC setup when the user
|
||||
# has not specified a custom IFAC size. This
|
||||
# option is specified in bytes.
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
# The following properties are local to this
|
||||
# particular interface implementation.
|
||||
owner = None
|
||||
port = None
|
||||
speed = None
|
||||
databits = None
|
||||
parity = None
|
||||
stopbits = None
|
||||
serial = None
|
||||
|
||||
# All Reticulum interfaces must have an __init__
|
||||
# method that takes 2 positional arguments:
|
||||
# The owner RNS Transport instance, and a dict
|
||||
# of configuration values.
|
||||
def __init__(self, owner, configuration):
|
||||
|
||||
# The following lines demonstrate handling
|
||||
# potential dependencies required for the
|
||||
# interface to function correctly.
|
||||
import importlib
|
||||
if importlib.util.find_spec('serial') != None:
|
||||
import serial
|
||||
else:
|
||||
RNS.log("Using this interface requires a serial communication module to be installed.", RNS.LOG_CRITICAL)
|
||||
RNS.log("You can install one with the command: python3 -m pip install pyserial", RNS.LOG_CRITICAL)
|
||||
RNS.panic()
|
||||
|
||||
# We start out by initialising the super-class
|
||||
super().__init__()
|
||||
|
||||
# To make sure the configuration data is in the
|
||||
# correct format, we parse it through the following
|
||||
# method on the generic Interface class. This step
|
||||
# is required to ensure compatibility on all the
|
||||
# platforms that Reticulum supports.
|
||||
ifconf = Interface.get_config_obj(configuration)
|
||||
|
||||
# Read the interface name from the configuration
|
||||
# and set it on our interface instance.
|
||||
name = ifconf["name"]
|
||||
self.name = name
|
||||
|
||||
# We read configuration parameters from the supplied
|
||||
# configuration data, and provide default values in
|
||||
# case any are missing.
|
||||
port = ifconf["port"] if "port" in ifconf else None
|
||||
speed = int(ifconf["speed"]) if "speed" in ifconf else 9600
|
||||
databits = int(ifconf["databits"]) if "databits" in ifconf else 8
|
||||
parity = ifconf["parity"] if "parity" in ifconf else "N"
|
||||
stopbits = int(ifconf["stopbits"]) if "stopbits" in ifconf else 1
|
||||
|
||||
# In case no port is specified, we abort setup by
|
||||
# raising an exception.
|
||||
if port == None:
|
||||
raise ValueError(f"No port specified for {self}")
|
||||
|
||||
# All interfaces must supply a hardware MTU value
|
||||
# to the RNS Transport instance. This value should
|
||||
# be the maximum data packet payload size that the
|
||||
# underlying medium is capable of handling in all
|
||||
# cases without any segmentation.
|
||||
self.HW_MTU = 564
|
||||
|
||||
# We initially set the "online" property to false,
|
||||
# since the interface has not actually been fully
|
||||
# initialised and connected yet.
|
||||
self.online = False
|
||||
|
||||
# In this case, we can also set the indicated bit-
|
||||
# rate of the interface to the serial port speed.
|
||||
self.bitrate = speed
|
||||
|
||||
# Configure internal properties on the interface
|
||||
# according to the supplied configuration.
|
||||
self.pyserial = serial
|
||||
self.serial = None
|
||||
self.owner = owner
|
||||
self.port = port
|
||||
self.speed = speed
|
||||
self.databits = databits
|
||||
self.parity = serial.PARITY_NONE
|
||||
self.stopbits = stopbits
|
||||
self.timeout = 100
|
||||
|
||||
if parity.lower() == "e" or parity.lower() == "even":
|
||||
self.parity = serial.PARITY_EVEN
|
||||
|
||||
if parity.lower() == "o" or parity.lower() == "odd":
|
||||
self.parity = serial.PARITY_ODD
|
||||
|
||||
# Since all required parameters are now configured,
|
||||
# we will try opening the serial port.
|
||||
try:
|
||||
self.open_port()
|
||||
except Exception as e:
|
||||
RNS.log("Could not open serial port for interface "+str(self), RNS.LOG_ERROR)
|
||||
raise e
|
||||
|
||||
# If opening the port succeeded, run any post-open
|
||||
# configuration required.
|
||||
if self.serial.is_open:
|
||||
self.configure_device()
|
||||
else:
|
||||
raise IOError("Could not open serial port")
|
||||
|
||||
# Open the serial port with supplied configuration
|
||||
# parameters and store a reference to the open port.
|
||||
def open_port(self):
|
||||
RNS.log("Opening serial port "+self.port+"...", RNS.LOG_VERBOSE)
|
||||
self.serial = self.pyserial.Serial(
|
||||
port = self.port,
|
||||
baudrate = self.speed,
|
||||
bytesize = self.databits,
|
||||
parity = self.parity,
|
||||
stopbits = self.stopbits,
|
||||
xonxoff = False,
|
||||
rtscts = False,
|
||||
timeout = 0,
|
||||
inter_byte_timeout = None,
|
||||
write_timeout = None,
|
||||
dsrdtr = False,
|
||||
)
|
||||
|
||||
# The only thing required after opening the port
|
||||
# is to wait a small amount of time for the
|
||||
# hardware to initialise and then start a thread
|
||||
# that reads any incoming data from the device.
|
||||
def configure_device(self):
|
||||
sleep(0.5)
|
||||
thread = threading.Thread(target=self.read_loop)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
self.online = True
|
||||
RNS.log("Serial port "+self.port+" is now open", RNS.LOG_VERBOSE)
|
||||
|
||||
|
||||
# This method will be called from our read-loop
|
||||
# whenever a full packet has been received over
|
||||
# the underlying medium.
|
||||
def process_incoming(self, data):
|
||||
# Update our received bytes counter
|
||||
self.rxb += len(data)
|
||||
|
||||
# And send the data packet to the Transport
|
||||
# instance for processing.
|
||||
self.owner.inbound(data, self)
|
||||
|
||||
# The running Reticulum Transport instance will
|
||||
# call this method on the interface whenever the
|
||||
# interface must transmit a packet.
|
||||
def process_outgoing(self,data):
|
||||
if self.online:
|
||||
# First, escape and packetize the data
|
||||
# according to HDLC framing.
|
||||
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
|
||||
|
||||
# Then write the framed data to the port
|
||||
written = self.serial.write(data)
|
||||
|
||||
# Update the transmitted bytes counter
|
||||
# and ensure that all data was written
|
||||
self.txb += len(data)
|
||||
if written != len(data):
|
||||
raise IOError("Serial interface only wrote "+str(written)+" bytes of "+str(len(data)))
|
||||
|
||||
# This read loop runs in a thread and continously
|
||||
# receives bytes from the underlying serial port.
|
||||
# When a full packet has been received, it will
|
||||
# be sent to the process_incoming methed, which
|
||||
# will in turn pass it to the Transport instance.
|
||||
def read_loop(self):
|
||||
try:
|
||||
in_frame = False
|
||||
escape = False
|
||||
data_buffer = b""
|
||||
last_read_ms = int(time.time()*1000)
|
||||
|
||||
while self.serial.is_open:
|
||||
if self.serial.in_waiting:
|
||||
byte = ord(self.serial.read(1))
|
||||
last_read_ms = int(time.time()*1000)
|
||||
|
||||
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])
|
||||
|
||||
else:
|
||||
time_since_last = int(time.time()*1000) - last_read_ms
|
||||
if len(data_buffer) > 0 and time_since_last > self.timeout:
|
||||
data_buffer = b""
|
||||
in_frame = False
|
||||
escape = False
|
||||
sleep(0.08)
|
||||
|
||||
except Exception as e:
|
||||
self.online = False
|
||||
RNS.log("A serial port error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR)
|
||||
|
||||
if RNS.Reticulum.panic_on_interface_error:
|
||||
RNS.panic()
|
||||
|
||||
RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR)
|
||||
|
||||
self.online = False
|
||||
self.serial.close()
|
||||
self.reconnect_port()
|
||||
|
||||
# This method handles serial port disconnects.
|
||||
def reconnect_port(self):
|
||||
while not self.online:
|
||||
try:
|
||||
time.sleep(5)
|
||||
RNS.log("Attempting to reconnect serial port "+str(self.port)+" for "+str(self)+"...", RNS.LOG_VERBOSE)
|
||||
self.open_port()
|
||||
if self.serial.is_open:
|
||||
self.configure_device()
|
||||
except Exception as e:
|
||||
RNS.log("Error while reconnecting port, the contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
RNS.log("Reconnected serial port for "+str(self))
|
||||
|
||||
# Signal to Reticulum that this interface should
|
||||
# not perform any ingress limiting.
|
||||
def should_ingress_limit(self):
|
||||
return False
|
||||
|
||||
# We must provide a string representation of this
|
||||
# interface, that is used whenever the interface
|
||||
# is printed in logs or external programs.
|
||||
def __str__(self):
|
||||
return "ExampleInterface["+self.name+"]"
|
||||
|
||||
# Finally, register the defined interface class as the
|
||||
# target class for Reticulum to use as an interface
|
||||
interface_class = ExampleInterface
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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", "I’ll 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 send 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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"drips": {
|
||||
"ethereum": {
|
||||
"ownedBy": "0xae89F3B94fC4AD6563F0864a55F9a697a90261ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
liberapay: Reticulum
|
||||
ko_fi: markqvist
|
||||
custom: "https://unsigned.io/donate"
|
||||
custom: "https://unsigned.io/donate"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License, unless otherwise noted
|
||||
Reticulum License
|
||||
|
||||
Copyright (c) 2016-2024 Mark Qvist / unsigned.io
|
||||
Copyright (c) 2016-2026 Mark Qvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
असतो मा सद्गमय
|
||||
तमसो मा ज्योतिर्गमय
|
||||
मृत्योर्मा अमृतं गमय
|
||||
@@ -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,13 +55,24 @@ 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
|
||||
|
||||
upload:
|
||||
@echo Ready to publish release, hit enter to continue
|
||||
upload: upload-rns upload-rnspure
|
||||
|
||||
upload-rns:
|
||||
@echo Ready to publish rns release, hit enter to continue
|
||||
@read VOID
|
||||
@echo Uploading to PyPi...
|
||||
twine upload dist/*
|
||||
twine upload dist/rns-*.whl dist/rns-*.tar.gz
|
||||
@echo Release published
|
||||
|
||||
upload-rnspure:
|
||||
@echo Ready to publish rnspure release, hit enter to continue
|
||||
@read VOID
|
||||
@echo Uploading to PyPi...
|
||||
twine upload dist/rnspure-*.whl
|
||||
@echo Release published
|
||||
@@ -1,8 +1,12 @@
|
||||
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/python-app.yml"><img align="right" src="https://github.com/markqvist/reticulum/actions/workflows/python-app.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.*
|
||||
|
||||
To understand the foundational philosophy and goals of this system, read the [Zen of Reticulum](Zen%20of%20Reticulum.md).
|
||||
|
||||
Reticulum is the cryptography-based networking stack for building local and wide-area
|
||||
networks with readily available hardware. It can operate even with very high latency
|
||||
and extremely low bandwidth. Reticulum allows you to build wide-area networks
|
||||
@@ -42,6 +46,9 @@ For more info, see [reticulum.network](https://reticulum.network/) and [the FAQ
|
||||
## Notable Features
|
||||
- Coordination-less globally unique addressing and identification
|
||||
- Fully self-configuring multi-hop routing over heterogeneous carriers
|
||||
- Flexible scalability over heterogeneous topologies
|
||||
- Reticulum can carry data over any mixture of physical mediums and topologies
|
||||
- Low-bandwidth networks can co-exist and interoperate with large, high-bandwidth networks
|
||||
- Initiator anonymity, communicate without revealing your identity
|
||||
- Reticulum does not include source addresses on any packets
|
||||
- Asymmetric X25519 encryption and Ed25519 signatures as a basis for all communication
|
||||
@@ -49,12 +56,18 @@ 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
|
||||
- A large variety of supported interface types
|
||||
- Flexible and extensible interface system
|
||||
- Reticulum includes a large variety of built-in interface types
|
||||
- Ability to load and utilise custom user- or community-supplied interface types
|
||||
- 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, 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
|
||||
- Sequencing, compression, transfer coordination and checksumming are automatic
|
||||
@@ -65,21 +78,32 @@ For more info, see [reticulum.network](https://reticulum.network/) and [the FAQ
|
||||
- Low cost of keeping links open at only 0.44 bits per second
|
||||
- Reliable sequential delivery with Channel and Buffer mechanisms
|
||||
|
||||
## Roadmap
|
||||
While Reticulum is already a fully featured and functional networking stack,
|
||||
many improvements and additions are actively being worked on, and planned for the future.
|
||||
## Reference Implementation
|
||||
|
||||
To learn more about the direction and future of Reticulum, please see the [Development Roadmap](./Roadmap.md).
|
||||
The Python code in this repository is the Reference Implementation of Reticulum.
|
||||
The Reticulum Protocol is defined entirely and authoritatively by this reference
|
||||
implementation, and its associated manual. It is maintained by Mark Qvist,
|
||||
identified by the Reticulum Identity `<bc7291552be7a58f361522990465165c>`.
|
||||
|
||||
Compatibility with the Reticulum Protocol is defined as having full interoperability,
|
||||
and sufficient functional parity with this reference implementation. Any specific protocol
|
||||
implementation that achieves this is Reticulum. Any that does not is not Reticulum.
|
||||
|
||||
The reference implementation is licensed under the Reticulum License.
|
||||
|
||||
The Reticulum Protocol was dedicated to the Public Domain in 2016.
|
||||
|
||||
## Examples of Reticulum Applications
|
||||
If you want to quickly get an idea of what Reticulum can do, take a look at the
|
||||
following resources.
|
||||
[Programs Using Reticulum](https://reticulum.network/manual/software.html)
|
||||
section of the manual, or the 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.
|
||||
- [MeshChatX](https://git.quad4.io/RNS-Things/MeshChatX) is a full-featured LXMF client with many built-in tools and functionalities, that also supports image and voice messages, file transfers and voice calls. It also includes a built-in page browser for browsing Nomad Network nodes.
|
||||
- You can use the included [rnsh](https://reticulum.network/manual/using.html#the-rnsh-utility) program to establish remote shell sessions over Reticulum.
|
||||
|
||||
## Where can Reticulum be used?
|
||||
Over practically any medium that can support at least a half-duplex channel
|
||||
@@ -160,8 +184,10 @@ section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/)
|
||||
- A diagnostics tool called `rnprobe` for checking connectivity to destinations
|
||||
- A simple file transfer program called `rncp` making it easy to transfer files between systems
|
||||
- The identity management and encryption utility `rnid` let's you manage Identities and encrypt/decrypt files
|
||||
- The remote command execution program `rnx` let's you run commands and
|
||||
programs and retrieve output from remote systems
|
||||
- The `rnsh` program allows you to establish fully interactive shell session with remote systems
|
||||
- The remote command execution program `rnx` let's you run simple commands and programs and retrieve output from remote systems
|
||||
- The `rngit` program provides a full multi-repository Git node for serving repositories over Reticulum
|
||||
- The included `git-remote-rns` helper allows you to interact with Git repositories over Reticulum
|
||||
|
||||
All tools, including `rnx` and `rncp`, work reliably and well even over very
|
||||
low-bandwidth links like LoRa or Packet Radio. For full-featured remote shells
|
||||
@@ -172,11 +198,12 @@ program.
|
||||
|
||||
Reticulum implements a range of generalised interface types that covers most of
|
||||
the communications hardware that Reticulum can run over. If your hardware is
|
||||
not supported, it's relatively simple to implement an interface class. I will
|
||||
gratefully accept pull requests for custom interfaces if they are generally
|
||||
useful.
|
||||
not supported, it's [simple to implement a custom interface module](https://markqvist.github.io/Reticulum/manual/interfaces.html#custom-interfaces).
|
||||
|
||||
Currently, the following interfaces are supported:
|
||||
Pull requests for custom interfaces are gratefully accepted, provided they are
|
||||
generally useful and well-tested in real-world usage.
|
||||
|
||||
Currently, the following built-in interfaces are supported:
|
||||
|
||||
- Any Ethernet device
|
||||
- LoRa using [RNode](https://unsigned.io/rnode/)
|
||||
@@ -195,18 +222,17 @@ 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
|
||||
The installation of the default `rns` package requires only two external dependencies, listed
|
||||
below. Almost all systems and distributions have readily available packages for
|
||||
these dependencies, and when the `rns` package is installed with `pip`, they
|
||||
will be downloaded and installed as well.
|
||||
@@ -234,75 +260,53 @@ that do not support [PyCA/cryptography](https://github.com/pyca/cryptography),
|
||||
it is important that you read and understand the [Cryptographic
|
||||
Primitives](#cryptographic-primitives) section of this document.
|
||||
|
||||
## Bootstrapping Connectivity
|
||||
|
||||
Reticulum is not a service you subscribe to, nor is it a single global network you "join".
|
||||
Reticulum provides functionality for discovering available public interfaces
|
||||
over the network itself, and the broader community has provided various directories
|
||||
of publicly available entrypoints to bootstrap connectivity.
|
||||
|
||||
To learn how to establish initial connectivity over Reticulum, read the [Bootstrapping Connectivity](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) section of the manual.
|
||||
|
||||
If you already have a general idea of how this works, you can use community-run
|
||||
sites such as [directory.rns.recipes](https://directory.rns.recipes/) and [rmap.world](https://rmap.world)
|
||||
to find interface definitions for initial connectivity to the global distributed Reticulum backbone.
|
||||
|
||||
## Public Testnet
|
||||
If you just want to get started experimenting without building any physical
|
||||
networks, you are welcome to join the RNS Development Testnet.
|
||||
|
||||
The testnet is just that, an informal network for testing and experimenting.
|
||||
It will be up most of the time, and anyone can join, but it also means that
|
||||
there's no guarantees for service availability.
|
||||
|
||||
It probably goes without saying, but *don't use the testnet entry-points as
|
||||
hardcoded or default interfaces in any applications you ship to users*. When
|
||||
shipping applications, the best practice is to provide your own default
|
||||
connectivity solutions, if needed and applicable, or in most cases, simply
|
||||
leave it up to the user which networks to connect to, and how.
|
||||
|
||||
The testnet runs the very latest version of Reticulum (often even a short while
|
||||
before it is publicly released). Sometimes experimental versions of Reticulum
|
||||
might be deployed to nodes on the testnet, which means strange behaviour might
|
||||
occur. If none of that scares you, you can join the testnet via either TCP or
|
||||
I2P. Just add one of the following interfaces to your Reticulum configuration
|
||||
file:
|
||||
|
||||
```
|
||||
# TCP/IP interface to the RNS Amsterdam Hub
|
||||
[[RNS Testnet Amsterdam]]
|
||||
type = TCPClientInterface
|
||||
enabled = yes
|
||||
target_host = amsterdam.connect.reticulum.network
|
||||
target_port = 4965
|
||||
|
||||
# TCP/IP interface to the BetweenTheBorders Hub (community-provided)
|
||||
[[RNS Testnet BetweenTheBorders]]
|
||||
type = TCPClientInterface
|
||||
enabled = yes
|
||||
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.
|
||||
***Important!** Historically, a developer-targeted testnet was made available by the Reticulum project itself. As the amount of global Reticulum nodes and entrypoints have grown to a substantial quantity, this public testnet, including the Amsterdam Testnet entrypoint, has now been decommissioned. If your still have instances that relied on this entrypoint for connectivity, transition to using the distributed backbone instead. Reticulum now includes a full on-network interface discovery and connectivity bootstrapping system. Read the [Bootstrapping Connectivity](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) section of the manual for pointers.*
|
||||
|
||||
## Support Reticulum
|
||||
You can help support the continued development of open, free and private communications systems by donating via one of the following channels:
|
||||
For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels:
|
||||
|
||||
- Monero:
|
||||
```
|
||||
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
|
||||
@@ -310,35 +314,41 @@ 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`,
|
||||
`HMAC`, `Fernet` primitives, and the `PKCS7` padding function are always
|
||||
`HMAC`, `Token` primitives, and the `PKCS7` padding function are always
|
||||
provided by the following internal implementations:
|
||||
|
||||
- [HKDF.py](RNS/Cryptography/HKDF.py)
|
||||
- [HMAC.py](RNS/Cryptography/HMAC.py)
|
||||
- [Fernet.py](RNS/Cryptography/Fernet.py)
|
||||
- [Token.py](RNS/Cryptography/Token.py)
|
||||
- [PKCS7.py](RNS/Cryptography/PKCS7.py)
|
||||
|
||||
|
||||
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.
|
||||
@@ -362,12 +372,13 @@ 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)
|
||||
- [rnsh](https://github.com/acehoss/rnsh) by [Aaron Heise](https://github.com/acehoss)
|
||||
- [Python](https://www.python.org)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
>> Reticulum Network Stack
|
||||
|
||||
To understand the foundational philosophy and goals of this system, read the [Zen of Reticulum](Zen%20of%20Reticulum.md).
|
||||
|
||||
Reticulum is the cryptography-based networking stack for building local and wide-area networks with readily available hardware. It can operate even with very high latency and extremely low bandwidth. Reticulum allows you to build wide-area networks with off-the-shelf tools, and offers end-to-end encryption and connectivity, initiator anonymity, autoconfiguring cryptographically backed multi-hop transport, efficient addressing, unforgeable delivery acknowledgements and more.
|
||||
|
||||
The vision of Reticulum is to allow anyone to be their own network operator, and to make it cheap and easy to cover vast areas with a myriad of independent, inter-connectable and autonomous networks. Reticulum **is not** *one* network. It is **a tool** for building *thousands of networks*. Networks without kill-switches, surveillance, censorship and control. Networks that can freely interoperate, associate and disassociate with each other, and require no central oversight. Networks for human beings. *Networks for the people*.
|
||||
|
||||
Reticulum is a complete networking stack, and does not rely on IP or higher layers, but it is possible to use IP as the underlying carrier for Reticulum. It is therefore trivial to tunnel Reticulum over the Internet or private IP networks.
|
||||
|
||||
Having no dependencies on traditional networking stacks frees up overhead that has been used to implement a networking stack built directly on cryptographic principles, allowing resilience and stable functionality, even in open and trustless networks.
|
||||
|
||||
No kernel modules or drivers are required. Reticulum runs completely in userland, and can run on practically any system that runs Python 3.
|
||||
|
||||
>> Read The Manual
|
||||
|
||||
The full documentation for Reticulum is available at [markqvist.github.io/Reticulum/manual/](https://markqvist.github.io/Reticulum/manual/).
|
||||
|
||||
You can also download the [Reticulum manual as a PDF](https://github.com/markqvist/Reticulum/raw/master/docs/Reticulum%20Manual.pdf) or [as an e-book in EPUB format](https://github.com/markqvist/Reticulum/raw/master/docs/Reticulum%20Manual.epub).
|
||||
|
||||
For more info, see [reticulum.network](https://reticulum.network/) and [the FAQ section of the wiki](https://github.com/markqvist/Reticulum/wiki/Frequently-Asked-Questions).
|
||||
|
||||
>> Notable Features
|
||||
|
||||
• Coordination-less globally unique addressing and identification
|
||||
• Fully self-configuring multi-hop routing over heterogeneous carriers
|
||||
• Flexible scalability over heterogeneous topologies
|
||||
• Reticulum can carry data over any mixture of physical mediums and topologies
|
||||
• Low-bandwidth networks can co-exist and interoperate with large, high-bandwidth networks
|
||||
• Initiator anonymity, communicate without revealing your identity
|
||||
• Reticulum does not include source addresses on any packets
|
||||
• Asymmetric X25519 encryption and Ed25519 signatures as a basis for all communication
|
||||
• The foundational Reticulum Identity Keys are 512-bit Elliptic Curve keysets
|
||||
• 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-256 in CBC mode with PKCS7 padding
|
||||
• HMAC using SHA256 for authentication
|
||||
• IVs are generated through os.urandom()
|
||||
• Unforgeable packet delivery confirmations
|
||||
• Flexible and extensible interface system
|
||||
• Reticulum includes a large variety of built-in interface types
|
||||
• Ability to load and utilise custom user- or community-supplied interface types
|
||||
• 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, 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
|
||||
• Sequencing, compression, transfer coordination and checksumming are automatic
|
||||
• The API is very easy to use, and provides transfer progress
|
||||
• Lightweight, flexible and expandable Request/Response mechanism
|
||||
• Efficient link establishment
|
||||
• Total cost of setting up an encrypted and verified link is only 3 packets, totalling 297 bytes
|
||||
• Low cost of keeping links open at only 0.44 bits per second
|
||||
• Reliable sequential delivery with Channel and Buffer mechanisms
|
||||
|
||||
>> Reference Implementation
|
||||
|
||||
The Python code in this repository is the Reference Implementation of Reticulum. The Reticulum Protocol is defined entirely and authoritatively by this reference implementation, and its associated manual. It is maintained by Mark Qvist, identified by the Reticulum Identity `B333<bc7291552be7a58f361522990465165c>`b.
|
||||
|
||||
Compatibility with the Reticulum Protocol is defined as having full interoperability, and sufficient functional parity with this reference implementation. Any specific protocol implementation that achieves this is Reticulum. Any that does not is not Reticulum.
|
||||
|
||||
The reference implementation is licensed under the Reticulum License.
|
||||
|
||||
The Reticulum Protocol was dedicated to the Public Domain in 2016.
|
||||
|
||||
>> Examples of Reticulum Applications
|
||||
|
||||
If you want to quickly get an idea of what Reticulum can do, take a look at the [Programs Using Reticulum](https://reticulum.network/manual/software.html) section of the manual, or the following resources:
|
||||
|
||||
• [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 `B333rnphone`b 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 many advanced features, such as file transfers, image and voice messages, real-time voice calls,
|
||||
a distributed telemetry system, mapping capabilities and full plugin extensibility.
|
||||
|
||||
• [MeshChatX](https://git.quad4.io/RNS-Things/MeshChatX) is a full-featured LXMF client with many built-in tools
|
||||
and functionalities, that also supports image and voice messages, file transfers and voice calls. It also
|
||||
includes a built-in page browser for browsing Nomad Network nodes.
|
||||
|
||||
• You can use the included [rnsh](https://reticulum.network/manual/using.html#the-rnsh-utility) program to
|
||||
establish remote shell sessions over Reticulum.
|
||||
|
||||
>> Where can Reticulum be used?
|
||||
|
||||
Over practically any medium that can support at least a half-duplex channel with greater throughput than 5 bits per second, and an MTU of 500 bytes. Data radios, modems, LoRa radios, serial lines, AX.25 TNCs, amateur radio digital modes, WiFi and Ethernet devices, free-space optical links, and similar systems are all examples of the types of physical devices Reticulum can use.
|
||||
|
||||
An open-source LoRa-based interface called [RNode](https://markqvist.github.io/Reticulum/manual/hardware.html#rnode) has been designed specifically for use with Reticulum. It is possible to build yourself, or it can be purchased as a complete transceiver that just needs a USB connection to the host.
|
||||
|
||||
Reticulum can also be encapsulated over existing IP networks, so there's nothing stopping you from using it over wired Ethernet, your local WiFi network or the Internet, where it'll work just as well. In fact, one of the strengths of Reticulum is how easily it allows you to connect different mediums into a self-configuring, resilient and encrypted mesh, using any available mixture of available infrastructure.
|
||||
|
||||
As an example, it's possible to set up a Raspberry Pi connected to both a LoRa radio, a packet radio TNC and a WiFi network. Once the interfaces are configured, Reticulum will take care of the rest, and any device on the WiFi network can communicate with nodes on the LoRa and packet radio sides of the network, and vice versa.
|
||||
|
||||
>> How do I get started?
|
||||
|
||||
The best way to get started with the Reticulum Network Stack depends on what you want to do. For full details and examples, have a look at the [Getting Started Fast](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html) section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
|
||||
|
||||
To simply install Reticulum and related utilities on your system, the easiest way is via `B333pip`b. You can then start any program that uses Reticulum, or start Reticulum as a system service with [the rnsd utility](https://markqvist.github.io/Reticulum/manual/using.html#the-rnsd-utility).
|
||||
|
||||
`B333
|
||||
`=
|
||||
pip install rns
|
||||
`=
|
||||
`b
|
||||
|
||||
If you are using an operating system that blocks normal user package installation via `B333pip`b, you can return `B333pip`b to normal behaviour by editing the `B333~/.config/pip/pip.conf`b file, and adding the following directive in the `B333[global]`b section:
|
||||
|
||||
`B333
|
||||
`=
|
||||
[global]
|
||||
break-system-packages = true
|
||||
`=
|
||||
`b
|
||||
|
||||
Alternatively, you can use the `B333pipx`b tool to install Reticulum in an isolated environment:
|
||||
|
||||
`B333
|
||||
`=
|
||||
pipx install rns
|
||||
`=
|
||||
`b
|
||||
|
||||
When first started, Reticulum will create a default configuration file, providing basic connectivity to other Reticulum peers that might be locally reachable. The default config file contains a few examples, and references for creating a more complex configuration.
|
||||
|
||||
If you have an old version of `B333pip`b on your system, you may need to upgrade it first with `B333pip install pip --upgrade`b. If you no not already have `B333pip`b installed, you can install it using the package manager of your system with `B333sudo apt install python3-pip`b or similar.
|
||||
|
||||
For more detailed examples on how to expand communication over many mediums such as packet radio or LoRa, serial ports, or over fast IP links and the Internet using the UDP and TCP interfaces, take a look at the [Supported Interfaces](https://markqvist.github.io/Reticulum/manual/interfaces.html) section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
|
||||
|
||||
>> Included Utilities
|
||||
Reticulum includes a range of useful utilities for managing your networks, viewing status and information, and other tasks. You can read more about these programs in the [Included Utility Programs](https://markqvist.github.io/Reticulum/manual/using.html#included-utility-programs) section of the [Reticulum Manual](https://markqvist.github.io/Reticulum/manual/).
|
||||
|
||||
• The system daemon `B333rnsd`b for running Reticulum as an always-available service
|
||||
• An interface status utility called `B333rnstatus`b, that displays information about interfaces
|
||||
• The path lookup and management tool `B333rnpath`b letting you view and modify path tables
|
||||
• A diagnostics tool called `B333rnprobe`b for checking connectivity to destinations
|
||||
• A simple file transfer program called `B333rncp`b making it easy to transfer files between systems
|
||||
• The identity management and encryption utility `B333rnid`b let's you manage Identities and encrypt/decrypt files
|
||||
• The `B333rnsh`b program allows you to establish fully interactive shell session with remote systems
|
||||
• The remote command execution program `B333rnx`b let's you run simple commands and programs and retrieve output from remote systems
|
||||
• The `B333rngit`b program provides a full multi-repository Git node for serving repositories over Reticulum
|
||||
• The included `B333git-remote-rns`b helper allows you to interact with Git repositories over Reticulum
|
||||
|
||||
All tools, including `B333rnx`b and `B333rncp`b, work reliably and well even over very low-bandwidth links like LoRa or Packet Radio. For full-featured remote shells over Reticulum, also have a look at the [rnsh](https://github.com/acehoss/rnsh) program.
|
||||
|
||||
>> Supported interface types and devices
|
||||
|
||||
Reticulum implements a range of generalised interface types that covers most of the communications hardware that Reticulum can run over. If your hardware is not supported, it's [simple to implement a custom interface module](https://markqvist.github.io/Reticulum/manual/interfaces.html#custom-interfaces).
|
||||
|
||||
Currently, the following built-in interfaces are supported:
|
||||
|
||||
• Any Ethernet device
|
||||
• LoRa using [RNode](https://unsigned.io/rnode/)
|
||||
• Packet Radio TNCs (with or without AX.25)
|
||||
• KISS-compatible hardware and software modems
|
||||
• Any device with a serial port
|
||||
• TCP over IP networks
|
||||
• UDP over IP networks
|
||||
• External programs via stdio or pipes
|
||||
• Custom hardware via stdio or pipes
|
||||
|
||||
>> Performance
|
||||
Reticulum targets a *very* wide usable performance envelope, but prioritises functionality and performance on low-bandwidth mediums. The goal is to provide a dynamic performance envelope from 250 bits per second, to 1 gigabit per second on normal hardware.
|
||||
|
||||
Currently, the usable performance envelope is approximately 150 bits per second to 500 megabits per second, with physical mediums faster than that not being saturated. Performance beyond the current level is intended for future upgrades, but not highly prioritised at this point in time.
|
||||
|
||||
>> Current Status
|
||||
All core protocol features are implemented and functioning, but additions will probably occur as real-world use is explored and understood. The API and wire-format can be considered stable.
|
||||
|
||||
>> Dependencies
|
||||
The installation of the default `B333rns`b package requires only two external dependencies, listed below. Almost all systems and distributions have readily available packages for these dependencies, and when the `B333rns`b package is installed with `B333pip`b, they will be downloaded and installed as well.
|
||||
|
||||
• [PyCA/cryptography](https://github.com/pyca/cryptography)
|
||||
• [pyserial](https://github.com/pyserial/pyserial)
|
||||
|
||||
On more unusual systems, and in some rare cases, it might not be possible to install or even compile one or more of the above modules. In such situations, you can use the `B333rnspure`b package instead, which require no external dependencies for installation. Please note that the contents of the `B333rns`b and `B333rnspure`b packages are *identical*. The only difference is that the `B333rnspure`b package lists no dependencies required for installation.
|
||||
|
||||
No matter how Reticulum is installed and started, it will load external dependencies only if they are *needed* and *available*. If for example you want to use Reticulum on a system that cannot support [pyserial](https://github.com/pyserial/pyserial), it is perfectly possible to do so using the `B333rnspure`b package, but Reticulum will not be able to use serial-based interfaces. All other available modules will still be loaded when needed.
|
||||
|
||||
**Please Note!** If you use the `B333rnspure`b package to run Reticulum on systems that do not support [PyCA/cryptography](https://github.com/pyca/cryptography), it is important that you read and understand the [Cryptographic Primitives](#cryptographic-primitives) section of this document.
|
||||
|
||||
>> Bootstrapping Connectivity
|
||||
|
||||
Reticulum is not a service you subscribe to, nor is it a single global network you "join". Reticulum provides functionality for discovering available public interfaces over the network itself, and the broader community has provided various directories of publicly available entrypoints to bootstrap connectivity.
|
||||
|
||||
To learn how to establish initial connectivity over Reticulum, read the [Bootstrapping Connectivity](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) section of the manual.
|
||||
|
||||
If you already have a general idea of how this works, you can use community-run sites such as [directory.rns.recipes](https://directory.rns.recipes/) and [rmap.world](https://rmap.world) to find interface definitions for initial connectivity to the global distributed Reticulum backbone.
|
||||
|
||||
>> Public Testnet
|
||||
***Important!** Historically, a developer-targeted testnet was made available by the Reticulum project itself. As the amount of global Reticulum nodes and entrypoints have grown to a substantial quantity, this public testnet, including the Amsterdam Testnet entrypoint, has now been decommissioned. If your still have instances that relied on this entrypoint for connectivity, transition to using the distributed backbone instead. Reticulum now includes a full on-network interface discovery and connectivity bootstrapping system. Read the [Bootstrapping Connectivity](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) section of the manual for pointers.*
|
||||
|
||||
>> Support Reticulum
|
||||
For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels:
|
||||
|
||||
• Monero:
|
||||
84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w
|
||||
|
||||
• Bitcoin
|
||||
bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6
|
||||
|
||||
• Ethereum
|
||||
0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E
|
||||
|
||||
• Liberapay: https://liberapay.com/Reticulum/
|
||||
|
||||
• 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.
|
||||
|
||||
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
|
||||
• A 256-bit X22519 key for ECDH key exchanges
|
||||
• 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
|
||||
• HMAC using SHA256 for message authentication
|
||||
• IVs must be generated through `B333os.urandom()`b 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 `B333X25519`b, `B333Ed25519`b, and `B333AES-256-CBC`b primitives are provided by [OpenSSL](https://www.openssl.org/) (via the [PyCA/cryptography](https://github.com/pyca/cryptography) package). The hashing functions `B333SHA-256`b and `B333SHA-512`b are provided by the standard Python [hashlib](https://docs.python.org/3/library/hashlib.html). The `B333HKDF`b, `B333HMAC`b, `B333Token`b primitives, and the `B333PKCS7`b padding function are always provided by the following internal implementations:
|
||||
|
||||
• [HKDF.py](RNS/Cryptography/HKDF.py)
|
||||
• [HMAC.py](RNS/Cryptography/HMAC.py)
|
||||
• [Token.py](RNS/Cryptography/Token.py)
|
||||
• [PKCS7.py](RNS/Cryptography/PKCS7.py)
|
||||
|
||||
Reticulum also includes a complete implementation of all necessary primitives in pure Python. If OpenSSL and PyCA are not available on the system when Reticulum is started, Reticulum will instead use the internal pure-python primitives. A trivial consequence of this is performance, with the OpenSSL backend being *much* faster. The most important consequence however, is the potential loss of security by using primitives that has not seen the same amount of scrutiny, testing and review as those from OpenSSL.
|
||||
|
||||
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 `B333rnspure`b package instead of the normal `B333rns`b 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.
|
||||
|
||||
Reticulum is relatively young software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy or security breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch.
|
||||
|
||||
>> Acknowledgements & Credits
|
||||
Reticulum can only exist because of the mountain of Open Source work it was built on top of, the contributions of everyone involved, and everyone that has supported the project through the years. To everyone who has helped, thank you so much.
|
||||
|
||||
A number of other modules and projects are either part of, or used by Reticulum. Sincere thanks to the authors and contributors of the following projects:
|
||||
|
||||
• [PyCA/cryptography](https://github.com/pyca/cryptography), *BSD License*
|
||||
• [Pure-25519](https://github.com/warner/python-pure25519) by [Brian Warner](https://github.com/warner), *MIT License*
|
||||
• [Pysha2](https://github.com/thomdixon/pysha2) by [Thom Dixon](https://github.com/thomdixon), *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*
|
||||
• [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)
|
||||
• [rnsh](https://github.com/acehoss/rnsh) by [Aaron Heise](https://github.com/acehoss)
|
||||
• [Python](https://www.python.org)
|
||||
+18
-6
@@ -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
|
||||
@@ -83,7 +92,9 @@ class StreamDataMessage(MessageBase):
|
||||
self.data = raw[2:]
|
||||
|
||||
if self.compressed:
|
||||
self.data = bz2.decompress(self.data)
|
||||
decompressor = bz2.BZ2Decompressor()
|
||||
self.data = decompressor.decompress(self.data, max_length=RawChannelWriter.MAX_CHUNK_LEN)
|
||||
if not decompressor.eof: raise IOError("Decompressed buffer chunk exceeds maximum legitimate size")
|
||||
|
||||
|
||||
class RawChannelReader(RawIOBase, AbstractContextManager):
|
||||
@@ -215,6 +226,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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2022 Mark Qvist / unsigned.io
|
||||
#
|
||||
# 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 time
|
||||
|
||||
from RNS.Cryptography import HMAC
|
||||
from RNS.Cryptography import PKCS7
|
||||
from RNS.Cryptography.AES import AES_128_CBC
|
||||
|
||||
class Fernet():
|
||||
"""
|
||||
This class provides a slightly modified implementation of the Fernet spec
|
||||
found at: https://github.com/fernet/spec/blob/master/Spec.md
|
||||
|
||||
According to the spec, a Fernet token includes a one byte VERSION and
|
||||
eight byte TIMESTAMP field at the start of each token. These fields are
|
||||
not relevant to Reticulum. They are therefore stripped from this
|
||||
implementation, since they incur overhead and leak initiator metadata.
|
||||
"""
|
||||
FERNET_OVERHEAD = 48 # Bytes
|
||||
|
||||
@staticmethod
|
||||
def generate_key():
|
||||
return os.urandom(32)
|
||||
|
||||
def __init__(self, key = None):
|
||||
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:]
|
||||
|
||||
|
||||
def verify_hmac(self, token):
|
||||
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
|
||||
|
||||
|
||||
def encrypt(self, data = None):
|
||||
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(
|
||||
plaintext = PKCS7.pad(data),
|
||||
key = self._encryption_key,
|
||||
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")
|
||||
|
||||
iv = token[:16]
|
||||
ciphertext = token[16:-32]
|
||||
|
||||
try:
|
||||
plaintext = PKCS7.unpad(
|
||||
AES_128_CBC.decrypt(
|
||||
ciphertext,
|
||||
self._encryption_key,
|
||||
iv,
|
||||
)
|
||||
)
|
||||
|
||||
return plaintext
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError("Could not decrypt token")
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(".")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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 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():
|
||||
"""
|
||||
This class provides a slightly modified implementation of the Fernet spec
|
||||
found at: https://github.com/fernet/spec/blob/master/Spec.md
|
||||
|
||||
According to the spec, a Fernet token includes a one byte VERSION and
|
||||
eight byte TIMESTAMP field at the start of each token. These fields are
|
||||
not relevant to Reticulum. They are therefore stripped from this
|
||||
implementation, since they incur overhead and leak initiator metadata.
|
||||
"""
|
||||
TOKEN_OVERHEAD = 48 # Bytes
|
||||
|
||||
@staticmethod
|
||||
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, mode=AES):
|
||||
if key == None: raise ValueError("Token key cannot be None")
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
def encrypt(self, data = None):
|
||||
if not isinstance(data, bytes): raise TypeError("Token plaintext input must be bytes")
|
||||
iv = os.urandom(16)
|
||||
|
||||
ciphertext = self.mode.encrypt(
|
||||
plaintext = PKCS7.pad(data),
|
||||
key = self._encryption_key,
|
||||
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")
|
||||
|
||||
iv = token[:16]
|
||||
ciphertext = token[16:-32]
|
||||
|
||||
try:
|
||||
return PKCS7.unpad(
|
||||
self.mode.decrypt(
|
||||
ciphertext = ciphertext,
|
||||
key = self._encryption_key,
|
||||
iv = iv))
|
||||
|
||||
except Exception as e: raise ValueError(f"Could not decrypt token: {e}")
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +35,7 @@ from .Hashes import sha256
|
||||
from .Hashes import sha512
|
||||
from .HKDF import hkdf
|
||||
from .PKCS7 import PKCS7
|
||||
from .Fernet import Fernet
|
||||
from .Token import Token
|
||||
from .Provider import backend
|
||||
|
||||
import RNS.Cryptography.Provider as cp
|
||||
@@ -20,5 +50,7 @@ elif cp.PROVIDER == cp.PROVIDER_PYCA:
|
||||
from RNS.Cryptography.Proxies import Ed25519PrivateKeyProxy as Ed25519PrivateKey
|
||||
from RNS.Cryptography.Proxies import Ed25519PublicKeyProxy as Ed25519PublicKey
|
||||
|
||||
modules = glob.glob(os.path.dirname(__file__)+"/*.py")
|
||||
__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
|
||||
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"))]))
|
||||
@@ -1 +1,2 @@
|
||||
from .aes import AES
|
||||
from .aes128 import AES128
|
||||
from .aes256 import AES256
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
+64
-46
@@ -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,
|
||||
@@ -26,7 +34,7 @@ import time
|
||||
import threading
|
||||
import RNS
|
||||
|
||||
from RNS.Cryptography import Fernet
|
||||
from RNS.Cryptography import Token
|
||||
from .vendor import umsgpack as umsgpack
|
||||
|
||||
class Callbacks:
|
||||
@@ -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)
|
||||
@@ -283,33 +295,26 @@ class Destination:
|
||||
app_data = returned_app_data
|
||||
|
||||
signed_data = self.hash+self.identity.get_public_key()+self.name_hash+random_hash+ratchet
|
||||
if app_data != None:
|
||||
signed_data += app_data
|
||||
if app_data != None: signed_data += app_data
|
||||
|
||||
signature = self.identity.sign(signed_data)
|
||||
announce_data = self.identity.get_public_key()+self.name_hash+random_hash+ratchet+signature
|
||||
|
||||
if app_data != None:
|
||||
announce_data += app_data
|
||||
if app_data != None: announce_data += app_data
|
||||
|
||||
self.path_responses[tag] = [time.time(), announce_data]
|
||||
|
||||
if path_response:
|
||||
announce_context = RNS.Packet.PATH_RESPONSE
|
||||
else:
|
||||
announce_context = RNS.Packet.NONE
|
||||
if path_response: announce_context = RNS.Packet.PATH_RESPONSE
|
||||
else: announce_context = RNS.Packet.NONE
|
||||
|
||||
if ratchet:
|
||||
context_flag = RNS.Packet.FLAG_SET
|
||||
else:
|
||||
context_flag = RNS.Packet.FLAG_UNSET
|
||||
if ratchet: context_flag = RNS.Packet.FLAG_SET
|
||||
else: context_flag = RNS.Packet.FLAG_UNSET
|
||||
|
||||
announce_packet = RNS.Packet(self, announce_data, RNS.Packet.ANNOUNCE, context = announce_context,
|
||||
attached_interface = attached_interface, context_flag=context_flag)
|
||||
if send:
|
||||
announce_packet.send()
|
||||
else:
|
||||
return announce_packet
|
||||
|
||||
if send: announce_packet.send()
|
||||
else: return announce_packet
|
||||
|
||||
def accepts_links(self, accepts = None):
|
||||
"""
|
||||
@@ -318,13 +323,10 @@ class Destination:
|
||||
:param accepts: If ``True`` or ``False``, this method sets whether the destination accepts incoming link requests. If not provided or ``None``, the method returns whether the destination currently accepts link requests.
|
||||
:returns: ``True`` or ``False`` depending on whether the destination accepts incoming link requests, if the *accepts* parameter is not provided or ``None``.
|
||||
"""
|
||||
if accepts == None:
|
||||
return self.accept_link_requests
|
||||
if accepts == None: return self.accept_link_requests
|
||||
|
||||
if accepts:
|
||||
self.accept_link_requests = True
|
||||
else:
|
||||
self.accept_link_requests = False
|
||||
if accepts: self.accept_link_requests = True
|
||||
else: self.accept_link_requests = False
|
||||
|
||||
def set_link_established_callback(self, callback):
|
||||
"""
|
||||
@@ -365,7 +367,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 +375,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,14 +407,16 @@ 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:
|
||||
self.callbacks.packet(plaintext, packet)
|
||||
try: self.callbacks.packet(plaintext, packet)
|
||||
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 +426,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 +435,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 +482,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
|
||||
|
||||
@@ -525,8 +543,8 @@ class Destination:
|
||||
raise TypeError("A single destination holds keys through an Identity instance")
|
||||
|
||||
if self.type == Destination.GROUP:
|
||||
self.prv_bytes = Fernet.generate_key()
|
||||
self.prv = Fernet(self.prv_bytes)
|
||||
self.prv_bytes = Token.generate_key()
|
||||
self.prv = Token(self.prv_bytes)
|
||||
|
||||
def get_private_key(self):
|
||||
"""
|
||||
@@ -556,7 +574,7 @@ class Destination:
|
||||
|
||||
if self.type == Destination.GROUP:
|
||||
self.prv_bytes = key
|
||||
self.prv = Fernet(self.prv_bytes)
|
||||
self.prv = Token(self.prv_bytes)
|
||||
|
||||
def load_public_key(self, key):
|
||||
if self.type != Destination.SINGLE:
|
||||
@@ -617,7 +635,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
|
||||
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
import os
|
||||
import re
|
||||
import RNS
|
||||
import time
|
||||
import random
|
||||
import threading
|
||||
import ipaddress
|
||||
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}
|
||||
|
||||
if interface_type == "TCPClientInterface" and not interface.kiss_framing:
|
||||
RNS.log(f"Invalid interface discovery configuration for {interface}, aborting discovery announce", RNS.LOG_ERROR)
|
||||
return None
|
||||
|
||||
if interface_type in ["BackboneInterface", "TCPServerInterface"]:
|
||||
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)
|
||||
if not (is_ip_address(reachable_on) or is_hostname(reachable_on)):
|
||||
raise ValueError(f"Valid IP address or hostname was not found in external script output \"{reachable_on}\"")
|
||||
|
||||
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 not (is_ip_address(reachable_on) or is_hostname(reachable_on)):
|
||||
RNS.log(f"The configured reachable_on parameter \"{reachable_on}\" for {interface} is not a valid IP address or hostname", RNS.LOG_ERROR)
|
||||
RNS.log(f"Aborting discovery announce", RNS.LOG_ERROR)
|
||||
return None
|
||||
|
||||
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 REACHABLE_ON in unpacked:
|
||||
if not (is_ip_address(unpacked[REACHABLE_ON]) or is_hostname(unpacked[REACHABLE_ON])):
|
||||
raise ValueError("Invalid data in reachable_on field of announce")
|
||||
|
||||
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_DEBUG)
|
||||
|
||||
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"]
|
||||
DISCOVERABLE_TYPES = ["BackboneInterface", "TCPServerInterface", "I2PInterface", "RNodeInterface", "WeaveInterface", "KISSInterface"]
|
||||
|
||||
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
|
||||
self.initial_autoconnect_ran = False
|
||||
|
||||
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, only_available=False, only_transport=False):
|
||||
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
|
||||
elif not "type" in info or ("type" in info and not info["type"] in self.DISCOVERABLE_TYPES): should_remove = True
|
||||
elif "reachable_on" in info:
|
||||
if not (is_ip_address(info["reachable_on"]) or is_hostname(info["reachable_on"])): 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"]]
|
||||
if not only_available and not only_transport: discovered_interfaces.append(info)
|
||||
else:
|
||||
should_append = True
|
||||
status = info["status"]
|
||||
transport = info["transport"]
|
||||
if only_available and status != "available": should_append = False
|
||||
if only_transport and not transport: should_append = False
|
||||
if should_append: discovered_interfaces.append(info)
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while loading discovered interface data: {e}", RNS.LOG_ERROR)
|
||||
RNS.log(f"The interface data file {os.path.join(self.storagepath, filename)} may be corrupt", RNS.LOG_ERROR)
|
||||
RNS.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"]
|
||||
discovered_type = info["type"]
|
||||
if not discovered_type in self.DISCOVERABLE_TYPES: return
|
||||
hops = info["hops"]; ms = "" if hops == 1 else "s"
|
||||
filename = RNS.hexrep(discovery_hash, delimit=False)
|
||||
filepath = os.path.join(self.storagepath, filename)
|
||||
RNS.log(f"Discovered {interface_type} {hops} hop{ms} away with stamp value {value}: {name}", RNS.LOG_DEBUG)
|
||||
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
|
||||
autoconnected_interfaces = self.autoconnect_count()
|
||||
for interface in self.monitored_interfaces:
|
||||
try:
|
||||
if interface.online:
|
||||
online_interfaces += 1
|
||||
if hasattr(interface, "autoconnect_down") and interface.autoconnect_down != None:
|
||||
RNS.log(f"Auto-discovered interface {interface} reconnected")
|
||||
interface.autoconnect_down = None
|
||||
|
||||
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)
|
||||
|
||||
max_autoconnected_interfaces = RNS.Reticulum.max_autoconnected_interfaces()
|
||||
free_slots = max(0, max_autoconnected_interfaces - autoconnected_interfaces)
|
||||
reserved_slots = max_autoconnected_interfaces//4
|
||||
|
||||
if online_interfaces >= 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"])
|
||||
|
||||
if self.initial_autoconnect_ran and free_slots > reserved_slots:
|
||||
candidate_interfaces = self.list_discovered_interfaces(only_available=True, only_transport=True)
|
||||
if len(candidate_interfaces) > 0:
|
||||
random.shuffle(candidate_interfaces)
|
||||
selected_interface = candidate_interfaces[0]
|
||||
if not self.interface_exists(selected_interface): self.autoconnect(selected_interface)
|
||||
|
||||
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(only_transport=True)
|
||||
for info in discovered_interfaces:
|
||||
if self.autoconnect_count() >= RNS.Reticulum.max_autoconnected_interfaces(): break
|
||||
self.autoconnect(info)
|
||||
|
||||
self.initial_autoconnect_ran = True
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while reconnecting discovered interfaces: {e}", RNS.LOG_ERROR)
|
||||
|
||||
def endpoint_hash(self, info):
|
||||
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"))
|
||||
return endpoint_hash
|
||||
|
||||
def interface_exists(self, info):
|
||||
exists = False
|
||||
for interface in RNS.Transport.interfaces:
|
||||
if hasattr(interface, "autoconnect_hash") and interface.autoconnect_hash == self.endpoint_hash(info):
|
||||
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
|
||||
|
||||
return exists
|
||||
|
||||
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_hash = self.endpoint_hash(info)
|
||||
exists = self.interface_exists(info)
|
||||
|
||||
if exists: RNS.log(f"Discovered {interface_type} already exists, not auto-connecting", RNS.LOG_DEBUG)
|
||||
else:
|
||||
if interface_type == "TCPClientInterface":
|
||||
RNS.log(f"Your operating system does not support the Backbone interface type, and must degrade to using TCPClientInterface instead", RNS.LOG_WARNING)
|
||||
RNS.log(f"Auto-connecting discovered TCPClient interfaces is not yet implemented, aborting auto-connect", RNS.LOG_WARNING)
|
||||
RNS.log(f"You can obtain the configuration entry and add this interface manually instead using rnstatus -D", RNS.LOG_WARNING)
|
||||
return
|
||||
|
||||
if interface_type == "I2PInterface":
|
||||
RNS.log(f"Auto-connecting discovered I2P interfaces is not yet implemented, aborting auto-connect", RNS.LOG_WARNING)
|
||||
RNS.log(f"You can obtain the configuration entry and add this interface manually instead using rnstatus -D", RNS.LOG_WARNING)
|
||||
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)
|
||||
|
||||
def is_ip_address(address_string):
|
||||
try:
|
||||
ipaddress.ip_address(address_string)
|
||||
return True
|
||||
except: return False
|
||||
|
||||
def is_hostname(hostname):
|
||||
if hostname[-1] == ".": hostname = hostname[:-1]
|
||||
if len(hostname) > 253: return False
|
||||
components = hostname.split(".")
|
||||
if re.match(r"[0-9]+$", components[-1]): return False
|
||||
allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(?<!-)$", re.IGNORECASE)
|
||||
return all(allowed.match(label) for label in components)
|
||||
+256
-111
@@ -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,
|
||||
@@ -31,7 +39,7 @@ import threading
|
||||
from .vendor import umsgpack as umsgpack
|
||||
|
||||
from RNS.Cryptography import X25519PrivateKey, X25519PublicKey, Ed25519PrivateKey, Ed25519PublicKey
|
||||
from RNS.Cryptography import Fernet
|
||||
from RNS.Cryptography import Token
|
||||
|
||||
|
||||
class Identity:
|
||||
@@ -66,58 +74,89 @@ class Identity:
|
||||
"""
|
||||
|
||||
# Non-configurable constants
|
||||
FERNET_OVERHEAD = RNS.Cryptography.Fernet.FERNET_OVERHEAD
|
||||
TOKEN_OVERHEAD = RNS.Cryptography.Token.TOKEN_OVERHEAD
|
||||
AES128_BLOCKSIZE = 16 # In bytes
|
||||
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 = {}
|
||||
|
||||
ratchet_persist_lock = threading.Lock()
|
||||
known_destinations_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def remember(packet_hash, destination_hash, public_key, app_data = None):
|
||||
if len(public_key) != Identity.KEYSIZE//8:
|
||||
raise TypeError("Can't remember "+RNS.prettyhexrep(destination_hash)+", the public key size of "+str(len(public_key))+" is not valid.", RNS.LOG_ERROR)
|
||||
else:
|
||||
Identity.known_destinations[destination_hash] = [time.time(), packet_hash, public_key, app_data]
|
||||
|
||||
with Identity.known_destinations_lock:
|
||||
if not destination_hash in Identity.known_destinations:
|
||||
Identity.known_destinations[destination_hash] = [time.time(), packet_hash, public_key, app_data, 0]
|
||||
else:
|
||||
entry = Identity.known_destinations[destination_hash]
|
||||
entry[0] = time.time()
|
||||
entry[1] = packet_hash
|
||||
entry[2] = public_key
|
||||
entry[3] = app_data
|
||||
|
||||
@staticmethod
|
||||
def recall(destination_hash):
|
||||
def recall(target_hash, from_identity_hash=False, _no_use=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]):
|
||||
if not _no_use: RNS.Reticulum.get_instance()._used_destination_data(destination_hash)
|
||||
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:
|
||||
if not _no_use: RNS.Reticulum.get_instance()._used_destination_data(target_hash)
|
||||
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):
|
||||
def recall_app_data(destination_hash, _no_use=False):
|
||||
"""
|
||||
Recall last heard app_data for a destination hash.
|
||||
|
||||
@@ -125,13 +164,14 @@ class Identity:
|
||||
:returns: *Bytes* containing app_data, or *None* if the destination is unknown.
|
||||
"""
|
||||
if destination_hash in Identity.known_destinations:
|
||||
if not _no_use: RNS.Reticulum.get_instance()._used_destination_data(destination_hash)
|
||||
app_data = Identity.known_destinations[destination_hash][3]
|
||||
return app_data
|
||||
else:
|
||||
return None
|
||||
|
||||
else: return None
|
||||
|
||||
@staticmethod
|
||||
def save_known_destinations():
|
||||
def save_known_destinations(background=False, recombine=True):
|
||||
# TODO: Improve the storage method so we don't have to
|
||||
# deserialize and serialize the entire table on every
|
||||
# save, but the only changes. It might be possible to
|
||||
@@ -152,34 +192,33 @@ class Identity:
|
||||
Identity.saving_known_destinations = True
|
||||
save_start = time.time()
|
||||
|
||||
storage_known_destinations = {}
|
||||
if os.path.isfile(RNS.Reticulum.storagepath+"/known_destinations"):
|
||||
if recombine:
|
||||
storage_known_destinations = {}
|
||||
if os.path.isfile(RNS.Reticulum.storagepath+"/known_destinations"):
|
||||
try:
|
||||
with open(RNS.Reticulum.storagepath+"/known_destinations","rb") as file:
|
||||
storage_known_destinations = umsgpack.load(file)
|
||||
|
||||
except: pass
|
||||
|
||||
try:
|
||||
file = open(RNS.Reticulum.storagepath+"/known_destinations","rb")
|
||||
storage_known_destinations = umsgpack.load(file)
|
||||
file.close()
|
||||
except:
|
||||
pass
|
||||
for destination_hash in storage_known_destinations:
|
||||
if not destination_hash in Identity.known_destinations:
|
||||
with Identity.known_destinations_lock:
|
||||
Identity.known_destinations[destination_hash] = storage_known_destinations[destination_hash]
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Skipped recombining known destinations from disk, since an error occurred: "+str(e), RNS.LOG_WARNING)
|
||||
|
||||
try:
|
||||
for destination_hash in storage_known_destinations:
|
||||
if not destination_hash in Identity.known_destinations:
|
||||
Identity.known_destinations[destination_hash] = storage_known_destinations[destination_hash]
|
||||
except Exception as e:
|
||||
RNS.log("Skipped recombining known destinations from disk, since an error occurred: "+str(e), RNS.LOG_WARNING)
|
||||
|
||||
RNS.log("Saving "+str(len(Identity.known_destinations))+" known destinations to storage...", RNS.LOG_DEBUG)
|
||||
file = open(RNS.Reticulum.storagepath+"/known_destinations","wb")
|
||||
umsgpack.dump(Identity.known_destinations, file)
|
||||
file.close()
|
||||
RNS.log("Saving "+str(len(Identity.known_destinations))+" known destinations to storage...", RNS.LOG_VERBOSE)
|
||||
with open(RNS.Reticulum.storagepath+"/known_destinations","wb") as file:
|
||||
umsgpack.dump(Identity.known_destinations.copy(), file)
|
||||
|
||||
save_time = time.time() - save_start
|
||||
if save_time < 1:
|
||||
time_str = str(round(save_time*1000,2))+"ms"
|
||||
else:
|
||||
time_str = str(round(save_time,2))+"s"
|
||||
if save_time < 1: time_str = str(round(save_time*1000,2))+"ms"
|
||||
else: time_str = str(round(save_time,2))+"s"
|
||||
|
||||
RNS.log("Saved known destinations to storage in "+time_str, RNS.LOG_DEBUG)
|
||||
RNS.log("Saved known destinations to storage in "+time_str, RNS.LOG_VERBOSE)
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Error while saving known destinations to disk, the contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
@@ -190,23 +229,110 @@ class Identity:
|
||||
@staticmethod
|
||||
def load_known_destinations():
|
||||
if os.path.isfile(RNS.Reticulum.storagepath+"/known_destinations"):
|
||||
st = time.time()
|
||||
try:
|
||||
file = open(RNS.Reticulum.storagepath+"/known_destinations","rb")
|
||||
loaded_known_destinations = umsgpack.load(file)
|
||||
file.close()
|
||||
with open(RNS.Reticulum.storagepath+"/known_destinations","rb") as file:
|
||||
loaded_known_destinations = umsgpack.load(file)
|
||||
|
||||
Identity.known_destinations = {}
|
||||
for known_destination in loaded_known_destinations:
|
||||
if len(known_destination) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8:
|
||||
Identity.known_destinations[known_destination] = loaded_known_destinations[known_destination]
|
||||
if len(loaded_known_destinations[known_destination]) < 5:
|
||||
e = loaded_known_destinations[known_destination]
|
||||
loaded_known_destinations[known_destination] = [e[0], e[1], e[2], e[3], 0]
|
||||
|
||||
RNS.log("Loaded "+str(len(Identity.known_destinations))+" known destination from storage", RNS.LOG_VERBOSE)
|
||||
with Identity.known_destinations_lock:
|
||||
Identity.known_destinations[known_destination] = loaded_known_destinations[known_destination]
|
||||
|
||||
RNS.log(f"Loaded {len(Identity.known_destinations)} known destination from storage in {RNS.prettyshorttime(time.time()-st)}", RNS.LOG_VERBOSE)
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Error loading known destinations from disk, file will be recreated on exit", RNS.LOG_ERROR)
|
||||
RNS.trace_exception(e)
|
||||
else:
|
||||
RNS.log("Destinations file does not exist, no known destinations loaded", RNS.LOG_VERBOSE)
|
||||
|
||||
@staticmethod
|
||||
def _used_destination_data(destination_hash):
|
||||
with Identity.known_destinations_lock:
|
||||
if destination_hash in Identity.known_destinations:
|
||||
if not Identity.known_destinations[destination_hash][4] < 0:
|
||||
Identity.known_destinations[destination_hash][4] = time.time()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _retain_destination_data(destination_hash):
|
||||
with Identity.known_destinations_lock:
|
||||
if destination_hash in Identity.known_destinations:
|
||||
Identity.known_destinations[destination_hash][4] = -1
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _unretain_destination_data(destination_hash):
|
||||
with Identity.known_destinations_lock:
|
||||
if destination_hash in Identity.known_destinations:
|
||||
Identity.known_destinations[destination_hash][4] = time.time()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def clean_known_destinations():
|
||||
now = time.time()
|
||||
st = now
|
||||
total = len(Identity.known_destinations)
|
||||
stale = []
|
||||
no_path = 0
|
||||
retained = 0
|
||||
never_used = 0
|
||||
for destination_hash in Identity.known_destinations:
|
||||
try:
|
||||
if RNS.Transport.has_path(destination_hash): has_path = True
|
||||
else:
|
||||
has_path = False
|
||||
no_path += 1
|
||||
|
||||
with Identity.known_destinations_lock:
|
||||
if destination_hash in Identity.known_destinations:
|
||||
last_announce = Identity.known_destinations[destination_hash][0]
|
||||
last_use = 0
|
||||
was_used = False
|
||||
is_retained = False
|
||||
|
||||
if Identity.known_destinations[destination_hash][4] > 0:
|
||||
was_used = True
|
||||
last_use = Identity.known_destinations[destination_hash][4]
|
||||
|
||||
elif Identity.known_destinations[destination_hash][4] == 0:
|
||||
was_used = False
|
||||
never_used += 1
|
||||
|
||||
elif Identity.known_destinations[destination_hash][4] == -1:
|
||||
is_retained = True
|
||||
retained += 1
|
||||
|
||||
unused_for = time.time() - Identity.known_destinations[destination_hash][4]
|
||||
|
||||
if not is_retained and not has_path:
|
||||
if not was_used and now - last_announce > RNS.Transport.UNUSED_DESTINATION_LINGER: stale.append(destination_hash)
|
||||
elif unused_for > RNS.Transport.DESTINATION_TIMEOUT*1.25: stale.append(destination_hash)
|
||||
|
||||
except Exception as e: RNS.log(f"Faulty entry for {RNS.prettyhexrep(destination_hash)} while cleaning known destinations: {e}", RNS.LOG_DEBUG)
|
||||
|
||||
removed = 0
|
||||
for destination_hash in stale:
|
||||
with Identity.known_destinations_lock:
|
||||
if destination_hash in Identity.known_destinations:
|
||||
Identity.known_destinations.pop(destination_hash)
|
||||
removed += 1
|
||||
|
||||
# RNS.log(f"Total destinations: {total}, stale: {len(stale)}, removed: {removed}, no path: {no_path}, never used: {never_used}, with path: {total-no_path}, used: {total-never_used}, retained: {retained}. Completed in {RNS.prettyshorttime(time.time()-st)}", RNS.LOG_WARNING) # TODO: Remove
|
||||
if not RNS.Transport.owner.is_connected_to_shared_instance: Identity.save_known_destinations(recombine=False)
|
||||
|
||||
@staticmethod
|
||||
def full_hash(data):
|
||||
"""
|
||||
@@ -267,31 +393,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}"
|
||||
ratchet_file = open(outpath, "wb")
|
||||
ratchet_file.write(umsgpack.packb(ratchet_data))
|
||||
ratchet_file.close()
|
||||
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)
|
||||
@@ -302,26 +431,40 @@ class Identity:
|
||||
def _clean_ratchets():
|
||||
RNS.log("Cleaning ratchets...", RNS.LOG_DEBUG)
|
||||
try:
|
||||
count = 0
|
||||
removed = 0
|
||||
not_known = 0
|
||||
now = time.time()
|
||||
ratchetdir = RNS.Reticulum.storagepath+"/ratchets"
|
||||
if os.path.isdir(ratchetdir):
|
||||
for filename in os.listdir(ratchetdir):
|
||||
count += 1
|
||||
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
|
||||
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
|
||||
|
||||
destination_hash = bytes.fromhex(filename)
|
||||
if not destination_hash in RNS.Identity.known_destinations: unknown = True; not_known += 1
|
||||
else: unknown = False
|
||||
|
||||
if expired or corrupted or unknown:
|
||||
os.unlink(f"{ratchetdir}/{filename}")
|
||||
removed += 1
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"An error occurred while cleaning ratchets, in the processing of {ratchetdir}/{filename}.", RNS.LOG_ERROR)
|
||||
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"An error occurred while cleaning ratchets. The contained exception was: {e}", RNS.LOG_ERROR)
|
||||
except Exception as e: RNS.log(f"An error occurred while cleaning ratchets. The contained exception was: {e}", RNS.LOG_ERROR)
|
||||
RNS.log(f"Processed {count} ratchets in {RNS.prettytime(time.time()-now)}, not in use {not_known}, removed {removed}", RNS.LOG_DEBUG)
|
||||
|
||||
@staticmethod
|
||||
def get_ratchet(destination_hash):
|
||||
@@ -331,12 +474,12 @@ class Identity:
|
||||
ratchet_path = f"{ratchetdir}/{hexhash}"
|
||||
if os.path.isfile(ratchet_path):
|
||||
try:
|
||||
ratchet_file = open(ratchet_path, "rb")
|
||||
ratchet_data = umsgpack.unpackb(ratchet_file.read())
|
||||
if time.time() < ratchet_data["received"]+Identity.RATCHET_EXPIRY and len(ratchet_data["ratchet"]) == Identity.RATCHETSIZE//8:
|
||||
Identity.known_ratchets[destination_hash] = ratchet_data["ratchet"]
|
||||
else:
|
||||
return None
|
||||
with open(ratchet_path, "rb") as ratchet_file:
|
||||
ratchet_data = umsgpack.unpackb(ratchet_file.read())
|
||||
if time.time() < ratchet_data["received"]+Identity.RATCHET_EXPIRY and len(ratchet_data["ratchet"]) == Identity.RATCHETSIZE//8:
|
||||
Identity.known_ratchets[destination_hash] = ratchet_data["ratchet"]
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"An error occurred while loading ratchet data for {RNS.prettyhexrep(destination_hash)} from storage.", RNS.LOG_ERROR)
|
||||
@@ -392,6 +535,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
|
||||
@@ -450,9 +598,9 @@ class Identity:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def persist_data():
|
||||
def persist_data(background=False):
|
||||
if not RNS.Transport.owner.is_connected_to_shared_instance:
|
||||
Identity.save_known_destinations()
|
||||
Identity.save_known_destinations(background=background)
|
||||
|
||||
@staticmethod
|
||||
def exit_handler():
|
||||
@@ -642,20 +790,30 @@ 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(),
|
||||
)
|
||||
|
||||
fernet = Fernet(derived_key)
|
||||
ciphertext = fernet.encrypt(plaintext)
|
||||
token = Token(derived_key)
|
||||
ciphertext = token.encrypt(plaintext)
|
||||
token = ephemeral_pub_bytes+ciphertext
|
||||
|
||||
return token
|
||||
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):
|
||||
"""
|
||||
@@ -665,6 +823,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
|
||||
@@ -679,15 +838,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(),
|
||||
)
|
||||
|
||||
fernet = Fernet(derived_key)
|
||||
plaintext = fernet.decrypt(ciphertext)
|
||||
plaintext = self.__decrypt(shared_key, ciphertext)
|
||||
if ratchet_id_receiver:
|
||||
ratchet_id_receiver.latest_ratchet_id = ratchet_id
|
||||
|
||||
@@ -704,15 +855,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)
|
||||
|
||||
fernet = Fernet(derived_key)
|
||||
plaintext = fernet.decrypt(ciphertext)
|
||||
if ratchet_id_receiver:
|
||||
ratchet_id_receiver.latest_ratchet_id = None
|
||||
|
||||
@@ -721,7 +865,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
|
||||
|
||||
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from time import sleep
|
||||
import sys
|
||||
import threading
|
||||
@@ -59,6 +67,7 @@ class AX25():
|
||||
class AX25KISSInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
BITRATE_GUESS = 1200
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
owner = None
|
||||
port = None
|
||||
@@ -68,8 +77,8 @@ class AX25KISSInterface(Interface):
|
||||
stopbits = None
|
||||
serial = None
|
||||
|
||||
def __init__(self, owner, name, callsign, ssid, port, speed, databits, parity, stopbits, preamble, txtail, persistence, slottime, flow_control):
|
||||
import importlib
|
||||
def __init__(self, owner, configuration):
|
||||
import importlib.util
|
||||
if importlib.util.find_spec('serial') != None:
|
||||
import serial
|
||||
else:
|
||||
@@ -79,6 +88,25 @@ class AX25KISSInterface(Interface):
|
||||
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
preamble = int(c["preamble"]) if "preamble" in c else None
|
||||
txtail = int(c["txtail"]) if "txtail" in c else None
|
||||
persistence = int(c["persistence"]) if "persistence" in c else None
|
||||
slottime = int(c["slottime"]) if "slottime" in c else None
|
||||
flow_control = c.as_bool("flow_control") if "flow_control" in c else False
|
||||
port = c["port"] if "port" in c else None
|
||||
speed = int(c["speed"]) if "speed" in c else 9600
|
||||
databits = int(c["databits"]) if "databits" in c else 8
|
||||
parity = c["parity"] if "parity" in c else "N"
|
||||
stopbits = int(c["stopbits"]) if "stopbits" in c else 1
|
||||
|
||||
callsign = c["callsign"] if "callsign" in c else ""
|
||||
ssid = int(c["ssid"]) if "ssid" in c else -1
|
||||
|
||||
if port == None:
|
||||
raise ValueError("No port specified for serial interface")
|
||||
|
||||
self.HW_MTU = 564
|
||||
|
||||
self.pyserial = serial
|
||||
@@ -225,13 +253,13 @@ class AX25KISSInterface(Interface):
|
||||
raise IOError("Could not enable AX.25 KISS interface flow control")
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
if (len(data) > AX25.HEADER_SIZE):
|
||||
self.rxb += len(data)
|
||||
self.owner.inbound(data[AX25.HEADER_SIZE:], self)
|
||||
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
datalen = len(data)
|
||||
if self.online:
|
||||
if self.interface_ready:
|
||||
@@ -281,7 +309,7 @@ class AX25KISSInterface(Interface):
|
||||
if len(self.packet_queue) > 0:
|
||||
data = self.packet_queue.pop(0)
|
||||
self.interface_ready = True
|
||||
self.processOutgoing(data)
|
||||
self.process_outgoing(data)
|
||||
elif len(self.packet_queue) == 0:
|
||||
self.interface_ready = True
|
||||
|
||||
@@ -300,7 +328,7 @@ class AX25KISSInterface(Interface):
|
||||
|
||||
if (in_frame and byte == KISS.FEND and command == KISS.CMD_DATA):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == KISS.FEND):
|
||||
in_frame = True
|
||||
command = KISS.CMD_UNKNOWN
|
||||
|
||||
@@ -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,
|
||||
@@ -52,6 +60,7 @@ class KISS():
|
||||
class KISSInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
BITRATE_GUESS = 1200
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
owner = None
|
||||
port = None
|
||||
@@ -61,8 +70,8 @@ class KISSInterface(Interface):
|
||||
stopbits = None
|
||||
serial = None
|
||||
|
||||
def __init__(self, owner, name, port, speed, databits, parity, stopbits, preamble, txtail, persistence, slottime, flow_control, beacon_interval, beacon_data):
|
||||
import importlib
|
||||
def __init__(self, owner, configuration):
|
||||
import importlib.util
|
||||
if RNS.vendor.platformutils.is_android():
|
||||
self.on_android = True
|
||||
if importlib.util.find_spec('usbserial4a') != None:
|
||||
@@ -83,6 +92,21 @@ class KISSInterface(Interface):
|
||||
raise SystemError("Android-specific interface was used on non-Android OS")
|
||||
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
preamble = int(c["preamble"]) if "preamble" in c else None
|
||||
txtail = int(c["txtail"]) if "txtail" in c else None
|
||||
persistence = int(c["persistence"]) if "persistence" in c else None
|
||||
slottime = int(c["slottime"]) if "slottime" in c else None
|
||||
flow_control = c.as_bool("flow_control") if "flow_control" in c else False
|
||||
port = c["port"] if "port" in c else None
|
||||
speed = int(c["speed"]) if "speed" in c else 9600
|
||||
databits = int(c["databits"]) if "databits" in c else 8
|
||||
parity = c["parity"] if "parity" in c else "N"
|
||||
stopbits = int(c["stopbits"]) if "stopbits" in c else 1
|
||||
beacon_interval = int(c["beacon_interval"]) if "beacon_interval" in c and c["beacon_interval"] != None else None
|
||||
beacon_data = c["beacon_data"] if "beacon_data" in c else None
|
||||
|
||||
self.HW_MTU = 564
|
||||
|
||||
@@ -267,13 +291,13 @@ class KISSInterface(Interface):
|
||||
raise IOError("Could not enable KISS interface flow control")
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
def af():
|
||||
self.owner.inbound(data, self)
|
||||
threading.Thread(target=af, daemon=True).start()
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
datalen = len(data)
|
||||
if self.online:
|
||||
if self.interface_ready:
|
||||
@@ -307,7 +331,7 @@ class KISSInterface(Interface):
|
||||
if len(self.packet_queue) > 0:
|
||||
data = self.packet_queue.pop(0)
|
||||
self.interface_ready = True
|
||||
self.processOutgoing(data)
|
||||
self.process_outgoing(data)
|
||||
elif len(self.packet_queue) == 0:
|
||||
self.interface_ready = True
|
||||
|
||||
@@ -328,7 +352,7 @@ class KISSInterface(Interface):
|
||||
|
||||
if (in_frame and byte == KISS.FEND and command == KISS.CMD_DATA):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == KISS.FEND):
|
||||
in_frame = True
|
||||
command = KISS.CMD_UNKNOWN
|
||||
@@ -373,7 +397,13 @@ class KISSInterface(Interface):
|
||||
if time.time() > self.first_tx + self.beacon_i:
|
||||
RNS.log("Interface "+str(self)+" is transmitting beacon data: "+str(self.beacon_d.decode("utf-8")), RNS.LOG_DEBUG)
|
||||
self.first_tx = None
|
||||
self.processOutgoing(self.beacon_d)
|
||||
|
||||
# Pad to minimum length
|
||||
frame = bytearray(self.beacon_d)
|
||||
while len(frame) < 15:
|
||||
frame.append(0x00)
|
||||
|
||||
self.process_outgoing(bytes(frame))
|
||||
|
||||
except Exception as e:
|
||||
self.online = False
|
||||
|
||||
@@ -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,10 +73,13 @@ 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
|
||||
CMD_FB_READ = 0x42
|
||||
CMD_DISP_READ = 0x66
|
||||
CMD_FB_WRITE = 0x43
|
||||
CMD_BT_CTRL = 0x46
|
||||
CMD_PLATFORM = 0x48
|
||||
@@ -83,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
|
||||
@@ -238,9 +251,10 @@ class AndroidBluetoothManager():
|
||||
|
||||
class RNodeInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
FREQ_MIN = 137000000
|
||||
FREQ_MAX = 1020000000
|
||||
FREQ_MAX = 3000000000
|
||||
|
||||
RSSI_OFFSET = 157
|
||||
|
||||
@@ -261,6 +275,8 @@ class RNodeInterface(Interface):
|
||||
BATTERY_STATE_CHARGING = 0x02
|
||||
BATTERY_STATE_CHARGED = 0x03
|
||||
|
||||
DISPLAY_READ_INTERVAL = 1.0
|
||||
|
||||
@classmethod
|
||||
def bluetooth_control(device_serial = None, port = None, enable_bluetooth = False, disable_bluetooth = False, pairing_mode = False):
|
||||
if (port != None or device_serial != None) and (enable_bluetooth or disable_bluetooth or pairing_mode):
|
||||
@@ -341,13 +357,30 @@ class RNodeInterface(Interface):
|
||||
serial.close()
|
||||
|
||||
|
||||
def __init__(
|
||||
self, owner, name, port, frequency = None, bandwidth = None, txpower = None,
|
||||
sf = None, cr = None, flow_control = False, id_interval = None,
|
||||
allow_bluetooth = False, target_device_name = None,
|
||||
target_device_address = None, id_callsign = None, st_alock = None, lt_alock = None,
|
||||
ble_addr = None, ble_name = None, force_ble=False):
|
||||
import importlib
|
||||
def __init__(self, owner, configuration):
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
allow_bluetooth = c.as_bool("allow_bluetooth") if "allow_bluetooth" in c else False
|
||||
target_device_name = c["target_device_name"] if "target_device_name" in c else None
|
||||
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
|
||||
sf = int(c["spreadingfactor"]) if "spreadingfactor" in c else 0
|
||||
cr = int(c["codingrate"]) if "codingrate" in c else 0
|
||||
flow_control = c.as_bool("flow_control") if "flow_control" in c else False
|
||||
id_interval = int(c["id_interval"]) if "id_interval" in c and c["id_interval"] != None else None
|
||||
id_callsign = c["id_callsign"] if "id_callsign" in c else None
|
||||
st_alock = float(c["airtime_limit_short"]) if "airtime_limit_short" in c and c["airtime_limit_short"] != None else None
|
||||
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.util
|
||||
if RNS.vendor.platformutils.is_android():
|
||||
self.on_android = True
|
||||
if importlib.util.find_spec('usbserial4a') != None:
|
||||
@@ -406,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
|
||||
@@ -415,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
|
||||
@@ -427,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
|
||||
@@ -446,12 +489,32 @@ 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""
|
||||
self.r_framebuffer_readtime = 0
|
||||
self.r_framebuffer_latency = 0
|
||||
self.r_disp = b""
|
||||
self.r_disp_readtime = 0
|
||||
self.r_disp_latency = 0
|
||||
|
||||
self.should_read_display = False
|
||||
self.read_display_interval = RNodeInterface.DISPLAY_READ_INTERVAL
|
||||
|
||||
self.packet_queue = []
|
||||
self.flow_control = flow_control
|
||||
@@ -461,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
|
||||
|
||||
@@ -512,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()
|
||||
@@ -533,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:
|
||||
@@ -553,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
|
||||
@@ -631,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
|
||||
@@ -640,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()
|
||||
@@ -647,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")
|
||||
@@ -670,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()
|
||||
@@ -687,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()
|
||||
@@ -793,6 +882,33 @@ class RNodeInterface(Interface):
|
||||
if written != len(kiss_command):
|
||||
raise IOError("An IO error occurred while writing framebuffer data device")
|
||||
|
||||
def read_framebuffer(self):
|
||||
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_FB_READ])+bytes([0x01])+bytes([KISS.FEND])
|
||||
written = self.serial.write(kiss_command)
|
||||
self.r_framebuffer_readtime = time.time()
|
||||
if written != len(kiss_command):
|
||||
raise IOError("An IO error occurred while sending framebuffer read command")
|
||||
|
||||
def read_display(self):
|
||||
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_DISP_READ])+bytes([0x01])+bytes([KISS.FEND])
|
||||
written = self.serial.write(kiss_command)
|
||||
self.r_disp_readtime = time.time()
|
||||
if written != len(kiss_command):
|
||||
raise IOError("An IO error occurred while sending display read command")
|
||||
|
||||
def _read_display_job(self):
|
||||
while self.should_read_display:
|
||||
self.read_display()
|
||||
time.sleep(self.read_display_interval)
|
||||
|
||||
def start_display_updates(self):
|
||||
if not self.should_read_display:
|
||||
self.should_read_display = True
|
||||
threading.Thread(target=self._read_display_job, daemon=True).start()
|
||||
|
||||
def stop_display_updates(self):
|
||||
self.should_read_display = False
|
||||
|
||||
def hard_reset(self):
|
||||
kiss_command = bytes([KISS.FEND, KISS.CMD_RESET, 0xf8, KISS.FEND])
|
||||
written = self.write_mux(kiss_command)
|
||||
@@ -898,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):
|
||||
@@ -941,7 +1055,7 @@ class RNodeInterface(Interface):
|
||||
except:
|
||||
self.bitrate = 0
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
|
||||
def af():
|
||||
@@ -949,7 +1063,7 @@ class RNodeInterface(Interface):
|
||||
threading.Thread(target=af, daemon=True).start()
|
||||
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
datalen = len(data)
|
||||
if self.online:
|
||||
if self.interface_ready:
|
||||
@@ -980,7 +1094,7 @@ class RNodeInterface(Interface):
|
||||
if len(self.packet_queue) > 0:
|
||||
data = self.packet_queue.pop(0)
|
||||
self.interface_ready = True
|
||||
self.processOutgoing(data)
|
||||
self.process_outgoing(data)
|
||||
elif len(self.packet_queue) == 0:
|
||||
self.interface_ready = True
|
||||
|
||||
@@ -1007,7 +1121,7 @@ class RNodeInterface(Interface):
|
||||
|
||||
if (in_frame and byte == KISS.FEND and command == KISS.CMD_DATA):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
data_buffer = b""
|
||||
command_buffer = b""
|
||||
elif (byte == KISS.FEND):
|
||||
@@ -1183,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
|
||||
@@ -1204,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
|
||||
@@ -1239,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):
|
||||
@@ -1269,6 +1443,37 @@ class RNodeInterface(Interface):
|
||||
raise IOError("ESP32 reset")
|
||||
elif (command == KISS.CMD_READY):
|
||||
self.process_queue()
|
||||
|
||||
elif (command == KISS.CMD_FB_READ):
|
||||
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) == 512):
|
||||
self.r_framebuffer_latency = time.time() - self.r_framebuffer_readtime
|
||||
self.r_framebuffer = command_buffer
|
||||
|
||||
elif (command == KISS.CMD_DISP_READ):
|
||||
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) == 1024):
|
||||
self.r_disp_latency = time.time() - self.r_disp_readtime
|
||||
self.r_disp = command_buffer
|
||||
|
||||
elif (command == KISS.CMD_DETECT):
|
||||
if byte == KISS.DETECT_RESP:
|
||||
self.detected = True
|
||||
@@ -1278,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
|
||||
@@ -1288,20 +1493,20 @@ class RNodeInterface(Interface):
|
||||
if self.first_tx != None:
|
||||
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.processOutgoing(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")
|
||||
self.process_outgoing(self.id_callsign)
|
||||
|
||||
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:
|
||||
@@ -1359,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
|
||||
@@ -1385,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
|
||||
@@ -1399,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"
|
||||
@@ -1411,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):
|
||||
@@ -1512,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)
|
||||
@@ -1530,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():
|
||||
@@ -1548,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
|
||||
|
||||
@@ -1563,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:
|
||||
@@ -1579,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:
|
||||
@@ -1586,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:
|
||||
@@ -1632,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)
|
||||
@@ -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,
|
||||
@@ -42,6 +50,7 @@ class HDLC():
|
||||
|
||||
class SerialInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
owner = None
|
||||
port = None
|
||||
@@ -51,8 +60,8 @@ class SerialInterface(Interface):
|
||||
stopbits = None
|
||||
serial = None
|
||||
|
||||
def __init__(self, owner, name, port, speed, databits, parity, stopbits):
|
||||
import importlib
|
||||
def __init__(self, owner, configuration):
|
||||
import importlib.util
|
||||
if RNS.vendor.platformutils.is_android():
|
||||
self.on_android = True
|
||||
if importlib.util.find_spec('usbserial4a') != None:
|
||||
@@ -74,6 +83,17 @@ class SerialInterface(Interface):
|
||||
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
port = c["port"] if "port" in c else None
|
||||
speed = int(c["speed"]) if "speed" in c else 9600
|
||||
databits = int(c["databits"]) if "databits" in c else 8
|
||||
parity = c["parity"] if "parity" in c else "N"
|
||||
stopbits = int(c["stopbits"]) if "stopbits" in c else 1
|
||||
|
||||
if port == None:
|
||||
raise ValueError("No port specified for serial interface")
|
||||
|
||||
self.HW_MTU = 564
|
||||
|
||||
self.pyserial = serial
|
||||
@@ -172,13 +192,13 @@ class SerialInterface(Interface):
|
||||
RNS.log("Serial port "+self.port+" is now open", RNS.LOG_VERBOSE)
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
def af():
|
||||
self.owner.inbound(data, self)
|
||||
threading.Thread(target=af, daemon=True).start()
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
if self.online:
|
||||
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
|
||||
written = self.serial.write(data)
|
||||
@@ -202,7 +222,7 @@ class SerialInterface(Interface):
|
||||
|
||||
if (in_frame and byte == HDLC.FLAG):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == HDLC.FLAG):
|
||||
in_frame = True
|
||||
data_buffer = b""
|
||||
|
||||
@@ -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,5 +31,7 @@
|
||||
import os
|
||||
import glob
|
||||
|
||||
modules = glob.glob(os.path.dirname(__file__)+"/*.py")
|
||||
__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
|
||||
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"))]))
|
||||
+289
-108
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from collections import deque
|
||||
import socketserver
|
||||
import threading
|
||||
@@ -33,9 +41,13 @@ 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")
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
SCOPE_LINK = "2"
|
||||
SCOPE_ADMIN = "4"
|
||||
@@ -46,11 +58,14 @@ 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"]
|
||||
ANDROID_IGNORE_IFS = ["dummy0", "lo", "tun0"]
|
||||
ANDROID_IGNORE_IFS = ["dummy0", "lo", "tun0", "rmnet0", "rmnet1", "rmnet2", "rmnet3", "rmnet4", "rmnet5", "rmnet6", "rmnet7"]
|
||||
|
||||
BITRATE_GUESS = 10*1000*1000
|
||||
|
||||
@@ -78,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():
|
||||
@@ -86,39 +100,55 @@ class AutoInterface(Interface):
|
||||
|
||||
return socket.if_nametoindex(ifname)
|
||||
|
||||
def __init__(self, owner, name, group_id=None, discovery_scope=None, discovery_port=None, multicast_address_type=None, data_port=None, allowed_interfaces=None, ignored_interfaces=None, configured_bitrate=None):
|
||||
from RNS.vendor.ifaddr import niwrapper
|
||||
def __init__(self, owner, configuration):
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
group_id = c["group_id"] if "group_id" in c else None
|
||||
discovery_scope = c["discovery_scope"] if "discovery_scope" in c else None
|
||||
discovery_port = int(c["discovery_port"]) if "discovery_port" in c else None
|
||||
multicast_address_type = c["multicast_address_type"] if "multicast_address_type" in c else None
|
||||
data_port = int(c["data_port"]) if "data_port" in c else None
|
||||
allowed_interfaces = c.as_list("devices") if "devices" in c else None
|
||||
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.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 = []
|
||||
@@ -140,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":
|
||||
@@ -215,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:
|
||||
@@ -249,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
|
||||
|
||||
@@ -276,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.processIncoming))
|
||||
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:
|
||||
@@ -335,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:
|
||||
@@ -369,7 +434,7 @@ class AutoInterface(Interface):
|
||||
|
||||
RNS.log("Starting new UDP listener for "+str(self)+" "+str(ifname), RNS.LOG_DEBUG)
|
||||
|
||||
udp_server = socketserver.UDPServer(listen_address, self.handler_factory(self.processIncoming))
|
||||
udp_server = socketserver.UDPServer(listen_address, self.handler_factory(self.process_incoming))
|
||||
self.interface_servers[ifname] = udp_server
|
||||
|
||||
thread = threading.Thread(target=udp_server.serve_forever)
|
||||
@@ -382,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:
|
||||
@@ -396,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):
|
||||
@@ -403,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]
|
||||
@@ -421,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
|
||||
@@ -430,58 +519,149 @@ 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.ingress_control = self.ingress_control
|
||||
spawned_interface.ic_max_held_announces = self.ic_max_held_announces
|
||||
spawned_interface.ic_burst_hold = self.ic_burst_hold
|
||||
spawned_interface.ic_burst_freq = self.ic_burst_freq
|
||||
spawned_interface.ic_burst_freq_new = self.ic_burst_freq_new
|
||||
spawned_interface.ic_new_time = self.ic_new_time
|
||||
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
|
||||
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
|
||||
|
||||
spawned_interface.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 processIncoming(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 processOutgoing(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):
|
||||
@@ -490,4 +670,5 @@ class AutoInterfaceHandler(socketserver.BaseRequestHandler):
|
||||
|
||||
def handle(self):
|
||||
data = self.request[0]
|
||||
self.callback(data)
|
||||
addr = self.client_address[0]
|
||||
self.callback(data, addr)
|
||||
@@ -0,0 +1,711 @@
|
||||
# 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(fileno, select.EPOLLOUT)
|
||||
except Exception as e:
|
||||
RNS.log(f"Error occurred on {interface} while modifying socket EPOLL state: {e}", RNS.LOG_WARNING)
|
||||
raise 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:]
|
||||
try:
|
||||
if len(spawned_interface.transmit_buffer) == 0: BackboneInterface.epoll.modify(fileno, select.EPOLLIN)
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while setting EPOLLIN on {spawned_interface}: {e}", RNS.LOG_ERROR)
|
||||
|
||||
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.ingress_control = self.ingress_control
|
||||
spawned_interface.ic_max_held_announces = self.ic_max_held_announces
|
||||
spawned_interface.ic_burst_hold = self.ic_burst_hold
|
||||
spawned_interface.ic_burst_freq = self.ic_burst_freq
|
||||
spawned_interface.ic_burst_freq_new = self.ic_burst_freq_new
|
||||
spawned_interface.ic_new_time = self.ic_new_time
|
||||
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
|
||||
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
|
||||
|
||||
spawned_interface.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)+"]"
|
||||
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
import socketserver
|
||||
import threading
|
||||
import platform
|
||||
@@ -627,14 +635,14 @@ class I2PInterfacePeer(Interface):
|
||||
RNS.log("Attempt to reconnect on a non-initiator I2P interface. This should not happen.", RNS.LOG_ERROR)
|
||||
raise IOError("Attempt to reconnect on a non-initiator I2P interface")
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
if hasattr(self, "parent_interface") and self.parent_interface != None and self.parent_count:
|
||||
self.parent_interface.rxb += len(data)
|
||||
|
||||
self.owner.inbound(data, self)
|
||||
|
||||
def processOutgoing(self, data):
|
||||
def process_outgoing(self, data):
|
||||
if self.online:
|
||||
while self.writing:
|
||||
time.sleep(0.001)
|
||||
@@ -732,7 +740,7 @@ class I2PInterfacePeer(Interface):
|
||||
# Read loop for KISS framing
|
||||
if (in_frame and byte == KISS.FEND and command == KISS.CMD_DATA):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == KISS.FEND):
|
||||
in_frame = True
|
||||
command = KISS.CMD_UNKNOWN
|
||||
@@ -759,7 +767,7 @@ class I2PInterfacePeer(Interface):
|
||||
# Read loop for HDLC framing
|
||||
if (in_frame and byte == HDLC.FLAG):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == HDLC.FLAG):
|
||||
in_frame = True
|
||||
data_buffer = b""
|
||||
@@ -815,8 +823,8 @@ class I2PInterfacePeer(Interface):
|
||||
self.IN = False
|
||||
|
||||
if hasattr(self, "parent_interface") and self.parent_interface != None:
|
||||
if self.parent_interface.clients > 0:
|
||||
self.parent_interface.clients -= 1
|
||||
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:
|
||||
@@ -829,14 +837,28 @@ class I2PInterfacePeer(Interface):
|
||||
|
||||
class I2PInterface(Interface):
|
||||
BITRATE_GUESS = 256*1000
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
def __init__(self, owner, name, rns_storagepath, peers, connectable = False, ifac_size = 16, ifac_netname = None, ifac_netkey = None):
|
||||
@property
|
||||
def clients(self):
|
||||
return len(self.spawned_interfaces)
|
||||
|
||||
def __init__(self, owner, configuration):
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
rns_storagepath = c["storagepath"]
|
||||
peers = c.as_list("peers") if "peers" in c else None
|
||||
connectable = c.as_bool("connectable") if "connectable" in c else False
|
||||
ifac_size = c["ifac_size"] if "ifac_size" in c else None
|
||||
ifac_netname = c["ifac_netname"] if "ifac_netname" in c else None
|
||||
ifac_netkey = c["ifac_netkey"] if "ifac_netkey" in c else None
|
||||
|
||||
self.HW_MTU = 1064
|
||||
|
||||
self.online = False
|
||||
self.clients = 0
|
||||
self.spawned_interfaces = []
|
||||
self.owner = owner
|
||||
self.connectable = connectable
|
||||
self.i2p_tunneled = True
|
||||
@@ -858,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
|
||||
|
||||
@@ -925,6 +948,16 @@ class I2PInterface(Interface):
|
||||
spawned_interface = I2PInterfacePeer(self, self.owner, interface_name, connected_socket=handler.request)
|
||||
spawned_interface.OUT = True
|
||||
spawned_interface.IN = True
|
||||
|
||||
spawned_interface.ingress_control = self.ingress_control
|
||||
spawned_interface.ic_max_held_announces = self.ic_max_held_announces
|
||||
spawned_interface.ic_burst_hold = self.ic_burst_hold
|
||||
spawned_interface.ic_burst_freq = self.ic_burst_freq
|
||||
spawned_interface.ic_burst_freq_new = self.ic_burst_freq_new
|
||||
spawned_interface.ic_new_time = self.ic_new_time
|
||||
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
|
||||
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
|
||||
|
||||
spawned_interface.parent_interface = self
|
||||
spawned_interface.online = True
|
||||
spawned_interface.bitrate = self.bitrate
|
||||
@@ -956,10 +989,12 @@ class I2PInterface(Interface):
|
||||
spawned_interface.HW_MTU = self.HW_MTU
|
||||
RNS.log("Spawned new I2PInterface Peer: "+str(spawned_interface), RNS.LOG_VERBOSE)
|
||||
RNS.Transport.interfaces.append(spawned_interface)
|
||||
self.clients += 1
|
||||
while spawned_interface in self.spawned_interfaces:
|
||||
self.spawned_interfaces.remove(spawned_interface)
|
||||
self.spawned_interfaces.append(spawned_interface)
|
||||
spawned_interface.read_loop()
|
||||
|
||||
def processOutgoing(self, data):
|
||||
def process_outgoing(self, data):
|
||||
pass
|
||||
|
||||
def received_announce(self, from_spawned=False):
|
||||
|
||||
+103
-55
@@ -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 @@ import RNS
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
from RNS.vendor.configobj import ConfigObj
|
||||
|
||||
class Interface:
|
||||
IN = False
|
||||
@@ -46,8 +55,8 @@ class Interface:
|
||||
|
||||
# How many samples to use for announce
|
||||
# frequency calculations
|
||||
IA_FREQ_SAMPLES = 6
|
||||
OA_FREQ_SAMPLES = 6
|
||||
IA_FREQ_SAMPLES = 128
|
||||
OA_FREQ_SAMPLES = 128
|
||||
|
||||
# Maximum amount of ingress limited announces
|
||||
# to hold at any given time.
|
||||
@@ -57,20 +66,34 @@ class Interface:
|
||||
# considered to be newly created. Two
|
||||
# hours by default.
|
||||
IC_NEW_TIME = 2*60*60
|
||||
IC_BURST_FREQ_NEW = 3.5
|
||||
IC_BURST_FREQ = 12
|
||||
IC_BURST_FREQ_NEW = 6
|
||||
IC_BURST_FREQ = 35
|
||||
IC_BURST_HOLD = 1*60
|
||||
IC_BURST_PENALTY = 5*60
|
||||
IC_HELD_RELEASE_INTERVAL = 30
|
||||
IC_BURST_PENALTY = 15
|
||||
IC_HELD_RELEASE_INTERVAL = 2
|
||||
IC_DEQUE_MIN_SAMPLE = 32
|
||||
|
||||
AUTOCONFIGURE_MTU = False
|
||||
FIXED_MTU = False
|
||||
|
||||
def __init__(self):
|
||||
self.rxb = 0
|
||||
self.txb = 0
|
||||
self.created = time.time()
|
||||
self.online = False
|
||||
self.bitrate = 1e6
|
||||
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.phy_keepalive = False
|
||||
self.ic_max_held_announces = Interface.MAX_HELD_ANNOUNCES
|
||||
self.ic_burst_hold = Interface.IC_BURST_HOLD
|
||||
self.ic_burst_active = False
|
||||
@@ -101,20 +124,46 @@ class Interface:
|
||||
if self.ic_burst_active:
|
||||
if ia_freq < freq_threshold and time.time() > self.ic_burst_activated+self.ic_burst_hold:
|
||||
self.ic_burst_active = False
|
||||
self.ic_held_release = time.time() + self.ic_burst_penalty
|
||||
|
||||
return True
|
||||
|
||||
else:
|
||||
if ia_freq > freq_threshold:
|
||||
self.ic_burst_active = True
|
||||
self.ic_burst_activated = time.time()
|
||||
self.ic_held_release = time.time() + self.ic_burst_penalty
|
||||
return True
|
||||
|
||||
else:
|
||||
return False
|
||||
else: return False
|
||||
|
||||
else:
|
||||
return False
|
||||
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
|
||||
@@ -127,7 +176,7 @@ class Interface:
|
||||
|
||||
def process_held_announces(self):
|
||||
try:
|
||||
if not self.should_ingress_limit() and len(self.held_announces) > 0 and time.time() > self.ic_held_release:
|
||||
if len(self.held_announces) > 0 and time.time() > self.ic_held_release:
|
||||
freq_threshold = self.ic_burst_freq_new if self.age() < self.ic_new_time else self.ic_burst_freq
|
||||
ia_freq = self.incoming_announce_frequency()
|
||||
if ia_freq < freq_threshold:
|
||||
@@ -143,57 +192,42 @@ class Interface:
|
||||
RNS.log("Releasing held announce packet "+str(selected_announce_packet)+" from "+str(self), RNS.LOG_EXTREME)
|
||||
self.ic_held_release = time.time() + self.ic_held_release_interval
|
||||
self.held_announces.pop(selected_announce_packet.destination_hash)
|
||||
def release():
|
||||
RNS.Transport.inbound(selected_announce_packet.raw, selected_announce_packet.receiving_interface)
|
||||
def release(): RNS.Transport.inbound(selected_announce_packet.raw, selected_announce_packet.receiving_interface)
|
||||
threading.Thread(target=release, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("An error occurred while processing held announces for "+str(self), RNS.LOG_ERROR)
|
||||
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
def received_announce(self):
|
||||
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)
|
||||
|
||||
def incoming_announce_frequency(self):
|
||||
if not len(self.ia_freq_deque) > 1:
|
||||
return 0
|
||||
n = len(self.ia_freq_deque)
|
||||
if not n > self.IC_DEQUE_MIN_SAMPLE: return 0
|
||||
else:
|
||||
dq_len = len(self.ia_freq_deque)
|
||||
delta_sum = 0
|
||||
for i in range(1,dq_len):
|
||||
delta_sum += self.ia_freq_deque[i]-self.ia_freq_deque[i-1]
|
||||
delta_sum += time.time() - self.ia_freq_deque[dq_len-1]
|
||||
|
||||
if delta_sum == 0:
|
||||
avg = 0
|
||||
else:
|
||||
avg = 1/(delta_sum/(dq_len))
|
||||
|
||||
return avg
|
||||
oldest = self.ia_freq_deque[0]
|
||||
span = time.time() - oldest
|
||||
if span <= 0: return 0
|
||||
hz = n / span
|
||||
return hz
|
||||
|
||||
def outgoing_announce_frequency(self):
|
||||
if not len(self.oa_freq_deque) > 1:
|
||||
return 0
|
||||
n = len(self.oa_freq_deque)
|
||||
if not len(self.oa_freq_deque) > 1: return 0
|
||||
else:
|
||||
dq_len = len(self.oa_freq_deque)
|
||||
delta_sum = 0
|
||||
for i in range(1,dq_len):
|
||||
delta_sum += self.oa_freq_deque[i]-self.oa_freq_deque[i-1]
|
||||
delta_sum += time.time() - self.oa_freq_deque[dq_len-1]
|
||||
|
||||
if delta_sum == 0:
|
||||
avg = 0
|
||||
else:
|
||||
avg = 1/(delta_sum/(dq_len))
|
||||
|
||||
return avg
|
||||
oldest = self.oa_freq_deque[0]
|
||||
span = time.time() - oldest
|
||||
if span <= 0: return 0
|
||||
hz = n / span
|
||||
return hz
|
||||
|
||||
def process_announce_queue(self):
|
||||
if not hasattr(self, "announce_cap"):
|
||||
@@ -222,7 +256,7 @@ class Interface:
|
||||
wait_time = (tx_time / self.announce_cap)
|
||||
self.announce_allowed_at = now + wait_time
|
||||
|
||||
self.processOutgoing(selected["raw"])
|
||||
self.process_outgoing(selected["raw"])
|
||||
self.sent_announce()
|
||||
|
||||
if selected in self.announce_queue:
|
||||
@@ -237,5 +271,19 @@ 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
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_config_obj(config_in):
|
||||
if type(config_in) == ConfigObj:
|
||||
return config_in
|
||||
else:
|
||||
try:
|
||||
return ConfigObj(config_in)
|
||||
except Exception as e:
|
||||
RNS.log(f"Could not parse supplied configuration data. The contained exception was: {e}", RNS.LOG_ERROR)
|
||||
raise SystemError("Invalid configuration data supplied")
|
||||
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from time import sleep
|
||||
import sys
|
||||
import threading
|
||||
@@ -52,6 +60,7 @@ class KISS():
|
||||
class KISSInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
BITRATE_GUESS = 1200
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
owner = None
|
||||
port = None
|
||||
@@ -61,8 +70,8 @@ class KISSInterface(Interface):
|
||||
stopbits = None
|
||||
serial = None
|
||||
|
||||
def __init__(self, owner, name, port, speed, databits, parity, stopbits, preamble, txtail, persistence, slottime, flow_control, beacon_interval, beacon_data):
|
||||
import importlib
|
||||
def __init__(self, owner, configuration):
|
||||
import importlib.util
|
||||
if importlib.util.find_spec('serial') != None:
|
||||
import serial
|
||||
else:
|
||||
@@ -71,6 +80,24 @@ class KISSInterface(Interface):
|
||||
RNS.panic()
|
||||
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
preamble = int(c["preamble"]) if "preamble" in c else None
|
||||
txtail = int(c["txtail"]) if "txtail" in c else None
|
||||
persistence = int(c["persistence"]) if "persistence" in c else None
|
||||
slottime = int(c["slottime"]) if "slottime" in c else None
|
||||
flow_control = c.as_bool("flow_control") if "flow_control" in c else False
|
||||
port = c["port"] if "port" in c else None
|
||||
speed = int(c["speed"]) if "speed" in c else 9600
|
||||
databits = int(c["databits"]) if "databits" in c else 8
|
||||
parity = c["parity"] if "parity" in c else "N"
|
||||
stopbits = int(c["stopbits"]) if "stopbits" in c else 1
|
||||
beacon_interval = int(c["id_interval"]) if "id_interval" in c else None
|
||||
beacon_data = c["id_callsign"] if "id_callsign" in c else None
|
||||
|
||||
if port == None:
|
||||
raise ValueError("No port specified for serial interface")
|
||||
|
||||
self.HW_MTU = 564
|
||||
|
||||
@@ -217,12 +244,12 @@ class KISSInterface(Interface):
|
||||
raise IOError("Could not enable KISS interface flow control")
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
self.owner.inbound(data, self)
|
||||
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
datalen = len(data)
|
||||
if self.online:
|
||||
if self.interface_ready:
|
||||
@@ -256,7 +283,7 @@ class KISSInterface(Interface):
|
||||
if len(self.packet_queue) > 0:
|
||||
data = self.packet_queue.pop(0)
|
||||
self.interface_ready = True
|
||||
self.processOutgoing(data)
|
||||
self.process_outgoing(data)
|
||||
elif len(self.packet_queue) == 0:
|
||||
self.interface_ready = True
|
||||
|
||||
@@ -275,7 +302,7 @@ class KISSInterface(Interface):
|
||||
|
||||
if (in_frame and byte == KISS.FEND and command == KISS.CMD_DATA):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == KISS.FEND):
|
||||
in_frame = True
|
||||
command = KISS.CMD_UNKNOWN
|
||||
@@ -319,7 +346,13 @@ class KISSInterface(Interface):
|
||||
if time.time() > self.first_tx + self.beacon_i:
|
||||
RNS.log("Interface "+str(self)+" is transmitting beacon data: "+str(self.beacon_d.decode("utf-8")), RNS.LOG_DEBUG)
|
||||
self.first_tx = None
|
||||
self.processOutgoing(self.beacon_d)
|
||||
|
||||
# Pad to minimum length
|
||||
frame = bytearray(self.beacon_d)
|
||||
while len(frame) < 15:
|
||||
frame.append(0x00)
|
||||
|
||||
self.process_outgoing(bytes(frame))
|
||||
|
||||
except Exception as e:
|
||||
self.online = False
|
||||
|
||||
+244
-114
@@ -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,
|
||||
@@ -20,7 +28,8 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from RNS.Interfaces.BackboneInterface import BackboneInterface
|
||||
import socketserver
|
||||
import threading
|
||||
import socket
|
||||
@@ -52,16 +61,18 @@ class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
|
||||
class LocalClientInterface(Interface):
|
||||
RECONNECT_WAIT = 8
|
||||
AUTOCONFIGURE_MTU = True
|
||||
CLIENT_SLEEP_PAUSE_TIMEOUT = 12
|
||||
|
||||
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 +83,34 @@ 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
|
||||
|
||||
self.pause_on_client_sleep = False
|
||||
|
||||
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
|
||||
|
||||
if RNS.vendor.platformutils.is_android():
|
||||
self.pause_on_client_sleep = True
|
||||
self.pause_timeout = time.time() + self.CLIENT_SLEEP_PAUSE_TIMEOUT
|
||||
|
||||
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 +118,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 +129,31 @@ 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 RNS.vendor.platformutils.is_android(): self.phy_keepalive = True
|
||||
if self.epoll_backend: BackboneInterface.add_client_socket(self.socket, self)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -139,9 +177,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()
|
||||
@@ -152,89 +192,129 @@ class LocalClientInterface(Interface):
|
||||
raise IOError("Attempt to reconnect on a non-initiator local interface")
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def send_keepalive(self):
|
||||
if self.online:
|
||||
RNS.log(f"Sending keepalive on {self}", RNS.LOG_DEBUG) # TODO: Remove
|
||||
try:
|
||||
if self.epoll_backend:
|
||||
self.transmit_buffer += bytes([HDLC.FLAG])+bytes([HDLC.FLAG])
|
||||
BackboneInterface.tx_ready(self)
|
||||
|
||||
else:
|
||||
self.writing = True
|
||||
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
|
||||
self.socket.sendall(data)
|
||||
self.writing = False
|
||||
|
||||
except Exception as e: RNS.log(f"Exception occurred while sending keepalive on {self}: {e}", RNS.LOG_ERROR)
|
||||
|
||||
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)
|
||||
try: self.owner.inbound(data, self)
|
||||
except Exception as e:
|
||||
RNS.log(f"An error occurred in the processing of an incoming frame for {self}: {e}", RNS.LOG_ERROR)
|
||||
RNS.trace_exception(e)
|
||||
|
||||
# TODO: Remove at some point
|
||||
# duration = time.time() - processing_start
|
||||
# self.rxptime += duration
|
||||
def process_outgoing(self, data):
|
||||
if self.pause_on_client_sleep and time.time() > self.pause_timeout:
|
||||
RNS.log(f"TX paused for LocalInterface client, dropping outbound packet", RNS.LOG_DEBUG) # TODO: Remove
|
||||
return
|
||||
|
||||
def processOutgoing(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()
|
||||
|
||||
if self.pause_on_client_sleep: self.pause_timeout = time.time() + self.CLIENT_SLEEP_PAUSE_TIMEOUT
|
||||
|
||||
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.processIncoming(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 +329,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))
|
||||
|
||||
@@ -273,7 +355,8 @@ class LocalClientInterface(Interface):
|
||||
if hasattr(self, "parent_interface") and self.parent_interface != None:
|
||||
self.parent_interface.clients -= 1
|
||||
if hasattr(RNS.Transport, "owner") and RNS.Transport.owner != None:
|
||||
RNS.Transport.owner._should_persist_data()
|
||||
background = not self.detached
|
||||
RNS.Transport.owner._should_persist_data(background=background)
|
||||
|
||||
if nowarning == False:
|
||||
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)
|
||||
@@ -288,69 +371,115 @@ 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}"
|
||||
|
||||
def processOutgoing(self, data):
|
||||
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
|
||||
|
||||
def received_announce(self, from_spawned=False):
|
||||
@@ -360,7 +489,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):
|
||||
|
||||
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from time import sleep
|
||||
import sys
|
||||
import threading
|
||||
@@ -46,16 +54,25 @@ class HDLC():
|
||||
class PipeInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
BITRATE_GUESS = 1*1000*1000
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
owner = None
|
||||
command = None
|
||||
|
||||
def __init__(self, owner, name, command, respawn_delay):
|
||||
def __init__(self, owner, configuration):
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
command = c["command"] if "command" in c else None
|
||||
respawn_delay = c.as_float("respawn_delay") if "respawn_delay" in c else None
|
||||
|
||||
if command == None:
|
||||
raise ValueError("No command specified for PipeInterface")
|
||||
|
||||
if respawn_delay == None:
|
||||
respawn_delay = 5
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.HW_MTU = 1064
|
||||
|
||||
self.owner = owner
|
||||
@@ -101,12 +118,12 @@ class PipeInterface(Interface):
|
||||
RNS.log("Subprocess pipe for "+str(self)+" is now connected", RNS.LOG_VERBOSE)
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
self.owner.inbound(data, self)
|
||||
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
if self.online:
|
||||
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
|
||||
written = self.process.stdin.write(data)
|
||||
@@ -134,7 +151,7 @@ class PipeInterface(Interface):
|
||||
|
||||
if (in_frame and byte == HDLC.FLAG):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == HDLC.FLAG):
|
||||
in_frame = True
|
||||
data_buffer = b""
|
||||
|
||||
@@ -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,
|
||||
@@ -20,10 +28,11 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from time import sleep
|
||||
import sys
|
||||
import threading
|
||||
import socket
|
||||
import time
|
||||
import math
|
||||
import RNS
|
||||
@@ -55,10 +64,13 @@ 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
|
||||
CMD_FB_READ = 0x42
|
||||
CMD_DISP_READ = 0x66
|
||||
CMD_FB_WRITE = 0x43
|
||||
CMD_BT_CTRL = 0x46
|
||||
CMD_PLATFORM = 0x48
|
||||
@@ -95,6 +107,7 @@ class KISS():
|
||||
|
||||
class RNodeInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
FREQ_MIN = 137000000
|
||||
FREQ_MAX = 3000000000
|
||||
@@ -117,11 +130,13 @@ class RNodeInterface(Interface):
|
||||
BATTERY_STATE_CHARGING = 0x02
|
||||
BATTERY_STATE_CHARGED = 0x03
|
||||
|
||||
def __init__(self, owner, name, port, frequency = None, bandwidth = None, txpower = None, sf = None, cr = None, flow_control = False, id_interval = None, id_callsign = None, st_alock = None, lt_alock = None, ble_addr = None, ble_name = None, force_ble=False):
|
||||
DISPLAY_READ_INTERVAL = 1.0
|
||||
|
||||
def __init__(self, owner, configuration):
|
||||
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:
|
||||
@@ -131,6 +146,52 @@ class RNodeInterface(Interface):
|
||||
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
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
|
||||
sf = int(c["spreadingfactor"]) if "spreadingfactor" in c else 0
|
||||
cr = int(c["codingrate"]) if "codingrate" in c else 0
|
||||
flow_control = c.as_bool("flow_control") if "flow_control" in c else False
|
||||
id_interval = int(c["id_interval"]) if "id_interval" in c else None
|
||||
id_callsign = c["id_callsign"] if "id_callsign" in c else None
|
||||
st_alock = float(c["airtime_limit_short"]) if "airtime_limit_short" in c else None
|
||||
lt_alock = float(c["airtime_limit_long"]) if "airtime_limit_long" in c else None
|
||||
|
||||
force_ble = False
|
||||
ble_name = None
|
||||
ble_addr = None
|
||||
|
||||
force_tcp = False
|
||||
tcp_host = None
|
||||
|
||||
port = c["port"] if "port" in c else None
|
||||
|
||||
if port == None:
|
||||
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
|
||||
ble_string = port[len(ble_uri_scheme):]
|
||||
port = None
|
||||
if len(ble_string) == 0:
|
||||
pass
|
||||
elif len(ble_string.split(":")) == 6 and len(ble_string) == 17:
|
||||
ble_addr = ble_string
|
||||
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
|
||||
@@ -145,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
|
||||
@@ -164,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
|
||||
@@ -194,27 +265,48 @@ 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
|
||||
self.r_disp = b""
|
||||
self.r_disp_readtime = 0
|
||||
self.r_disp_latency = 0
|
||||
|
||||
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
|
||||
|
||||
@@ -256,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)
|
||||
@@ -272,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,
|
||||
@@ -289,17 +379,39 @@ class RNodeInterface(Interface):
|
||||
)
|
||||
|
||||
else:
|
||||
RNS.log(f"Opening BLE connection for {self}...")
|
||||
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.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
|
||||
|
||||
open_time = time.time()
|
||||
while not self.ble.connected and time.time() < open_time + self.ble.CONNECT_TIMEOUT:
|
||||
time.sleep(1)
|
||||
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)
|
||||
|
||||
def configure_device(self):
|
||||
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
|
||||
self.r_bandwidth = None
|
||||
self.r_txpower = None
|
||||
@@ -307,6 +419,10 @@ class RNodeInterface(Interface):
|
||||
self.r_cr = None
|
||||
self.r_state = None
|
||||
self.r_lock = None
|
||||
self.detected = False
|
||||
|
||||
def configure_device(self):
|
||||
self.reset_radio_state()
|
||||
sleep(2.0)
|
||||
|
||||
thread = threading.Thread(target=self.readLoop)
|
||||
@@ -314,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):
|
||||
@@ -409,7 +528,34 @@ class RNodeInterface(Interface):
|
||||
|
||||
written = self.serial.write(kiss_command)
|
||||
if written != len(kiss_command):
|
||||
raise IOError("An IO error occurred while writing framebuffer data device")
|
||||
raise IOError("An IO error occurred while writing framebuffer data to device")
|
||||
|
||||
def read_framebuffer(self):
|
||||
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_FB_READ])+bytes([0x01])+bytes([KISS.FEND])
|
||||
written = self.serial.write(kiss_command)
|
||||
self.r_framebuffer_readtime = time.time()
|
||||
if written != len(kiss_command):
|
||||
raise IOError("An IO error occurred while sending framebuffer read command")
|
||||
|
||||
def read_display(self):
|
||||
kiss_command = bytes([KISS.FEND])+bytes([KISS.CMD_DISP_READ])+bytes([0x01])+bytes([KISS.FEND])
|
||||
written = self.serial.write(kiss_command)
|
||||
self.r_disp_readtime = time.time()
|
||||
if written != len(kiss_command):
|
||||
raise IOError("An IO error occurred while sending display read command")
|
||||
|
||||
def _read_display_job(self):
|
||||
while self.should_read_display:
|
||||
self.read_display()
|
||||
time.sleep(self.read_display_interval)
|
||||
|
||||
def start_display_updates(self):
|
||||
if not self.should_read_display:
|
||||
self.should_read_display = True
|
||||
threading.Thread(target=self._read_display_job, daemon=True).start()
|
||||
|
||||
def stop_display_updates(self):
|
||||
self.should_read_display = False
|
||||
|
||||
def hard_reset(self):
|
||||
kiss_command = bytes([KISS.FEND, KISS.CMD_RESET, 0xf8, KISS.FEND])
|
||||
@@ -513,7 +659,13 @@ class RNodeInterface(Interface):
|
||||
|
||||
def validateRadioState(self):
|
||||
RNS.log("Waiting for radio configuration validation for "+str(self)+"...", RNS.LOG_VERBOSE)
|
||||
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)
|
||||
return False
|
||||
|
||||
self.validcfg = True
|
||||
if (self.r_frequency != None and abs(self.frequency - int(self.r_frequency)) > 100):
|
||||
@@ -546,14 +698,14 @@ class RNodeInterface(Interface):
|
||||
except:
|
||||
self.bitrate = 0
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
self.owner.inbound(data, self)
|
||||
self.r_stat_rssi = None
|
||||
self.r_stat_snr = None
|
||||
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
datalen = len(data)
|
||||
if self.online:
|
||||
if self.interface_ready:
|
||||
@@ -584,7 +736,7 @@ class RNodeInterface(Interface):
|
||||
if len(self.packet_queue) > 0:
|
||||
data = self.packet_queue.pop(0)
|
||||
self.interface_ready = True
|
||||
self.processOutgoing(data)
|
||||
self.process_outgoing(data)
|
||||
elif len(self.packet_queue) == 0:
|
||||
self.interface_ready = True
|
||||
|
||||
@@ -604,7 +756,7 @@ class RNodeInterface(Interface):
|
||||
|
||||
if (in_frame and byte == KISS.FEND and command == KISS.CMD_DATA):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
data_buffer = b""
|
||||
command_buffer = b""
|
||||
elif (byte == KISS.FEND):
|
||||
@@ -779,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
|
||||
@@ -800,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
|
||||
@@ -835,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):
|
||||
@@ -865,6 +1094,36 @@ class RNodeInterface(Interface):
|
||||
raise IOError("ESP32 reset")
|
||||
elif (command == KISS.CMD_READY):
|
||||
self.process_queue()
|
||||
elif (command == KISS.CMD_FB_READ):
|
||||
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) == 512):
|
||||
self.r_framebuffer_latency = time.time() - self.r_framebuffer_readtime
|
||||
self.r_framebuffer = command_buffer
|
||||
|
||||
elif (command == KISS.CMD_DISP_READ):
|
||||
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) == 1024):
|
||||
self.r_disp_latency = time.time() - self.r_disp_readtime
|
||||
self.r_disp = command_buffer
|
||||
|
||||
elif (command == KISS.CMD_DETECT):
|
||||
if byte == KISS.DETECT_RESP:
|
||||
self.detected = True
|
||||
@@ -874,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
|
||||
@@ -884,7 +1143,12 @@ class RNodeInterface(Interface):
|
||||
if self.first_tx != None:
|
||||
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.processOutgoing(self.id_callsign)
|
||||
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)
|
||||
|
||||
@@ -914,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
|
||||
@@ -951,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
|
||||
@@ -965,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"
|
||||
@@ -1010,13 +1290,15 @@ class BLEConnection():
|
||||
self.target_bt_addr = target_bt_addr
|
||||
self.scan_timeout = BLEConnection.SCAN_TIMEOUT
|
||||
self.ble_device = None
|
||||
self.last_client = None
|
||||
self.connected = False
|
||||
self.running = False
|
||||
self.should_run = False
|
||||
self.must_disconnect = False
|
||||
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
|
||||
@@ -1032,8 +1314,17 @@ class BLEConnection():
|
||||
self.should_run = True
|
||||
self.connection_thread = threading.Thread(target=self.connection_job, daemon=True).start()
|
||||
|
||||
def cleanup(self):
|
||||
try:
|
||||
if self.last_client != None:
|
||||
self.asyncio.run(self.last_client.disconnect())
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while disconnecting BLE device on cleanup for {self.owner}", RNS.LOG_ERROR)
|
||||
|
||||
self.should_run = False
|
||||
|
||||
def connection_job(self):
|
||||
while (self.should_run):
|
||||
while self.should_run:
|
||||
if self.ble_device == None:
|
||||
self.ble_device = self.find_target_device()
|
||||
|
||||
@@ -1043,6 +1334,10 @@ class BLEConnection():
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
self.cleanup()
|
||||
self.running = False
|
||||
RNS.log(f"BLE connection job for {self.owner} ended", RNS.LOG_DEBUG)
|
||||
|
||||
def connect_device(self):
|
||||
if self.ble_device != None and type(self.ble_device) == self.bleak.backends.device.BLEDevice:
|
||||
RNS.log(f"Connecting BLE device {self.ble_device} for {self.owner}...", RNS.LOG_DEBUG)
|
||||
@@ -1056,6 +1351,7 @@ class BLEConnection():
|
||||
|
||||
self.connected = True
|
||||
self.ble_device = ble_client
|
||||
self.last_client = ble_client
|
||||
self.owner.port = str(f"ble://{ble_client.address}")
|
||||
|
||||
loop = self.asyncio.get_running_loop()
|
||||
@@ -1077,12 +1373,14 @@ class BLEConnection():
|
||||
self.asyncio.run(connect_job())
|
||||
except Exception as e:
|
||||
RNS.log(f"Could not connect BLE device {self.ble_device} for {self.owner}. Possibly missing authentication.", RNS.LOG_ERROR)
|
||||
|
||||
self.connect_job_running = False
|
||||
|
||||
def device_disconnected(self, device):
|
||||
RNS.log(f"BLE device for {self.owner} disconnected", RNS.LOG_NOTICE)
|
||||
self.connected = False
|
||||
self.ble_device = None
|
||||
self.device_disappeared = True
|
||||
|
||||
def find_target_device(self):
|
||||
RNS.log(f"Searching for attachable BLE device for {self.owner}...", RNS.LOG_EXTREME)
|
||||
@@ -1106,7 +1404,13 @@ class BLEConnection():
|
||||
|
||||
return False
|
||||
|
||||
device = self.asyncio.run(self.bleak.BleakScanner.find_device_by_filter(device_filter, timeout=self.scan_timeout))
|
||||
device = None
|
||||
try:
|
||||
device = self.asyncio.run(self.bleak.BleakScanner.find_device_by_filter(device_filter, timeout=self.scan_timeout))
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while finding BLE device for {self.owner}: {e}", RNS.LOG_ERROR)
|
||||
self.should_run = False
|
||||
|
||||
return device
|
||||
|
||||
def device_bonded(self, device):
|
||||
@@ -1120,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)
|
||||
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from time import sleep
|
||||
import sys
|
||||
import threading
|
||||
@@ -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:
|
||||
@@ -163,6 +134,7 @@ class KISS():
|
||||
|
||||
class RNodeMultiInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
CALLSIGN_MAX_LEN = 32
|
||||
|
||||
@@ -173,11 +145,11 @@ class RNodeMultiInterface(Interface):
|
||||
|
||||
MAX_SUBINTERFACES = 11
|
||||
|
||||
def __init__(self, owner, name, port, subint_config, id_interval = None, id_callsign = None):
|
||||
def __init__(self, owner, configuration):
|
||||
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:
|
||||
@@ -187,6 +159,77 @@ class RNodeMultiInterface(Interface):
|
||||
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
|
||||
count = 0
|
||||
enabled_count = 0
|
||||
|
||||
# Count how many interfaces are in the file
|
||||
for subinterface in c:
|
||||
# if the retrieved entry is not a string, it must be a dictionary, which is what we want
|
||||
if isinstance(c[subinterface], dict):
|
||||
count += 1
|
||||
|
||||
# Count how many interfaces are enabled to allow for appropriate matrix sizing
|
||||
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):
|
||||
enabled_count += 1
|
||||
|
||||
# Create an array with a row for each subinterface
|
||||
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_config[subint_index][1] = subint_vport
|
||||
|
||||
frequency = int(subinterface_config["frequency"]) if "frequency" in subinterface_config else None
|
||||
subint_config[subint_index][2] = frequency
|
||||
bandwidth = int(subinterface_config["bandwidth"]) if "bandwidth" in subinterface_config else None
|
||||
subint_config[subint_index][3] = bandwidth
|
||||
txpower = int(subinterface_config["txpower"]) if "txpower" in subinterface_config else None
|
||||
subint_config[subint_index][4] = txpower
|
||||
spreadingfactor = int(subinterface_config["spreadingfactor"]) if "spreadingfactor" in subinterface_config else None
|
||||
subint_config[subint_index][5] = spreadingfactor
|
||||
codingrate = int(subinterface_config["codingrate"]) if "codingrate" in subinterface_config else None
|
||||
subint_config[subint_index][6] = codingrate
|
||||
flow_control = subinterface_config.as_bool("flow_control") if "flow_control" in subinterface_config else False
|
||||
subint_config[subint_index][7] = flow_control
|
||||
st_alock = float(subinterface_config["airtime_limit_short"]) if "airtime_limit_short" in subinterface_config else None
|
||||
subint_config[subint_index][8] = st_alock
|
||||
lt_alock = float(subinterface_config["airtime_limit_long"]) if "airtime_limit_long" in subinterface_config else None
|
||||
subint_config[subint_index][9] = lt_alock
|
||||
|
||||
if "outgoing" in subinterface_config and subinterface_config.as_bool("outgoing") == False:
|
||||
subint_config[subint_index][10] = False
|
||||
else:
|
||||
subint_config[subint_index][10] = True
|
||||
|
||||
subint_index += 1
|
||||
|
||||
# if no subinterfaces are defined
|
||||
if count == 0:
|
||||
raise ValueError("No subinterfaces configured for "+name)
|
||||
# if no subinterfaces are enabled
|
||||
elif enabled_count == 0:
|
||||
raise ValueError("No subinterfaces enabled for "+name)
|
||||
|
||||
id_interval = int(c["id_interval"]) if "id_interval" in c else None
|
||||
id_callsign = c["id_callsign"] if "id_callsign" in c else None
|
||||
port = c["port"] if "port" in c else None
|
||||
|
||||
if port == None:
|
||||
raise ValueError("No port specified for "+name)
|
||||
|
||||
self.HW_MTU = 508
|
||||
|
||||
self.clients = 0
|
||||
@@ -404,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
|
||||
@@ -417,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:
|
||||
@@ -454,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:
|
||||
@@ -467,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):
|
||||
@@ -494,13 +529,13 @@ class RNodeMultiInterface(Interface):
|
||||
RNS.log("Please update your RNode firmware with rnodeconf from https://github.com/markqvist/Reticulum/RNS/Utilities/rnodeconf.py")
|
||||
RNS.panic()
|
||||
|
||||
def processOutgoing(self, data, interface = None):
|
||||
def process_outgoing(self, data, interface = None):
|
||||
if interface is None:
|
||||
# do nothing if RNS tries to transmit on this interface directly
|
||||
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)
|
||||
@@ -529,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)].processIncoming(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):
|
||||
@@ -608,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
|
||||
@@ -821,7 +847,7 @@ class RNodeMultiInterface(Interface):
|
||||
for interface in self.subinterfaces:
|
||||
if interface != 0 and interface.online:
|
||||
interface_available = True
|
||||
self.subinterfaces[interface.index].processOutgoing(self.id_callsign)
|
||||
self.subinterfaces[interface.index].process_outgoing(self.id_callsign)
|
||||
|
||||
if interface_available:
|
||||
RNS.log("Interface "+str(self)+" is transmitting beacon data on all subinterfaces: "+str(self.id_callsign.decode("utf-8")), RNS.LOG_DEBUG)
|
||||
@@ -909,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:
|
||||
@@ -919,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
|
||||
@@ -1009,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
|
||||
|
||||
@@ -1029,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
|
||||
|
||||
@@ -1127,13 +1110,13 @@ class RNodeSubInterface(Interface):
|
||||
except:
|
||||
self.bitrate = 0
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
self.owner.inbound(data, self)
|
||||
self.r_stat_rssi = None
|
||||
self.r_stat_snr = None
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
if self.online:
|
||||
if self.interface_ready:
|
||||
if self.flow_control:
|
||||
@@ -1145,7 +1128,7 @@ class RNodeSubInterface(Interface):
|
||||
if self.parent_interface.first_tx == None:
|
||||
self.parent_interface.first_tx = time.time()
|
||||
self.txb += len(data)
|
||||
self.parent_interface.processOutgoing(data, self)
|
||||
self.parent_interface.process_outgoing(data, self)
|
||||
else:
|
||||
self.queue(data)
|
||||
|
||||
@@ -1157,7 +1140,7 @@ class RNodeSubInterface(Interface):
|
||||
if len(self.packet_queue) > 0:
|
||||
data = self.packet_queue.pop(0)
|
||||
self.interface_ready = True
|
||||
self.processOutgoing(data)
|
||||
self.process_outgoing(data)
|
||||
elif len(self.packet_queue) == 0:
|
||||
self.interface_ready = True
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from time import sleep
|
||||
import sys
|
||||
import threading
|
||||
@@ -42,6 +50,7 @@ class HDLC():
|
||||
|
||||
class SerialInterface(Interface):
|
||||
MAX_CHUNK = 32768
|
||||
DEFAULT_IFAC_SIZE = 8
|
||||
|
||||
owner = None
|
||||
port = None
|
||||
@@ -51,8 +60,8 @@ class SerialInterface(Interface):
|
||||
stopbits = None
|
||||
serial = None
|
||||
|
||||
def __init__(self, owner, name, port, speed, databits, parity, stopbits):
|
||||
import importlib
|
||||
def __init__(self, owner, configuration):
|
||||
import importlib.util
|
||||
if importlib.util.find_spec('serial') != None:
|
||||
import serial
|
||||
else:
|
||||
@@ -62,6 +71,17 @@ class SerialInterface(Interface):
|
||||
|
||||
super().__init__()
|
||||
|
||||
c = Interface.get_config_obj(configuration)
|
||||
name = c["name"]
|
||||
port = c["port"] if "port" in c else None
|
||||
speed = int(c["speed"]) if "speed" in c else 9600
|
||||
databits = int(c["databits"]) if "databits" in c else 8
|
||||
parity = c["parity"] if "parity" in c else "N"
|
||||
stopbits = int(c["stopbits"]) if "stopbits" in c else 1
|
||||
|
||||
if port == None:
|
||||
raise ValueError("No port specified for serial interface")
|
||||
|
||||
self.HW_MTU = 564
|
||||
|
||||
self.pyserial = serial
|
||||
@@ -121,12 +141,12 @@ class SerialInterface(Interface):
|
||||
RNS.log("Serial port "+self.port+" is now open", RNS.LOG_VERBOSE)
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
self.owner.inbound(data, self)
|
||||
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
if self.online:
|
||||
data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG])
|
||||
written = self.serial.write(data)
|
||||
@@ -149,7 +169,7 @@ class SerialInterface(Interface):
|
||||
|
||||
if (in_frame and byte == HDLC.FLAG):
|
||||
in_frame = False
|
||||
self.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == HDLC.FLAG):
|
||||
in_frame = True
|
||||
data_buffer = b""
|
||||
|
||||
+210
-82
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
import socketserver
|
||||
import threading
|
||||
import platform
|
||||
@@ -30,6 +38,9 @@ import sys
|
||||
import os
|
||||
import RNS
|
||||
|
||||
class TCPInterface():
|
||||
HW_MTU = 262144
|
||||
|
||||
class HDLC():
|
||||
FLAG = 0x7E
|
||||
ESC = 0x7D
|
||||
@@ -58,8 +69,13 @@ class KISS():
|
||||
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
pass
|
||||
|
||||
class ThreadingTCP6Server(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
class TCPClientInterface(Interface):
|
||||
BITRATE_GUESS = 10*1000*1000
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
AUTOCONFIGURE_MTU = True
|
||||
|
||||
RECONNECT_WAIT = 5
|
||||
RECONNECT_MAX_TRIES = None
|
||||
@@ -78,11 +94,26 @@ class TCPClientInterface(Interface):
|
||||
I2P_PROBE_INTERVAL = 9
|
||||
I2P_PROBES = 5
|
||||
|
||||
def __init__(self, owner, name, target_ip=None, target_port=None, connected_socket=None, max_reconnect_tries=None, kiss_framing=False, i2p_tunneled = False, connect_timeout = None):
|
||||
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
|
||||
kiss_framing = False
|
||||
if "kiss_framing" in c and c.as_bool("kiss_framing") == True:
|
||||
kiss_framing = True
|
||||
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
|
||||
@@ -100,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
|
||||
@@ -177,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))
|
||||
|
||||
@@ -200,10 +232,14 @@ class TCPClientInterface(Interface):
|
||||
if initial:
|
||||
RNS.log("Establishing TCP connection for "+str(self)+"...", RNS.LOG_DEBUG)
|
||||
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
address_info = socket.getaddrinfo(self.target_ip, 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(TCPClientInterface.INITIAL_CONNECT_TIMEOUT)
|
||||
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
self.socket.connect((self.target_ip, self.target_port))
|
||||
self.socket.connect(target_address)
|
||||
self.socket.settimeout(None)
|
||||
self.online = True
|
||||
|
||||
@@ -265,15 +301,16 @@ class TCPClientInterface(Interface):
|
||||
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 processIncoming(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)
|
||||
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 processOutgoing(self, data):
|
||||
if self.online:
|
||||
def process_outgoing(self, data):
|
||||
if self.online and not self.detached:
|
||||
# while self.writing:
|
||||
# time.sleep(0.01)
|
||||
|
||||
@@ -301,22 +338,23 @@ 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.processIncoming(data_buffer)
|
||||
self.process_incoming(data_buffer)
|
||||
elif (byte == KISS.FEND):
|
||||
in_frame = True
|
||||
command = KISS.CMD_UNKNOWN
|
||||
@@ -339,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.processIncoming(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:
|
||||
@@ -394,7 +433,8 @@ class TCPClientInterface(Interface):
|
||||
self.IN = False
|
||||
|
||||
if hasattr(self, "parent_interface") and self.parent_interface != None:
|
||||
self.parent_interface.clients -= 1
|
||||
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:
|
||||
@@ -402,31 +442,81 @@ class TCPClientInterface(Interface):
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return "TCPInterface["+str(self.name)+"/"+str(self.target_ip)+":"+str(self.target_port)+"]"
|
||||
if ":" in self.target_ip:
|
||||
ip_str = f"[{self.target_ip}]"
|
||||
else:
|
||||
ip_str = f"{self.target_ip}"
|
||||
|
||||
return "TCPInterface["+str(self.name)+"/"+ip_str+":"+str(self.target_port)+"]"
|
||||
|
||||
|
||||
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):
|
||||
import RNS.vendor.ifaddr.niwrapper as netinfo
|
||||
def get_address_for_if(name, bind_port, prefer_ipv6=False):
|
||||
from RNS.Interfaces import netinfo
|
||||
ifaddr = netinfo.ifaddresses(name)
|
||||
return ifaddr[netinfo.AF_INET][0]["addr"]
|
||||
if len(ifaddr) < 1:
|
||||
raise SystemError(f"No addresses available on specified kernel interface \"{name}\" for TCPServerInterface 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 TCPServerInterface.get_address_for_host(f"{bind_ip}%{name}", bind_port, prefer_ipv6)
|
||||
else:
|
||||
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)
|
||||
else:
|
||||
raise SystemError(f"No addresses available on specified kernel interface \"{name}\" for TCPServerInterface to bind to")
|
||||
|
||||
@staticmethod
|
||||
def get_broadcast_for_if(name):
|
||||
import RNS.vendor.ifaddr.niwrapper as netinfo
|
||||
ifaddr = netinfo.ifaddresses(name)
|
||||
return ifaddr[netinfo.AF_INET][0]["broadcast"]
|
||||
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
|
||||
|
||||
def __init__(self, owner, name, device=None, bindip=None, bindport=None, i2p_tunneled=False):
|
||||
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 TCPServerInterface to bind to")
|
||||
|
||||
|
||||
@property
|
||||
def clients(self):
|
||||
return len(self.spawned_interfaces)
|
||||
|
||||
def __init__(self, owner, configuration):
|
||||
super().__init__()
|
||||
|
||||
self.HW_MTU = 1064
|
||||
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
|
||||
i2p_tunneled = c.as_bool("i2p_tunneled") if "i2p_tunneled" in c else False
|
||||
prefer_ipv6 = c.as_bool("prefer_ipv6") if "prefer_ipv6" in c else False
|
||||
|
||||
if port != None:
|
||||
bindport = port
|
||||
|
||||
self.supports_discovery = True
|
||||
self.HW_MTU = TCPInterface.HW_MTU
|
||||
|
||||
self.online = False
|
||||
self.clients = 0
|
||||
self.spawned_interfaces = []
|
||||
|
||||
self.IN = True
|
||||
self.OUT = False
|
||||
@@ -436,24 +526,41 @@ class TCPServerInterface(Interface):
|
||||
self.i2p_tunneled = i2p_tunneled
|
||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||
|
||||
if device != None:
|
||||
bindip = TCPServerInterface.get_address_for_if(device)
|
||||
|
||||
if (bindip != None and bindport != None):
|
||||
self.receives = True
|
||||
self.bind_ip = bindip
|
||||
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 = TCPServerInterface.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 = TCPServerInterface.get_address_for_host(bindip, self.bind_port, prefer_ipv6)
|
||||
|
||||
if bind_address != None:
|
||||
self.receives = True
|
||||
self.bind_ip = bind_address[0]
|
||||
|
||||
def handlerFactory(callback):
|
||||
def createHandler(*args, **keys):
|
||||
return TCPInterfaceHandler(callback, *args, **keys)
|
||||
return createHandler
|
||||
|
||||
self.owner = owner
|
||||
address = (self.bind_ip, self.bind_port)
|
||||
|
||||
ThreadingTCPServer.allow_reuse_address = True
|
||||
self.server = ThreadingTCPServer(address, handlerFactory(self.incoming_connection))
|
||||
if len(bind_address) == 4:
|
||||
try:
|
||||
ThreadingTCP6Server.allow_reuse_address = True
|
||||
self.server = ThreadingTCP6Server(bind_address, handlerFactory(self.incoming_connection))
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while binding IPv6 socket for interface, the contained exception was: {e}", RNS.LOG_ERROR)
|
||||
raise SystemError("Could not bind IPv6 socket for interface. Please check the specified \"listen_ip\" configuration option")
|
||||
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
|
||||
|
||||
@@ -463,17 +570,30 @@ class TCPServerInterface(Interface):
|
||||
|
||||
self.online = True
|
||||
|
||||
else:
|
||||
raise SystemError("Insufficient parameters to create TCP listener")
|
||||
|
||||
def incoming_connection(self, handler):
|
||||
RNS.log("Accepting incoming TCP connection", RNS.LOG_VERBOSE)
|
||||
interface_name = "Client on "+self.name
|
||||
spawned_interface = TCPClientInterface(self.owner, interface_name, target_ip=None, target_port=None, connected_socket=handler.request, i2p_tunneled=self.i2p_tunneled)
|
||||
spawned_configuration = {"name": "Client on "+self.name, "target_host": None, "target_port": None, "i2p_tunneled": self.i2p_tunneled}
|
||||
spawned_interface = TCPClientInterface(self.owner, spawned_configuration, connected_socket=handler.request)
|
||||
spawned_interface.OUT = self.OUT
|
||||
spawned_interface.IN = self.IN
|
||||
|
||||
spawned_interface.ingress_control = self.ingress_control
|
||||
spawned_interface.ic_max_held_announces = self.ic_max_held_announces
|
||||
spawned_interface.ic_burst_hold = self.ic_burst_hold
|
||||
spawned_interface.ic_burst_freq = self.ic_burst_freq
|
||||
spawned_interface.ic_burst_freq_new = self.ic_burst_freq_new
|
||||
spawned_interface.ic_new_time = self.ic_new_time
|
||||
spawned_interface.ic_burst_penalty = self.ic_burst_penalty
|
||||
spawned_interface.ic_held_release_interval = self.ic_held_release_interval
|
||||
|
||||
spawned_interface.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
|
||||
spawned_interface.optimise_mtu()
|
||||
|
||||
spawned_interface.ifac_size = self.ifac_size
|
||||
spawned_interface.ifac_netname = self.ifac_netname
|
||||
@@ -503,7 +623,9 @@ class TCPServerInterface(Interface):
|
||||
spawned_interface.online = True
|
||||
RNS.log("Spawned new TCPClient Interface: "+str(spawned_interface), RNS.LOG_VERBOSE)
|
||||
RNS.Transport.interfaces.append(spawned_interface)
|
||||
self.clients += 1
|
||||
while spawned_interface in self.spawned_interfaces:
|
||||
self.spawned_interfaces.remove(spawned_interface)
|
||||
self.spawned_interfaces.append(spawned_interface)
|
||||
spawned_interface.read_loop()
|
||||
|
||||
def received_announce(self, from_spawned=False):
|
||||
@@ -512,18 +634,19 @@ class TCPServerInterface(Interface):
|
||||
def sent_announce(self, from_spawned=False):
|
||||
if from_spawned: self.oa_freq_deque.append(time.time())
|
||||
|
||||
def processOutgoing(self, data):
|
||||
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:
|
||||
@@ -531,7 +654,12 @@ class TCPServerInterface(Interface):
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return "TCPServerInterface["+self.name+"/"+self.bind_ip+":"+str(self.bind_port)+"]"
|
||||
if ":" in self.bind_ip:
|
||||
ip_str = f"[{self.bind_ip}]"
|
||||
else:
|
||||
ip_str = f"{self.bind_ip}"
|
||||
|
||||
return "TCPServerInterface["+self.name+"/"+ip_str+":"+str(self.bind_port)+"]"
|
||||
|
||||
|
||||
class TCPInterfaceHandler(socketserver.BaseRequestHandler):
|
||||
|
||||
@@ -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,
|
||||
@@ -20,7 +28,7 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from .Interface import Interface
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
import socketserver
|
||||
import threading
|
||||
import socket
|
||||
@@ -31,22 +39,38 @@ import RNS
|
||||
|
||||
class UDPInterface(Interface):
|
||||
BITRATE_GUESS = 10*1000*1000
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
@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"]
|
||||
|
||||
def __init__(self, owner, name, device=None, bindip=None, bindport=None, forwardip=None, forwardport=None):
|
||||
def __init__(self, owner, configuration):
|
||||
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
|
||||
forwardip = c["forward_ip"] if "forward_ip" in c else None
|
||||
forwardport = int(c["forward_port"]) if "forward_port" in c else None
|
||||
|
||||
if port != None:
|
||||
if bindport == None:
|
||||
bindport = port
|
||||
if forwardport == None:
|
||||
forwardport = port
|
||||
|
||||
self.HW_MTU = 1064
|
||||
|
||||
self.IN = True
|
||||
@@ -75,7 +99,7 @@ class UDPInterface(Interface):
|
||||
self.owner = owner
|
||||
address = (self.bind_ip, self.bind_port)
|
||||
socketserver.UDPServer.address_family = socket.AF_INET
|
||||
self.server = socketserver.UDPServer(address, handlerFactory(self.processIncoming))
|
||||
self.server = socketserver.UDPServer(address, handlerFactory(self.process_incoming))
|
||||
|
||||
thread = threading.Thread(target=self.server.serve_forever)
|
||||
thread.daemon = True
|
||||
@@ -89,11 +113,11 @@ class UDPInterface(Interface):
|
||||
self.forward_port = forwardport
|
||||
|
||||
|
||||
def processIncoming(self, data):
|
||||
def process_incoming(self, data):
|
||||
self.rxb += len(data)
|
||||
self.owner.inbound(data, self)
|
||||
|
||||
def processOutgoing(self,data):
|
||||
def process_outgoing(self,data):
|
||||
try:
|
||||
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,10 @@
|
||||
import os
|
||||
import glob
|
||||
import RNS.Interfaces.Android
|
||||
import RNS.Interfaces.util
|
||||
import RNS.Interfaces.util.netinfo as netinfo
|
||||
|
||||
modules = glob.glob(os.path.dirname(__file__)+"/*.py")
|
||||
__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
|
||||
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"))]))
|
||||
@@ -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"))]))
|
||||
@@ -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
|
||||
+357
-138
@@ -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,
|
||||
@@ -21,17 +29,18 @@
|
||||
# SOFTWARE.
|
||||
|
||||
from RNS.Cryptography import X25519PrivateKey, X25519PublicKey, Ed25519PrivateKey, Ed25519PublicKey
|
||||
from RNS.Cryptography import Fernet
|
||||
from RNS.Cryptography import Token
|
||||
from RNS.Channel import Channel, LinkChannelOutlet
|
||||
|
||||
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):
|
||||
@@ -61,28 +70,33 @@ class Link:
|
||||
ECPUBSIZE = 32+32
|
||||
KEYSIZE = 32
|
||||
|
||||
MDU = math.floor((RNS.Reticulum.MTU-RNS.Reticulum.IFAC_MIN_SIZE-RNS.Reticulum.HEADER_MINSIZE-RNS.Identity.FERNET_OVERHEAD)/RNS.Identity.AES128_BLOCKSIZE)*RNS.Identity.AES128_BLOCKSIZE - 1
|
||||
MDU = math.floor((RNS.Reticulum.MTU-RNS.Reticulum.IFAC_MIN_SIZE-RNS.Reticulum.HEADER_MINSIZE-RNS.Identity.TOKEN_OVERHEAD)/RNS.Identity.AES128_BLOCKSIZE)*RNS.Identity.AES128_BLOCKSIZE - 1
|
||||
|
||||
ESTABLISHMENT_TIMEOUT_PER_HOP = RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT
|
||||
"""
|
||||
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
|
||||
@@ -123,34 +215,39 @@ class Link:
|
||||
link.request_time = time.time()
|
||||
RNS.Transport.register_link(link)
|
||||
link.last_inbound = time.time()
|
||||
link.__update_phy_stats(packet, force_update=True)
|
||||
link.start_watchdog()
|
||||
|
||||
|
||||
RNS.log("Incoming link request "+str(link)+" accepted on "+str(link.attached_interface), RNS.LOG_DEBUG)
|
||||
return link
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Validating link request failed", RNS.LOG_VERBOSE)
|
||||
RNS.log("exc: "+str(e))
|
||||
RNS.log(f"Validating link request failed: {e}", RNS.LOG_VERBOSE)
|
||||
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
|
||||
@@ -188,7 +285,7 @@ class Link:
|
||||
self.prv = X25519PrivateKey.generate()
|
||||
self.sig_prv = Ed25519PrivateKey.generate()
|
||||
|
||||
self.fernet = None
|
||||
self.token = None
|
||||
|
||||
self.pub = self.prv.public_key()
|
||||
self.pub_bytes = self.pub.public_bytes()
|
||||
@@ -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,19 +424,24 @@ 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()
|
||||
self.had_outbound()
|
||||
self.__update_phy_stats(packet)
|
||||
|
||||
if self.callbacks.link_established != None:
|
||||
thread = threading.Thread(target=self.callbacks.link_established, args=(self,))
|
||||
@@ -360,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()
|
||||
|
||||
@@ -394,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
|
||||
@@ -407,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)
|
||||
@@ -435,19 +574,28 @@ class Link:
|
||||
"""
|
||||
:returns: The physical layer *Received Signal Strength Indication* if available, otherwise ``None``. Physical layer statistics must be enabled on the link for this method to return a value.
|
||||
"""
|
||||
return self.rssi
|
||||
if self.__track_phy_stats:
|
||||
return self.rssi
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_snr(self):
|
||||
"""
|
||||
:returns: The physical layer *Signal-to-Noise Ratio* if available, otherwise ``None``. Physical layer statistics must be enabled on the link for this method to return a value.
|
||||
"""
|
||||
return self.rssi
|
||||
if self.__track_phy_stats:
|
||||
return self.snr
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_q(self):
|
||||
"""
|
||||
:returns: The physical layer *Link Quality* if available, otherwise ``None``. Physical layer statistics must be enabled on the link for this method to return a value.
|
||||
"""
|
||||
return self.rssi
|
||||
if self.__track_phy_stats:
|
||||
return self.q
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_establishment_rate(self):
|
||||
"""
|
||||
@@ -458,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
|
||||
|
||||
@@ -507,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):
|
||||
@@ -541,12 +722,9 @@ class Link:
|
||||
pass
|
||||
|
||||
def link_closed(self):
|
||||
for resource in self.incoming_resources:
|
||||
resource.cancel()
|
||||
for resource in self.outgoing_resources:
|
||||
resource.cancel()
|
||||
if self._channel:
|
||||
self._channel._shutdown()
|
||||
for resource in self.incoming_resources: resource.cancel()
|
||||
for resource in self.outgoing_resources: resource.cancel()
|
||||
if self._channel: self._channel._shutdown()
|
||||
|
||||
self.prv = None
|
||||
self.pub = None
|
||||
@@ -560,8 +738,7 @@ class Link:
|
||||
self.destination.links.remove(self)
|
||||
|
||||
if self.callbacks.link_closed != None:
|
||||
try:
|
||||
self.callbacks.link_closed(self)
|
||||
try: self.callbacks.link_closed(self)
|
||||
except Exception as e:
|
||||
RNS.log("Error while executing link closed callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
@@ -610,9 +787,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:
|
||||
@@ -626,6 +804,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()
|
||||
@@ -638,11 +817,17 @@ 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:
|
||||
self.rssi = None
|
||||
self.snr = None
|
||||
self.q = None
|
||||
|
||||
def __update_phy_stats(self, packet, query_shared = True):
|
||||
if self.__track_phy_stats:
|
||||
|
||||
def __update_phy_stats(self, packet, query_shared = True, force_update = False):
|
||||
if self.__track_phy_stats or force_update:
|
||||
if query_shared:
|
||||
reticulum = RNS.Reticulum.get_instance()
|
||||
if packet.rssi == None: packet.rssi = reticulum.get_packet_rssi(packet.packet_hash)
|
||||
@@ -655,6 +840,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)
|
||||
@@ -673,6 +862,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:
|
||||
@@ -691,18 +881,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:
|
||||
@@ -713,7 +914,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)
|
||||
|
||||
@@ -730,18 +931,28 @@ class Link:
|
||||
request_id = RNS.Identity.truncated_hash(packed_request)
|
||||
request_data = unpacked_request
|
||||
|
||||
self.handle_request(request_id, request_data)
|
||||
def job(): self.handle_request(request_id, request_data)
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
else:
|
||||
RNS.log("Incoming request resource failed with status: "+RNS.hexrep([resource.status]), RNS.LOG_DEBUG)
|
||||
|
||||
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:
|
||||
@@ -778,6 +989,8 @@ class Link:
|
||||
plaintext = self.decrypt(packet.data)
|
||||
packet.ratchet_id = self.link_id
|
||||
if plaintext != None:
|
||||
self.__update_phy_stats(packet, query_shared=True)
|
||||
|
||||
if self.callbacks.packet != None:
|
||||
thread = threading.Thread(target=self.callbacks.packet, args=(plaintext, packet))
|
||||
thread.daemon = True
|
||||
@@ -785,19 +998,15 @@ class Link:
|
||||
|
||||
if self.destination.proof_strategy == RNS.Destination.PROVE_ALL:
|
||||
packet.prove()
|
||||
should_query = True
|
||||
|
||||
elif self.destination.proof_strategy == RNS.Destination.PROVE_APP:
|
||||
if self.destination.callbacks.proof_requested:
|
||||
try:
|
||||
if self.destination.callbacks.proof_requested(packet):
|
||||
packet.prove()
|
||||
should_query = True
|
||||
except Exception as e:
|
||||
RNS.log("Error while executing proof request callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
self.__update_phy_stats(packet, query_shared=should_query)
|
||||
|
||||
elif packet.context == RNS.Packet.LINKIDENTIFY:
|
||||
plaintext = self.decrypt(packet.data)
|
||||
if plaintext != None:
|
||||
@@ -824,7 +1033,8 @@ class Link:
|
||||
packed_request = self.decrypt(packet.data)
|
||||
if packed_request != None:
|
||||
unpacked_request = umsgpack.unpackb(packed_request)
|
||||
self.handle_request(request_id, unpacked_request)
|
||||
def job(): self.handle_request(request_id, unpacked_request)
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
self.__update_phy_stats(packet, query_shared=True)
|
||||
except Exception as e:
|
||||
RNS.log("Error occurred while handling request. The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
@@ -837,7 +1047,8 @@ class Link:
|
||||
request_id = unpacked_response[0]
|
||||
response_data = unpacked_response[1]
|
||||
transfer_size = len(umsgpack.packb(response_data))-2
|
||||
self.handle_response(request_id, response_data, transfer_size, transfer_size)
|
||||
def job(): self.handle_response(request_id, response_data, transfer_size, transfer_size)
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
self.__update_phy_stats(packet, query_shared=True)
|
||||
except Exception as e:
|
||||
RNS.log("Error occurred while handling response. The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
@@ -873,15 +1084,14 @@ class Link:
|
||||
pending_request.started_at = time.time()
|
||||
pending_request.response_resource_progress(response_resource)
|
||||
|
||||
elif self.resource_strategy == Link.ACCEPT_NONE:
|
||||
pass
|
||||
elif self.resource_strategy == Link.ACCEPT_NONE: pass
|
||||
elif self.resource_strategy == Link.ACCEPT_APP:
|
||||
if self.callbacks.resource != None:
|
||||
try:
|
||||
resource_advertisement = RNS.ResourceAdvertisement.unpack(packet.plaintext)
|
||||
resource_advertisement.link = self
|
||||
if self.callbacks.resource(resource_advertisement):
|
||||
RNS.Resource.accept(packet, self.callbacks.resource_concluded)
|
||||
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:
|
||||
@@ -927,6 +1137,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)
|
||||
@@ -958,7 +1177,8 @@ class Link:
|
||||
resource_hash = packet.data[0:RNS.Identity.HASHLENGTH//8]
|
||||
for resource in self.outgoing_resources:
|
||||
if resource_hash == resource.hash:
|
||||
resource.validate_proof(packet.data)
|
||||
def job(resource=resource): resource.validate_proof(packet.data)
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
self.__update_phy_stats(packet, query_shared=True)
|
||||
|
||||
self.watchdog_lock = False
|
||||
@@ -966,14 +1186,13 @@ class Link:
|
||||
|
||||
def encrypt(self, plaintext):
|
||||
try:
|
||||
if not self.fernet:
|
||||
try:
|
||||
self.fernet = Fernet(self.derived_key)
|
||||
if not self.token:
|
||||
try: self.token = Token(self.derived_key)
|
||||
except Exception as e:
|
||||
RNS.log("Could not instantiate Fernet while performin encryption on link "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
RNS.log("Could not instantiate token while performing encryption on link "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
raise e
|
||||
|
||||
return self.fernet.encrypt(plaintext)
|
||||
return self.token.encrypt(plaintext)
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Encryption on link "+str(self)+" failed. The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
@@ -982,10 +1201,8 @@ class Link:
|
||||
|
||||
def decrypt(self, ciphertext):
|
||||
try:
|
||||
if not self.fernet:
|
||||
self.fernet = Fernet(self.derived_key)
|
||||
|
||||
return self.fernet.decrypt(ciphertext)
|
||||
if not self.token: self.token = Token(self.derived_key)
|
||||
return self.token.decrypt(ciphertext)
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Decryption failed on link "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
@@ -1062,10 +1279,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):
|
||||
"""
|
||||
@@ -1074,10 +1296,8 @@ class Link:
|
||||
:param resource_strategy: One of ``RNS.Link.ACCEPT_NONE``, ``RNS.Link.ACCEPT_ALL`` or ``RNS.Link.ACCEPT_APP``. If ``RNS.Link.ACCEPT_APP`` is set, the `resource_callback` will be called to determine whether the resource should be accepted or not.
|
||||
:raises: *TypeError* if the resource strategy is unsupported.
|
||||
"""
|
||||
if not resource_strategy in Link.resource_strategies:
|
||||
raise TypeError("Unsupported resource strategy")
|
||||
else:
|
||||
self.resource_strategy = resource_strategy
|
||||
if not resource_strategy in Link.resource_strategies: raise TypeError("Unsupported resource strategy")
|
||||
else: self.resource_strategy = resource_strategy
|
||||
|
||||
def register_outgoing_resource(self, resource):
|
||||
self.outgoing_resources.append(resource)
|
||||
@@ -1087,31 +1307,29 @@ class Link:
|
||||
|
||||
def has_incoming_resource(self, resource):
|
||||
for incoming_resource in self.incoming_resources:
|
||||
if incoming_resource.hash == resource.hash:
|
||||
return True
|
||||
if incoming_resource.hash == resource.hash: return True
|
||||
|
||||
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)
|
||||
else:
|
||||
RNS.log("Attempt to cancel a non-existing outgoing resource", RNS.LOG_ERROR)
|
||||
if resource in self.outgoing_resources: self.outgoing_resources.remove(resource)
|
||||
else: RNS.log("Attempt to cancel a non-existing outgoing resource", RNS.LOG_WARNING)
|
||||
|
||||
def cancel_incoming_resource(self, resource):
|
||||
if resource in self.incoming_resources:
|
||||
self.incoming_resources.remove(resource)
|
||||
else:
|
||||
RNS.log("Attempt to cancel a non-existing incoming resource", RNS.LOG_ERROR)
|
||||
if resource in self.incoming_resources: self.incoming_resources.remove(resource)
|
||||
else: RNS.log("Attempt to cancel a non-existing incoming resource", RNS.LOG_WARNING)
|
||||
|
||||
def ready_for_new_resource(self):
|
||||
if len(self.outgoing_resources) > 0:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
if len(self.outgoing_resources) > 0: return False
|
||||
else: return True
|
||||
|
||||
def __str__(self):
|
||||
return RNS.prettyhexrep(self.link_id)
|
||||
def __str__(self): return RNS.prettyhexrep(self.link_id)
|
||||
|
||||
|
||||
class RequestReceipt():
|
||||
@@ -1148,6 +1366,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
|
||||
@@ -1195,20 +1414,21 @@ class RequestReceipt():
|
||||
now = time.time()
|
||||
if now > self.__resource_response_timeout:
|
||||
self.request_timed_out(None)
|
||||
break
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def request_timed_out(self, packet_receipt):
|
||||
self.status = RequestReceipt.FAILED
|
||||
self.concluded_at = time.time()
|
||||
self.link.pending_requests.remove(self)
|
||||
if self in self.link.pending_requests and self.status == RequestReceipt.DELIVERED:
|
||||
self.status = RequestReceipt.FAILED
|
||||
self.concluded_at = time.time()
|
||||
self.link.pending_requests.remove(self)
|
||||
|
||||
if self.callbacks.failed != None:
|
||||
try:
|
||||
self.callbacks.failed(self)
|
||||
except Exception as e:
|
||||
RNS.log("Error while executing request timed out callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
if self.callbacks.failed != None:
|
||||
try: self.callbacks.failed(self)
|
||||
except Exception as e:
|
||||
RNS.log("Error while executing request timed out callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
|
||||
def response_resource_progress(self, resource):
|
||||
@@ -1234,10 +1454,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()
|
||||
|
||||
@@ -1249,14 +1470,12 @@ class RequestReceipt():
|
||||
self.packet_receipt.callbacks.delivery(self.packet_receipt)
|
||||
|
||||
if self.callbacks.progress != None:
|
||||
try:
|
||||
self.callbacks.progress(self)
|
||||
try: self.callbacks.progress(self)
|
||||
except Exception as e:
|
||||
RNS.log("Error while executing response progress callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
if self.callbacks.response != None:
|
||||
try:
|
||||
self.callbacks.response(self)
|
||||
try: self.callbacks.response(self)
|
||||
except Exception as e:
|
||||
RNS.log("Error while executing response received callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
|
||||
+65
-15
@@ -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**.
|
||||
@@ -95,7 +103,7 @@ class Packet:
|
||||
# With an MTU of 500, the maximum of data we can
|
||||
# send in a single encrypted packet is given by
|
||||
# the below calculation; 383 bytes.
|
||||
ENCRYPTED_MDU = math.floor((RNS.Reticulum.MDU-RNS.Identity.FERNET_OVERHEAD-RNS.Identity.KEYSIZE//16)/RNS.Identity.AES128_BLOCKSIZE)*RNS.Identity.AES128_BLOCKSIZE - 1
|
||||
ENCRYPTED_MDU = math.floor((RNS.Reticulum.MDU-RNS.Identity.TOKEN_OVERHEAD-RNS.Identity.KEYSIZE//16)/RNS.Identity.AES128_BLOCKSIZE)*RNS.Identity.AES128_BLOCKSIZE - 1
|
||||
"""
|
||||
The maximum size of the payload data in a single encrypted packet
|
||||
"""
|
||||
@@ -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,19 +279,21 @@ 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)
|
||||
RNS.log("No interfaces could process the outbound packet", RNS.LOG_DEBUG)
|
||||
self.sent = False
|
||||
self.receipt = None
|
||||
return False
|
||||
@@ -296,7 +315,7 @@ class Packet:
|
||||
if RNS.Transport.outbound(self):
|
||||
return self.receipt
|
||||
else:
|
||||
RNS.log("No interfaces could process the outbound packet", RNS.LOG_ERROR)
|
||||
RNS.log("Re-send failed. No interfaces could process the outbound packet", RNS.LOG_WARNING)
|
||||
self.sent = False
|
||||
self.receipt = None
|
||||
return False
|
||||
@@ -341,6 +360,33 @@ class Packet:
|
||||
|
||||
return hashable_part
|
||||
|
||||
def get_rssi(self):
|
||||
"""
|
||||
:returns: The physical layer *Received Signal Strength Indication* if available, otherwise ``None``.
|
||||
"""
|
||||
if self.rssi != None:
|
||||
return self.rssi
|
||||
else:
|
||||
return reticulum.get_packet_rssi(self.packet_hash)
|
||||
|
||||
def get_snr(self):
|
||||
"""
|
||||
:returns: The physical layer *Signal-to-Noise Ratio* if available, otherwise ``None``.
|
||||
"""
|
||||
if self.snr != None:
|
||||
return self.snr
|
||||
else:
|
||||
return reticulum.get_packet_snr(self.packet_hash)
|
||||
|
||||
def get_q(self):
|
||||
"""
|
||||
:returns: The physical layer *Link Quality* if available, otherwise ``None``.
|
||||
"""
|
||||
if self.q != None:
|
||||
return self.q
|
||||
else:
|
||||
return reticulum.get_packet_q(self.packet_hash)
|
||||
|
||||
class ProofDestination:
|
||||
def __init__(self, packet):
|
||||
self.hash = packet.get_hash()[:RNS.Reticulum.TRUNCATED_HASHLENGTH//8];
|
||||
@@ -474,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
@@ -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,
|
||||
|
||||
+305
-130
@@ -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,25 +108,25 @@ 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
|
||||
PROOF_TIMEOUT_FACTOR = 3
|
||||
HMU_WAIT_FACTOR = 3.5
|
||||
MAX_RETRIES = 16
|
||||
MAX_ADV_RETRIES = 4
|
||||
SENDER_GRACE_TIME = 10.0
|
||||
@@ -140,6 +149,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):
|
||||
@@ -149,42 +171,54 @@ 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.advertisement_packet = advertisement_packet
|
||||
|
||||
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)
|
||||
@@ -197,9 +231,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:
|
||||
@@ -213,15 +245,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()
|
||||
@@ -229,31 +279,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
|
||||
@@ -266,10 +328,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
|
||||
@@ -285,10 +353,25 @@ 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.max_decompressed_size = Resource.AUTO_COMPRESS_MAX_SIZE
|
||||
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
|
||||
@@ -304,10 +387,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
|
||||
|
||||
@@ -316,25 +399,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
|
||||
@@ -345,13 +429,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)
|
||||
@@ -367,11 +451,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:
|
||||
@@ -387,8 +471,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:
|
||||
@@ -441,12 +526,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()
|
||||
@@ -454,9 +540,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):
|
||||
@@ -468,7 +570,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:
|
||||
@@ -492,22 +593,26 @@ 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_hmu_wait_remaining = (self.sdu*8*self.HMU_WAIT_FACTOR)/self.eifr if self.waiting_for_hmu or self.outstanding_parts == 0 else 0
|
||||
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 + expected_hmu_wait_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)}, EHWR {RNS.prettyshorttime(expected_hmu_wait_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:
|
||||
@@ -532,6 +637,10 @@ class Resource:
|
||||
sleep_time = 0.001
|
||||
|
||||
elif self.status == Resource.AWAITING_PROOF:
|
||||
# Decrease timeout factor since proof packets are
|
||||
# significantly smaller than full req/resp roundtrip
|
||||
self.timeout_factor = Resource.PROOF_TIMEOUT_FACTOR
|
||||
|
||||
sleep_time = self.last_part_sent + (self.rtt*self.timeout_factor+self.sender_grace_time) - time.time()
|
||||
if sleep_time < 0:
|
||||
if self.retries_left <= 0:
|
||||
@@ -548,8 +657,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()
|
||||
@@ -563,29 +675,47 @@ 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)
|
||||
if not self.compressed: self.data = data
|
||||
else:
|
||||
self.data = data
|
||||
decompressor = bz2.BZ2Decompressor()
|
||||
self.data = decompressor.decompress(data, max_length=self.max_decompressed_size)
|
||||
if not decompressor.eof:
|
||||
self.status = Resource.CORRUPT
|
||||
self.cancel()
|
||||
RNS.log(f"Decompressed resource exceeded maximum decompressed size. The resource was rejected.", RNS.LOG_ERROR)
|
||||
return
|
||||
|
||||
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:
|
||||
@@ -597,21 +727,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)
|
||||
|
||||
@@ -623,6 +759,7 @@ class Resource:
|
||||
proof_data = self.hash+proof
|
||||
proof_packet = RNS.Packet(self.link, proof_data, packet_type=RNS.Packet.PROOF, context=RNS.Packet.RESOURCE_PRF)
|
||||
proof_packet.send()
|
||||
RNS.Transport.cache(proof_packet, force_cache=True)
|
||||
except Exception as e:
|
||||
RNS.log("Could not send proof packet, cancelling resource", RNS.LOG_DEBUG)
|
||||
RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG)
|
||||
@@ -632,16 +769,18 @@ class Resource:
|
||||
# Prepare the next segment for advertisement
|
||||
RNS.log(f"Preparing segment {self.segment_index+1} of {self.total_segments} for resource {self}", RNS.LOG_DEBUG)
|
||||
self.preparing_next_segment = True
|
||||
self.next_segment = Resource(
|
||||
self.input_file, self.link,
|
||||
callback = self.callback,
|
||||
segment_index = self.segment_index+1,
|
||||
original_hash=self.original_hash,
|
||||
progress_callback = self.__progress_callback,
|
||||
request_id = self.request_id,
|
||||
is_response = self.is_response,
|
||||
advertise = False,
|
||||
)
|
||||
self.next_segment = Resource(self.input_file, self.link,
|
||||
callback = self.callback,
|
||||
segment_index = self.segment_index+1,
|
||||
original_hash=self.original_hash,
|
||||
progress_callback = self.__progress_callback,
|
||||
request_id = self.request_id,
|
||||
is_response = self.is_response,
|
||||
advertise = False,
|
||||
auto_compress = self.auto_compress_option,
|
||||
sent_metadata_size = self.metadata_size)
|
||||
if self.__progress_callback:
|
||||
self.next_segment.progress_callback(self.__progress_callback)
|
||||
|
||||
def validate_proof(self, proof_data):
|
||||
if not self.status == Resource.FAILED:
|
||||
@@ -653,18 +792,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
|
||||
@@ -672,8 +811,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
|
||||
@@ -749,7 +896,7 @@ class Resource:
|
||||
|
||||
if self.received_count == self.total_parts and not self.assembly_lock:
|
||||
self.assembly_lock = True
|
||||
self.assemble()
|
||||
threading.Thread(target=self.assemble, daemon=True).start()
|
||||
elif self.outstanding_parts == 0:
|
||||
# TODO: Figure out if there is a mathematically
|
||||
# optimal way to adjust windows
|
||||
@@ -764,6 +911,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:
|
||||
@@ -825,6 +973,7 @@ class Resource:
|
||||
self.last_activity = time.time()
|
||||
self.req_sent = self.last_activity
|
||||
self.req_sent_bytes = len(request_packet.raw)
|
||||
self.rtt_rxd_bytes_at_part_req = self.rtt_rxd_bytes
|
||||
self.req_resp = None
|
||||
|
||||
except Exception as e:
|
||||
@@ -919,10 +1068,10 @@ class Resource:
|
||||
|
||||
if self.sent_parts == len(self.parts):
|
||||
self.status = Resource.AWAITING_PROOF
|
||||
self.retries_left = 3
|
||||
|
||||
if self.__progress_callback != None:
|
||||
try:
|
||||
self.__progress_callback(self)
|
||||
try: self.__progress_callback(self)
|
||||
except Exception as e:
|
||||
RNS.log("Error while executing progress callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
@@ -930,7 +1079,14 @@ class Resource:
|
||||
"""
|
||||
Cancels transferring the resource.
|
||||
"""
|
||||
if self.status < Resource.COMPLETE:
|
||||
if self.next_segment: self.next_segment.cancel()
|
||||
|
||||
if self.status == Resource.CORRUPT:
|
||||
self.link.cancel_incoming_resource(self)
|
||||
self.reject(self.advertisement_packet)
|
||||
self.link.teardown()
|
||||
|
||||
elif self.status < Resource.COMPLETE:
|
||||
self.status = Resource.FAILED
|
||||
if self.initiator:
|
||||
if self.link.status == RNS.Link.ACTIVE:
|
||||
@@ -950,11 +1106,25 @@ 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)
|
||||
def job(): self.callback(self)
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
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
|
||||
|
||||
def progress_callback(self, callback):
|
||||
self.__progress_callback = callback
|
||||
if self.next_segment: self.next_segment.progress_callback(callback)
|
||||
|
||||
def get_progress(self):
|
||||
"""
|
||||
@@ -974,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
|
||||
|
||||
@@ -997,7 +1167,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
|
||||
|
||||
@@ -1121,6 +1291,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
|
||||
@@ -1136,7 +1307,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
|
||||
@@ -1156,6 +1327,9 @@ class ResourceAdvertisement:
|
||||
def is_compressed(self):
|
||||
return self.c
|
||||
|
||||
def has_metadata(self):
|
||||
return self.x
|
||||
|
||||
def get_link(self):
|
||||
return self.link
|
||||
|
||||
@@ -1205,5 +1379,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
|
||||
+880
-820
File diff suppressed because it is too large
Load Diff
+1840
-1174
File diff suppressed because it is too large
Load Diff
@@ -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,5 +31,7 @@
|
||||
import os
|
||||
import glob
|
||||
|
||||
modules = glob.glob(os.path.dirname(__file__)+"/*.py")
|
||||
__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
|
||||
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"))]))
|
||||
+182
-156
@@ -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']
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
APP_NAME = "git"
|
||||
|
||||
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"))]))
|
||||
@@ -0,0 +1,674 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import RNS
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
import threading
|
||||
import subprocess
|
||||
|
||||
from RNS._version import __version__
|
||||
from RNS.Utilities.rngit import APP_NAME
|
||||
|
||||
from RNS.vendor.configobj import ConfigObj
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
def program_setup(configdir, rnsconfigdir, destination_hexhash, group_name, repo_name):
|
||||
git_client = ReticulumGitClient(configdir=configdir, rnsconfigdir=rnsconfigdir, destination_hexhash=destination_hexhash,
|
||||
group_name=group_name, repo_name=repo_name)
|
||||
|
||||
if not git_client.ready: sys.exit(1)
|
||||
else: git_client.run()
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: git-remote-rns <remote-name> <url>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[2]
|
||||
if not url.startswith("rns://"):
|
||||
print("Invalid URL scheme. Must be rns://", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
parts = url[6:].split("/", 2)
|
||||
destination_hexhash = parts[0]
|
||||
group_name = parts[1]
|
||||
repo_name = parts[2]
|
||||
|
||||
except IndexError: print("Invalid URL format. Use rns://<hash>/<group>/<repo>", file=sys.stderr); sys.exit(1)
|
||||
|
||||
configdir = os.environ.get("RNGIT_CONFIG", None)
|
||||
rnsconfigdir = os.environ.get("RNS_CONFIG", None)
|
||||
|
||||
program_setup(configdir, rnsconfigdir, destination_hexhash, group_name, repo_name)
|
||||
exit(0)
|
||||
|
||||
|
||||
class ReticulumGitClient():
|
||||
PATH_LIST = "/git/list"
|
||||
PATH_FETCH = "/git/fetch"
|
||||
PATH_PUSH = "/git/push"
|
||||
PATH_DELETE = "/git/delete"
|
||||
|
||||
RES_DISALLOWED = 0x01
|
||||
RES_INVALID_REQ = 0x02
|
||||
RES_NOT_FOUND = 0x03
|
||||
RES_REMOTE_FAIL = 0xFF
|
||||
|
||||
IDX_REPOSITORY = 0x00
|
||||
IDX_RESULT_CODE = 0x01
|
||||
|
||||
REF_BATCH_SIZE = 25
|
||||
PATH_TIMEOUT = 15
|
||||
LINK_TIMEOUT = 15
|
||||
|
||||
def __init__(self, configdir, rnsconfigdir, destination_hexhash, group_name, repo_name):
|
||||
# Client state and configuration
|
||||
self.identity = None
|
||||
self.userdir = os.path.expanduser("~")
|
||||
self.config = None
|
||||
self.ready = False
|
||||
|
||||
self.remote_identity = None
|
||||
self.destination = None
|
||||
self.link = None
|
||||
self.link_ready = False
|
||||
self.link_failed = False
|
||||
self.link_timeout = self.LINK_TIMEOUT
|
||||
self.path_timeout = self.PATH_TIMEOUT
|
||||
|
||||
self.destination_hexhash = destination_hexhash
|
||||
self.group_name = group_name
|
||||
self.repo_name = repo_name
|
||||
self.repo_path = f"{group_name}/{repo_name}"
|
||||
|
||||
self.tmp_dir = TemporaryDirectory()
|
||||
self.request_event = threading.Event()
|
||||
self.request_response = None
|
||||
self.response_metadata = None
|
||||
|
||||
self.ref_batch_size = self.REF_BATCH_SIZE
|
||||
self.remote_refs = {}
|
||||
|
||||
self.response_progress = 0
|
||||
self.previous_progress = 0
|
||||
self.response_size = None
|
||||
self.response_transfer_size = None
|
||||
self.progress_updated_at = None
|
||||
self.progress_enabled = False
|
||||
|
||||
if configdir != None: self.configdir = configdir
|
||||
else:
|
||||
if os.path.isdir(self.userdir+"/.config/rngit") and os.path.isfile(self.userdir+"/.config/rngit/config"): self.configdir = self.userdir+"/.rngit/reticulum"
|
||||
else: self.configdir = self.userdir+"/.rngit"
|
||||
|
||||
self.logfile = self.configdir+"/client_log"
|
||||
self.configpath = self.configdir+"/client_config"
|
||||
self.identitypath = self.configdir+"/client_identity"
|
||||
|
||||
RNS.logfile = self.logfile
|
||||
try: self.reticulum = RNS.Reticulum(configdir=rnsconfigdir, logdest=RNS.LOG_FILE)
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize Reticulum: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
if os.path.isfile(self.configpath):
|
||||
try: self.config = ConfigObj(self.configpath)
|
||||
except Exception as e:
|
||||
RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR)
|
||||
return
|
||||
|
||||
else: self.__create_default_config()
|
||||
|
||||
self.__apply_config()
|
||||
self.ready = True
|
||||
|
||||
def __create_default_config(self):
|
||||
self.config = ConfigObj(__default_rngit_config__)
|
||||
self.config.filename = self.configpath
|
||||
if not os.path.isdir(self.configdir): os.makedirs(self.configdir)
|
||||
self.config.write()
|
||||
|
||||
def __apply_config(self):
|
||||
if "logging" in self.config:
|
||||
section = self.config["logging"]
|
||||
if "loglevel" in section: RNS.loglevel = max(RNS.LOG_NONE, min(RNS.LOG_EXTREME, section.as_int("loglevel")))
|
||||
|
||||
if "client" in self.config:
|
||||
section = self.config["client"]
|
||||
if "ref_batch_size" in section: self.ref_batch_size = max(0, min(1024, section.as_int("ref_batch_size")))
|
||||
|
||||
if not os.path.isfile(self.identitypath):
|
||||
identity = RNS.Identity()
|
||||
identity.to_file(self.identitypath)
|
||||
RNS.log(f"Client identity generated and persisted to {self.identitypath}", RNS.LOG_VERBOSE)
|
||||
|
||||
else:
|
||||
identity = RNS.Identity.from_file(self.identitypath)
|
||||
RNS.log(f"Client identity loaded from {self.identitypath}", RNS.LOG_VERBOSE)
|
||||
|
||||
if not identity:
|
||||
RNS.log("Could not initialize client identity.", RNS.LOG_ERROR)
|
||||
self.ready = False
|
||||
|
||||
else: self.identity = identity
|
||||
|
||||
def abort(self, reason=None, code=255):
|
||||
if not reason: reason = "Unknown reason"
|
||||
print(f"git-remote-rns failed: {reason}", file=sys.stderr)
|
||||
if self.link: self.link.teardown()
|
||||
sys.exit(code)
|
||||
|
||||
def connect_server(self):
|
||||
try: destination_hash = bytes.fromhex(self.destination_hexhash)
|
||||
except Exception as e: self.abort(f"Invalid destination hash: {e}")
|
||||
|
||||
RNS.log(f"Requesting path to {RNS.prettyhexrep(destination_hash)}", RNS.LOG_DEBUG)
|
||||
sys.stderr.write(f"Requesting path..."); sys.stderr.flush()
|
||||
if not RNS.Transport.await_path(destination_hash, timeout=self.path_timeout):
|
||||
sys.stderr.write(f"\n"); sys.stderr.flush()
|
||||
self.abort(f"Could not resolve path to {RNS.prettyhexrep(destination_hash)}")
|
||||
|
||||
else:
|
||||
RNS.log(f"Path to {RNS.prettyhexrep(destination_hash)} resolved", RNS.LOG_DEBUG);
|
||||
sys.stderr.write(f"\rPath resolved "); sys.stderr.flush()
|
||||
|
||||
self.remote_identity = RNS.Identity.recall(destination_hash)
|
||||
if not self.remote_identity: self.abort("Could not recall remote identity. Is the server announcing?")
|
||||
|
||||
sys.stderr.write(f"\rEstablishing link..."); sys.stderr.flush()
|
||||
self.destination = RNS.Destination(self.remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, "repositories")
|
||||
self.link = RNS.Link(self.destination)
|
||||
self.link.set_link_established_callback(self.link_established)
|
||||
self.link.set_link_closed_callback(self.link_closed)
|
||||
|
||||
def link_established(self, link):
|
||||
RNS.log(f"Link established, identifying...", RNS.LOG_DEBUG)
|
||||
sys.stderr.write(f"\rLink established with remote\n"); sys.stderr.flush()
|
||||
link.identify(self.identity)
|
||||
self.link_ready = True
|
||||
|
||||
def link_closed(self, link):
|
||||
RNS.log(f"Link was closed", RNS.LOG_DEBUG)
|
||||
if not self.link_ready: self.link_failed = True
|
||||
|
||||
def _on_progress(self, transfer_instance):
|
||||
if hasattr(transfer_instance, "progress"):
|
||||
self.response_progress = transfer_instance.progress
|
||||
self.response_size = transfer_instance.response_size
|
||||
self.response_transfer_size = transfer_instance.response_transfer_size
|
||||
|
||||
elif hasattr(transfer_instance, "get_progress") and callable(transfer_instance.get_progress):
|
||||
self.response_progress = transfer_instance.get_progress()
|
||||
self.response_size = transfer_instance.total_size
|
||||
self.response_transfer_size = transfer_instance.size
|
||||
|
||||
now = time.time()
|
||||
if self.progress_updated_at == None: self.progress_updated_at = now
|
||||
|
||||
if now > self.progress_updated_at+1:
|
||||
td = now - self.progress_updated_at
|
||||
pd = self.response_progress - self.previous_progress
|
||||
bd = pd*self.response_size if self.response_size else 0
|
||||
self.response_speed = (bd/td)*8 if td > 0 else 0
|
||||
self.previous_progress = self.response_progress
|
||||
self.progress_updated_at = now
|
||||
|
||||
# Report progress to git via stderr
|
||||
if self.progress_enabled and self.response_size:
|
||||
percent = round(self.response_progress * 100, 1)
|
||||
size = self.response_size
|
||||
rxd = size*self.response_progress
|
||||
speed_kbps = (self.response_speed / 1000) if hasattr(self, 'response_speed') else 0
|
||||
sys.stderr.write(f"Transferring: {percent}% ({RNS.prettysize(rxd)}/{RNS.prettysize(size)}) {RNS.prettyspeed(self.response_speed)} \r")
|
||||
sys.stderr.flush()
|
||||
|
||||
################################
|
||||
# Synchronous Request Wrappers #
|
||||
################################
|
||||
|
||||
def _response_ready(self, request_receipt):
|
||||
self.request_response = request_receipt.response
|
||||
self.response_metadata = request_receipt.metadata
|
||||
|
||||
if hasattr(self.request_response, "read") and callable(self.request_response.read):
|
||||
response_path = self.request_response.name
|
||||
base_name = os.path.basename(response_path)
|
||||
retained_path = os.path.join(self.tmp_dir.name, base_name)
|
||||
shutil.move(response_path, retained_path)
|
||||
self.request_response = open(retained_path, "rb")
|
||||
|
||||
self.request_event.set()
|
||||
|
||||
def _response_failed(self, request_receipt=None):
|
||||
self.request_response = None
|
||||
self.request_event.set()
|
||||
|
||||
def send_request(self, path, data, timeout=7200):
|
||||
if not self.link_ready: self.abort("Link not ready for request")
|
||||
|
||||
self.request_event.clear()
|
||||
self.request_response = None
|
||||
self.response_metadata = None
|
||||
self.previous_progress = 0
|
||||
self.progress_updated_at = None
|
||||
|
||||
RNS.log(f"Sending request: {path}", RNS.LOG_DEBUG)
|
||||
request_receipt = self.link.request(path, data, progress_callback=self._on_progress, response_callback=self._response_ready, failed_callback=self._response_failed, timeout=timeout)
|
||||
if request_receipt.resource: request_receipt.resource.progress_callback(self._on_progress)
|
||||
self.request_event.wait(timeout=timeout)
|
||||
|
||||
if self.request_response is None: self.abort("Request failed or timed out")
|
||||
RNS.log(f"Got response for: {path}", RNS.LOG_DEBUG)
|
||||
|
||||
return self.request_response, self.response_metadata
|
||||
|
||||
#############################
|
||||
# Git Helper Protocol Logic #
|
||||
#############################
|
||||
|
||||
def _detach_stdout(self):
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
def run(self):
|
||||
try: self.connect_server()
|
||||
except Exception as e: self.abort(str(e))
|
||||
|
||||
timeout = self.link_timeout
|
||||
while not self.link_ready and not self.link_failed and timeout > 0:
|
||||
time.sleep(0.5)
|
||||
timeout -= 1
|
||||
|
||||
if not self.link_ready: self.abort("Failed to establish link")
|
||||
|
||||
self.progress_enabled = False
|
||||
|
||||
git_stdin = sys.stdin
|
||||
git_stdout = sys.stdout
|
||||
git_stderr = sys.stderr
|
||||
|
||||
fetch_queue = []
|
||||
push_queue = []
|
||||
|
||||
while True:
|
||||
line = git_stdin.readline()
|
||||
if not line: break
|
||||
|
||||
line = line.strip()
|
||||
if line == "capabilities":
|
||||
git_stdout.write("list\n")
|
||||
git_stdout.write("fetch\n")
|
||||
git_stdout.write("push\n")
|
||||
git_stdout.write("option\n")
|
||||
git_stdout.write("\n")
|
||||
git_stdout.flush()
|
||||
|
||||
elif line == "list": self.handle_git_list(git_stdout)
|
||||
|
||||
elif line.startswith("list "): self.handle_git_list(git_stdout, for_push=True) # List for push
|
||||
|
||||
elif line.startswith("option"):
|
||||
# Line format: option <name> <value>
|
||||
parts = line.split(maxsplit=2)
|
||||
opt_name = parts[1] if len(parts) > 1 else ""
|
||||
opt_value = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
if opt_name == "progress": self.progress_enabled = opt_value.lower() in ("true", "1", "yes"); git_stdout.write("ok\n")
|
||||
else: git_stdout.write("unsupported\n")
|
||||
|
||||
git_stdout.flush()
|
||||
|
||||
elif line.startswith("fetch"):
|
||||
# Line format: fetch <sha> <ref>
|
||||
parts = line.split()
|
||||
sha = parts[1]
|
||||
ref = parts[2]
|
||||
# Avoid duplicates in the same batch - TODO: Re-evaluate this
|
||||
if (sha, ref) not in fetch_queue: fetch_queue.append((sha, ref))
|
||||
push_queue = []
|
||||
|
||||
elif line.startswith("push"):
|
||||
# Line format: push <local_ref>:<remote_ref>
|
||||
parts = line.split()
|
||||
refspec = parts[1]
|
||||
local_ref, remote_ref = refspec.split(":", 1)
|
||||
push_queue.append((local_ref, remote_ref))
|
||||
fetch_queue = []
|
||||
|
||||
elif line == "": # End of batch
|
||||
try:
|
||||
self.process_fetch_queue(fetch_queue, git_stdout, self.progress_enabled, self.ref_batch_size)
|
||||
self.process_push_queue(push_queue, git_stdout, git_stderr, self.progress_enabled)
|
||||
fetch_queue = []
|
||||
push_queue = []
|
||||
git_stdout.write("\n")
|
||||
git_stdout.flush()
|
||||
|
||||
except BrokenPipeError:
|
||||
self._detach_stdout()
|
||||
RNS.log("Git closed connection, exiting", RNS.LOG_DEBUG)
|
||||
break
|
||||
|
||||
else: self.abort(f"Unknown Git command: {line}")
|
||||
|
||||
try: sys.stdout.flush()
|
||||
except BrokenPipeError: pass
|
||||
|
||||
if self.link: self.link.teardown()
|
||||
|
||||
def handle_git_list(self, git_stdout, for_push=False):
|
||||
RNS.log("Handle git list" + (" for-push" if for_push else ""), RNS.LOG_DEBUG)
|
||||
request_data = {self.IDX_REPOSITORY: self.repo_path, "for_push": for_push}
|
||||
response, metadata = self.send_request(self.PATH_LIST, request_data)
|
||||
|
||||
if not response or not isinstance(response, bytes): self.abort("Invalid list response from server")
|
||||
|
||||
status_byte = response[0]
|
||||
payload = response[1:]
|
||||
|
||||
if status_byte != 0: self.abort(f"Server refused list: {payload.decode('utf-8', errors='ignore')}")
|
||||
|
||||
response_text = payload.decode("utf-8")
|
||||
|
||||
self.remote_refs = {}
|
||||
for line in response_text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
parts = line.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
sha, ref_name = parts
|
||||
if ref_name == "HEAD": continue
|
||||
self.remote_refs[ref_name] = sha
|
||||
|
||||
git_stdout.write(response_text)
|
||||
git_stdout.write("\n") # Required to terminate list
|
||||
git_stdout.flush()
|
||||
|
||||
def escape_for_stdout(self, value):
|
||||
if isinstance(value, bytes): value = value.decode('utf-8', errors='replace')
|
||||
|
||||
escaped = '"'
|
||||
for char in value:
|
||||
if char == '\\': escaped += '\\\\'
|
||||
elif char == '"': escaped += '\\"'
|
||||
elif char == '\n': escaped += '\\n'
|
||||
elif char == '\t': escaped += '\\t'
|
||||
elif char == '\r': escaped += '\\r'
|
||||
elif ord(char) < 32 or ord(char) > 126: escaped += f'\\x{ord(char):02x}'
|
||||
else: escaped += char
|
||||
|
||||
return escaped + '"'
|
||||
|
||||
def process_fetch_queue(self, fetch_queue, git_stdout, progress_enabled=False, ref_batch_size=REF_BATCH_SIZE):
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
if not fetch_queue: return
|
||||
|
||||
# Build a global have list from all remote refs that the client already has objects for
|
||||
have_shas = []
|
||||
for sha in self.remote_refs.values():
|
||||
try:
|
||||
result = subprocess.run(["git", "cat-file", "-t", sha], capture_output=True, check=False)
|
||||
if result.returncode == 0: have_shas.append(sha)
|
||||
|
||||
except Exception as e: RNS.log(f"Could not verify remote SHA {sha} locally: {e}", RNS.LOG_WARNING)
|
||||
|
||||
while fetch_queue:
|
||||
batch = fetch_queue[:ref_batch_size]
|
||||
fetch_queue = fetch_queue[ref_batch_size:]
|
||||
|
||||
refs_list = []
|
||||
for sha, ref in batch:
|
||||
ref_entry = {"sha": sha, "ref": ref}
|
||||
try:
|
||||
# Attempt to get local ref SHA for incremental bundle generation on remote
|
||||
result = subprocess.run(["git", "rev-parse", ref], capture_output=True, text=True, check=False)
|
||||
if result.returncode == 0:
|
||||
local_sha = result.stdout.strip()
|
||||
if local_sha != sha: ref_entry["have"] = local_sha
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Could not resolve local SHA for {ref} during fetch enumeration, getting full history for this ref: {e}", RNS.LOG_WARNING)
|
||||
|
||||
refs_list.append(ref_entry)
|
||||
|
||||
ref_names = [ref for _, ref in batch]
|
||||
RNS.log(f"Fetching batch of {len(refs_list)} refs: {ref_names} (have {len(have_shas)} common objects)", RNS.LOG_DEBUG)
|
||||
|
||||
request_data = { self.IDX_REPOSITORY: self.repo_path, "refs": refs_list }
|
||||
if have_shas: request_data["have"] = have_shas
|
||||
|
||||
response, metadata = self.send_request(self.PATH_FETCH, request_data)
|
||||
|
||||
if not response: self.abort(f"No data in fetch response for batch")
|
||||
if not metadata:
|
||||
if not isinstance(response, bytes): self.abort(f"Invalid fetch response for batch")
|
||||
status_byte = response[0]
|
||||
|
||||
if status_byte == 0:
|
||||
RNS.log(f"Server returned empty bundle, all objects already exist locally", RNS.LOG_DEBUG)
|
||||
continue
|
||||
|
||||
else:
|
||||
error_msg = response[1:].decode('utf-8', errors='ignore')
|
||||
self.abort(f"Fetch failed for batch: {error_msg}")
|
||||
|
||||
else:
|
||||
if not self.IDX_RESULT_CODE in metadata: self.abort(f"No result metadata on bundle response")
|
||||
status_byte = metadata[self.IDX_RESULT_CODE]
|
||||
if status_byte == 0: bundle_path = response.name
|
||||
else: self.abort(f"Unknown remote state for batch ref fetch")
|
||||
|
||||
if progress_enabled:
|
||||
size = os.stat(bundle_path).st_size
|
||||
sys.stderr.write(f"Transferring: 100% ({RNS.prettysize(size)}). \n")
|
||||
sys.stderr.flush()
|
||||
|
||||
stderr_arg = sys.stderr if progress_enabled else subprocess.DEVNULL
|
||||
|
||||
verify_cmd = ["git", "bundle", "verify", "-q", bundle_path]
|
||||
verify_result = subprocess.run(verify_cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
||||
|
||||
if verify_result.returncode != 0: self.abort(f"Bundle verification failed for batch")
|
||||
|
||||
unbundle_cmd = ["git", "bundle", "unbundle"]
|
||||
if progress_enabled: unbundle_cmd.append("--progress")
|
||||
unbundle_cmd.append(bundle_path)
|
||||
|
||||
unbundle_result = subprocess.run(unbundle_cmd, stderr=stderr_arg, stdout=subprocess.DEVNULL)
|
||||
|
||||
if unbundle_result.returncode != 0: self.abort(f"Bundle unbundle failed for batch: Non-zero return code")
|
||||
|
||||
def process_push_queue(self, push_queue, git_stdout, git_stderr, progress_enabled=False):
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
for local_ref, remote_ref in push_queue:
|
||||
RNS.log(f"Pushing {local_ref} to {remote_ref}", RNS.LOG_DEBUG)
|
||||
|
||||
# Handle potential deletions
|
||||
if not local_ref or local_ref == "":
|
||||
request_data = { self.IDX_REPOSITORY: self.repo_path, "ref": remote_ref }
|
||||
response, metadata = self.send_request(self.PATH_DELETE, request_data)
|
||||
|
||||
if not response or not isinstance(response, bytes):
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout('No response from server')}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
status_byte = response[0]
|
||||
if status_byte != 0:
|
||||
error_msg = response[1:].decode("utf-8", errors="ignore")
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
git_stdout.write(f"ok {remote_ref}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
force = local_ref.startswith("+")
|
||||
if force: local_ref = local_ref[1:]
|
||||
|
||||
stderr_arg = sys.stderr if progress_enabled else subprocess.DEVNULL
|
||||
|
||||
# Resolve the SHA that local_ref points to
|
||||
sha_result = subprocess.run(["git", "rev-parse", local_ref], capture_output=True, text=True, check=False)
|
||||
if sha_result.returncode != 0:
|
||||
error_msg = f"Could not resolve local ref {local_ref}"
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
local_sha = sha_result.stdout.strip()
|
||||
|
||||
bundle_empty = False
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bundle_path = tmpdir + "/push.bundle"
|
||||
|
||||
create_cmd = ["git", "bundle", "create", bundle_path, local_ref]
|
||||
|
||||
# Exclude all remote ref SHAs that exist locally, so the
|
||||
# bundle only contains objects the remote doesn't already have
|
||||
exclude_count = 0
|
||||
for sha in self.remote_refs.values():
|
||||
try:
|
||||
# We need to verify each SHA actually exists locally, since git
|
||||
# bundle create will fail if a ^<sha> argument references an object
|
||||
# not present in the local repository.
|
||||
result = subprocess.run(["git", "cat-file", "-t", sha], capture_output=True, check=False)
|
||||
if result.returncode == 0:
|
||||
create_cmd.append(f"^{sha}")
|
||||
exclude_count += 1
|
||||
|
||||
except Exception as e: RNS.log(f"Could not verify remote SHA {sha} locally: {e}", RNS.LOG_WARNING)
|
||||
|
||||
RNS.log(f"Excluding {exclude_count}/{len(self.remote_refs)} remote refs for {local_ref}", RNS.LOG_DEBUG)
|
||||
|
||||
if progress_enabled: create_cmd.insert(3, "--progress")
|
||||
|
||||
create_result = subprocess.run(create_cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
if create_result.returncode == 0:
|
||||
if create_result.stderr:
|
||||
# git_stderr.write(create_result.stderr)
|
||||
pass
|
||||
else:
|
||||
if "empty bundle" in create_result.stderr.lower():
|
||||
# All objects reachable from local_ref already exist on
|
||||
# the remote. In this case, no bundle is needed and we can
|
||||
# update the ref directly via the operations path instead.
|
||||
bundle_empty = True
|
||||
RNS.log(f"Empty bundle for {local_ref}, all objects already on remote", RNS.LOG_DEBUG)
|
||||
|
||||
else:
|
||||
if progress_enabled and create_result.stderr: git_stderr.write(create_result.stderr)
|
||||
error_msg = "Bundle creation failed"
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
if not bundle_empty:
|
||||
with open(bundle_path, "rb") as f: bundle_data = f.read()
|
||||
|
||||
request_data = { self.IDX_REPOSITORY: self.repo_path, "local_ref": local_ref, "remote_ref": remote_ref,
|
||||
"force": force, "bundle": bundle_data }
|
||||
|
||||
response, metadata = self.send_request(self.PATH_PUSH, request_data)
|
||||
|
||||
if not response or not isinstance(response, bytes):
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout('No response from server')}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
status_byte = response[0]
|
||||
if status_byte != 0:
|
||||
error_msg = response[1:].decode('utf-8', errors='ignore')
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
# When all reachable objects already exist on the remote, send a
|
||||
# direct ref update operation instead of a bundle.
|
||||
if bundle_empty:
|
||||
operation = {"action": "update_ref", "ref": remote_ref, "sha": local_sha, "force": force}
|
||||
request_data = { self.IDX_REPOSITORY: self.repo_path,
|
||||
"operations": [operation] }
|
||||
|
||||
response, metadata = self.send_request(self.PATH_PUSH, request_data)
|
||||
|
||||
if not response or not isinstance(response, bytes):
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout('No response from server')}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
status_byte = response[0]
|
||||
if status_byte != 0:
|
||||
error_msg = response[1:].decode('utf-8', errors='ignore')
|
||||
git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n")
|
||||
git_stdout.flush()
|
||||
continue
|
||||
|
||||
git_stdout.write(f"ok {remote_ref}\n")
|
||||
git_stdout.flush()
|
||||
|
||||
|
||||
__default_rngit_config__ = '''# This is the default rngit client config file.
|
||||
|
||||
[client]
|
||||
|
||||
# You can control the batch size of ref transfers
|
||||
# using the ref_batch_size directive:
|
||||
|
||||
ref_batch_size = 25
|
||||
|
||||
[logging]
|
||||
# Valid log levels are 0 through 7:
|
||||
# 0: Log only critical information
|
||||
# 1: Log errors and lower log levels
|
||||
# 2: Log warnings and lower log levels
|
||||
# 3: Log notices and lower log levels
|
||||
# 4: Log info and lower (this is the default)
|
||||
# 5: Verbose logging
|
||||
# 6: Debug logging
|
||||
# 7: Extreme logging
|
||||
|
||||
loglevel = 4
|
||||
|
||||
'''.splitlines()
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
@@ -0,0 +1,379 @@
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import os
|
||||
import io
|
||||
import RNS
|
||||
|
||||
class SyntaxHighlighter:
|
||||
|
||||
def __init__(self, theme=None):
|
||||
self.pygments_available = False
|
||||
self.pygments = None
|
||||
self._lexer_cache = {}
|
||||
self._check_pygments()
|
||||
self.theme = theme or self._get_default_theme()
|
||||
|
||||
def _get_default_theme(self):
|
||||
return {
|
||||
# Control flow - warm coral-red
|
||||
"keyword": "ff7b72",
|
||||
"keyword_constant": "ff7b72",
|
||||
"keyword_control": "ff7b72",
|
||||
"keyword_declaration": "ff7b72",
|
||||
|
||||
# Function definitions - bright sky blue
|
||||
"function_def": "79c0ff",
|
||||
"function_magic": "ff7b72",
|
||||
|
||||
# Function calls - soft lavender
|
||||
"function_call": "d2a8ff",
|
||||
"function_builtin": "ffa657", # amber
|
||||
|
||||
# Class definitions - fresh mint green
|
||||
"class_def": "7ee787",
|
||||
"class_ref": "56d364", # muted when referenced
|
||||
|
||||
# Instance context - soft pink
|
||||
"self": "ff9bce",
|
||||
"cls": "ff9bce",
|
||||
|
||||
# Data literals - cool, calm ice blue
|
||||
"string": "a5d6ff",
|
||||
"string_quoted": "a5d6ff",
|
||||
"string_doc": "8b949e", # docstrings - like comments
|
||||
"string_interpol": "ffd700", # f-string braces - gold
|
||||
"string_escape": "ffea00", # escape sequences - bright yellow
|
||||
|
||||
# Numbers - same as function def
|
||||
"number": "79c0ff",
|
||||
"number_float": "79c0ff",
|
||||
"number_integer": "79c0ff",
|
||||
"number_hex": "79c0ff",
|
||||
|
||||
# Comments - muted gray
|
||||
"comment": "8b949e",
|
||||
"comment_doc": "8b949e",
|
||||
"comment_preproc": "ff7b72", # preprocessor directives
|
||||
|
||||
# Operators - distinct pink/red for visibility
|
||||
"operator": "ff7b72", # General operators - coral
|
||||
"operator_arithmetic": "ff7b72", # +, -, *, /, etc.
|
||||
"operator_comparison": "ff7b72", # ==, !=, <, >, etc.
|
||||
"operator_assignment": "ff7b72", # =, +=, -=, etc.
|
||||
"operator_word": "ff7b72", # and, or, not, in, is
|
||||
"operator_dot": "c9d1d9", # . - subtle for attribute access
|
||||
|
||||
# Punctuation - neutral
|
||||
"punctuation": "b4b4b4",
|
||||
"punctuation_brace": "b4b4b4", # [, ], {, }
|
||||
"punctuation_paren": "b4b4b4", # (, )
|
||||
"punctuation_colon": "b4b4b4", # :, ;
|
||||
"punctuation_comma": "8b949e", # , - slightly dimmed
|
||||
|
||||
# Decorators - burnt orange
|
||||
"decorator": "f0883e",
|
||||
|
||||
# Constants - same as keywords
|
||||
"constant": "ff7b72",
|
||||
"constant_builtin": "ff7b72", # True, False, None
|
||||
|
||||
# Type hints and annotations - amber
|
||||
"type_hint": "ffa657",
|
||||
"type_builtin": "ffa657",
|
||||
|
||||
# Exception handling - alert red
|
||||
"exception": "f85149",
|
||||
"exception_builtin": "f85149",
|
||||
|
||||
# Names and attributes - near-white for readability
|
||||
"name": "e6edf3",
|
||||
"attribute": "e6edf3",
|
||||
"attribute_call": "d2a8ff", # Function/method calls after dot - lavender
|
||||
"variable": "e6edf3",
|
||||
"parameter": "e6edf3",
|
||||
|
||||
# Namespaces and modules
|
||||
"namespace": "7ee787",
|
||||
"module": "a5d6ff",
|
||||
|
||||
# Generic tokens
|
||||
"generic_heading": "c9d1d9",
|
||||
"generic_subheading": "c9d1d9",
|
||||
"generic_prompt": "8b949e",
|
||||
"generic_error": "f85149",
|
||||
"generic_deleted": "f85149",
|
||||
"generic_inserted": "7ee787",
|
||||
"generic_output": "e6edf3",
|
||||
|
||||
# Text and whitespace - no color (None means no color tag)
|
||||
"text": None,
|
||||
"whitespace": None,
|
||||
}
|
||||
|
||||
def _check_pygments(self):
|
||||
try:
|
||||
import pygments
|
||||
from pygments.lexers import get_lexer_for_filename, guess_lexer, get_lexer_by_name
|
||||
from pygments.formatter import Formatter
|
||||
from pygments.token import Token
|
||||
|
||||
self.pygments = pygments
|
||||
self.pygments_available = True
|
||||
RNS.log("Pygments syntax highlighting available", RNS.LOG_DEBUG)
|
||||
|
||||
except ImportError:
|
||||
self.pygments_available = False
|
||||
RNS.log("Pygments not available, using plain text rendering", RNS.LOG_DEBUG)
|
||||
|
||||
def highlight(self, content, filename=None, language=None):
|
||||
if not content: return self._plain_text(content)
|
||||
|
||||
if self.pygments_available:
|
||||
try:
|
||||
highlighted = self._highlight_pygments(content, filename, language)
|
||||
# Fix pygments insisting on trailing newlines
|
||||
if highlighted.endswith("\n") and not content.endswith("\n"): highlighted = highlighted[:-1]
|
||||
return highlighted
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Pygments highlighting failed, falling back: {e}", RNS.LOG_WARNING)
|
||||
return self._plain_text(content)
|
||||
|
||||
# TODO: Implement Python tokenize fallback for .py files.
|
||||
# For now, route to plain text
|
||||
if filename and filename.endswith(".py"):
|
||||
return self._plain_text(content)
|
||||
|
||||
# Universal fallback
|
||||
return self._plain_text(content)
|
||||
|
||||
def _highlight_pygments(self, content, filename=None, language=None):
|
||||
from pygments.lexers import get_lexer_for_filename, guess_lexer, get_lexer_by_name
|
||||
from pygments.util import ClassNotFound
|
||||
|
||||
lexer = None
|
||||
if language:
|
||||
if language == "env": language = "bash"
|
||||
if language == "environment": language = "bash"
|
||||
try: lexer = get_lexer_by_name(language)
|
||||
except ClassNotFound: pass
|
||||
|
||||
if lexer is None and filename:
|
||||
try: lexer = get_lexer_for_filename(filename)
|
||||
except ClassNotFound: pass
|
||||
|
||||
if lexer is None:
|
||||
try:
|
||||
if len(content) > 20: lexer = guess_lexer(content)
|
||||
except ClassNotFound: pass
|
||||
|
||||
if lexer is None: return self._plain_text(content)
|
||||
|
||||
formatter = MicronFormatter(theme=self.theme)
|
||||
result = self.pygments.highlight(content, lexer, formatter)
|
||||
return result
|
||||
|
||||
def _plain_text(self, content):
|
||||
escaped = self._escape_micron(content)
|
||||
return f"`=\n{escaped}\n`="
|
||||
|
||||
@staticmethod
|
||||
def _escape_micron(text): return text.replace("`", "\\`")
|
||||
|
||||
|
||||
class MicronFormatter:
|
||||
def __init__(self, theme, **options):
|
||||
self.theme = theme
|
||||
self.options = options
|
||||
|
||||
def format(self, tokensource, outfile):
|
||||
output_parts = []
|
||||
prev_was_dot = False
|
||||
|
||||
for ttype, value in tokensource:
|
||||
is_dot = (str(ttype) == "Token.Operator" and value == ".")
|
||||
|
||||
# If previous token was a dot and this is a Name, treat as attribute/function call
|
||||
# TODO: Improve this if we can check next token as parantheses or something.
|
||||
if prev_was_dot and str(ttype).startswith("Token.Name") and value:
|
||||
color = self._get_color_from_key("attribute_call")
|
||||
if color:
|
||||
escaped = self._escape_value(value)
|
||||
output_parts.append(f"`FT{color}{escaped}`f")
|
||||
else:
|
||||
output_parts.append(self._escape_value(value))
|
||||
|
||||
else:
|
||||
color_key = self._get_color_key_for_token(ttype)
|
||||
color = self._get_color_from_key(color_key)
|
||||
|
||||
if color and value:
|
||||
escaped = self._escape_value(value)
|
||||
output_parts.append(f"`FT{color}{escaped}`f")
|
||||
|
||||
else: output_parts.append(self._escape_value(value))
|
||||
|
||||
prev_was_dot = is_dot
|
||||
|
||||
outfile.write("".join(output_parts))
|
||||
|
||||
def _get_color_key_for_token(self, ttype):
|
||||
token_parts = []
|
||||
current = ttype
|
||||
while current:
|
||||
token_parts.insert(0, current[0] if isinstance(current, tuple) else str(current).split(".")[-1])
|
||||
current = current.parent if hasattr(current, "parent") else None
|
||||
|
||||
token_str = ".".join(["Token"] + token_parts[1:] if len(token_parts) > 1 else token_parts)
|
||||
|
||||
current_type = ttype
|
||||
while current_type:
|
||||
token_key = str(current_type)
|
||||
if token_key in granular_token_map: return granular_token_map[token_key]
|
||||
|
||||
# Move to parent
|
||||
current_type = current_type.parent if hasattr(current_type, "parent") else None
|
||||
|
||||
return None
|
||||
|
||||
def _get_color_from_key(self, color_key):
|
||||
if color_key and color_key in self.theme: return self.theme[color_key]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _escape_value(value: str) -> str: return value.replace("`", "\\`")
|
||||
|
||||
# Required by Pygments formatter API, returns None for Micron
|
||||
def get_style_defs(self, arg=None): return None
|
||||
|
||||
|
||||
# Convenience function for direct use
|
||||
def highlight_code(content: str, filename: str = None, language: str = None, theme=None) -> str:
|
||||
highlighter = SyntaxHighlighter(theme=theme)
|
||||
return highlighter.highlight(content, filename, language)
|
||||
|
||||
granular_token_map = {
|
||||
# Keywords with semantic distinction
|
||||
"Token.Keyword": "keyword",
|
||||
"Token.Keyword.Constant": "keyword_constant",
|
||||
"Token.Keyword.Declaration": "keyword_declaration",
|
||||
"Token.Keyword.Namespace": "keyword_control",
|
||||
"Token.Keyword.Pseudo": "keyword_control",
|
||||
"Token.Keyword.Reserved": "keyword_control",
|
||||
"Token.Keyword.Type": "type_builtin",
|
||||
|
||||
# Names - functions with definition vs call distinction
|
||||
"Token.Name.Function": "function_call",
|
||||
"Token.Name.Function.Magic": "function_magic",
|
||||
"Token.Name.Class": "class_ref",
|
||||
"Token.Name.Builtin": "function_builtin",
|
||||
"Token.Name.Builtin.Pseudo": "constant_builtin",
|
||||
"Token.Name.Exception": "exception_builtin",
|
||||
"Token.Name.Decorator": "decorator",
|
||||
"Token.Name.Namespace": "namespace",
|
||||
"Token.Name.Attribute": "attribute",
|
||||
"Token.Name.Variable": "variable",
|
||||
"Token.Name.Variable.Magic": "function_magic",
|
||||
"Token.Name.Other": "name",
|
||||
"Token.Name": "name",
|
||||
"Token.Name.Tag": "keyword", # HTML/XML tags
|
||||
"Token.Name.Constant": "constant",
|
||||
"Token.Name.Label": "name",
|
||||
"Token.Name.Entity": "name",
|
||||
|
||||
# Literals - strings with detailed handling
|
||||
"Token.Literal.String": "string",
|
||||
"Token.Literal.String.Affix": "string", # f, r, b prefixes
|
||||
"Token.Literal.String.Backtick": "string",
|
||||
"Token.Literal.String.Char": "string",
|
||||
"Token.Literal.String.Delimiter": "string",
|
||||
"Token.Literal.String.Doc": "string_doc",
|
||||
"Token.Literal.String.Double": "string_quoted",
|
||||
"Token.Literal.String.Escape": "string_escape",
|
||||
"Token.Literal.String.Heredoc": "string",
|
||||
"Token.Literal.String.Interpol": "string_interpol",
|
||||
"Token.Literal.String.Other": "string",
|
||||
"Token.Literal.String.Regex": "string",
|
||||
"Token.Literal.String.Single": "string_quoted",
|
||||
"Token.Literal.String.Symbol": "string",
|
||||
|
||||
# Numbers
|
||||
"Token.Literal.Number": "number",
|
||||
"Token.Literal.Number.Bin": "number",
|
||||
"Token.Literal.Number.Float": "number_float",
|
||||
"Token.Literal.Number.Hex": "number_hex",
|
||||
"Token.Literal.Number.Integer": "number_integer",
|
||||
"Token.Literal.Number.Integer.Long": "number_integer",
|
||||
"Token.Literal.Number.Oct": "number",
|
||||
"Token.Literal": "string",
|
||||
"Token.Literal.Date": "string",
|
||||
|
||||
# Operators - all operators get distinct coloring
|
||||
"Token.Operator": "operator",
|
||||
"Token.Operator.Word": "operator_word",
|
||||
"Token.Operator.Comparison": "operator_comparison",
|
||||
"Token.Operator.Assignment": "operator_assignment",
|
||||
"Token.Operator.Arithmetic": "operator_arithmetic",
|
||||
|
||||
# Punctuation - braces, parens, colons, commas
|
||||
"Token.Punctuation": "punctuation",
|
||||
"Token.Punctuation.Marker": "punctuation",
|
||||
"Token.Punctuation.Brace": "punctuation_brace",
|
||||
"Token.Punctuation.Bracket": "punctuation_brace",
|
||||
"Token.Punctuation.Parenthesis": "punctuation_paren",
|
||||
"Token.Punctuation.Colon": "punctuation_colon",
|
||||
"Token.Punctuation.Comma": "punctuation_comma",
|
||||
|
||||
# Comments
|
||||
"Token.Comment": "comment",
|
||||
"Token.Comment.Hashbang": "comment",
|
||||
"Token.Comment.Multiline": "comment_doc",
|
||||
"Token.Comment.Preproc": "comment_preproc",
|
||||
"Token.Comment.Single": "comment",
|
||||
"Token.Comment.Special": "comment",
|
||||
|
||||
# Generic tokens
|
||||
"Token.Generic.Deleted": "generic_deleted",
|
||||
"Token.Generic.Emph": "text",
|
||||
"Token.Generic.Error": "generic_error",
|
||||
"Token.Generic.Heading": "generic_heading",
|
||||
"Token.Generic.Inserted": "generic_inserted",
|
||||
"Token.Generic.Output": "generic_output",
|
||||
"Token.Generic.Prompt": "generic_prompt",
|
||||
"Token.Generic.Strong": "text",
|
||||
"Token.Generic.Subheading": "generic_subheading",
|
||||
"Token.Generic.Traceback": "generic_error",
|
||||
"Token.Generic": "text",
|
||||
|
||||
# Text and whitespace
|
||||
"Token.Text": "text",
|
||||
"Token.Text.Whitespace": "whitespace",
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import sys
|
||||
from RNS.Utilities.rngit import client, server
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[0]
|
||||
if cmd == "rngit": ec = server.main()
|
||||
elif cmd == "git-remote-rns": ec = client.main()
|
||||
else: raise NotImplementedError(f"The {cmd} executable entrypoint is not yet implemented in rngit")
|
||||
|
||||
sys.exit(ec)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import re
|
||||
import RNS
|
||||
|
||||
class MarkdownToMicron:
|
||||
BOLD = "`!"
|
||||
BOLD_END = "`!"
|
||||
ITALIC = "`*"
|
||||
ITALIC_END = "`*"
|
||||
UNDERLINE = "`_"
|
||||
UNDERLINE_END = "`_"
|
||||
|
||||
CODE_BG = "`BT282828"
|
||||
CODE_BG_INLINE = "`BT383838"
|
||||
CODE_FG = "`Fddd"
|
||||
CODE_RESET = "`f`b"
|
||||
|
||||
LITERAL_START = "`="
|
||||
LITERAL_END = "`="
|
||||
|
||||
BULLET = "•"
|
||||
|
||||
# Regex patterns for markdown elements
|
||||
HEADER_RE = re.compile(r'^(#{1,6})\s+(.+)$')
|
||||
CODE_FENCE_RE = re.compile(r'^(\s*)```(.*)$')
|
||||
HORIZONTAL_RULE_RE = re.compile(r'^(\s*)(---+|===+|\*\*\*+|___+)\s*$')
|
||||
UNORDERED_LIST_RE = re.compile(r'^(\s*)([-*+])\s+(.+)$')
|
||||
|
||||
# Table patterns
|
||||
TABLE_ROW_RE = re.compile(r'^\s*\|?(.+?)\|?\s*$')
|
||||
TABLE_SEP_RE = re.compile(r'^\s*\|?(?:\s*:?-+:?\s*\|)+\s*$')
|
||||
|
||||
# Inline patterns (processed in order of specificity)
|
||||
LINK_RE = re.compile(r'\[([^\]]+)\]\(([^)]+)\)')
|
||||
INLINE_CODE_RE = re.compile(r'`([^`]+)`')
|
||||
BOLD_RE = re.compile(r'\*\*(.+?)\*\*|__(.+?)__')
|
||||
ITALIC_RE = re.compile(r'\*(.+?)\*|_(.+?)_')
|
||||
|
||||
TABLE_H = "─"
|
||||
TABLE_V = "│"
|
||||
TABLE_TL = "┌"
|
||||
TABLE_TR = "┐"
|
||||
TABLE_BL = "└"
|
||||
TABLE_BR = "┘"
|
||||
TABLE_ML = "├"
|
||||
TABLE_MR = "┤"
|
||||
TABLE_TM = "┬"
|
||||
TABLE_BM = "┴"
|
||||
TABLE_MM = "┼"
|
||||
|
||||
TABLE_MIN_COL_WIDTH = 3
|
||||
|
||||
def __init__(self, max_width=100, syntax_highlighter=None):
|
||||
self.max_width = max_width
|
||||
self.syntax_highlighter = syntax_highlighter
|
||||
self.wcwidth = None
|
||||
|
||||
try:
|
||||
import wcwidth
|
||||
self.wcwidth = wcwidth
|
||||
|
||||
except: RNS.log(f"The wcwidth module is unavailable, display width calculations for some glyphs will be incorrect", RNS.LOG_WARNING)
|
||||
|
||||
def display_width(self, text):
|
||||
if not self.wcwidth: return len(text)
|
||||
else:
|
||||
# wcswidth returns -1 for non-printable strings,
|
||||
# fallback to len in this case
|
||||
w = self.wcwidth.wcswidth(text)
|
||||
return w if w is not None and w >= 0 else len(text)
|
||||
|
||||
def format_block(self, text):
|
||||
lines = text.split('\n')
|
||||
result_lines = []
|
||||
in_code_block = False
|
||||
code_block_lang = None
|
||||
code_buffer = []
|
||||
in_table = False
|
||||
table_buffer = []
|
||||
|
||||
def flush_table_buffer():
|
||||
nonlocal result_lines, table_buffer, in_table
|
||||
if not table_buffer:
|
||||
in_table = False
|
||||
return
|
||||
|
||||
if len(table_buffer) >= 2 and self._is_table_separator(table_buffer[1]):
|
||||
formatted_lines = self.format_table(table_buffer)
|
||||
result_lines.extend(formatted_lines)
|
||||
|
||||
else:
|
||||
for line in table_buffer: result_lines.append(self.format_line(line))
|
||||
|
||||
table_buffer = []
|
||||
in_table = False
|
||||
|
||||
def flush_code_block():
|
||||
nonlocal result_lines, code_buffer, code_block_lang
|
||||
if not code_buffer:
|
||||
return
|
||||
|
||||
code_content = '\n'.join(code_buffer)
|
||||
|
||||
if self.syntax_highlighter and code_block_lang:
|
||||
try:
|
||||
highlighted = self.syntax_highlighter.highlight(code_content, language=code_block_lang)
|
||||
result_lines.append(f"{self.CODE_BG}{self.CODE_FG}")
|
||||
result_lines.append(highlighted)
|
||||
result_lines.append(self.CODE_RESET)
|
||||
|
||||
except Exception:
|
||||
# Fallback to plain literal block on any error
|
||||
result_lines.append(f"{self.CODE_BG}{self.CODE_FG}")
|
||||
result_lines.append(self.LITERAL_START)
|
||||
result_lines.append(self._escape_literals(code_content))
|
||||
result_lines.append(self.LITERAL_END)
|
||||
result_lines.append(self.CODE_RESET)
|
||||
else:
|
||||
result_lines.append(f"{self.CODE_BG}{self.CODE_FG}")
|
||||
result_lines.append(self.LITERAL_START)
|
||||
result_lines.append(self._escape_literals(code_content))
|
||||
result_lines.append(self.LITERAL_END)
|
||||
result_lines.append(self.CODE_RESET)
|
||||
|
||||
code_buffer = []
|
||||
|
||||
for line in lines:
|
||||
is_fence, lang_hint = self._detect_code_fence(line)
|
||||
|
||||
if is_fence:
|
||||
# Flush any pending table before code fence
|
||||
flush_table_buffer()
|
||||
|
||||
if not in_code_block:
|
||||
# Opening fence, start buffering
|
||||
in_code_block = True
|
||||
code_block_lang = lang_hint.strip() if lang_hint else None
|
||||
code_buffer = []
|
||||
|
||||
else:
|
||||
# Closing fence, flush highlighted code
|
||||
flush_code_block()
|
||||
in_code_block = False
|
||||
code_block_lang = None
|
||||
|
||||
else:
|
||||
if in_code_block:
|
||||
# Buffer code lines for later highlighting
|
||||
code_buffer.append(line)
|
||||
else:
|
||||
if self._is_table_row(line):
|
||||
if not in_table:
|
||||
in_table = True
|
||||
table_buffer = [line]
|
||||
|
||||
else: table_buffer.append(line)
|
||||
|
||||
else:
|
||||
# Line breaks table, flush buffer
|
||||
if in_table: flush_table_buffer()
|
||||
formatted = self.format_line(line)
|
||||
result_lines.append(formatted)
|
||||
|
||||
# Handle unclosed structures
|
||||
if in_table: flush_table_buffer()
|
||||
if in_code_block:
|
||||
# Unclosed code block, flush what we have
|
||||
flush_code_block()
|
||||
|
||||
return '\n'.join(result_lines)
|
||||
|
||||
def format_line(self, line, mode="normal"):
|
||||
if mode == "codeblock": return self._escape_literals(line)
|
||||
|
||||
if self.HORIZONTAL_RULE_RE.match(line): return self._format_horizontal_rule()
|
||||
|
||||
header_match = self.HEADER_RE.match(line)
|
||||
if header_match: return self._format_header(header_match)
|
||||
|
||||
list_match = self.UNORDERED_LIST_RE.match(line)
|
||||
if list_match: return self._format_list_item(list_match)
|
||||
|
||||
line = self._format_inline(line)
|
||||
|
||||
return line
|
||||
|
||||
def _format_inline(self, text):
|
||||
code_blocks = []
|
||||
def extract_code(match):
|
||||
code_blocks.append(match.group(1))
|
||||
return f"\x00CODE{len(code_blocks)-1}\x00"
|
||||
|
||||
links = []
|
||||
def extract_link(match):
|
||||
links.append((match.group(1), match.group(2)))
|
||||
return f"\x00LINK{len(links)-1}\x00"
|
||||
|
||||
text = self.INLINE_CODE_RE.sub(extract_code, text)
|
||||
text = self.LINK_RE.sub(extract_link, text)
|
||||
text = self.BOLD_RE.sub(self._bold_sub, text)
|
||||
text = self.ITALIC_RE.sub(self._italic_sub, text)
|
||||
|
||||
def restore_link(match):
|
||||
idx = int(match.group(1))
|
||||
text, url = links[idx]
|
||||
text = text.replace('`', '')
|
||||
return f"`!`[{text}`{url}]`!"
|
||||
|
||||
text = re.sub(r'\x00LINK(\d+)\x00', restore_link, text)
|
||||
|
||||
def restore_code(match):
|
||||
idx = int(match.group(1))
|
||||
content = code_blocks[idx]
|
||||
|
||||
# Disabled for now
|
||||
# highlighted = self._highlight_inline_code(content)
|
||||
# if highlighted: return highlighted
|
||||
|
||||
# Use plain inline code formatting
|
||||
content = content.replace('`', '\\`')
|
||||
return f"{self.CODE_BG_INLINE}{self.CODE_FG}{content}{self.CODE_RESET}"
|
||||
|
||||
text = re.sub(r'\x00CODE(\d+)\x00', restore_code, text)
|
||||
return text
|
||||
|
||||
def _highlight_inline_code(self, content):
|
||||
if not self.syntax_highlighter: return None
|
||||
return self.syntax_highlighter.highlight(content, language=None)
|
||||
|
||||
def _bold_sub(self, match):
|
||||
content = match.group(1) or match.group(2)
|
||||
return f"{self.BOLD}{content}{self.BOLD_END}"
|
||||
|
||||
def _italic_sub(self, match):
|
||||
content = match.group(1) or match.group(2)
|
||||
return f"{self.ITALIC}{content}{self.ITALIC_END}"
|
||||
|
||||
def _format_header(self, match):
|
||||
hashes = match.group(1)
|
||||
content = match.group(2)
|
||||
level = len(hashes)
|
||||
prefix = ">" * min(level, 6)
|
||||
return f"{prefix}{content}"
|
||||
|
||||
def _format_list_item(self, match):
|
||||
indent = match.group(1)
|
||||
content = match.group(3)
|
||||
content = self._format_inline(content)
|
||||
return f"{indent} {self.BULLET} {content}"
|
||||
|
||||
def _format_horizontal_rule(self):
|
||||
return "-"
|
||||
|
||||
def _detect_code_fence(self, line):
|
||||
match = self.CODE_FENCE_RE.match(line)
|
||||
if match:
|
||||
# match.group(2) contains everything after the backticks (language hint)
|
||||
return True, match.group(2)
|
||||
return False, ""
|
||||
|
||||
def _is_table_row(self, line):
|
||||
if '|' not in line: return False
|
||||
match = self.TABLE_ROW_RE.match(line)
|
||||
if match is None: return False
|
||||
content = match.group(1)
|
||||
return '|' in content or line.strip().startswith('|')
|
||||
|
||||
def _is_table_separator(self, line):
|
||||
if '|' not in line: return False
|
||||
match = self.TABLE_SEP_RE.match(line)
|
||||
return match is not None
|
||||
|
||||
def _escape_literals(self, text):
|
||||
return text.replace('`', '\\`')
|
||||
|
||||
def format_table(self, rows, align="c"):
|
||||
if len(rows) < 2: return rows
|
||||
|
||||
# Parse header and separator
|
||||
header_cells = self._parse_table_row(rows[0])
|
||||
alignments = self._parse_table_alignments(rows[1])
|
||||
|
||||
# Ensure alignment count matches header cells
|
||||
while len(alignments) < len(header_cells): alignments.append('left')
|
||||
alignments = alignments[:len(header_cells)]
|
||||
|
||||
# Parse data rows
|
||||
data_rows = []
|
||||
for i in range(2, len(rows)):
|
||||
cells = self._parse_table_row(rows[i])
|
||||
while len(cells) < len(header_cells): cells.append("")
|
||||
cells = cells[:len(header_cells)]
|
||||
data_rows.append(cells)
|
||||
|
||||
# Calculate column widths based on content
|
||||
num_cols = len(header_cells)
|
||||
col_widths = [0] * num_cols
|
||||
|
||||
all_rows = [header_cells] + data_rows
|
||||
for row in all_rows:
|
||||
for i, cell in enumerate(row):
|
||||
formatted = self._format_inline(cell)
|
||||
width = self._visible_width(formatted)
|
||||
col_widths[i] = max(col_widths[i], width)
|
||||
|
||||
# Apply minimum width and calculate total
|
||||
col_widths = [max(w, self.TABLE_MIN_COL_WIDTH) for w in col_widths]
|
||||
|
||||
# Check max_width constraint
|
||||
# Total = sum of columns + 3 chars per column (space + 2 borders) + 1 for final border
|
||||
total_width = sum(col_widths) + (num_cols * 3) + 1
|
||||
|
||||
if total_width > self.max_width:
|
||||
# Reduce widest columns proportionally
|
||||
excess = total_width - self.max_width
|
||||
indexed_widths = [(i, w) for i, w in enumerate(col_widths)]
|
||||
indexed_widths.sort(key=lambda x: -x[1])
|
||||
|
||||
for i, w in indexed_widths:
|
||||
if excess <= 0: break
|
||||
reduction = min(excess, w - self.TABLE_MIN_COL_WIDTH)
|
||||
col_widths[i] -= reduction
|
||||
excess -= reduction
|
||||
|
||||
# Build formatted table
|
||||
result = []
|
||||
|
||||
# Alignment start
|
||||
if align: result.append(f"`{align}")
|
||||
|
||||
# Top border
|
||||
border = self.TABLE_TL
|
||||
for i, w in enumerate(col_widths):
|
||||
border += self.TABLE_H * (w + 2)
|
||||
if i < len(col_widths) - 1: border += self.TABLE_TM
|
||||
else: border += self.TABLE_TR
|
||||
|
||||
result.append(self._escape_literals(border))
|
||||
|
||||
# Header row
|
||||
header_line = self.TABLE_V
|
||||
for i, cell in enumerate(header_cells):
|
||||
formatted = self._format_inline(cell)
|
||||
padded = self._pad_cell(formatted, col_widths[i], 'left')
|
||||
header_line += f" {padded} {self.TABLE_V}"
|
||||
result.append(self._escape_literals(header_line))
|
||||
|
||||
# Separator row
|
||||
sep_line = self.TABLE_ML
|
||||
for i, w in enumerate(col_widths):
|
||||
cell_width = w + 2
|
||||
sep_line += self.TABLE_H * cell_width
|
||||
|
||||
if i < len(col_widths) - 1: sep_line += self.TABLE_MM
|
||||
else: sep_line += self.TABLE_MR
|
||||
|
||||
result.append(self._escape_literals(sep_line))
|
||||
|
||||
# Data rows
|
||||
for row in data_rows:
|
||||
row_line = self.TABLE_V
|
||||
for i, cell in enumerate(row):
|
||||
formatted = self._format_inline(cell)
|
||||
padded = self._pad_cell(formatted, col_widths[i], alignments[i])
|
||||
row_line += f" {padded} {self.TABLE_V}"
|
||||
|
||||
result.append(row_line)
|
||||
|
||||
# Bottom border
|
||||
border = self.TABLE_BL
|
||||
for i, w in enumerate(col_widths):
|
||||
border += self.TABLE_H * (w + 2)
|
||||
if i < len(col_widths) - 1: border += self.TABLE_BM
|
||||
else: border += self.TABLE_BR
|
||||
|
||||
result.append(self._escape_literals(border))
|
||||
|
||||
# End alignment
|
||||
if align: result.append("`a")
|
||||
|
||||
return result
|
||||
|
||||
def format_table_raw(self, rows, align="c"):
|
||||
if len(rows) < 2: return rows
|
||||
|
||||
# Parse header and separator
|
||||
header_cells = self._parse_table_row(rows[0])
|
||||
alignments = self._parse_table_alignments(rows[1])
|
||||
|
||||
# Ensure alignment count matches header cells
|
||||
while len(alignments) < len(header_cells): alignments.append('left')
|
||||
alignments = alignments[:len(header_cells)]
|
||||
|
||||
# Parse data rows
|
||||
data_rows = []
|
||||
for i in range(2, len(rows)):
|
||||
cells = self._parse_table_row(rows[i])
|
||||
while len(cells) < len(header_cells): cells.append("")
|
||||
cells = cells[:len(header_cells)]
|
||||
data_rows.append(cells)
|
||||
|
||||
# Calculate column widths based on raw content
|
||||
num_cols = len(header_cells)
|
||||
col_widths = [0] * num_cols
|
||||
|
||||
all_rows = [header_cells] + data_rows
|
||||
for row in all_rows:
|
||||
for i, cell in enumerate(row):
|
||||
width = self._visible_width(cell)
|
||||
col_widths[i] = max(col_widths[i], width)
|
||||
|
||||
# Apply minimum width and calculate total
|
||||
col_widths = [max(w, self.TABLE_MIN_COL_WIDTH) for w in col_widths]
|
||||
|
||||
# Check max_width constraint
|
||||
total_width = sum(col_widths) + (num_cols * 3) + 1
|
||||
|
||||
if total_width > self.max_width:
|
||||
# Reduce widest columns proportionally
|
||||
excess = total_width - self.max_width
|
||||
indexed_widths = [(i, w) for i, w in enumerate(col_widths)]
|
||||
indexed_widths.sort(key=lambda x: -x[1])
|
||||
|
||||
for i, w in indexed_widths:
|
||||
if excess <= 0: break
|
||||
reduction = min(excess, w - self.TABLE_MIN_COL_WIDTH)
|
||||
col_widths[i] -= reduction
|
||||
excess -= reduction
|
||||
|
||||
# Build formatted table
|
||||
result = []
|
||||
|
||||
# Alignment start
|
||||
if align: result.append(f"`{align}")
|
||||
|
||||
# Top border
|
||||
border = self.TABLE_TL
|
||||
for i, w in enumerate(col_widths):
|
||||
border += self.TABLE_H * (w + 2)
|
||||
if i < len(col_widths) - 1: border += self.TABLE_TM
|
||||
else: border += self.TABLE_TR
|
||||
|
||||
result.append(self._escape_literals(border))
|
||||
|
||||
# Header row
|
||||
header_line = self.TABLE_V
|
||||
for i, cell in enumerate(header_cells):
|
||||
padded = self._pad_cell(cell, col_widths[i], 'left')
|
||||
header_line += f" {padded} {self.TABLE_V}"
|
||||
result.append(header_line)
|
||||
|
||||
# Separator row - clean horizontal lines without alignment markers
|
||||
sep_line = self.TABLE_ML
|
||||
for i, w in enumerate(col_widths):
|
||||
cell_width = w + 2
|
||||
sep_line += self.TABLE_H * cell_width
|
||||
|
||||
if i < len(col_widths) - 1: sep_line += self.TABLE_MM
|
||||
else: sep_line += self.TABLE_MR
|
||||
|
||||
result.append(self._escape_literals(sep_line))
|
||||
|
||||
# Data rows (with alignment)
|
||||
for row in data_rows:
|
||||
row_line = self.TABLE_V
|
||||
for i, cell in enumerate(row):
|
||||
padded = self._pad_cell(cell, col_widths[i], alignments[i])
|
||||
row_line += f" {padded} {self.TABLE_V}"
|
||||
|
||||
result.append(row_line)
|
||||
|
||||
# Bottom border
|
||||
border = self.TABLE_BL
|
||||
for i, w in enumerate(col_widths):
|
||||
border += self.TABLE_H * (w + 2)
|
||||
if i < len(col_widths) - 1: border += self.TABLE_BM
|
||||
else: border += self.TABLE_BR
|
||||
|
||||
result.append(self._escape_literals(border))
|
||||
|
||||
# End alignment
|
||||
if align: result.append("`a")
|
||||
|
||||
return result
|
||||
|
||||
def _parse_table_row(self, line):
|
||||
line = line.strip()
|
||||
if line.startswith('|'): line = line[1:]
|
||||
if line.endswith('|'): line = line[:-1]
|
||||
|
||||
cells = []
|
||||
current = ""
|
||||
escaped = False
|
||||
for char in line:
|
||||
if escaped:
|
||||
current += char
|
||||
escaped = False
|
||||
elif char == '\\':
|
||||
escaped = True
|
||||
elif char == '|':
|
||||
cells.append(current.strip())
|
||||
current = ""
|
||||
else:
|
||||
current += char
|
||||
|
||||
cells.append(current.strip())
|
||||
return cells
|
||||
|
||||
def _parse_table_alignments(self, line):
|
||||
cells = self._parse_table_row(line)
|
||||
alignments = []
|
||||
for cell in cells:
|
||||
cell = cell.strip()
|
||||
if cell.startswith(':') and cell.endswith(':'): alignments.append('center')
|
||||
elif cell.endswith(':'): alignments.append('right')
|
||||
else: alignments.append('left')
|
||||
|
||||
return alignments
|
||||
|
||||
def _visible_width(self, text):
|
||||
text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text)
|
||||
text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text)
|
||||
text = re.sub(r'`[!*_=]', '', text)
|
||||
text = re.sub(r'`f`b', '', text)
|
||||
text = re.sub(r'`f', '', text)
|
||||
text = re.sub(r'`b', '', text)
|
||||
return self.display_width(text)
|
||||
|
||||
def _pad_cell(self, text, width, align):
|
||||
text = self._truncate_cell(text, width)
|
||||
text_width = self._visible_width(text)
|
||||
padding = width - text_width
|
||||
|
||||
if align == 'right':
|
||||
return " " * padding + text
|
||||
elif align == 'center':
|
||||
left = padding // 2
|
||||
right = padding - left
|
||||
return " " * left + text + " " * right
|
||||
else:
|
||||
return text + " " * padding
|
||||
|
||||
def _truncate_cell(self, text, width):
|
||||
if self._visible_width(text) <= width: return text
|
||||
|
||||
stripped = text
|
||||
stripped = re.sub(r'`[FB][0-9a-fA-F]{3}', '', stripped)
|
||||
stripped = re.sub(r'`[!*_]', '', stripped)
|
||||
stripped = re.sub(r'`f`b', '', stripped)
|
||||
|
||||
if len(stripped) <= width - 1: return text
|
||||
|
||||
truncated = stripped[:width - 1] + "…"
|
||||
return truncated
|
||||
|
||||
|
||||
def convert_markdown_to_micron(text):
|
||||
converter = MarkdownToMicron()
|
||||
return converter.format_block(text)
|
||||
+27
-15
@@ -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)
|
||||
|
||||
+13
-8
@@ -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,
|
||||
@@ -48,7 +56,7 @@ def main():
|
||||
parser.add_argument('-v', '--verbose', action='count', default=0)
|
||||
parser.add_argument('-q', '--quiet', action='count', default=0)
|
||||
parser.add_argument("--exampleconfig", action='store_true', default=False, help="print verbose configuration example to stdout and exit")
|
||||
parser.add_argument("--version", action="version", version="ir {version}".format(version=__version__))
|
||||
parser.add_argument("--version", action="version", version="rnir {version}".format(version=__version__))
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -67,8 +75,5 @@ def main():
|
||||
print("")
|
||||
exit()
|
||||
|
||||
__example_rns_config__ = '''# This is an example Identity Resolver file.
|
||||
'''
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+726
-142
File diff suppressed because it is too large
Load Diff
+236
-259
@@ -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()
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# 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 RNS
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from RNS._version import __version__
|
||||
|
||||
def program_setup(configdir, verbosity = 0, quietness = 0, service = False):
|
||||
targetverbosity = verbosity-quietness
|
||||
|
||||
if service:
|
||||
targetlogdest = RNS.LOG_FILE
|
||||
targetverbosity = None
|
||||
else:
|
||||
targetlogdest = RNS.LOG_STDOUT
|
||||
|
||||
reticulum = RNS.Reticulum(configdir=configdir, verbosity=targetverbosity, logdest=targetlogdest)
|
||||
exit(0)
|
||||
|
||||
def main():
|
||||
try:
|
||||
parser = argparse.ArgumentParser(description="Reticulum Meta Package Manager")
|
||||
parser.add_argument("--config", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
|
||||
parser.add_argument('-v', '--verbose', action='count', default=0)
|
||||
parser.add_argument('-q', '--quiet', action='count', default=0)
|
||||
parser.add_argument("--exampleconfig", action='store_true', default=False, help="print verbose configuration example to stdout and exit")
|
||||
parser.add_argument("--version", action="version", version="rnpkg {version}".format(version=__version__))
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.exampleconfig:
|
||||
print(__example_rnpkg_config__)
|
||||
exit()
|
||||
|
||||
if args.config: configarg = args.config
|
||||
else: configarg = None
|
||||
|
||||
program_setup(configdir = configarg, verbosity=args.verbose, quietness=args.quiet)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("")
|
||||
exit()
|
||||
|
||||
__example_rnpkg_config__ = '''# This is an example package manager configuration file.
|
||||
'''
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from ._version import __version__
|
||||
|
||||
import os
|
||||
module_abs_filename = os.path.abspath(__file__)
|
||||
module_dir = os.path.dirname(module_abs_filename)
|
||||
|
||||
def _get_version(): return __version__
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.2.0"
|
||||
@@ -0,0 +1,93 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from RNS.Utilities.rnsh._version import __version__ as __rnsh_version__
|
||||
from RNS._version import __version__
|
||||
|
||||
DEFAULT_SERVICE_NAME = "default"
|
||||
|
||||
def setup_argument_parser():
|
||||
parser = argparse.ArgumentParser(description="Reticulum Remote Shell Utility", epilog="When specifying a command to execute, separate rnsh\noptions from the command and its arguments with --\n\nFor example:\n rnsh -l -- /bin/bash --login\n rnsh <destination> -- ls -la /tmp", formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
|
||||
# Common options
|
||||
parser.add_argument("--config", "-c", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
|
||||
parser.add_argument("--identity", "-i", action="store", default=None, help="path to identity file to use", type=str)
|
||||
parser.add_argument("-v", "--verbose", action="count", default=0, help="increase verbosity")
|
||||
parser.add_argument("-q", "--quiet", action="count", default=0, help="decrease verbosity")
|
||||
parser.add_argument("-p", "--print-identity", action="store_true", default=False, help="print identity and destination info and exit")
|
||||
parser.add_argument("--version", action="version", version="rnsh {rv} (protocol {pv})".format(rv=__version__, pv=__rnsh_version__))
|
||||
|
||||
# Listener options
|
||||
parser.add_argument("-l", "--listen", action="store_true", default=False, help="listen (server) mode; any command specified after -- will be used as the default command when the initiator does not provide one or when remote command execution is disabled; if no command is specified, the default shell of the user running rnsh will be used")
|
||||
parser.add_argument("-s", "--service", action="store", default=None, help="service name for identity file if not the default", type=str)
|
||||
parser.add_argument("-b", "--announce",action="store", default=None,help="announce on startup and every PERIOD seconds; specify 0 to announce on startup only",metavar="PERIOD", type=int)
|
||||
parser.add_argument("-a", "--allowed", action="append", default=None, metavar="HASH", type=str, help="allow this identity to connect (may be specified multiple times); allowed identities can also be specified in ~/.rnsh/allowed_identities or ~/.config/rnsh/allowed_identities, one hash per line")
|
||||
parser.add_argument("-n", "--no-auth", action="store_true", default=False, help="disable authentication (allow any identity to connect)")
|
||||
parser.add_argument("-A", "--remote-command-as-args", action="store_true", default=False, help="concatenate remote command to the argument list of the default program or shell")
|
||||
parser.add_argument("-C", "--no-remote-command", action="store_true", default=False, help="disable executing command lines received from the remote initiator")
|
||||
|
||||
# Initiator options
|
||||
parser.add_argument("-N", "--no-id", action="store_true", default=False, help="disable identity announcement on connect")
|
||||
parser.add_argument("-m", "--mirror", action="store_true", default=False, help="return with the exit code of the remote process")
|
||||
parser.add_argument("-w", "--timeout", action="store", default=None, help="connect and request timeout in seconds", metavar="SECONDS", type=float)
|
||||
|
||||
parser.add_argument("destination", nargs="?", default=None, help="hexadecimal hash of the destination to connect to", type=str)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def parse_arguments(argv=None):
|
||||
if argv is None: argv = sys.argv[1:]
|
||||
|
||||
# Split at -- to separate rnsh options from the command to execute.
|
||||
# Everything before -- (or the entire argv if no --) goes to argparse.
|
||||
# Everything after -- becomes the command list.
|
||||
try:
|
||||
split_idx = argv.index("--")
|
||||
rnsh_argv = argv[:split_idx]
|
||||
command = argv[split_idx + 1:]
|
||||
except ValueError:
|
||||
rnsh_argv = argv
|
||||
command = []
|
||||
|
||||
parser = setup_argument_parser()
|
||||
args = parser.parse_args(rnsh_argv)
|
||||
args.command = command
|
||||
|
||||
if args.listen and not args.service: args.service = DEFAULT_SERVICE_NAME
|
||||
|
||||
return args, parser
|
||||
@@ -0,0 +1,60 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import contextlib
|
||||
from contextlib import AbstractContextManager
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
class permit(AbstractContextManager):
|
||||
"""Context manager to allow specified exceptions
|
||||
|
||||
The specified exceptions will be allowed to bubble up. Other
|
||||
exceptions are suppressed.
|
||||
|
||||
After a non-matching exception is suppressed, execution proceeds
|
||||
with the next statement following the with statement.
|
||||
|
||||
with allow(KeyboardInterrupt):
|
||||
time.sleep(300)
|
||||
# Execution still resumes here if no KeyboardInterrupt
|
||||
"""
|
||||
|
||||
def __init__(self, *exceptions): self._exceptions = exceptions
|
||||
|
||||
def __enter__(self): pass
|
||||
|
||||
def __exit__(self, exctype, excinst, exctb):
|
||||
return exctype is not None and not issubclass(exctype, self._exceptions)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
def bitwise_or_if(value: int, condition: bool, orval: int):
|
||||
if not condition: return value
|
||||
return value | orval
|
||||
|
||||
def check_and(value: int, andval: int) -> bool:
|
||||
return (value & andval) > 0
|
||||
|
||||
class SleepRate:
|
||||
def __init__(self, target_period: float):
|
||||
self.target_period = target_period
|
||||
self.last_wake = time.time()
|
||||
|
||||
def next_sleep_time(self) -> float:
|
||||
old_last_wake = self.last_wake
|
||||
self.last_wake = time.time()
|
||||
next_wake = max(old_last_wake + 0.01, self.last_wake)
|
||||
sleep_for = next_wake - self.last_wake
|
||||
return sleep_for if sleep_for > 0 else 0
|
||||
|
||||
async def sleep_async(self): await asyncio.sleep(self.next_sleep_time())
|
||||
|
||||
def sleep_block(self): time.sleep(self.next_sleep_time())
|
||||
@@ -0,0 +1,484 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import enum
|
||||
import functools
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
import tty
|
||||
from typing import Callable, TypeVar
|
||||
import RNS
|
||||
import RNS.Utilities.rnsh.exception as exception
|
||||
import RNS.Utilities.rnsh.process as process
|
||||
import RNS.Utilities.rnsh.retry as retry
|
||||
import RNS.Utilities.rnsh.session as session
|
||||
import re
|
||||
import contextlib
|
||||
|
||||
import pwd
|
||||
import bz2
|
||||
import RNS.Utilities.rnsh.protocol as protocol
|
||||
import RNS.Utilities.rnsh.helpers as helpers
|
||||
import RNS.Utilities.rnsh.rnsh as rnsh
|
||||
|
||||
_identity = None
|
||||
_reticulum = None
|
||||
_cmd: [str] | None = None
|
||||
DATA_AVAIL_MSG = "data available"
|
||||
_finished: asyncio.Event = None
|
||||
_retry_timer: retry.RetryThread | None = None
|
||||
_destination: RNS.Destination | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
async def _check_finished(timeout: float = 0):
|
||||
return _finished is not None and await process.event_wait(_finished, timeout=timeout)
|
||||
|
||||
def _sigint_handler(sig, loop):
|
||||
global _finished
|
||||
RNS.log(f"{signal.Signals(sig).name}", RNS.LOG_DEBUG)
|
||||
if _finished is not None: _finished.set()
|
||||
else: raise KeyboardInterrupt()
|
||||
|
||||
async def _spin_tty(until=None, msg=None, timeout=None):
|
||||
i = 0
|
||||
syms = "⢄⢂⢁⡁⡈⡐⡠"
|
||||
if timeout != None: timeout = time.time()+timeout
|
||||
|
||||
print(msg+" ", end=" ")
|
||||
while (timeout == None or time.time()<timeout) and not until():
|
||||
await asyncio.sleep(0.1)
|
||||
print(("\b\b"+syms[i]+" "), end="")
|
||||
sys.stdout.flush()
|
||||
i = (i+1)%len(syms)
|
||||
|
||||
print("\r"+" "*len(msg)+" \r", end="")
|
||||
|
||||
if timeout != None and time.time() > timeout: return False
|
||||
else: return True
|
||||
|
||||
|
||||
async def _spin_pipe(until: callable = None, msg=None, timeout: float | None = None) -> bool:
|
||||
if timeout is not None: timeout += time.time()
|
||||
|
||||
while (timeout is None or time.time() < timeout) and not until():
|
||||
if await _check_finished(0.1): raise asyncio.CancelledError()
|
||||
|
||||
if timeout is not None and time.time() > timeout: return False
|
||||
else: return True
|
||||
|
||||
async def _spin(until: callable = None, msg=None, timeout: float | None = None, quiet: bool = False) -> bool:
|
||||
if not quiet and os.isatty(1): return await _spin_tty(until, msg, timeout)
|
||||
else: return await _spin_pipe(until, msg, timeout)
|
||||
|
||||
_link: RNS.Link | None = None
|
||||
_remote_exec_grace = 2.0
|
||||
_pq = queue.Queue()
|
||||
|
||||
|
||||
class InitiatorState(enum.IntEnum):
|
||||
IS_INITIAL = 0
|
||||
IS_LINKED = 1
|
||||
IS_WAIT_VERS = 2
|
||||
IS_RUNNING = 3
|
||||
IS_TERMINATE = 4
|
||||
IS_TEARDOWN = 5
|
||||
|
||||
def _client_link_closed(link):
|
||||
if _finished: _finished.set()
|
||||
|
||||
def _client_message_handler(message: RNS.MessageBase): _pq.put(message)
|
||||
|
||||
def compute_target_rns_loglevel(verbosity: int, quietness: int, base_level: int = RNS.LOG_INFO) -> int:
|
||||
try:
|
||||
target = int(base_level) + int(verbosity) - int(quietness)
|
||||
if target < RNS.LOG_CRITICAL: target = RNS.LOG_CRITICAL
|
||||
if target > RNS.LOG_DEBUG: target = RNS.LOG_DEBUG
|
||||
return target
|
||||
|
||||
except Exception: return base_level
|
||||
|
||||
|
||||
class RemoteExecutionError(Exception):
|
||||
def __init__(self, msg): self.msg = msg
|
||||
|
||||
|
||||
async def _initiate_link(configdir, rnsconfigdir, identitypath=None, verbosity=0, quietness=0, noid=False, destination=None,
|
||||
timeout=RNS.Transport.PATH_REQUEST_TIMEOUT):
|
||||
global _identity, _reticulum, _link, _destination, _remote_exec_grace
|
||||
|
||||
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
|
||||
if len(destination) != dest_len:
|
||||
raise RemoteExecutionError(
|
||||
"Allowed 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)
|
||||
except Exception as e:
|
||||
raise RemoteExecutionError("Invalid destination entered. Check your input.")
|
||||
|
||||
if _reticulum is None:
|
||||
targetloglevel = compute_target_rns_loglevel(verbosity, quietness, RNS.LOG_ERROR)
|
||||
RNS.logfile = os.path.join(configdir, "logfile")
|
||||
_reticulum = RNS.Reticulum(configdir=rnsconfigdir, loglevel=targetloglevel, logdest=RNS.LOG_FILE)
|
||||
|
||||
if _identity is None:
|
||||
_identity = rnsh.prepare_identity(identitypath)
|
||||
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
RNS.Transport.request_path(destination_hash)
|
||||
RNS.log(f"Requesting path...", RNS.LOG_INFO)
|
||||
if not await _spin(until=lambda: RNS.Transport.has_path(destination_hash), msg="Requesting path...",
|
||||
timeout=timeout, quiet=quietness > 0):
|
||||
raise RemoteExecutionError("Path not found")
|
||||
|
||||
if _destination is None:
|
||||
listener_identity = RNS.Identity.recall(destination_hash)
|
||||
_destination = RNS.Destination(
|
||||
listener_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
rnsh.APP_NAME
|
||||
)
|
||||
|
||||
if _link is None or _link.status == RNS.Link.PENDING:
|
||||
RNS.log("No link", RNS.LOG_DEBUG)
|
||||
_link = RNS.Link(_destination)
|
||||
_link.did_identify = False
|
||||
|
||||
_link.set_link_closed_callback(_client_link_closed)
|
||||
|
||||
RNS.log(f"Establishing link...", RNS.LOG_VERBOSE)
|
||||
if not await _spin(until=lambda: _link.status == RNS.Link.ACTIVE, msg="Establishing link...",
|
||||
timeout=timeout, quiet=quietness > 0):
|
||||
raise RemoteExecutionError("Could not establish link with " + RNS.prettyhexrep(destination_hash))
|
||||
|
||||
RNS.log("Have link", RNS.LOG_DEBUG)
|
||||
if not noid and not _link.did_identify:
|
||||
# Delay a tiny bit to allow listener to fully enter WAIT_IDENT state
|
||||
await asyncio.sleep(min(1, _link.rtt * 1.1 + 0.05))
|
||||
_link.identify(_identity)
|
||||
_link.did_identify = True
|
||||
|
||||
|
||||
async def _handle_error(errmsg: RNS.MessageBase):
|
||||
if isinstance(errmsg, protocol.ErrorMessage):
|
||||
with contextlib.suppress(Exception):
|
||||
if _link and _link.status == RNS.Link.ACTIVE:
|
||||
_link.teardown()
|
||||
await asyncio.sleep(0.1)
|
||||
raise RemoteExecutionError(f"Remote error: {errmsg.msg}")
|
||||
|
||||
|
||||
async def initiate(configdir: str, rnsconfigdir:str, identitypath: str, verbosity: int, quietness: int, noid: bool, destination: str,
|
||||
timeout: float, command: [str] | None = None):
|
||||
global _finished, _link
|
||||
with process.TTYRestorer(sys.stdin.fileno()) as ttyRestorer:
|
||||
loop = asyncio.get_running_loop()
|
||||
state = InitiatorState.IS_INITIAL
|
||||
data_buffer = bytearray(sys.stdin.buffer.read()) if not os.isatty(sys.stdin.fileno()) else bytearray()
|
||||
line_buffer = bytearray()
|
||||
|
||||
await _initiate_link(configdir=configdir,
|
||||
rnsconfigdir=rnsconfigdir,
|
||||
identitypath=identitypath,
|
||||
verbosity=verbosity,
|
||||
quietness=quietness,
|
||||
noid=noid,
|
||||
destination=destination,
|
||||
timeout=timeout)
|
||||
|
||||
if not _link or _link.status not in [RNS.Link.ACTIVE, RNS.Link.PENDING]:
|
||||
return 255
|
||||
|
||||
state = InitiatorState.IS_LINKED
|
||||
outlet = session.RNSOutlet(_link)
|
||||
channel = _link.get_channel()
|
||||
protocol.register_message_types(channel)
|
||||
channel.add_message_handler(_client_message_handler)
|
||||
|
||||
# Next step after linking and identifying: send version
|
||||
# if not await _spin(lambda: messenger.is_outlet_ready(outlet), timeout=5, quiet=quietness > 0):
|
||||
# print("Error bringing up link")
|
||||
# return 253
|
||||
|
||||
channel.send(protocol.VersionInfoMessage())
|
||||
try:
|
||||
vm = _pq.get(timeout=max(outlet.rtt * 20, 5))
|
||||
await _handle_error(vm)
|
||||
if not isinstance(vm, protocol.VersionInfoMessage):
|
||||
raise Exception("Invalid message received")
|
||||
RNS.log(f"Server version info: sw {vm.sw_version} prot {vm.protocol_version}", RNS.LOG_DEBUG)
|
||||
state = InitiatorState.IS_RUNNING
|
||||
except queue.Empty:
|
||||
print("Protocol error")
|
||||
return 254
|
||||
|
||||
winch = False
|
||||
def sigwinch_handler():
|
||||
nonlocal winch
|
||||
winch = True
|
||||
|
||||
esc = False
|
||||
pre_esc = True
|
||||
line_mode = False
|
||||
line_flush = False
|
||||
blind_write_count = 0
|
||||
flush_chars = ["\x01", "\x03", "\x04", "\x05", "\x0c", "\x11", "\x13", "\x15", "\x19", "\t", "\x1A", "\x1B"]
|
||||
def handle_escape(b):
|
||||
nonlocal line_mode
|
||||
if b == "?":
|
||||
os.write(1, "\n\r\n\rSupported rnsh escape sequences:".encode("utf-8"))
|
||||
os.write(1, "\n\r ~~ Send the escape character by typing it twice".encode("utf-8"))
|
||||
os.write(1, "\n\r ~. Terminate session and exit immediately".encode("utf-8"))
|
||||
os.write(1, "\n\r ~L Toggle line-interactive mode".encode("utf-8"))
|
||||
os.write(1, "\n\r ~? Display this quick reference\n\r".encode("utf-8"))
|
||||
os.write(1, "\n\r(Escape sequences are only recognized immediately after newline)\n\r".encode("utf-8"))
|
||||
return None
|
||||
elif b == ".":
|
||||
_link.teardown()
|
||||
return None
|
||||
elif b == "L":
|
||||
line_mode = not line_mode
|
||||
if line_mode:
|
||||
os.write(1, "\n\rLine-interactive mode enabled\n\r".encode("utf-8"))
|
||||
else:
|
||||
os.write(1, "\n\rLine-interactive mode disabled\n\r".encode("utf-8"))
|
||||
return None
|
||||
|
||||
return b
|
||||
|
||||
stdin_eof = False
|
||||
def stdin():
|
||||
nonlocal stdin_eof, pre_esc, esc, line_mode
|
||||
nonlocal line_flush, blind_write_count
|
||||
try:
|
||||
in_data = process.tty_read(sys.stdin.fileno())
|
||||
if in_data is not None:
|
||||
data = bytearray()
|
||||
for b in bytes(in_data):
|
||||
c = chr(b)
|
||||
if c == "\r":
|
||||
pre_esc = True
|
||||
line_flush = True
|
||||
data.append(b)
|
||||
elif line_mode and c in flush_chars:
|
||||
pre_esc = False
|
||||
line_flush = True
|
||||
data.append(b)
|
||||
elif line_mode and (c == "\b" or c == "\x7f"):
|
||||
pre_esc = False
|
||||
if len(line_buffer)>0:
|
||||
line_buffer.pop(-1)
|
||||
blind_write_count -= 1
|
||||
os.write(1, "\b \b".encode("utf-8"))
|
||||
elif pre_esc == True and c == "~":
|
||||
pre_esc = False
|
||||
esc = True
|
||||
elif esc == True:
|
||||
ret = handle_escape(c)
|
||||
if ret != None:
|
||||
if ret != "~":
|
||||
data.append(ord("~"))
|
||||
data.append(ord(ret))
|
||||
esc = False
|
||||
else:
|
||||
pre_esc = False
|
||||
data.append(b)
|
||||
|
||||
if not line_mode:
|
||||
data_buffer.extend(data)
|
||||
else:
|
||||
line_buffer.extend(data)
|
||||
if line_flush:
|
||||
data_buffer.extend(line_buffer)
|
||||
line_buffer.clear()
|
||||
os.write(1, ("\b \b"*blind_write_count).encode("utf-8"))
|
||||
line_flush = False
|
||||
blind_write_count = 0
|
||||
else:
|
||||
os.write(1, data)
|
||||
blind_write_count += len(data)
|
||||
|
||||
except EOFError:
|
||||
if os.isatty(0):
|
||||
data_buffer.extend(process.CTRL_D)
|
||||
stdin_eof = True
|
||||
process.tty_unset_reader_callbacks(sys.stdin.fileno())
|
||||
|
||||
process.tty_add_reader_callback(sys.stdin.fileno(), stdin)
|
||||
|
||||
tcattr = None
|
||||
rows, cols, hpix, vpix = (None, None, None, None)
|
||||
try:
|
||||
tcattr = termios.tcgetattr(0)
|
||||
rows, cols, hpix, vpix = process.tty_get_winsize(0)
|
||||
except:
|
||||
try:
|
||||
tcattr = termios.tcgetattr(1)
|
||||
rows, cols, hpix, vpix = process.tty_get_winsize(1)
|
||||
except:
|
||||
try:
|
||||
tcattr = termios.tcgetattr(2)
|
||||
rows, cols, hpix, vpix = process.tty_get_winsize(2)
|
||||
except:
|
||||
pass
|
||||
|
||||
await _spin(lambda: channel.is_ready_to_send(), "Waiting for channel...", 1, quietness > 0)
|
||||
channel.send(protocol.ExecuteCommandMesssage(cmdline=command,
|
||||
pipe_stdin=not os.isatty(0),
|
||||
pipe_stdout=not os.isatty(1),
|
||||
pipe_stderr=not os.isatty(2),
|
||||
tcflags=tcattr,
|
||||
term=os.environ.get("TERM", None),
|
||||
rows=rows,
|
||||
cols=cols,
|
||||
hpix=hpix,
|
||||
vpix=vpix))
|
||||
|
||||
loop.add_signal_handler(signal.SIGWINCH, sigwinch_handler)
|
||||
_finished = asyncio.Event()
|
||||
loop.add_signal_handler(signal.SIGINT, functools.partial(_sigint_handler, signal.SIGINT, loop))
|
||||
loop.add_signal_handler(signal.SIGTERM, functools.partial(_sigint_handler, signal.SIGTERM, loop))
|
||||
mdu = _link.MDU - 16
|
||||
sent_eof = False
|
||||
last_winch = time.time()
|
||||
sleeper = helpers.SleepRate(0.01)
|
||||
processed = False
|
||||
while not await _check_finished() and state in [InitiatorState.IS_RUNNING]:
|
||||
try:
|
||||
try:
|
||||
message = _pq.get(timeout=sleeper.next_sleep_time() if not processed else 0.0005)
|
||||
await _handle_error(message)
|
||||
processed = True
|
||||
if isinstance(message, protocol.StreamDataMessage):
|
||||
if message.stream_id == protocol.StreamDataMessage.STREAM_ID_STDOUT:
|
||||
if message.data and len(message.data) > 0:
|
||||
ttyRestorer.raw()
|
||||
RNS.log(f"stdout: {message.data}", RNS.LOG_DEBUG)
|
||||
os.write(1, message.data)
|
||||
sys.stdout.flush()
|
||||
if message.eof:
|
||||
os.close(1)
|
||||
if message.stream_id == protocol.StreamDataMessage.STREAM_ID_STDERR:
|
||||
if message.data and len(message.data) > 0:
|
||||
ttyRestorer.raw()
|
||||
RNS.log(f"stdout: {message.data}", RNS.LOG_DEBUG)
|
||||
os.write(2, message.data)
|
||||
sys.stderr.flush()
|
||||
if message.eof:
|
||||
os.close(2)
|
||||
elif isinstance(message, protocol.CommandExitedMessage):
|
||||
RNS.log(f"received return code {message.return_code}, exiting", RNS.LOG_DEBUG)
|
||||
return message.return_code
|
||||
elif isinstance(message, protocol.ErrorMessage):
|
||||
RNS.log(f"Remote error: {message.data}", RNS.LOG_ERROR)
|
||||
if message.fatal:
|
||||
_link.teardown()
|
||||
return 200
|
||||
|
||||
except queue.Empty:
|
||||
processed = False
|
||||
|
||||
if channel.is_ready_to_send():
|
||||
def compress_adaptive(buf: bytes):
|
||||
comp_tries = RNS.RawChannelWriter.COMPRESSION_TRIES
|
||||
comp_try = 1
|
||||
comp_success = False
|
||||
|
||||
chunk_len = len(buf)
|
||||
if chunk_len > RNS.RawChannelWriter.MAX_CHUNK_LEN:
|
||||
chunk_len = RNS.RawChannelWriter.MAX_CHUNK_LEN
|
||||
chunk_segment = None
|
||||
|
||||
chunk_segment = None
|
||||
max_data_len = channel.mdu - protocol.StreamDataMessage.OVERHEAD
|
||||
while chunk_len > 32 and comp_try < comp_tries:
|
||||
chunk_segment_length = int(chunk_len/comp_try)
|
||||
compressed_chunk = bz2.compress(buf[:chunk_segment_length])
|
||||
compressed_length = len(compressed_chunk)
|
||||
if compressed_length < max_data_len and compressed_length < chunk_segment_length:
|
||||
comp_success = True
|
||||
break
|
||||
else:
|
||||
comp_try += 1
|
||||
|
||||
if comp_success:
|
||||
diff = max_data_len - len(compressed_chunk)
|
||||
chunk = compressed_chunk
|
||||
processed_length = chunk_segment_length
|
||||
else:
|
||||
chunk = bytes(buf[:max_data_len])
|
||||
processed_length = len(chunk)
|
||||
|
||||
return comp_success, processed_length, chunk
|
||||
|
||||
comp_success, processed_length, chunk = compress_adaptive(data_buffer)
|
||||
stdin = chunk
|
||||
data_buffer = data_buffer[processed_length:]
|
||||
eof = not sent_eof and stdin_eof and len(stdin) == 0
|
||||
if len(stdin) > 0 or eof:
|
||||
channel.send(protocol.StreamDataMessage(protocol.StreamDataMessage.STREAM_ID_STDIN, stdin, eof, comp_success))
|
||||
sent_eof = eof
|
||||
processed = True
|
||||
|
||||
# send window change, but rate limited
|
||||
if winch and time.time() - last_winch > _link.rtt * 25:
|
||||
last_winch = time.time()
|
||||
winch = False
|
||||
with contextlib.suppress(Exception):
|
||||
r, c, h, v = process.tty_get_winsize(0)
|
||||
channel.send(protocol.WindowSizeMessage(r, c, h, v))
|
||||
processed = True
|
||||
except RemoteExecutionError as e:
|
||||
print(e.msg)
|
||||
return 255
|
||||
except Exception as ex:
|
||||
print(f"Client exception: {ex}")
|
||||
if _link and _link.status != RNS.Link.CLOSED:
|
||||
_link.teardown()
|
||||
return 127
|
||||
|
||||
RNS.log("Main loop done", RNS.LOG_DEBUG)
|
||||
return 0
|
||||
@@ -0,0 +1,229 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
import tty
|
||||
from typing import Callable, TypeVar
|
||||
import RNS
|
||||
import RNS.Utilities.rnsh.exception as exception
|
||||
import RNS.Utilities.rnsh.process as process
|
||||
import RNS.Utilities.rnsh.retry as retry
|
||||
import RNS.Utilities.rnsh.session as session
|
||||
import re
|
||||
import contextlib
|
||||
|
||||
import pwd
|
||||
import RNS.Utilities.rnsh.protocol as protocol
|
||||
import RNS.Utilities.rnsh.helpers as helpers
|
||||
import RNS.Utilities.rnsh.rnsh as rnsh
|
||||
|
||||
|
||||
_identity = None
|
||||
_reticulum = None
|
||||
_allow_all = False
|
||||
_allowed_file = None
|
||||
_allowed_identity_hashes = []
|
||||
_allowed_file_identity_hashes = []
|
||||
_cmd: [str] | None = None
|
||||
DATA_AVAIL_MSG = "data available"
|
||||
_finished: asyncio.Event = None
|
||||
_retry_timer: retry.RetryThread | None = None
|
||||
_destination: RNS.Destination | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
_no_remote_command = True
|
||||
_remote_cmd_as_args = False
|
||||
|
||||
|
||||
async def _check_finished(timeout: float = 0):
|
||||
return await process.event_wait(_finished, timeout=timeout)
|
||||
|
||||
|
||||
def _sigint_handler(sig, loop):
|
||||
global _finished
|
||||
RNS.log(f"Signal: {signal.Signals(sig).name}", RNS.LOG_DEBUG)
|
||||
if _finished is not None: _finished.set()
|
||||
else: raise KeyboardInterrupt()
|
||||
|
||||
def _reload_allowed_file():
|
||||
global _allowed_file, _allowed_file_identity_hashes
|
||||
if _allowed_file != None:
|
||||
try:
|
||||
with open(_allowed_file, "r") as file:
|
||||
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
|
||||
added = 0
|
||||
line = 0
|
||||
_allowed_file_identity_hashes = []
|
||||
for allow in file.read().replace("\r", "").split("\n"):
|
||||
line += 1
|
||||
if len(allow) == dest_len:
|
||||
try:
|
||||
destination_hash = bytes.fromhex(allow)
|
||||
_allowed_file_identity_hashes.append(destination_hash)
|
||||
added += 1
|
||||
except Exception:
|
||||
RNS.log(f"Discarded invalid Identity hash in {_allowed_file} at line {line}", RNS.LOG_DEBUG)
|
||||
|
||||
ms = "y" if added == 1 else "ies"
|
||||
RNS.log(f"Loaded {added} allowed identit{ms} from "+str(_allowed_file), RNS.LOG_DEBUG)
|
||||
|
||||
except Exception as e: RNS.log(f"Error while reloading allowed indetities file: {e}", RNS.LOG_ERROR)
|
||||
|
||||
def compute_target_rns_loglevel(verbosity: int, quietness: int, base_level: int = RNS.LOG_INFO) -> int:
|
||||
try:
|
||||
target = int(base_level) + int(verbosity) - int(quietness)
|
||||
if target < RNS.LOG_CRITICAL: target = RNS.LOG_CRITICAL
|
||||
if target > RNS.LOG_DEBUG: target = RNS.LOG_DEBUG
|
||||
return target
|
||||
|
||||
except Exception: return base_level
|
||||
|
||||
async def listen(configdir, rnsconfigdir, command, identitypath=None, service_name=None, verbosity=0, quietness=0, allowed=None,
|
||||
allowed_file=None, disable_auth=None, announce_period=900, no_remote_command=True, remote_cmd_as_args=False,
|
||||
loop: asyncio.AbstractEventLoop = None):
|
||||
global _identity, _allow_all, _allowed_identity_hashes, _allowed_file, _allowed_file_identity_hashes
|
||||
global _reticulum, _cmd, _destination, _no_remote_command, _remote_cmd_as_args, _finished
|
||||
|
||||
if not loop: loop = asyncio.get_running_loop()
|
||||
if service_name is None or len(service_name) == 0:
|
||||
service_name = "default"
|
||||
|
||||
RNS.log(f"Using service name {service_name}", RNS.LOG_INFO)
|
||||
|
||||
# More -v should increase verbosity (higher RNS.loglevel); -q should decrease it
|
||||
targetloglevel = compute_target_rns_loglevel(verbosity, quietness, RNS.LOG_INFO)
|
||||
_reticulum = RNS.Reticulum(configdir=rnsconfigdir, loglevel=targetloglevel)
|
||||
_identity = rnsh.prepare_identity(identitypath, service_name)
|
||||
_destination = RNS.Destination(_identity, RNS.Destination.IN, RNS.Destination.SINGLE, rnsh.APP_NAME)
|
||||
|
||||
RNS.log(f"rnsh listening for commands on {RNS.prettyhexrep(_destination.hash)}", RNS.LOG_NOTICE)
|
||||
|
||||
_cmd = command
|
||||
if _cmd is None or len(_cmd) == 0:
|
||||
shell = None
|
||||
try: shell = pwd.getpwuid(os.getuid()).pw_shell
|
||||
except Exception as e: RNS.log(f"Error looking up shell: {e}", RNS.LOG_ERROR)
|
||||
RNS.log(f"Using {shell} for default command.", RNS.LOG_INFO)
|
||||
|
||||
# Ensure a sane shell default. Fall back to /bin/sh if lookup fails.
|
||||
if not shell or len(shell) == 0: shell = "/bin/sh"
|
||||
_cmd = [shell]
|
||||
|
||||
else: RNS.log(f"Using command {shlex.join(_cmd)}", RNS.LOG_INFO)
|
||||
|
||||
_no_remote_command = no_remote_command
|
||||
session.ListenerSession.allow_remote_command = not no_remote_command
|
||||
_remote_cmd_as_args = remote_cmd_as_args
|
||||
if (_cmd is None or len(_cmd) == 0 or _cmd[0] is None or len(_cmd[0]) == 0) \
|
||||
and (_no_remote_command or _remote_cmd_as_args):
|
||||
raise Exception(f"Unable to look up shell for {os.getlogin}, cannot proceed with -A or -C and no <program>.")
|
||||
|
||||
session.ListenerSession.default_command = _cmd
|
||||
session.ListenerSession.remote_cmd_as_args = _remote_cmd_as_args
|
||||
|
||||
if disable_auth:
|
||||
_allow_all = True
|
||||
session.ListenerSession.allow_all = True
|
||||
else:
|
||||
if allowed_file is not None:
|
||||
_allowed_file = allowed_file
|
||||
_reload_allowed_file()
|
||||
|
||||
if allowed is not None:
|
||||
for a in allowed:
|
||||
try:
|
||||
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
|
||||
if len(a) != dest_len:
|
||||
raise ValueError(
|
||||
"Allowed destination length is invalid, must be {hex} hexadecimal " +
|
||||
"characters ({byte} bytes).".format(
|
||||
hex=dest_len, byte=dest_len // 2))
|
||||
try:
|
||||
destination_hash = bytes.fromhex(a)
|
||||
_allowed_identity_hashes.append(destination_hash)
|
||||
session.ListenerSession.allowed_identity_hashes.append(destination_hash)
|
||||
except Exception:
|
||||
raise ValueError("Invalid destination entered. Check your input.")
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Unhandled error: {e}", RNS.LOG_ERROR)
|
||||
RNS.trace_exception(e)
|
||||
exit(1)
|
||||
|
||||
if (len(_allowed_identity_hashes) < 1 and len(_allowed_file_identity_hashes) < 1) and not disable_auth:
|
||||
RNS.log("Warning: No allowed identities configured, rnsh will not accept any connections!", RNS.LOG_WARNING)
|
||||
|
||||
def link_established(lnk: RNS.Link):
|
||||
_reload_allowed_file()
|
||||
session.ListenerSession.allowed_file_identity_hashes = _allowed_file_identity_hashes
|
||||
session.ListenerSession(session.RNSOutlet.get_outlet(lnk), lnk.get_channel(), loop)
|
||||
_destination.set_link_established_callback(link_established)
|
||||
|
||||
_finished = asyncio.Event()
|
||||
signal.signal(signal.SIGINT, _sigint_handler)
|
||||
|
||||
if announce_period is not None: _destination.announce()
|
||||
|
||||
last_announce = time.time()
|
||||
sleeper = helpers.SleepRate(0.01)
|
||||
|
||||
try:
|
||||
while not await _check_finished():
|
||||
if announce_period and 0 < announce_period < time.time() - last_announce:
|
||||
last_announce = time.time()
|
||||
_destination.announce()
|
||||
if len(session.ListenerSession.sessions) > 0:
|
||||
# no sleep if there's work to do
|
||||
if not await session.ListenerSession.pump_all():
|
||||
await sleeper.sleep_async()
|
||||
else:
|
||||
await asyncio.sleep(0.25)
|
||||
finally:
|
||||
RNS.log("Shutting down", RNS.LOG_NOTICE)
|
||||
await session.ListenerSession.terminate_all("Shutting down")
|
||||
await asyncio.sleep(1)
|
||||
links_still_active = list(filter(lambda l: l.status != RNS.Link.CLOSED, _destination.links))
|
||||
for link in links_still_active:
|
||||
if link.status not in [RNS.Link.CLOSED]:
|
||||
link.teardown()
|
||||
await asyncio.sleep(0.01)
|
||||
@@ -0,0 +1,46 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from typing import Callable
|
||||
|
||||
def sig_handler_sys_to_loop(handler: Callable[[int, any], None]) -> Callable[[int, asyncio.AbstractEventLoop], None]:
|
||||
def wrapped(cb: Callable[[int, any], None], signal: int, loop: asyncio.AbstractEventLoop): cb(signal, None)
|
||||
return functools.partial(wrapped, handler)
|
||||
|
||||
def loop_set_signal(sig, handler: Callable[[int, asyncio.AbstractEventLoop], None], loop: asyncio.AbstractEventLoop = None):
|
||||
if loop is None: loop = asyncio.get_running_loop()
|
||||
loop.remove_signal_handler(sig)
|
||||
loop.add_signal_handler(sig, functools.partial(handler, sig, loop))
|
||||
@@ -0,0 +1,785 @@
|
||||
# Based on the original rnsh program by Aaron Heise (@acehoss)
|
||||
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
|
||||
# This version of rnsh is included in RNS under the Reticulum License
|
||||
#
|
||||
# Reticulum License
|
||||
#
|
||||
# Copyright (c) 2016-2026 Mark Qvist
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# - The Software shall not be used in any kind of system which includes amongst
|
||||
# its functions the ability to purposefully do harm to human beings.
|
||||
#
|
||||
# - The Software shall not be used, directly or indirectly, in the creation of
|
||||
# an artificial intelligence, machine learning or language model training
|
||||
# dataset, including but not limited to any use that contributes to the
|
||||
# training or development of such a model or algorithm.
|
||||
#
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import copy
|
||||
import errno
|
||||
import fcntl
|
||||
import functools
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import termios
|
||||
import threading
|
||||
import tty
|
||||
import types
|
||||
import typing
|
||||
import RNS
|
||||
|
||||
import RNS.Utilities.rnsh.exception as exception
|
||||
|
||||
CTRL_C = "\x03".encode("utf-8")
|
||||
CTRL_D = "\x04".encode("utf-8")
|
||||
|
||||
def tty_add_reader_callback(fd: int, callback: callable, loop: asyncio.AbstractEventLoop = None):
|
||||
"""
|
||||
Add an async reader callback for a tty file descriptor.
|
||||
|
||||
Example usage:
|
||||
|
||||
def reader():
|
||||
data = tty_read(fd)
|
||||
# do something with data
|
||||
|
||||
tty_add_reader_callback(self._child_fd, reader, self._loop)
|
||||
|
||||
:param fd: file descriptor
|
||||
:param callback: callback function
|
||||
:param loop: asyncio event loop to which the reader should be added. If None, use the currently-running loop.
|
||||
"""
|
||||
if loop is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.add_reader(fd, callback)
|
||||
|
||||
|
||||
def tty_read(fd: int) -> bytes:
|
||||
"""
|
||||
Read available bytes from a tty file descriptor. When used in a callback added to a file descriptor using
|
||||
tty_add_reader_callback(...), this function creates a solution for non-blocking reads from ttys.
|
||||
:param fd: tty file descriptor
|
||||
:return: bytes read
|
||||
"""
|
||||
if fd_is_closed(fd):
|
||||
raise EOFError
|
||||
|
||||
try:
|
||||
run = True
|
||||
result = bytearray()
|
||||
while not fd_is_closed(fd):
|
||||
ready, _, _ = select.select([fd], [], [], 0)
|
||||
if len(ready) == 0:
|
||||
break
|
||||
for f in ready:
|
||||
try:
|
||||
data = os.read(f, 4096)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EIO and e.errno != errno.EWOULDBLOCK:
|
||||
raise
|
||||
else:
|
||||
if not data: # EOF
|
||||
if data is not None and len(data) > 0:
|
||||
result.extend(data)
|
||||
return result
|
||||
elif len(result) > 0:
|
||||
return result
|
||||
else:
|
||||
raise EOFError
|
||||
if data is not None and len(data) > 0:
|
||||
result.extend(data)
|
||||
return result
|
||||
|
||||
except EOFError: raise
|
||||
except Exception as e: RNS.log(f"TTY read error: {e}", RNS.LOG_ERROR)
|
||||
|
||||
|
||||
def tty_read_poll(fd: int) -> bytes:
|
||||
"""
|
||||
Read available bytes from a tty file descriptor. When used in a callback added to a file descriptor using
|
||||
tty_add_reader_callback(...), this function creates a solution for non-blocking reads from ttys.
|
||||
:param fd: tty file descriptor
|
||||
:return: bytes read
|
||||
"""
|
||||
if fd_is_closed(fd):
|
||||
raise EOFError
|
||||
|
||||
result = bytearray()
|
||||
try:
|
||||
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
while True:
|
||||
try:
|
||||
data = os.read(fd, 4096)
|
||||
if not data:
|
||||
# EOF
|
||||
if len(result) > 0:
|
||||
return result
|
||||
raise EOFError
|
||||
result.extend(data)
|
||||
# continue loop to drain
|
||||
except OSError as e:
|
||||
if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN):
|
||||
break
|
||||
if e.errno == errno.EIO:
|
||||
if len(result) > 0:
|
||||
return result
|
||||
raise EOFError
|
||||
raise
|
||||
except EOFError: raise
|
||||
except Exception as e: RNS.log(f"TTY read error: {e}", RNS.LOG_ERROR)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def fd_is_closed(fd: int) -> bool:
|
||||
"""
|
||||
Check if file descriptor is closed
|
||||
:param fd: file descriptor
|
||||
:return: True if file descriptor is closed
|
||||
"""
|
||||
try:
|
||||
fcntl.fcntl(fd, fcntl.F_GETFL) < 0
|
||||
except OSError as ose:
|
||||
return ose.errno == errno.EBADF
|
||||
|
||||
|
||||
def tty_unset_reader_callbacks(fd: int, loop: asyncio.AbstractEventLoop = None):
|
||||
"""
|
||||
Remove async reader callbacks for file descriptor.
|
||||
:param fd: file descriptor
|
||||
:param loop: asyncio event loop from which to remove callbacks
|
||||
"""
|
||||
with exception.permit(SystemExit):
|
||||
if loop is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.remove_reader(fd)
|
||||
|
||||
|
||||
def tty_get_winsize(fd: int) -> [int, int, int, int]:
|
||||
"""
|
||||
Ge the window size of a tty.
|
||||
:param fd: file descriptor of tty
|
||||
:return: (rows, cols, h_pixels, v_pixels)
|
||||
"""
|
||||
packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
|
||||
rows, cols, h_pixels, v_pixels = struct.unpack('HHHH', packed)
|
||||
return rows, cols, h_pixels, v_pixels
|
||||
|
||||
|
||||
def tty_set_winsize(fd: int, rows: int, cols: int, h_pixels: int, v_pixels: int):
|
||||
"""
|
||||
Set the window size on a tty.
|
||||
:param fd: file descriptor of tty
|
||||
:param rows: number of visible rows
|
||||
:param cols: number of visible columns
|
||||
:param h_pixels: number of visible horizontal pixels
|
||||
:param v_pixels: number of visible vertical pixels
|
||||
"""
|
||||
if fd < 0:
|
||||
return
|
||||
packed = struct.pack('HHHH', rows, cols, h_pixels, v_pixels)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)
|
||||
|
||||
|
||||
def process_exists(pid) -> bool:
|
||||
"""
|
||||
Check For the existence of a unix pid.
|
||||
:param pid: process id to check
|
||||
:return: True if process exists
|
||||
"""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class TTYRestorer(contextlib.AbstractContextManager):
|
||||
# Indexes of flags within the attrs array
|
||||
ATTR_IDX_IFLAG = 0
|
||||
ATTR_IDX_OFLAG = 1
|
||||
ATTR_IDX_CFLAG = 2
|
||||
ATTR_IDX_LFLAG = 4
|
||||
ATTR_IDX_CC = 5
|
||||
|
||||
def __init__(self, fd: int, suppress_logs=False):
|
||||
"""
|
||||
Saves termios attributes for a tty for later restoration.
|
||||
|
||||
The attributes are an array of values with the following meanings.
|
||||
|
||||
tcflag_t c_iflag; /* input modes */
|
||||
tcflag_t c_oflag; /* output modes */
|
||||
tcflag_t c_cflag; /* control modes */
|
||||
tcflag_t c_lflag; /* local modes */
|
||||
cc_t c_cc[NCCS]; /* special characters */
|
||||
|
||||
:param fd: file descriptor of tty
|
||||
"""
|
||||
self._fd = fd
|
||||
self._tattr = None
|
||||
self._suppress_logs = suppress_logs
|
||||
self._tattr = self.current_attr()
|
||||
if not self._tattr and not self._suppress_logs: RNS.log(f"Could not get attrs for fd {fd}", RNS.LOG_DEBUG)
|
||||
|
||||
def raw(self):
|
||||
"""
|
||||
Set raw mode on tty
|
||||
"""
|
||||
if self._fd is None:
|
||||
return
|
||||
with contextlib.suppress(termios.error):
|
||||
tty.setraw(self._fd, termios.TCSANOW)
|
||||
|
||||
def original_attr(self) -> [any]:
|
||||
return copy.deepcopy(self._tattr)
|
||||
|
||||
def current_attr(self) -> [any]:
|
||||
"""
|
||||
Get the current termios attributes for the wrapped fd.
|
||||
:return: attribute array
|
||||
"""
|
||||
if self._fd is None:
|
||||
return None
|
||||
|
||||
with contextlib.suppress(termios.error):
|
||||
return copy.deepcopy(termios.tcgetattr(self._fd))
|
||||
return None
|
||||
|
||||
def set_attr(self, attr: [any], when: int = termios.TCSADRAIN):
|
||||
"""
|
||||
Set termios attributes
|
||||
:param attr: attribute list to set
|
||||
:param when: when attributes should be applied (termios.TCSANOW, termios.TCSADRAIN, termios.TCSAFLUSH)
|
||||
"""
|
||||
if not attr or self._fd is None:
|
||||
return
|
||||
|
||||
with contextlib.suppress(termios.error):
|
||||
termios.tcsetattr(self._fd, when, attr)
|
||||
|
||||
def isatty(self):
|
||||
return os.isatty(self._fd) if self._fd is not None else None
|
||||
|
||||
def restore(self):
|
||||
"""
|
||||
Restore termios settings to state captured in constructor.
|
||||
"""
|
||||
self.set_attr(self._tattr, termios.TCSADRAIN)
|
||||
|
||||
def __exit__(self, __exc_type: typing.Type[BaseException], __exc_value: BaseException,
|
||||
__traceback: types.TracebackType) -> bool:
|
||||
self.restore()
|
||||
return False #__exc_type is not None and issubclass(__exc_type, termios.error)
|
||||
|
||||
|
||||
def _task_from_event(evt: asyncio.Event, loop: asyncio.AbstractEventLoop = None):
|
||||
if not loop:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
#TODO: this is hacky
|
||||
async def wait():
|
||||
while not evt.is_set():
|
||||
await asyncio.sleep(0.1)
|
||||
return True
|
||||
|
||||
return loop.create_task(wait())
|
||||
|
||||
|
||||
class AggregateException(Exception):
|
||||
def __init__(self, inner_exceptions: [Exception]):
|
||||
super().__init__()
|
||||
self.inner_exceptions = inner_exceptions
|
||||
|
||||
def __str__(self):
|
||||
return "Multiple exceptions encountered: \n\n" + "\n\n".join(map(lambda e: str(e), self.inner_exceptions))
|
||||
|
||||
|
||||
async def event_wait_any(evts: [asyncio.Event], timeout: float = None) -> (any, any):
|
||||
tasks = list(map(lambda evt: (evt, _task_from_event(evt)), evts))
|
||||
try:
|
||||
finished, unfinished = await asyncio.wait(map(lambda t: t[1], tasks),
|
||||
timeout=timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
if len(unfinished) > 0:
|
||||
for task in unfinished:
|
||||
task.cancel()
|
||||
await asyncio.wait(unfinished)
|
||||
|
||||
exceptions = []
|
||||
|
||||
for f in finished:
|
||||
ex = f.exception()
|
||||
if ex and not isinstance(ex, asyncio.CancelledError) and not isinstance(ex, TimeoutError):
|
||||
exceptions.append(ex)
|
||||
|
||||
if len(exceptions) > 0:
|
||||
raise AggregateException(exceptions)
|
||||
|
||||
return next(map(lambda t: next(map(lambda tt: tt[0], tasks)), finished), None)
|
||||
finally:
|
||||
unfinished = []
|
||||
for task in map(lambda t: t[1], tasks):
|
||||
if task.done():
|
||||
if not task.cancelled():
|
||||
task.exception()
|
||||
else:
|
||||
task.cancel()
|
||||
unfinished.append(task)
|
||||
if len(unfinished) > 0:
|
||||
await asyncio.wait(unfinished)
|
||||
|
||||
|
||||
async def event_wait(evt: asyncio.Event, timeout: float) -> bool:
|
||||
"""
|
||||
Wait for event to be set, or timeout to expire.
|
||||
:param evt: asyncio.Event to wait on
|
||||
:param timeout: maximum number of seconds to wait.
|
||||
:return: True if event was set, False if timeout expired
|
||||
"""
|
||||
await event_wait_any([evt], timeout=timeout)
|
||||
return evt.is_set()
|
||||
|
||||
|
||||
def _launch_child(cmd_line: list[str], env: dict[str, str], stdin_is_pipe: bool, stdout_is_pipe: bool,
|
||||
stderr_is_pipe: bool) -> tuple[int, int, int, int]:
|
||||
# Set up PTY and/or pipes
|
||||
child_fd = parent_fd = None
|
||||
if not (stdin_is_pipe and stdout_is_pipe and stderr_is_pipe):
|
||||
parent_fd, child_fd = pty.openpty()
|
||||
child_stdin, parent_stdin = (os.pipe() if stdin_is_pipe else (child_fd, parent_fd))
|
||||
parent_stdout, child_stdout = (os.pipe() if stdout_is_pipe else (parent_fd, child_fd))
|
||||
parent_stderr, child_stderr = (os.pipe() if stderr_is_pipe else (parent_fd, child_fd))
|
||||
|
||||
# Fork
|
||||
pid = os.fork()
|
||||
|
||||
if pid == 0:
|
||||
try:
|
||||
# We are in the child process, so close all open sockets and pipes except for the PTY and/or pipes
|
||||
max_fd = os.sysconf("SC_OPEN_MAX")
|
||||
for fd in range(3, max_fd):
|
||||
if fd not in (child_stdin, child_stdout, child_stderr):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Set up PTY and/or pipes
|
||||
os.dup2(child_stdin, 0)
|
||||
os.dup2(child_stdout, 1)
|
||||
os.dup2(child_stderr, 2)
|
||||
# Make PTY controlling if necessary so that CTRL_C/CTRL_D behave as expected
|
||||
if child_fd is not None:
|
||||
os.setsid()
|
||||
try:
|
||||
tty_fd = 0 if not stdin_is_pipe else (1 if not stdout_is_pipe else 2)
|
||||
# Set controlling TTY for this session
|
||||
fcntl.ioctl(tty_fd, termios.TIOCSCTTY, 0)
|
||||
except Exception:
|
||||
pass
|
||||
# Ensure the child is the foreground process group for the TTY
|
||||
try:
|
||||
os.setpgid(0, 0)
|
||||
pgid = os.getpgrp()
|
||||
import struct as _struct
|
||||
fcntl.ioctl(tty_fd, termios.TIOCSPGRP, _struct.pack('i', pgid))
|
||||
except Exception:
|
||||
pass
|
||||
# Ensure canonical input with signals and local echo enabled
|
||||
try:
|
||||
tty_fd = 0 if not stdin_is_pipe else (1 if not stdout_is_pipe else 2)
|
||||
attrs = termios.tcgetattr(tty_fd)
|
||||
lflag = attrs[3]
|
||||
lflag |= termios.ICANON | termios.ISIG | termios.ECHO
|
||||
attrs[3] = lflag
|
||||
termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Execute the command
|
||||
os.execvpe(cmd_line[0], cmd_line, env)
|
||||
except Exception as err:
|
||||
exc_type, exc_obj, exc_tb = sys.exc_info()
|
||||
fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
|
||||
print(f"Unable to start {cmd_line[0]}: {err} ({fname}:{exc_tb.tb_lineno})")
|
||||
sys.stdout.flush()
|
||||
# don't let any other modules get in our way, do an immediate silent exit.
|
||||
os._exit(255)
|
||||
|
||||
else:
|
||||
# We are in the parent process, so close the child-side of the PTY and/or pipes
|
||||
if child_fd is not None:
|
||||
os.close(child_fd)
|
||||
if child_stdin != child_fd:
|
||||
os.close(child_stdin)
|
||||
if child_stdout != child_fd:
|
||||
os.close(child_stdout)
|
||||
if child_stderr != child_fd:
|
||||
os.close(child_stderr)
|
||||
# # Close the write end of the pipe if a pipe is used for standard input
|
||||
# if not stdin_is_pipe:
|
||||
# os.close(parent_stdin)
|
||||
# Return the child PID and the file descriptors for the PTY and/or pipes
|
||||
return pid, parent_stdin, parent_stdout, parent_stderr
|
||||
|
||||
|
||||
class CallbackSubprocess:
|
||||
# time between checks of child process
|
||||
PROCESS_POLL_TIME: float = 0.1
|
||||
# Close pipes soon after process exit to avoid scheduling on closed event loops
|
||||
PROCESS_PIPE_TIME: int = 1
|
||||
|
||||
def __init__(self, argv: [str], env: dict, loop: asyncio.AbstractEventLoop, stdout_callback: callable,
|
||||
stderr_callback: callable, terminated_callback: callable, stdin_is_pipe: bool, stdout_is_pipe: bool,
|
||||
stderr_is_pipe: bool):
|
||||
"""
|
||||
Fork a child process and generate callbacks with output from the process.
|
||||
:param argv: the command line, tokenized. The first element must be the absolute path to an executable file.
|
||||
:param env: environment variables to override
|
||||
:param loop: the asyncio event loop to use
|
||||
:param stdout_callback: callback for data, e.g. def callback(data:bytes) -> None
|
||||
:param terminated_callback: callback for termination/return code, e.g. def callback(return_code:int) -> None
|
||||
"""
|
||||
assert loop is not None, "loop should not be None"
|
||||
assert stdout_callback is not None, "stdout_callback should not be None"
|
||||
assert terminated_callback is not None, "terminated_callback should not be None"
|
||||
|
||||
self._command: [str] = argv
|
||||
self._env = env or {}
|
||||
self._loop = loop
|
||||
self._stdout_cb = stdout_callback
|
||||
self._stderr_cb = stderr_callback
|
||||
self._terminated_cb = terminated_callback
|
||||
self._pid: int = None
|
||||
self._child_stdin: int = None
|
||||
self._child_stdout: int = None
|
||||
self._child_stderr: int = None
|
||||
self._return_code: int = None
|
||||
self._stdout_eof: bool = False
|
||||
self._stderr_eof: bool = False
|
||||
self._stdin_is_pipe = stdin_is_pipe
|
||||
self._stdout_is_pipe = stdout_is_pipe
|
||||
self._stderr_is_pipe = stderr_is_pipe
|
||||
self._at_line_start: bool = True
|
||||
self._tty_line_buffer: bytearray = bytearray()
|
||||
|
||||
def _ensure_pipes_closed(self):
|
||||
stdin = self._child_stdin
|
||||
stdout = self._child_stdout
|
||||
stderr = self._child_stderr
|
||||
fds = set(filter(lambda x: x is not None, list({stdin, stdout, stderr})))
|
||||
RNS.log(f"Queuing close of pipes for ended process (fds: {fds})", RNS.LOG_DEBUG)
|
||||
|
||||
def ensure_pipes_closed_inner():
|
||||
RNS.log(f"Ensuring pipes are closed (fds: {fds})", RNS.LOG_DEBUG)
|
||||
for fd in fds:
|
||||
RNS.log(f"Closing fd {fd}", RNS.LOG_DEBUG)
|
||||
with contextlib.suppress(OSError): tty_unset_reader_callbacks(fd)
|
||||
with contextlib.suppress(OSError): os.close(fd)
|
||||
|
||||
self._child_stdin = None
|
||||
self._child_stdout = None
|
||||
self._child_stderr = None
|
||||
|
||||
# Avoid scheduling on a closed loop
|
||||
if self._loop.is_closed(): ensure_pipes_closed_inner()
|
||||
else: self._loop.call_later(CallbackSubprocess.PROCESS_PIPE_TIME, ensure_pipes_closed_inner)
|
||||
|
||||
def terminate(self, kill_delay: float = 1.0):
|
||||
"""
|
||||
Terminate child process if running
|
||||
:param kill_delay: if after kill_delay seconds the child process has not exited, escalate to SIGHUP and SIGKILL
|
||||
"""
|
||||
|
||||
RNS.log("terminate()", RNS.LOG_EXTREME)
|
||||
if not self.running: return
|
||||
|
||||
with exception.permit(SystemExit): os.kill(self._pid, signal.SIGTERM)
|
||||
|
||||
def kill():
|
||||
if process_exists(self._pid):
|
||||
RNS.log("kill()", RNS.LOG_EXTREME)
|
||||
with exception.permit(SystemExit):
|
||||
os.kill(self._pid, signal.SIGHUP)
|
||||
os.kill(self._pid, signal.SIGKILL)
|
||||
|
||||
self._loop.call_later(kill_delay, kill)
|
||||
|
||||
def wait():
|
||||
RNS.log("wait()", RNS.LOG_EXTREME)
|
||||
with contextlib.suppress(OSError): os.waitpid(self._pid, 0)
|
||||
self._ensure_pipes_closed()
|
||||
RNS.log("wait() finish", RNS.LOG_EXTREME)
|
||||
|
||||
threading.Thread(target=wait, daemon=True).start()
|
||||
|
||||
def close_stdin(self):
|
||||
with contextlib.suppress(Exception):
|
||||
os.close(self._child_stdin)
|
||||
# Encourage prompt shutdown if child lingers after stdin close
|
||||
def _ensure_terminate():
|
||||
if self.running:
|
||||
self.terminate(kill_delay=0.2)
|
||||
if not self._loop.is_closed():
|
||||
self._loop.call_later(0.05, _ensure_terminate)
|
||||
|
||||
@property
|
||||
def started(self) -> bool:
|
||||
"""
|
||||
:return: True if child process has been started
|
||||
"""
|
||||
return self._pid is not None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""
|
||||
:return: True if child process is still running
|
||||
"""
|
||||
return self._pid is not None and process_exists(self._pid)
|
||||
|
||||
def write(self, data: bytes):
|
||||
"""
|
||||
Write bytes to the stdin of the child process.
|
||||
:param data: bytes to write
|
||||
"""
|
||||
|
||||
os.write(self._child_stdin, data)
|
||||
|
||||
# TODO: Check what this is actually supposed to solve.
|
||||
#
|
||||
# For pipe-in + TTY-out, echo should be visible immediately
|
||||
if self._stdin_is_pipe and not self._stdout_is_pipe and self._stdout_cb is not None and data not in (CTRL_C, CTRL_D):
|
||||
try: self._stdout_cb(data)
|
||||
except Exception: pass
|
||||
|
||||
def set_winsize(self, r: int, c: int, h: int, v: int):
|
||||
"""
|
||||
Set the window size on the tty of the child process.
|
||||
:param r: rows visible
|
||||
:param c: columns visible
|
||||
:param h: horizontal pixels visible
|
||||
:param v: vertical pixels visible
|
||||
:return:
|
||||
"""
|
||||
RNS.log(f"set_winsize({r},{c},{h},{v}", RNS.LOG_DEBUG)
|
||||
tty_set_winsize(self._child_stdout, r, c, h, v)
|
||||
|
||||
def copy_winsize(self, fromfd: int):
|
||||
"""
|
||||
Copy window size from one tty to another.
|
||||
:param fromfd: source tty file descriptor
|
||||
"""
|
||||
r, c, h, v = tty_get_winsize(fromfd)
|
||||
self.set_winsize(r, c, h, v)
|
||||
|
||||
def tcsetattr(self, when: int, attr: list[any]): # actual type is list[int | list[int | bytes]]
|
||||
"""
|
||||
Set tty attributes.
|
||||
:param when: when to apply change: termios.TCSANOW or termios.TCSADRAIN or termios.TCSAFLUSH
|
||||
:param attr: attributes to set
|
||||
"""
|
||||
termios.tcsetattr(self._child_stdin, when, attr)
|
||||
|
||||
def tcgetattr(self) -> list[any]: # actual type is list[int | list[int | bytes]]
|
||||
"""
|
||||
Get tty attributes.
|
||||
:return: tty attributes value
|
||||
"""
|
||||
return termios.tcgetattr(self._child_stdout)
|
||||
|
||||
def ttysetraw(self):
|
||||
tty.setraw(self._child_stdout, termios.TCSADRAIN)
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the child process.
|
||||
"""
|
||||
RNS.log("start()", RNS.LOG_EXTREME)
|
||||
|
||||
# # Using the parent environment seems to do some weird stuff, at least on macOS
|
||||
# parentenv = os.environ.copy()
|
||||
# env = {"HOME": parentenv["HOME"],
|
||||
# "PATH": parentenv["PATH"],
|
||||
# "TERM": self._term if self._term is not None else parentenv.get("TERM", "xterm"),
|
||||
# "LANG": parentenv.get("LANG"),
|
||||
# "SHELL": self._command[0]}
|
||||
|
||||
env = os.environ.copy()
|
||||
for key in self._env:
|
||||
env[key] = self._env[key]
|
||||
|
||||
program = self._command[0]
|
||||
assert isinstance(program, str)
|
||||
|
||||
# match = re.search("^/bin/(.*sh)$", program)
|
||||
# if match:
|
||||
# self._command[0] = "-" + match.group(1)
|
||||
# env["SHELL"] = program
|
||||
# self._log.debug(f"set login shell {self._command}")
|
||||
|
||||
self._pid, \
|
||||
self._child_stdin, \
|
||||
self._child_stdout, \
|
||||
self._child_stderr = _launch_child(self._command, env, self._stdin_is_pipe, self._stdout_is_pipe,
|
||||
self._stderr_is_pipe)
|
||||
RNS.log(f"Started pid {self.pid}, fds: {self._child_stdin}, {self._child_stdout}, {self._child_stderr}", RNS.LOG_DEBUG)
|
||||
|
||||
def poll():
|
||||
try:
|
||||
pid, self._return_code = os.waitpid(self._pid, os.WNOHANG)
|
||||
if self._return_code is not None:
|
||||
self._return_code = self._return_code & 0xff
|
||||
if self._return_code is not None and not process_exists(self._pid):
|
||||
RNS.log(f"polled return code {self._return_code}", RNS.LOG_DEBUG)
|
||||
self._terminated_cb(self._return_code)
|
||||
if self.running:
|
||||
self._loop.call_later(CallbackSubprocess.PROCESS_POLL_TIME, poll)
|
||||
else:
|
||||
self._ensure_pipes_closed()
|
||||
except Exception as e:
|
||||
if not hasattr(e, "errno") or e.errno != errno.ECHILD:
|
||||
RNS.log(f"Error in process poll: {e}", RNS.LOG_DEBUG)
|
||||
|
||||
self._loop.call_later(CallbackSubprocess.PROCESS_POLL_TIME, poll)
|
||||
|
||||
def stdout():
|
||||
try:
|
||||
with exception.permit(SystemExit):
|
||||
data = tty_read_poll(self._child_stdout)
|
||||
if data is not None and len(data) > 0:
|
||||
self._stdout_cb(data)
|
||||
# Opportunistically drain shortly after to coalesce immediate follow-up output
|
||||
if not self._loop.is_closed():
|
||||
self._loop.call_later(0.01, stdout)
|
||||
except EOFError:
|
||||
self._stdout_eof = True
|
||||
tty_unset_reader_callbacks(self._child_stdout)
|
||||
self._stdout_cb(bytearray())
|
||||
|
||||
def stderr():
|
||||
try:
|
||||
with exception.permit(SystemExit):
|
||||
data = tty_read_poll(self._child_stderr)
|
||||
if data is not None and len(data) > 0:
|
||||
self._stderr_cb(data)
|
||||
if not self._loop.is_closed():
|
||||
self._loop.call_later(0.01, stderr)
|
||||
except EOFError:
|
||||
self._stderr_eof = True
|
||||
tty_unset_reader_callbacks(self._child_stderr)
|
||||
self._stderr_cb(bytearray())
|
||||
|
||||
tty_add_reader_callback(self._child_stdout, stdout, self._loop)
|
||||
if self._child_stderr != self._child_stdout:
|
||||
tty_add_reader_callback(self._child_stderr, stderr, self._loop)
|
||||
|
||||
@property
|
||||
def stdout_eof(self):
|
||||
return self._stdout_eof or not self.running
|
||||
|
||||
@property
|
||||
def stderr_eof(self):
|
||||
return self._stderr_eof or not self.running
|
||||
|
||||
|
||||
@property
|
||||
def return_code(self) -> int:
|
||||
return self._return_code
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
return self._pid
|
||||
|
||||
|
||||
async def main():
|
||||
"""
|
||||
A test driver for the CallbackProcess class.
|
||||
python ./process.py /bin/zsh --login
|
||||
"""
|
||||
|
||||
if len(sys.argv) <= 1:
|
||||
print(f"Usage: {sys.argv} <absolute_path_to_child_executable> [child_arg ...]")
|
||||
exit(1)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
# asyncio.set_event_loop(loop)
|
||||
retcode = loop.create_future()
|
||||
|
||||
def stdout(data: bytes): os.write(sys.stdout.fileno(), data)
|
||||
|
||||
def terminated(rc: int): retcode.set_result(rc)
|
||||
|
||||
process = CallbackSubprocess(argv=sys.argv[1:],
|
||||
env={"TERM": os.environ.get("TERM", "xterm")},
|
||||
loop=loop,
|
||||
stdout_callback=stdout,
|
||||
terminated_callback=terminated)
|
||||
|
||||
def sigint_handler(sig, frame):
|
||||
if process is None or process.started and not process.running:
|
||||
raise KeyboardInterrupt
|
||||
elif process.running:
|
||||
process.write("\x03".encode("utf-8"))
|
||||
|
||||
def sigwinch_handler(sig, frame):
|
||||
process.copy_winsize(sys.stdin.fileno())
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGWINCH, sigwinch_handler)
|
||||
|
||||
def stdin():
|
||||
try:
|
||||
data = tty_read(sys.stdin.fileno())
|
||||
if data is not None:
|
||||
process.write(data)
|
||||
|
||||
except EOFError:
|
||||
tty_unset_reader_callbacks(sys.stdin.fileno())
|
||||
process.write(CTRL_D)
|
||||
|
||||
tty_add_reader_callback(sys.stdin.fileno(), stdin)
|
||||
process.start()
|
||||
# call_soon called it too soon, not sure why.
|
||||
loop.call_later(0.001, functools.partial(process.copy_winsize, sys.stdin.fileno()))
|
||||
|
||||
val = await retcode
|
||||
RNS.log(f"Got return code {val}", RNS.LOG_DEBUG)
|
||||
return val
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tr = TTYRestorer(sys.stdin.fileno())
|
||||
try:
|
||||
tr.raw()
|
||||
asyncio.run(main())
|
||||
finally:
|
||||
tty_unset_reader_callbacks(sys.stdin.fileno())
|
||||
tr.restore()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user